From 124de9f5b8a6291f89a03962e74e2808cfd282f1 Mon Sep 17 00:00:00 2001 From: Oleksandr Shuryha Date: Thu, 26 Mar 2026 18:44:33 +0100 Subject: [PATCH] Add locations to todo items and automate Gitea deploy --- .gitea/workflows/redeploy-compose.yml | 61 +++++++++++++++++++++++++++ CHANGELOG.md | 7 +++ README.md | 18 ++++++++ backend/server.js | 14 ++++++ frontend/src/App.tsx | 58 ++++++++++++++++++++----- frontend/src/marathonApi.ts | 41 +++++++++++++++++- frontend/src/styles.css | 32 +++++++++++++- frontend/src/types.ts | 3 ++ 8 files changed, 220 insertions(+), 14 deletions(-) create mode 100644 .gitea/workflows/redeploy-compose.yml diff --git a/.gitea/workflows/redeploy-compose.yml b/.gitea/workflows/redeploy-compose.yml new file mode 100644 index 0000000..5691fbf --- /dev/null +++ b/.gitea/workflows/redeploy-compose.yml @@ -0,0 +1,61 @@ +name: Redeploy Docker Compose + +on: + push: + branches: + - main + +jobs: + redeploy: + runs-on: ubuntu-latest + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} + DEPLOY_BRANCH: main + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + steps: + - name: Validate required secrets + shell: bash + run: | + set -euo pipefail + : "${DEPLOY_PORT:=22}" + missing=0 + for key in DEPLOY_HOST DEPLOY_USER DEPLOY_PATH SSH_PRIVATE_KEY; do + if [ -z "${!key:-}" ]; then + echo "Missing required secret: $key" + missing=1 + fi + done + if [ "$missing" -ne 0 ]; then + exit 1 + fi + + - name: Configure SSH key + shell: bash + run: | + set -euo pipefail + : "${DEPLOY_PORT:=22}" + mkdir -p ~/.ssh + chmod 700 ~/.ssh + printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -p "$DEPLOY_PORT" -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts + + - name: Redeploy on remote host + shell: bash + run: | + set -euo pipefail + : "${DEPLOY_PORT:=22}" + ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \ + "DEPLOY_PATH='$DEPLOY_PATH' DEPLOY_BRANCH='$DEPLOY_BRANCH' bash -se" <<'EOF' + set -euo pipefail + cd "$DEPLOY_PATH" + git fetch origin "$DEPLOY_BRANCH" + git checkout "$DEPLOY_BRANCH" + git pull --ff-only origin "$DEPLOY_BRANCH" + docker compose pull + docker compose up -d --build --remove-orphans + EOF diff --git a/CHANGELOG.md b/CHANGELOG.md index 00c6c05..864d88f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.5.1 - 2026-03-26 +- Added Gitea Actions workflow for automatic Docker Compose redeploy on `main` updates: + - New workflow file: `.gitea/workflows/redeploy-compose.yml`. + - Uses SSH to connect to deployment host and run `git pull` + `docker compose up -d --build --remove-orphans`. + - Supports optional `DEPLOY_PORT` secret (defaults to `22`). +- Updated `README.md` with Gitea deployment workflow setup and required secrets. + ## 0.5.0 - 2026-03-26 - Added anonymous backend visit logging in SQLite: - New `visit_logs` table records timestamp, hashed visitor id, and country metadata. diff --git a/README.md b/README.md index 36f319d..f18279d 100644 --- a/README.md +++ b/README.md @@ -56,3 +56,21 @@ A React + TypeScript app with a local Node + SQLite backend for planning what to ```bash npm run build ``` + +## Gitea Auto Deploy (Docker Compose) +When `main` is updated, Gitea Actions can redeploy your Docker Compose stack using: +- Workflow file: `.gitea/workflows/redeploy-compose.yml` +- Trigger: push to `main` +- Remote commands run over SSH: + - `git fetch` + - `git checkout main` + - `git pull --ff-only` + - `docker compose pull` + - `docker compose up -d --build --remove-orphans` + +Set these repository secrets in Gitea: +- `DEPLOY_HOST`: server hostname or IP +- `DEPLOY_USER`: SSH username on the deployment server +- `DEPLOY_PATH`: absolute path to this repo on the server +- `SSH_PRIVATE_KEY`: private key for SSH auth (matching an authorized public key on the server) +- `DEPLOY_PORT` (optional): SSH port, defaults to `22` diff --git a/backend/server.js b/backend/server.js index feb54a9..5f12c6e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -422,6 +422,17 @@ function getRarity(raw) { return ''; } +function getKnownLocations(raw) { + const value = raw.known_locations; + if (!Array.isArray(value)) { + return []; + } + + return value + .map((location) => normalizeName(location)) + .filter(Boolean); +} + function getItemIconUrl(iconPath, slug) { const itemImageBase = 'https://items.marathondb.gg/images/items'; const apiBase = 'https://helpbot.marathondb.gg'; @@ -554,6 +565,7 @@ function buildUpgradeResults(factionUpgrades, itemsBySlug) { name: entry.item.name, iconUrl: entry.item.iconUrl, rarity: entry.item.rarity, + knownLocations: entry.item.knownLocations, })); mappedLevels.push({ @@ -602,12 +614,14 @@ function buildCatalog(itemsPayload, factionUpgradesPayload, updatedAtMs) { const slug = getSlug(row); const iconPath = getIconPath(row); const rarity = getRarity(row); + const knownLocations = getKnownLocations(row); return { id: slug, slug, name, iconUrl: getItemIconUrl(iconPath, slug), rarity, + knownLocations, }; }) .filter(Boolean); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 39c0c10..4018d35 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,6 +25,18 @@ function isUpgradeResult(result: SearchResult): result is UpgradeSearchResult { return 'isUpgrade' in result; } +function normalizeKnownLocations(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const normalized = value + .map((location) => (typeof location === 'string' ? location.trim() : '')) + .filter((location) => Boolean(location)); + + return normalized.length > 0 ? normalized : undefined; +} + function ItemIcon({ src, alt, @@ -85,6 +97,9 @@ function loadTodoItems(): TodoItem[] { typeof row.name === 'string' && typeof row.iconUrl === 'string' && (row.rarity === undefined || typeof row.rarity === 'string') && + (row.knownLocations === undefined || + (Array.isArray(row.knownLocations) && + row.knownLocations.every((location: unknown) => typeof location === 'string'))) && typeof row.quantity === 'number' && typeof row.completed === 'boolean' ); @@ -92,6 +107,7 @@ function loadTodoItems(): TodoItem[] { .map((item) => ({ ...item, rarity: typeof item.rarity === 'string' ? item.rarity : undefined, + knownLocations: normalizeKnownLocations(item.knownLocations), quantity: Math.max(1, Math.floor(item.quantity)), })); } catch { @@ -177,6 +193,7 @@ function decodeListFromUrl(encoded: string, allItems: SearchItem[]): TodoItem[] name: match.name, iconUrl: match.iconUrl, rarity: match.rarity, + knownLocations: match.knownLocations, quantity, completed, }; @@ -212,11 +229,14 @@ export default function App() { return; } - const rarityBySlug = new Map(allItems.map((item) => [item.slug, item.rarity])); + const itemMetadataBySlug = new Map( + allItems.map((item) => [item.slug, { rarity: item.rarity, knownLocations: item.knownLocations }]), + ); setTodoItems((prevItems) => prevItems.map((item) => ({ ...item, - rarity: item.rarity ?? rarityBySlug.get(item.slug), + rarity: item.rarity ?? itemMetadataBySlug.get(item.slug)?.rarity, + knownLocations: item.knownLocations ?? itemMetadataBySlug.get(item.slug)?.knownLocations, })), ); @@ -359,7 +379,14 @@ export default function App() { const existing = prevItems.find((entry) => entry.slug === item.slug); if (existing) { return prevItems.map((entry) => - entry.slug === item.slug ? { ...entry, quantity: entry.quantity + 1 } : entry, + entry.slug === item.slug + ? { + ...entry, + quantity: entry.quantity + 1, + rarity: entry.rarity ?? item.rarity, + knownLocations: entry.knownLocations ?? item.knownLocations, + } + : entry, ); } @@ -370,6 +397,7 @@ export default function App() { name: item.name, iconUrl: item.iconUrl, rarity: item.rarity, + knownLocations: item.knownLocations, quantity: 1, completed: false, }, @@ -398,6 +426,8 @@ export default function App() { nextItems[existingIndex] = { ...nextItems[existingIndex], quantity: nextItems[existingIndex].quantity + amount, + rarity: nextItems[existingIndex].rarity ?? salvageEntry.rarity, + knownLocations: nextItems[existingIndex].knownLocations ?? salvageEntry.knownLocations, }; continue; } @@ -408,6 +438,7 @@ export default function App() { name: salvageEntry.name, iconUrl: salvageEntry.iconUrl, rarity: salvageEntry.rarity, + knownLocations: salvageEntry.knownLocations, quantity: amount, completed: false, }); @@ -501,7 +532,9 @@ export default function App() {
-

