diff --git a/src/bggpipe/static/app.css b/src/bggpipe/static/app.css index 4536c3a..f463f21 100644 --- a/src/bggpipe/static/app.css +++ b/src/bggpipe/static/app.css @@ -460,6 +460,34 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: # .game .info { padding: .55rem .7rem .7rem; } .game .gname { font-weight: 700; font-family: var(--font-display); } .game .gmeta { font-size: .78rem; color: var(--ink-soft); margin-top: .2rem; line-height: 1.5; } +/* -- library: card grid links + one game's detail ---------------------- */ +a.game { text-decoration: none; color: inherit; } +a.game:hover { border-color: var(--accent); box-shadow: var(--shadow-raised); } +a.game:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.gamedetail { display: flex; gap: 1.4rem; align-items: flex-start; flex-wrap: wrap; } +.gameartcol { flex: 0 0 clamp(12rem, 28vw, 18rem); } +.gameart { + width: 100%; border: var(--line); border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); display: block; background: var(--board); +} +.gameart.noart { + aspect-ratio: 1; display: flex; align-items: center; justify-content: center; + background: var(--sky-deep); color: var(--navy); + font-family: var(--font-display); font-size: 3rem; font-weight: 700; +} +.gamefacts { + flex: 1 1 18rem; background: var(--board); border: var(--line); + border-radius: var(--radius-lg); box-shadow: var(--shadow-card); + padding: .9rem 1.1rem; +} +.factrow { margin: .45rem 0; line-height: 1.5; } +.factrow b { + font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; + color: var(--ink-soft); margin-right: .4rem; +} +.chiplist { display: inline-flex; flex-wrap: wrap; gap: .3rem; vertical-align: middle; } +.gdesc { white-space: pre-wrap; line-height: 1.6; } + .empty { background: var(--board); border: 2px dashed var(--board-edge); border-radius: var(--radius-lg); padding: 2rem; text-align: center; diff --git a/src/bggpipe/templates/pages/help.html b/src/bggpipe/templates/pages/help.html index cf4150c..248c85d 100644 --- a/src/bggpipe/templates/pages/help.html +++ b/src/bggpipe/templates/pages/help.html @@ -30,7 +30,7 @@

Titles — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: edit, split, remove. Its badge counts shaky read lines — the model wasn't sure and nothing has verified them; filter to them, then press ✓ looks right or edit each one.

Review — the decisions only you can make: which game a title is, which edition a copy is, whether two same-game reads are really one box (merges show a veto), and whether an unmatched title is a real game BGG simply doesn't have (keep locally: it joins the Library, never uploads). Keyboard-first; see shortcuts.

Queue — exactly what upload will do (new entries and version upgrades) and the log of everything it has done. Nothing reaches BGG that isn't visible here first. A job that fails is skipped by later runs (so one broken game can't loop forever); when any exist, the Pipeline's upload card offers a retry N failed checkbox. Each queued row shows what upload did with it — pending, done, failed, or retired (a review decision since the last diff withdrew it). Finished rows stay listed until the next diff rebuilds the queue; the log below them is the permanent record.

-

Library — your enriched collection: filter by board games or RPGs. RPG matches are identified and enriched but never uploaded — BGG collections can't hold them, so they stay local citizens.

+

Library — your enriched collection. Search titles, designers, mechanics and categories at once; filter by kind (board games, RPGs, off-BGG) or by how many people are playing tonight; sort by name, year, BGG rank, weight, or playing time. Click any game for its full detail: art, the usual stats, designers and mechanics, your edition, the shelf photos it was read from, and a link to its BGG page. RPG and off-BGG games live here too — identified and enriched, never uploaded.

Fixing the titles: edit, split, remove

diff --git a/src/bggpipe/templates/pages/library.html b/src/bggpipe/templates/pages/library.html index d70bedd..5295077 100644 --- a/src/bggpipe/templates/pages/library.html +++ b/src/bggpipe/templates/pages/library.html @@ -1,10 +1,25 @@

