Library filters sidebar, clickable tags, remove Ready badge
Build & Deploy / build-and-deploy (push) Successful in 59s

- 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
This commit is contained in:
Hermes Agent
2026-05-28 22:56:51 +02:00
parent e15ce10f5a
commit 676e101ffc
4 changed files with 469 additions and 157 deletions
+8 -2
View File
@@ -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 @@
<main class="container">
{#if route === "/"}
<Library />
<Library genreFilter={filterGenre} yearFilter={filterYear} />
{:else if route === "/game" && gameId}
<GameDetail id={gameId} />
{:else if route === "/play" && gameId}
+1 -7
View File
@@ -28,14 +28,8 @@
<span class="genre">{game.genre}</span>
{/if}
</div>
{#if game.ready}
<div class="badge" class:badge-windows={game.platform === 'windows'}>
{#if game.platform === 'windows'}
🪟 Win
{:else}
Ready
{/if}
</div>
<div class="badge badge-windows">🪟 Win</div>
{/if}
</button>
+8 -2
View File
@@ -173,8 +173,12 @@
{#if !editing}
{#if game.year || game.genre}
<div class="meta">
{#if game.year}<span class="meta-item">{game.year}</span>{/if}
{#if game.genre}<span class="meta-item">{game.genre}</span>{/if}
{#if game.year}
<a href="#/?year={game.year}" class="meta-item meta-link">{game.year}</a>
{/if}
{#if game.genre}
<a href="#/?genre={encodeURIComponent(game.genre)}" class="meta-item meta-link">{game.genre}</a>
{/if}
</div>
{/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; }
+324 -18
View File
@@ -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,7 +197,69 @@
});
</script>
<div class="library">
<div class="library-layout">
<!-- ─── Filter Sidebar ─────────────────────────── -->
<aside class="sidebar" class:sidebar-active={hasActiveFilters()}>
<div class="sidebar-header">
<h2>Filters</h2>
{#if hasActiveFilters()}
<button class="btn btn-sm btn-reset" onclick={resetFilters}> Reset</button>
{/if}
</div>
<!-- Year filter group -->
<div class="filter-group">
<h3 class="filter-label">Year</h3>
{#if filterOptions.years.length === 0}
<p class="filter-empty"></p>
{:else}
{#each (showAllYears ? filterOptions.years : filterOptions.years.slice(0, 5)) as opt}
<label class="filter-option">
<input
type="checkbox"
checked={selectedYears.has(opt.label)}
onchange={() => selectedYears = toggleFilter(selectedYears, opt.label)}
/>
<span class="filter-text">{opt.label}</span>
<span class="filter-count">{opt.count}</span>
</label>
{/each}
{#if filterOptions.years.length > 5}
<button class="btn btn-link" onclick={() => showAllYears = !showAllYears}>
{showAllYears ? "△ Show less" : `▾ Show all (${filterOptions.years.length})`}
</button>
{/if}
{/if}
</div>
<!-- Genre filter group -->
<div class="filter-group">
<h3 class="filter-label">Genre</h3>
{#if filterOptions.genres.length === 0}
<p class="filter-empty"></p>
{:else}
{#each (showAllGenres ? filterOptions.genres : filterOptions.genres.slice(0, 5)) as opt}
<label class="filter-option">
<input
type="checkbox"
checked={selectedGenres.has(opt.label)}
onchange={() => selectedGenres = toggleFilter(selectedGenres, opt.label)}
/>
<span class="filter-text">{opt.label}</span>
<span class="filter-count">{opt.count}</span>
</label>
{/each}
{#if filterOptions.genres.length > 5}
<button class="btn btn-link" onclick={() => showAllGenres = !showAllGenres}>
{showAllGenres ? "△ Show less" : `▾ Show all (${filterOptions.genres.length})`}
</button>
{/if}
{/if}
</div>
</aside>
<!-- ─── Main Content ──────────────────────────── -->
<div class="library-main">
<div class="toolbar">
<h1>Library</h1>
<div class="toolbar-actions">
@@ -141,6 +285,22 @@
</div>
</div>
<!-- Active filter chips -->
{#if hasActiveFilters()}
<div class="active-filters">
{#each [...selectedYears] as y}
<button class="filter-chip" onclick={() => selectedYears = toggleFilter(selectedYears, y)}>
{y}
</button>
{/each}
{#each [...selectedGenres] as g}
<button class="filter-chip" onclick={() => selectedGenres = toggleFilter(selectedGenres, g)}>
{g}
</button>
{/each}
</div>
{/if}
{#if pendingFile}
<div class="upload-overlay" onclick={cancelUpload} role="presentation">
<div class="upload-dialog" onclick={(e) => e.stopPropagation()} role="dialog">
@@ -208,7 +368,7 @@
{searchError}
{#if manualSearched}
<button class="btn btn-sm btn-upload-anyway" onclick={() => doUpload(pendingTitle.trim())} disabled={uploading}>
{uploading ? "Uploading..." : "Upload anyway as \"" + pendingTitle.trim() + "\""}
{uploading ? "Uploading..." : 'Upload anyway as "' + pendingTitle.trim() + '"'}
</button>
{/if}
</div>
@@ -244,33 +404,180 @@
{#if loading}
<div class="loading">Scanning library...</div>
{:else if games.length === 0}
{:else if filteredGames.length === 0}
<div class="empty-state">
<div class="empty-icon">💾</div>
<h2>No games yet</h2>
<p>Upload a DOS game ZIP to get started</p>
<h2>{hasActiveFilters() ? "No matching games" : "No games yet"}</h2>
<p>{hasActiveFilters() ? "Try changing your filters" : "Upload a DOS game ZIP to get started"}</p>
{#if hasActiveFilters()}
<button class="btn btn-primary" style="margin-top: 16px;" onclick={resetFilters}> Clear filters</button>
{:else}
<label class="btn btn-primary" style="margin-top: 16px;">
Upload your first game
<input type="file" accept=".zip" class="hidden-input" onchange={handleFileSelected} />
</label>
{/if}
</div>
{:else}
<div class="grid">
{#each games as game (game.id)}
{#each filteredGames as game (game.id)}
<GameCard {game} />
{/each}
</div>
<div class="count">{games.length} game{games.length !== 1 ? "s" : ""}</div>
<div class="count">{filteredGames.length} game{filteredGames.length !== 1 ? "s" : ""}</div>
{/if}
</div>
</div>
<style>
.library {
/* ─── Layout ────────────────────────────────── */
.library-layout {
display: flex;
gap: 28px;
padding: 32px 0;
align-items: flex-start;
}
@media (max-width: 640px) {
.library { padding: 16px 0; }
@media (max-width: 800px) {
.library-layout { flex-direction: column; padding: 16px 0; gap: 16px; }
}
/* ─── Sidebar ────────────────────────────────── */
.sidebar {
width: 220px;
flex-shrink: 0;
position: sticky;
top: 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
max-height: calc(100vh - 120px);
overflow-y: auto;
}
@media (max-width: 800px) {
.sidebar {
width: 100%;
position: static;
max-height: none;
}
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.sidebar-header h2 {
color: var(--phosphor);
font-family: var(--font-mono);
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.filter-group {
margin-bottom: 20px;
}
.filter-label {
font-size: 0.75rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
font-weight: 600;
}
.filter-empty {
color: var(--text-dim);
font-size: 0.8rem;
font-style: italic;
}
.filter-option {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 0;
cursor: pointer;
font-size: 0.85rem;
color: var(--text);
transition: color 0.1s;
}
.filter-option:hover {
color: var(--phosphor);
}
.filter-option input[type="checkbox"] {
accent-color: var(--phosphor);
margin: 0;
flex-shrink: 0;
}
.filter-text {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.filter-count {
font-size: 0.75rem;
color: var(--text-dim);
background: var(--surface-hover);
padding: 0 6px;
border-radius: 8px;
min-width: 20px;
text-align: center;
}
.btn-link {
background: none;
border: none;
color: var(--phosphor-dim);
font-size: 0.8rem;
cursor: pointer;
padding: 4px 0;
font-family: var(--font-sans);
}
.btn-link:hover {
color: var(--phosphor);
}
.btn-reset {
border-color: #883333;
color: #ff6666;
font-size: 0.75rem;
padding: 2px 10px;
}
.btn-reset:hover {
background: #331111;
border-color: #ff4444;
}
/* ─── Active filter chips ────────────────────── */
.active-filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.filter-chip {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--phosphor-dark);
color: var(--phosphor);
border: 1px solid transparent;
border-radius: 14px;
padding: 3px 12px;
font-size: 0.8rem;
cursor: pointer;
font-family: var(--font-sans);
transition: all 0.15s;
}
.filter-chip:hover {
background: #1a4422;
border-color: var(--phosphor-dim);
}
/* ─── Main content ───────────────────────────── */
.library-main {
flex: 1;
min-width: 0;
}
.toolbar {
display: flex;
justify-content: space-between;
@@ -356,7 +663,6 @@
}
/* ─── Upload Dialog ─────────────────────────────── */
.upload-overlay {
position: fixed;
inset: 0;
@@ -516,7 +822,7 @@
border-color: #ffaa44;
}
/* ─── Upload Progress ──────────────────────────── */
/* Upload Progress spinner */
.upload-progress {
display: flex;
flex-direction: column;