marathon.todo

+

+ marathon.todo v0.0.1 +

{!minimalMode ?

Plan what to loot (or do) in your next Marathon raid.

: null}
{!minimalMode ? ( @@ -738,12 +771,17 @@ export default function App() { - - {item.name} - +
+ + {item.name} + + {item.knownLocations && item.knownLocations.length > 0 ? ( + {item.knownLocations.join(', ')} + ) : null} +
{minimalMode ? ( x{item.quantity} diff --git a/frontend/src/marathonApi.ts b/frontend/src/marathonApi.ts index 53b4de3..5532f75 100644 --- a/frontend/src/marathonApi.ts +++ b/frontend/src/marathonApi.ts @@ -15,10 +15,38 @@ interface PopularResponse { let catalogPromise: Promise | null = null; +function normalizeKnownLocations(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const normalized = value + .map((location) => (typeof location === 'string' ? location.trim() : '')) + .filter((location) => Boolean(location)); + + return normalized.length > 0 ? normalized : undefined; +} + function normalizeCatalog(payload: CatalogResponse): CatalogResponse { + const normalizedItems = payload.items.map((item) => ({ + ...item, + knownLocations: normalizeKnownLocations(item.knownLocations), + })); + const normalizedUpgrades = payload.upgrades.map((upgrade) => { if (Array.isArray(upgrade.levels)) { - return upgrade; + return { + ...upgrade, + levels: upgrade.levels.map((level) => ({ + ...level, + salvage: Array.isArray(level.salvage) + ? level.salvage.map((salvageItem) => ({ + ...salvageItem, + knownLocations: normalizeKnownLocations(salvageItem.knownLocations), + })) + : [], + })), + }; } const legacyUpgrade = upgrade as UpgradeSearchResult & { salvage?: UpgradeSearchResult['levels'][number]['salvage'] }; @@ -26,12 +54,21 @@ function normalizeCatalog(payload: CatalogResponse): CatalogResponse { return { ...upgrade, - levels: [{ level: 1, salvage }], + levels: [ + { + level: 1, + salvage: salvage.map((salvageItem) => ({ + ...salvageItem, + knownLocations: normalizeKnownLocations(salvageItem.knownLocations), + })), + }, + ], }; }); return { ...payload, + items: normalizedItems, upgrades: normalizedUpgrades, }; } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 940e492..eb28dfd 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -20,7 +20,7 @@ --ui-accent: #caf61d; --color-text-main: #e8edf7; --color-text-input: #f2f6ff; - --color-text-muted: #d6dbe8; + --color-text-muted: #7f828a; --color-text-done: #8d9bbb; --color-border: #444444; --color-hover: #31373d; @@ -74,6 +74,11 @@ h2 { color: var(--ui-accent); } +.app-version { + font-size: 0.35em; + color: var(--color-text-muted); +} + label { color: var(--ui-accent); } @@ -257,6 +262,7 @@ input[type='number'] { justify-content: center; flex: 0 0 auto; overflow: visible; + align-self: flex-start; } .result-name { @@ -322,7 +328,7 @@ input[type='number'] { padding: 0.65rem; display: grid; grid-template-columns: auto 1fr auto auto; - align-items: center; + align-items: flex-start; gap: 0.5rem; background: var(--ui-dark-elevated); cursor: pointer; @@ -351,6 +357,7 @@ input[type='number'] { font-weight: 700; min-width: 52px; text-align: right; + align-self: center; } .todo-item.highlighted { @@ -359,12 +366,28 @@ input[type='number'] { .item-name { overflow-wrap: anywhere; + line-height: 1.2; +} + +.item-main { + min-width: 0; + display: grid; + gap: 0.2rem; + align-self: flex-start; +} + +.item-locations { + overflow-wrap: anywhere; + font-size: 0.72rem; + color: var(--color-text-muted); + line-height: 1.2; } .quantity-field { display: flex; align-items: center; gap: 0.35rem; + align-self: center; } .quantity-field input { @@ -400,6 +423,10 @@ input[type='number'] { color: var(--color-text-done); } +.todo-item.done .item-locations { + color: var(--color-text-done); +} + .todo-item.done .minimal-qty, .todo-item.done .quantity-field input { text-decoration: line-through; @@ -434,6 +461,7 @@ input[type='number'] { align-items: center; justify-content: center; cursor: pointer; + align-self: center; } .delete:hover { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 89add4e..02af97f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -4,6 +4,7 @@ export interface SearchItem { name: string; iconUrl: string; rarity?: string; + knownLocations?: string[]; } export interface UpgradeSalvageItem { @@ -12,6 +13,7 @@ export interface UpgradeSalvageItem { name: string; iconUrl: string; rarity?: string; + knownLocations?: string[]; } export interface UpgradeLevel { @@ -38,6 +40,7 @@ export interface TodoItem { name: string; iconUrl: string; rarity?: string; + knownLocations?: string[]; quantity: number; completed: boolean; }