Library

- + + +
+
+ +
diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index 4d9c1f0..4f46455 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -723,6 +723,11 @@ def create_app( def library_page() -> str: return render_page("library") + @app.get("/library/game/{key:path}", response_class=HTMLResponse) + def library_game_page(key: str) -> str: + # the fragment reads the key from its own URL; nothing interpolated + return render_page("librarygame", active="library") + @app.get("/help", response_class=HTMLResponse) def help_page() -> str: return render_page("help") @@ -781,10 +786,51 @@ def create_app( "log": log, } + def library_entries() -> dict[str, dict]: + """games.json plus what only the pipeline knows: the entry's key and + the photos the game was read from (its provenance back to a shelf).""" + games = read_games() + available = photo_names() + photos_by_key: dict[str, list[str]] = {} + for row in session.rows: + if not row["bgg_id"]: + continue + key = ( + f"{row['bgg_id']}:{row['version_id']}" + if row["version_id"] + else row["bgg_id"] + ) + photos_by_key.setdefault(key, []).extend( + p for p in row["source_photos"].split(";") if p + ) + out = {} + for key, game in games.items(): + photos = game.get("source_photos") or photos_by_key.get(key, []) + out[key] = { + **game, + "key": key, + "photos": [p for p in dict.fromkeys(photos) if p in available], + } + return out + @app.get("/api/library") def api_library() -> list[dict]: - games = read_games() - return sorted(games.values(), key=lambda g: (g.get("name") or "").casefold()) + # the list view never needs the description: it is by far the + # largest field, and 136 of them is a megabyte of dead weight + return sorted( + ( + {k: v for k, v in g.items() if k != "description"} + for g in library_entries().values() + ), + key=lambda g: (g.get("name") or "").casefold(), + ) + + @app.get("/api/library/{key:path}") + def api_library_game(key: str) -> dict: + game = library_entries().get(key) + if game is None: + raise HTTPException(404, "no such game in the library") + return game def _pending(path: Path, action: str, log_rows: list[dict]) -> int: from bggpipe.upload import annotate_queue diff --git a/tests/test_webreview.py b/tests/test_webreview.py index 27aa0f5..881ff35 100644 --- a/tests/test_webreview.py +++ b/tests/test_webreview.py @@ -1243,3 +1243,48 @@ def test_same_title_lines_pair_rows_by_photos_not_csv_order(tmp_path): } assert lines[("a.jpg",)]["version_status"] == "version_unknown" assert lines[("b.jpg",)]["version_status"] == "version_ambiguous" + + +def test_library_detail_serves_one_game_with_provenance(tmp_path): + cfg = make_cfg(tmp_path) + rows = read_matches(cfg.matches_path) + rows.append( + _row( + title_raw="Britannia", + match_status="auto", + bgg_id="240", + bgg_name="Britannia", + version_id="24621", + source_photos="shelf.jpg", + ) + ) + write_matches(cfg.matches_path, rows) + cfg.games_path.write_text( + json.dumps( + { + "240:24621": { + "bgg_id": 240, + "name": "Britannia", + "year": 1986, + "type": "boardgame", + "description": "A long description.", + "version": {"version_id": 24621, "name": "Avalon Hill second"}, + } + } + ) + ) + web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path))) + + (listed,) = web.get("/api/library").json() + assert listed["key"] == "240:24621" + assert "description" not in listed # the list view stays light + + detail = web.get("/api/library/240:24621").json() + assert detail["name"] == "Britannia" + assert detail["description"] == "A long description." + # provenance the pipeline knows and games.json doesn't: the shelf photo + assert detail["photos"] == ["shelf.jpg"] + + assert web.get("/api/library/nope").status_code == 404 + page = web.get("/library/game/240:24621") + assert page.status_code == 200 and 'href="/library"' in page.text