Add locations to todo items and automate Gitea deploy
Some checks failed
Redeploy Docker Compose / redeploy (push) Failing after 2s
Some checks failed
Redeploy Docker Compose / redeploy (push) Failing after 2s
This commit is contained in:
61
.gitea/workflows/redeploy-compose.yml
Normal file
61
.gitea/workflows/redeploy-compose.yml
Normal 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
|
||||
@@ -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.
|
||||
|
||||
18
README.md
18
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`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
<header>
|
||||
<div className="header-top">
|
||||
<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}
|
||||
</div>
|
||||
{!minimalMode ? (
|
||||
@@ -738,12 +771,17 @@ export default function App() {
|
||||
<ItemIcon src={item.iconUrl} alt="" size={37} />
|
||||
</span>
|
||||
|
||||
<div className="item-main">
|
||||
<span
|
||||
className="item-name"
|
||||
style={getRarityGlowColor(item.rarity) ? { color: getRarityGlowColor(item.rarity)! } : undefined}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
{item.knownLocations && item.knownLocations.length > 0 ? (
|
||||
<span className="item-locations">{item.knownLocations.join(', ')}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{minimalMode ? (
|
||||
<span className="minimal-qty">x{item.quantity}</span>
|
||||
|
||||
@@ -15,10 +15,38 @@ interface PopularResponse {
|
||||
|
||||
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 {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user