{
@@ -105,4 +150,12 @@ export const EMPTY_GAME: GameRequest = {
dumped: false,
played: false,
finished: false,
+ rating: null,
+ notes: null,
+ condition: 'Unspecified',
+ region: 'Unspecified',
+ purchasePrice: null,
+ purchaseDate: null,
+ marketValue: null,
+ marketValueSource: null,
};
diff --git a/frontend/src/app/features/game-edit/game-edit.html b/frontend/src/app/features/game-edit/game-edit.html
index 380784d..1f8d42b 100644
--- a/frontend/src/app/features/game-edit/game-edit.html
+++ b/frontend/src/app/features/game-edit/game-edit.html
@@ -116,6 +116,79 @@
Finished
+
+
Cancel
diff --git a/frontend/src/app/features/game-edit/game-edit.scss b/frontend/src/app/features/game-edit/game-edit.scss
index 20c650e..63de03c 100644
--- a/frontend/src/app/features/game-edit/game-edit.scss
+++ b/frontend/src/app/features/game-edit/game-edit.scss
@@ -150,3 +150,19 @@
margin: 0 0 0.5rem;
}
}
+
+.collector {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ border: 1px solid var(--mat-sys-outline-variant);
+ border-radius: 0.5rem;
+ padding: 0.75rem 1rem 0.5rem;
+ margin: 0 0 1rem;
+
+ legend {
+ padding-inline: 0.375rem;
+ font-size: 0.8125rem;
+ color: var(--mat-sys-on-surface-variant);
+ }
+}
diff --git a/frontend/src/app/features/game-edit/game-edit.ts b/frontend/src/app/features/game-edit/game-edit.ts
index 07e9e93..2f42000 100644
--- a/frontend/src/app/features/game-edit/game-edit.ts
+++ b/frontend/src/app/features/game-edit/game-edit.ts
@@ -1,9 +1,12 @@
+import { DatePipe } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import { Component, computed, inject, input, signal } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatDatepickerModule } from '@angular/material/datepicker';
+import { provideNativeDateAdapter } from '@angular/material/core';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
@@ -15,7 +18,13 @@ import { Router, RouterLink } from '@angular/router';
import { ConfirmDialog, ConfirmDialogData } from '../../shared/confirm-dialog';
import { GamesService } from '../../core/games.service';
-import { GameRequest } from '../../core/models';
+import {
+ CONDITIONS,
+ GameCondition,
+ GameRegion,
+ GameRequest,
+ REGIONS,
+} from '../../core/models';
import { Toolbar } from '../../shared/toolbar';
/** Kept in sync with the values already present in the 2018 data. */
@@ -44,7 +53,10 @@ const GENRES = [
MatIconModule,
MatProgressBarModule,
MatDialogModule,
+ MatDatepickerModule,
+ DatePipe,
],
+ providers: [provideNativeDateAdapter()],
templateUrl: './game-edit.html',
styleUrl: './game-edit.scss',
})
@@ -70,6 +82,9 @@ export class GameEdit {
/** Preview URL: a freshly-picked local file, or the stored art from the API. */
protected readonly artPreview = signal
(null);
+ protected readonly conditions = CONDITIONS;
+ protected readonly regions = REGIONS;
+
protected readonly form = this.fb.group({
title: ['', [Validators.required, Validators.maxLength(200)]],
system: [''],
@@ -83,8 +98,20 @@ export class GameEdit {
dumped: [false],
played: [false],
finished: [false],
+
+ rating: [null as number | null, [Validators.min(1), Validators.max(10)]],
+ notes: [''],
+ condition: ['Unspecified' as GameCondition],
+ region: ['Unspecified' as GameRegion],
+ purchasePrice: [null as number | null, Validators.min(0)],
+ purchaseDate: [null as Date | null],
+ marketValue: [null as number | null, Validators.min(0)],
+ marketValueSource: [''],
});
+ /** When the loaded valuation was captured, for the "as of" note. */
+ protected readonly valuedAt = signal(null);
+
constructor() {
// input() is a signal, so this reacts if the route id ever changes without
// the component being torn down.
@@ -113,8 +140,17 @@ export class GameEdit {
dumped: game.dumped,
played: game.played,
finished: game.finished,
+ rating: game.rating,
+ notes: game.notes ?? '',
+ condition: game.condition,
+ region: game.region,
+ purchasePrice: game.purchasePrice,
+ purchaseDate: game.purchaseDate ? new Date(game.purchaseDate) : null,
+ marketValue: game.marketValue,
+ marketValueSource: game.marketValueSource ?? '',
});
this.artPreview.set(game.artUrl);
+ this.valuedAt.set(game.marketValueUpdatedAt);
this.loading.set(false);
},
error: () => {
@@ -243,6 +279,27 @@ export class GameEdit {
dumped: value.dumped,
played: value.played,
finished: value.finished,
+
+ rating: value.rating ?? null,
+ notes: blankToNull(value.notes),
+ condition: value.condition,
+ region: value.region,
+ purchasePrice: value.purchasePrice ?? null,
+ // The date picker holds a local Date; send the calendar day only, so a
+ // timezone west of UTC cannot shift the purchase back a day.
+ purchaseDate: value.purchaseDate ? toIsoDate(value.purchaseDate) : null,
+ marketValue: value.marketValue ?? null,
+ marketValueSource: blankToNull(value.marketValueSource),
};
}
}
+
+/**
+ * Formats a picked date as a plain calendar day. toISOString() would convert to
+ * UTC first, which moves the date back a day for anyone west of Greenwich.
+ */
+function toIsoDate(date: Date): string {
+ const month = `${date.getMonth() + 1}`.padStart(2, '0');
+ const day = `${date.getDate()}`.padStart(2, '0');
+ return `${date.getFullYear()}-${month}-${day}`;
+}
diff --git a/frontend/src/app/features/game-grid/game-grid.html b/frontend/src/app/features/game-grid/game-grid.html
index 203a106..ab8fb5c 100644
--- a/frontend/src/app/features/game-grid/game-grid.html
+++ b/frontend/src/app/features/game-grid/game-grid.html
@@ -58,6 +58,10 @@
System
Genre
Year
+ Rating
+ Market value
+ Paid
+ Purchase date
Date added
Last updated
@@ -108,6 +112,15 @@
videogame_asset
}
+
+ @if (game.rating) {
+
+ star{{ game.rating }}
+
+ }
+ @if (game.marketValue !== null) {
+ {{ game.marketValue | currency: 'USD' : 'symbol' : '1.0-0' }}
+ }
diff --git a/frontend/src/app/features/game-grid/game-grid.scss b/frontend/src/app/features/game-grid/game-grid.scss
index 6053b14..27641cc 100644
--- a/frontend/src/app/features/game-grid/game-grid.scss
+++ b/frontend/src/app/features/game-grid/game-grid.scss
@@ -183,3 +183,42 @@
mat-paginator {
background: transparent;
}
+
+/* Rating and value sit over the art so a card stays the same height whether
+ or not they are set. */
+.game-card .art {
+ position: relative;
+}
+
+.game-card .rating,
+.game-card .value {
+ position: absolute;
+ top: 0.375rem;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.125rem;
+ padding: 0.125rem 0.375rem;
+ border-radius: 0.75rem;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ line-height: 1.4;
+ /* Legible over any box art, light or dark. */
+ background: rgb(0 0 0 / 0.72);
+ color: #fff;
+ backdrop-filter: blur(2px);
+}
+
+.game-card .rating {
+ left: 0.375rem;
+
+ mat-icon {
+ font-size: 0.75rem;
+ width: 0.75rem;
+ height: 0.75rem;
+ color: #ffc93c;
+ }
+}
+
+.game-card .value {
+ right: 0.375rem;
+}
diff --git a/frontend/src/app/features/game-grid/game-grid.ts b/frontend/src/app/features/game-grid/game-grid.ts
index d6d1073..20d27ce 100644
--- a/frontend/src/app/features/game-grid/game-grid.ts
+++ b/frontend/src/app/features/game-grid/game-grid.ts
@@ -1,3 +1,4 @@
+import { CurrencyPipe } from '@angular/common';
import { Component, computed, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
@@ -17,7 +18,18 @@ import { GamesService } from '../../core/games.service';
import { Facets, Game } from '../../core/models';
import { Toolbar } from '../../shared/toolbar';
-type SortKey = 'title' | 'system' | 'genre' | 'year' | 'created' | 'updated';
+/** Must stay in step with the allow-list in GamesController.ApplySort. */
+type SortKey =
+ | 'title'
+ | 'system'
+ | 'genre'
+ | 'year'
+ | 'rating'
+ | 'value'
+ | 'price'
+ | 'purchased'
+ | 'created'
+ | 'updated';
@Component({
selector: 'app-game-grid',
@@ -34,6 +46,7 @@ type SortKey = 'title' | 'system' | 'genre' | 'year' | 'created' | 'updated';
MatPaginatorModule,
MatProgressBarModule,
MatChipsModule,
+ CurrencyPipe,
],
templateUrl: './game-grid.html',
styleUrl: './game-grid.scss',
diff --git a/tools/library/__pycache__/fetch_art.cpython-314.pyc b/tools/library/__pycache__/fetch_art.cpython-314.pyc
index 879d252..0fb2661 100644
Binary files a/tools/library/__pycache__/fetch_art.cpython-314.pyc and b/tools/library/__pycache__/fetch_art.cpython-314.pyc differ
diff --git a/tools/library/enrich_metadata.py b/tools/library/enrich_metadata.py
index 2151e22..5d50d72 100644
--- a/tools/library/enrich_metadata.py
+++ b/tools/library/enrich_metadata.py
@@ -29,7 +29,8 @@ from pathlib import Path
# The cover fetcher already owns the throttled, cached Wikipedia client.
from fetch_art import (
- WIKI_UA, api_json, http, is_game_article, normalise, wiki_api, wiki_article,
+ WIKI_UA, api_json, http, is_game_article, normalise, to_update_payload,
+ wiki_api, wiki_article,
)
FIELDS = ("developer", "publisher", "year", "description")
@@ -400,10 +401,7 @@ def main() -> int:
if args.dry_run:
continue
- payload = {k: game.get(k) for k in (
- "title", "system", "genre", "year", "developer", "publisher",
- "art", "description", "own", "dumped", "played", "finished")}
- payload.update(updates)
+ payload = to_update_payload(game, **updates)
try:
api_json(base, f"/api/games/{game['id']}", token, data=payload, method="PUT")
diff --git a/tools/library/fetch_art.py b/tools/library/fetch_art.py
index 363d5e2..8aab684 100644
--- a/tools/library/fetch_art.py
+++ b/tools/library/fetch_art.py
@@ -165,6 +165,23 @@ def http(url: str, *, data=None, headers=None, method=None, timeout=90) -> bytes
raise RuntimeError(f"GET {url} failed after 4 attempts: {last_error}")
+# Fields the server derives; everything else on a game round-trips through PUT.
+# Listing what to DROP rather than what to KEEP is deliberate: with a keep-list,
+# any column added to the model later is silently omitted from the payload and
+# therefore nulled on every update. That is exactly how an earlier version of
+# these tools erased ratings, notes and valuations across a whole library.
+SERVER_OWNED_FIELDS = frozenset({
+ "id", "artUrl", "createdAt", "updatedAt", "marketValueUpdatedAt",
+})
+
+
+def to_update_payload(game: dict, **overrides) -> dict:
+ """A full-object PUT body for a game, preserving fields we do not touch."""
+ payload = {k: v for k, v in game.items() if k not in SERVER_OWNED_FIELDS}
+ payload.update(overrides)
+ return payload
+
+
def api_json(base: str, path: str, token: str | None = None, *, data=None, method=None):
headers = {"Accept": "application/json"}
if token:
@@ -463,11 +480,8 @@ def main() -> int:
def attach(game: dict, blob: bytes, filename: str) -> None:
"""Upload the image and point the game at it."""
uploaded = upload_image(base, token, filename, blob)
- payload = {k: game.get(k) for k in (
- "title", "system", "genre", "year", "developer", "publisher",
- "description", "own", "dumped", "played", "finished")}
- payload["art"] = uploaded["fileName"]
- api_json(base, f"/api/games/{game['id']}", token, data=payload, method="PUT")
+ api_json(base, f"/api/games/{game['id']}", token,
+ data=to_update_payload(game, art=uploaded["fileName"]), method="PUT")
for game in sorted(games, key=lambda g: g["title"].lower()):
title, system = game["title"], game["system"]
@@ -483,7 +497,7 @@ def main() -> int:
if candidate and score >= args.min_score:
flag = " " if score >= 0.95 else "~"
- print(f" {flag} {system:4} {title[:46]:48} {score:.2f} {candidate.filename[:50]}")
+ print(f" {flag} {system or '-':4} {title[:46]:48} {score:.2f} {candidate.filename[:50]}")
if score < 0.95:
weak.append((game, candidate.filename, score))
@@ -503,7 +517,7 @@ def main() -> int:
# --- pass 2: Wikipedia, for anything libretro does not carry --------
if args.no_wikipedia:
- print(f" ? {system:4} {title[:46]:48} no match")
+ print(f" ? {system or '-':4} {title[:46]:48} no match")
unmatched.append(game)
failed += 1
continue
@@ -511,17 +525,17 @@ def main() -> int:
try:
found = wiki_cover_url(game, cache_dir)
except Exception as exc: # noqa: BLE001
- print(f" ! {system:4} {title[:46]:48} wikipedia error: {exc}")
+ print(f" ! {system or '-':4} {title[:46]:48} wikipedia error: {exc}")
found = None
if not found:
- print(f" ? {system:4} {title[:46]:48} no match")
+ print(f" ? {system or '-':4} {title[:46]:48} no match")
unmatched.append(game)
failed += 1
continue
url, source = found
- print(f" W {system:4} {title[:46]:48} {source[:50]}")
+ print(f" W {system or '-':4} {title[:46]:48} {source[:50]}")
if args.dry_run:
applied += 1