From 676e101ffca5ca9731304e43d5f92837206dc63b Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 28 May 2026 22:56:51 +0200 Subject: [PATCH] Library filters sidebar, clickable tags, remove Ready badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GameCard: removed 'Ready' badge from covers (keep '๐ŸชŸ Win' for Windows games only โ€” it's informative since they can't be played) - Library: sidebar with Year + Genre checkbox filter groups - Each shows up to 5 items with game counts, 'Show all' button - Active filters shown as removable chips above the grid - Reset button clears all filters - Filters reflected in URL hash (#/?genre=FPS&year=1996) - GameDetail: year and genre tags are now clickable links to #/?year=1996 or #/?genre=FPS, which opens the Library with that filter pre-applied - App.svelte: parses query params from hash and passes to Library --- frontend/src/App.svelte | 10 +- frontend/src/lib/GameCard.svelte | 10 +- frontend/src/pages/GameDetail.svelte | 10 +- frontend/src/pages/Library.svelte | 596 ++++++++++++++++++++------- 4 files changed, 469 insertions(+), 157 deletions(-) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index eae9ebf..16d35b7 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -7,14 +7,20 @@ let route = $state("/"); let gameId = $state(null); + let filterGenre = $state(""); + let filterYear = $state(""); function parseHash() { const hash = window.location.hash.slice(1) || "/"; - const [pathPart] = hash.split("?"); // strip query params + const [pathPart, qs] = hash.split("?"); const parts = pathPart.split("/").filter(Boolean); if (parts.length === 0 || (parts.length === 1 && parts[0] === "")) { route = "/"; gameId = null; + // Parse filter query params + const params = new URLSearchParams(qs || ""); + filterGenre = params.get("genre") || ""; + filterYear = params.get("year") || ""; } else if (parts[0] === "game" && parts[1]) { route = "/game"; gameId = parts[1]; @@ -40,7 +46,7 @@
{#if route === "/"} - + {:else if route === "/game" && gameId} {:else if route === "/play" && gameId} diff --git a/frontend/src/lib/GameCard.svelte b/frontend/src/lib/GameCard.svelte index 9a72fee..a605a2e 100644 --- a/frontend/src/lib/GameCard.svelte +++ b/frontend/src/lib/GameCard.svelte @@ -28,14 +28,8 @@ {game.genre} {/if} - {#if game.ready} -
- {#if game.platform === 'windows'} - ๐ŸชŸ Win - {:else} - Ready - {/if} -
+ {#if game.platform === 'windows'} +
๐ŸชŸ Win
{/if} diff --git a/frontend/src/pages/GameDetail.svelte b/frontend/src/pages/GameDetail.svelte index 617fc97..68a01ec 100644 --- a/frontend/src/pages/GameDetail.svelte +++ b/frontend/src/pages/GameDetail.svelte @@ -173,8 +173,12 @@ {#if !editing} {#if game.year || game.genre}
- {#if game.year}{game.year}{/if} - {#if game.genre}{game.genre}{/if} + {#if game.year} + {game.year} + {/if} + {#if game.genre} + {game.genre} + {/if}
{/if} {#if game.developer} @@ -386,6 +390,8 @@ } .meta { display: flex; gap: 8px; margin-bottom: 8px; } .meta-item { background: var(--surface-hover); color: var(--phosphor-dim); padding: 4px 12px; border-radius: 12px; font-size: 0.85rem; } + .meta-link { cursor: pointer; text-decoration: none; transition: all 0.15s; display: inline-block; } + .meta-link:hover { background: var(--phosphor-dark); color: var(--phosphor); } .dev { color: var(--text-dim); margin-bottom: 16px; } .description { color: var(--text); line-height: 1.7; margin-bottom: 24px; overflow-wrap: break-word; word-break: break-word; } .actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 24px; } diff --git a/frontend/src/pages/Library.svelte b/frontend/src/pages/Library.svelte index a947998..6fe488e 100644 --- a/frontend/src/pages/Library.svelte +++ b/frontend/src/pages/Library.svelte @@ -2,6 +2,8 @@ import { listGames, uploadGame, searchIGDB } from "../lib/api.js"; import GameCard from "../lib/GameCard.svelte"; + let { genreFilter = "", yearFilter = "" } = $props(); + let games = $state([]); let loading = $state(true); let search = $state(""); @@ -14,12 +16,93 @@ let pendingTitle = $state(""); let searching = $state(false); let searchError = $state(""); - let igdbResults = $state(null); // null=loading, []=no results, [{...}]=matches - let manualSearched = $state(false); // true after user clicked Search manually - let duplicateError = $state(""); // title of duplicate game if blocked + let igdbResults = $state(null); + let manualSearched = $state(false); + let duplicateError = $state(""); + + // Filter state + let selectedGenres = $state(new Set()); + let selectedYears = $state(new Set()); + let showAllGenres = $state(false); + let showAllYears = $state(false); let fileInput; + // Derive filter options from the loaded games + let filterOptions = $derived.by(() => { + const years = {}; + const genres = {}; + for (const g of games) { + if (g.year) { + const y = String(g.year); + years[y] = (years[y] || 0) + 1; + } + if (g.genre) { + genres[g.genre] = (genres[g.genre] || 0) + 1; + } + } + return { + years: Object.entries(years) + .map(([label, count]) => ({ label, count })) + .sort((a, b) => Number(b.label) - Number(a.label)), + genres: Object.entries(genres) + .map(([label, count]) => ({ label, count })) + .sort((a, b) => a.label.localeCompare(b.label)), + }; + }); + + // Apply initial filter values from URL params + $effect(() => { + if (genreFilter) selectedGenres = new Set([genreFilter]); + if (yearFilter) selectedYears = new Set([yearFilter]); + }); + + // Update URL hash when filters change (but not on initial load) + let initialFilter = true; + $effect(() => { + if (initialFilter) { initialFilter = false; return; } + const params = new URLSearchParams(); + if (selectedGenres.size === 1) params.set("genre", [...selectedGenres][0]); + if (selectedYears.size === 1) params.set("year", [...selectedYears][0]); + const qs = params.toString(); + const base = window.location.hash.split("?")[0] || "#/"; + const newHash = qs ? base + "?" + qs : base; + if (window.location.hash !== newHash) { + window.location.hash = newHash; + } + }); + + // Filtered games + let filteredGames = $derived.by(() => { + let list = games; + if (selectedGenres.size > 0) { + list = list.filter(g => g.genre && selectedGenres.has(g.genre)); + } + if (selectedYears.size > 0) { + list = list.filter(g => g.year && selectedYears.has(String(g.year))); + } + return list; + }); + + function toggleFilter(set, value) { + const next = new Set(set); + if (next.has(value)) next.delete(value); + else next.add(value); + return next; + } + + function resetFilters() { + selectedGenres = new Set(); + selectedYears = new Set(); + showAllGenres = false; + showAllYears = false; + window.location.hash = "#/"; + } + + function hasActiveFilters() { + return selectedGenres.size > 0 || selectedYears.size > 0; + } + async function loadGames() { loading = true; try { @@ -42,8 +125,8 @@ searching = true; searchError = ""; manualSearched = false; + duplicateError = ""; fileInput.value = ""; - // Auto-search IGDB with the filename searchIGDB(name).then(data => { igdbResults = data.results || []; }).catch(err => { @@ -65,7 +148,7 @@ const data = await searchIGDB(q); igdbResults = data.results || []; if (igdbResults.length === 0) { - searchError = "No matches found for \"" + q + "\" on IGDB."; + searchError = 'No matches found for "' + q + '" on IGDB.'; } } catch (err) { searchError = "IGDB search failed: " + err.message; @@ -87,7 +170,6 @@ async function doUpload(title, igdbId) { if (!pendingFile) return; - // Block duplicates: case-insensitive title match const finalTitle = title || pendingFileName; if (finalTitle) { const dup = games.find(g => g.title?.toLowerCase() === finalTitle.toLowerCase()); @@ -115,162 +197,387 @@ }); -
-
-

Library

-
- loadGames()} - /> -
- +
+ + + + +
+
+

Library

+
+ loadGames()} + /> +
+ +
-
- {#if pendingFile} -