Add locations to todo items and automate Gitea deploy
Some checks failed
Redeploy Docker Compose / redeploy (push) Failing after 2s

This commit is contained in:
2026-03-26 18:44:33 +01:00
parent 0ec3222873
commit 124de9f5b8
8 changed files with 220 additions and 14 deletions

View File

@@ -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

View File

@@ -1,5 +1,12 @@
# Changelog # 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 ## 0.5.0 - 2026-03-26
- Added anonymous backend visit logging in SQLite: - Added anonymous backend visit logging in SQLite:
- New `visit_logs` table records timestamp, hashed visitor id, and country metadata. - New `visit_logs` table records timestamp, hashed visitor id, and country metadata.

View File

@@ -56,3 +56,21 @@ A React + TypeScript app with a local Node + SQLite backend for planning what to
```bash ```bash
npm run build 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`

View File

@@ -422,6 +422,17 @@ function getRarity(raw) {
return ''; 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) { function getItemIconUrl(iconPath, slug) {
const itemImageBase = 'https://items.marathondb.gg/images/items'; const itemImageBase = 'https://items.marathondb.gg/images/items';
const apiBase = 'https://helpbot.marathondb.gg'; const apiBase = 'https://helpbot.marathondb.gg';
@@ -554,6 +565,7 @@ function buildUpgradeResults(factionUpgrades, itemsBySlug) {
name: entry.item.name, name: entry.item.name,
iconUrl: entry.item.iconUrl, iconUrl: entry.item.iconUrl,
rarity: entry.item.rarity, rarity: entry.item.rarity,
knownLocations: entry.item.knownLocations,
})); }));
mappedLevels.push({ mappedLevels.push({
@@ -602,12 +614,14 @@ function buildCatalog(itemsPayload, factionUpgradesPayload, updatedAtMs) {
const slug = getSlug(row); const slug = getSlug(row);
const iconPath = getIconPath(row); const iconPath = getIconPath(row);
const rarity = getRarity(row); const rarity = getRarity(row);
const knownLocations = getKnownLocations(row);
return { return {
id: slug, id: slug,
slug, slug,
name, name,
iconUrl: getItemIconUrl(iconPath, slug), iconUrl: getItemIconUrl(iconPath, slug),
rarity, rarity,
knownLocations,
}; };
}) })
.filter(Boolean); .filter(Boolean);

View File

@@ -25,6 +25,18 @@ function isUpgradeResult(result: SearchResult): result is UpgradeSearchResult {
return 'isUpgrade' in result; 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({ function ItemIcon({
src, src,
alt, alt,
@@ -85,6 +97,9 @@ function loadTodoItems(): TodoItem[] {
typeof row.name === 'string' && typeof row.name === 'string' &&
typeof row.iconUrl === 'string' && typeof row.iconUrl === 'string' &&
(row.rarity === undefined || typeof row.rarity === '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.quantity === 'number' &&
typeof row.completed === 'boolean' typeof row.completed === 'boolean'
); );
@@ -92,6 +107,7 @@ function loadTodoItems(): TodoItem[] {
.map((item) => ({ .map((item) => ({
...item, ...item,
rarity: typeof item.rarity === 'string' ? item.rarity : undefined, rarity: typeof item.rarity === 'string' ? item.rarity : undefined,
knownLocations: normalizeKnownLocations(item.knownLocations),
quantity: Math.max(1, Math.floor(item.quantity)), quantity: Math.max(1, Math.floor(item.quantity)),
})); }));
} catch { } catch {
@@ -177,6 +193,7 @@ function decodeListFromUrl(encoded: string, allItems: SearchItem[]): TodoItem[]
name: match.name, name: match.name,
iconUrl: match.iconUrl, iconUrl: match.iconUrl,
rarity: match.rarity, rarity: match.rarity,
knownLocations: match.knownLocations,
quantity, quantity,
completed, completed,
}; };
@@ -212,11 +229,14 @@ export default function App() {
return; 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) => setTodoItems((prevItems) =>
prevItems.map((item) => ({ prevItems.map((item) => ({
...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); const existing = prevItems.find((entry) => entry.slug === item.slug);
if (existing) { if (existing) {
return prevItems.map((entry) => 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, name: item.name,
iconUrl: item.iconUrl, iconUrl: item.iconUrl,
rarity: item.rarity, rarity: item.rarity,
knownLocations: item.knownLocations,
quantity: 1, quantity: 1,
completed: false, completed: false,
}, },
@@ -398,6 +426,8 @@ export default function App() {
nextItems[existingIndex] = { nextItems[existingIndex] = {
...nextItems[existingIndex], ...nextItems[existingIndex],
quantity: nextItems[existingIndex].quantity + amount, quantity: nextItems[existingIndex].quantity + amount,
rarity: nextItems[existingIndex].rarity ?? salvageEntry.rarity,
knownLocations: nextItems[existingIndex].knownLocations ?? salvageEntry.knownLocations,
}; };
continue; continue;
} }
@@ -408,6 +438,7 @@ export default function App() {
name: salvageEntry.name, name: salvageEntry.name,
iconUrl: salvageEntry.iconUrl, iconUrl: salvageEntry.iconUrl,
rarity: salvageEntry.rarity, rarity: salvageEntry.rarity,
knownLocations: salvageEntry.knownLocations,
quantity: amount, quantity: amount,
completed: false, completed: false,
}); });
@@ -501,7 +532,9 @@ export default function App() {
<header> <header>
<div className="header-top"> <div className="header-top">
<div> <div>
<h1>marathon.todo</h1> <h1>
marathon.todo <span className="app-version">v0.0.1</span>
</h1>
{!minimalMode ? <p>Plan what to loot (or do) in your next Marathon raid.</p> : null} {!minimalMode ? <p>Plan what to loot (or do) in your next Marathon raid.</p> : null}
</div> </div>
{!minimalMode ? ( {!minimalMode ? (
@@ -738,12 +771,17 @@ export default function App() {
<ItemIcon src={item.iconUrl} alt="" size={37} /> <ItemIcon src={item.iconUrl} alt="" size={37} />
</span> </span>
<span <div className="item-main">
className="item-name" <span
style={getRarityGlowColor(item.rarity) ? { color: getRarityGlowColor(item.rarity)! } : undefined} className="item-name"
> style={getRarityGlowColor(item.rarity) ? { color: getRarityGlowColor(item.rarity)! } : undefined}
{item.name} >
</span> {item.name}
</span>
{item.knownLocations && item.knownLocations.length > 0 ? (
<span className="item-locations">{item.knownLocations.join(', ')}</span>
) : null}
</div>
{minimalMode ? ( {minimalMode ? (
<span className="minimal-qty">x{item.quantity}</span> <span className="minimal-qty">x{item.quantity}</span>

View File

@@ -15,10 +15,38 @@ interface PopularResponse {
let catalogPromise: Promise<CatalogResponse> | null = null; let catalogPromise: Promise<CatalogResponse> | 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 { function normalizeCatalog(payload: CatalogResponse): CatalogResponse {
const normalizedItems = payload.items.map((item) => ({
...item,
knownLocations: normalizeKnownLocations(item.knownLocations),
}));
const normalizedUpgrades = payload.upgrades.map((upgrade) => { const normalizedUpgrades = payload.upgrades.map((upgrade) => {
if (Array.isArray(upgrade.levels)) { 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'] }; const legacyUpgrade = upgrade as UpgradeSearchResult & { salvage?: UpgradeSearchResult['levels'][number]['salvage'] };
@@ -26,12 +54,21 @@ function normalizeCatalog(payload: CatalogResponse): CatalogResponse {
return { return {
...upgrade, ...upgrade,
levels: [{ level: 1, salvage }], levels: [
{
level: 1,
salvage: salvage.map((salvageItem) => ({
...salvageItem,
knownLocations: normalizeKnownLocations(salvageItem.knownLocations),
})),
},
],
}; };
}); });
return { return {
...payload, ...payload,
items: normalizedItems,
upgrades: normalizedUpgrades, upgrades: normalizedUpgrades,
}; };
} }

View File

@@ -20,7 +20,7 @@
--ui-accent: #caf61d; --ui-accent: #caf61d;
--color-text-main: #e8edf7; --color-text-main: #e8edf7;
--color-text-input: #f2f6ff; --color-text-input: #f2f6ff;
--color-text-muted: #d6dbe8; --color-text-muted: #7f828a;
--color-text-done: #8d9bbb; --color-text-done: #8d9bbb;
--color-border: #444444; --color-border: #444444;
--color-hover: #31373d; --color-hover: #31373d;
@@ -74,6 +74,11 @@ h2 {
color: var(--ui-accent); color: var(--ui-accent);
} }
.app-version {
font-size: 0.35em;
color: var(--color-text-muted);
}
label { label {
color: var(--ui-accent); color: var(--ui-accent);
} }
@@ -257,6 +262,7 @@ input[type='number'] {
justify-content: center; justify-content: center;
flex: 0 0 auto; flex: 0 0 auto;
overflow: visible; overflow: visible;
align-self: flex-start;
} }
.result-name { .result-name {
@@ -322,7 +328,7 @@ input[type='number'] {
padding: 0.65rem; padding: 0.65rem;
display: grid; display: grid;
grid-template-columns: auto 1fr auto auto; grid-template-columns: auto 1fr auto auto;
align-items: center; align-items: flex-start;
gap: 0.5rem; gap: 0.5rem;
background: var(--ui-dark-elevated); background: var(--ui-dark-elevated);
cursor: pointer; cursor: pointer;
@@ -351,6 +357,7 @@ input[type='number'] {
font-weight: 700; font-weight: 700;
min-width: 52px; min-width: 52px;
text-align: right; text-align: right;
align-self: center;
} }
.todo-item.highlighted { .todo-item.highlighted {
@@ -359,12 +366,28 @@ input[type='number'] {
.item-name { .item-name {
overflow-wrap: anywhere; 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 { .quantity-field {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
align-self: center;
} }
.quantity-field input { .quantity-field input {
@@ -400,6 +423,10 @@ input[type='number'] {
color: var(--color-text-done); color: var(--color-text-done);
} }
.todo-item.done .item-locations {
color: var(--color-text-done);
}
.todo-item.done .minimal-qty, .todo-item.done .minimal-qty,
.todo-item.done .quantity-field input { .todo-item.done .quantity-field input {
text-decoration: line-through; text-decoration: line-through;
@@ -434,6 +461,7 @@ input[type='number'] {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
align-self: center;
} }
.delete:hover { .delete:hover {

View File

@@ -4,6 +4,7 @@ export interface SearchItem {
name: string; name: string;
iconUrl: string; iconUrl: string;
rarity?: string; rarity?: string;
knownLocations?: string[];
} }
export interface UpgradeSalvageItem { export interface UpgradeSalvageItem {
@@ -12,6 +13,7 @@ export interface UpgradeSalvageItem {
name: string; name: string;
iconUrl: string; iconUrl: string;
rarity?: string; rarity?: string;
knownLocations?: string[];
} }
export interface UpgradeLevel { export interface UpgradeLevel {
@@ -38,6 +40,7 @@ export interface TodoItem {
name: string; name: string;
iconUrl: string; iconUrl: string;
rarity?: string; rarity?: string;
knownLocations?: string[];
quantity: number; quantity: number;
completed: boolean; completed: boolean;
} }