Library filters sidebar, clickable tags, remove Ready badge
Build & Deploy / build-and-deploy (push) Successful in 59s
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:
@@ -7,14 +7,20 @@
|
|||||||
|
|
||||||
let route = $state("/");
|
let route = $state("/");
|
||||||
let gameId = $state(null);
|
let gameId = $state(null);
|
||||||
|
let filterGenre = $state("");
|
||||||
|
let filterYear = $state("");
|
||||||
|
|
||||||
function parseHash() {
|
function parseHash() {
|
||||||
const hash = window.location.hash.slice(1) || "/";
|
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);
|
const parts = pathPart.split("/").filter(Boolean);
|
||||||
if (parts.length === 0 || (parts.length === 1 && parts[0] === "")) {
|
if (parts.length === 0 || (parts.length === 1 && parts[0] === "")) {
|
||||||
route = "/";
|
route = "/";
|
||||||
gameId = null;
|
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]) {
|
} else if (parts[0] === "game" && parts[1]) {
|
||||||
route = "/game";
|
route = "/game";
|
||||||
gameId = parts[1];
|
gameId = parts[1];
|
||||||
@@ -40,7 +46,7 @@
|
|||||||
|
|
||||||
<main class="container">
|
<main class="container">
|
||||||
{#if route === "/"}
|
{#if route === "/"}
|
||||||
<Library />
|
<Library genreFilter={filterGenre} yearFilter={filterYear} />
|
||||||
{:else if route === "/game" && gameId}
|
{:else if route === "/game" && gameId}
|
||||||
<GameDetail id={gameId} />
|
<GameDetail id={gameId} />
|
||||||
{:else if route === "/play" && gameId}
|
{:else if route === "/play" && gameId}
|
||||||
|
|||||||
@@ -28,14 +28,8 @@
|
|||||||
<span class="genre">{game.genre}</span>
|
<span class="genre">{game.genre}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if game.ready}
|
|
||||||
<div class="badge" class:badge-windows={game.platform === 'windows'}>
|
|
||||||
{#if game.platform === 'windows'}
|
{#if game.platform === 'windows'}
|
||||||
🪟 Win
|
<div class="badge badge-windows">🪟 Win</div>
|
||||||
{:else}
|
|
||||||
Ready
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -173,8 +173,12 @@
|
|||||||
{#if !editing}
|
{#if !editing}
|
||||||
{#if game.year || game.genre}
|
{#if game.year || game.genre}
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
{#if game.year}<span class="meta-item">{game.year}</span>{/if}
|
{#if game.year}
|
||||||
{#if game.genre}<span class="meta-item">{game.genre}</span>{/if}
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if game.developer}
|
{#if game.developer}
|
||||||
@@ -386,6 +390,8 @@
|
|||||||
}
|
}
|
||||||
.meta { display: flex; gap: 8px; margin-bottom: 8px; }
|
.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-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; }
|
.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; }
|
.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; }
|
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 24px; }
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { listGames, uploadGame, searchIGDB } from "../lib/api.js";
|
import { listGames, uploadGame, searchIGDB } from "../lib/api.js";
|
||||||
import GameCard from "../lib/GameCard.svelte";
|
import GameCard from "../lib/GameCard.svelte";
|
||||||
|
|
||||||
|
let { genreFilter = "", yearFilter = "" } = $props();
|
||||||
|
|
||||||
let games = $state([]);
|
let games = $state([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let search = $state("");
|
let search = $state("");
|
||||||
@@ -14,12 +16,93 @@
|
|||||||
let pendingTitle = $state("");
|
let pendingTitle = $state("");
|
||||||
let searching = $state(false);
|
let searching = $state(false);
|
||||||
let searchError = $state("");
|
let searchError = $state("");
|
||||||
let igdbResults = $state(null); // null=loading, []=no results, [{...}]=matches
|
let igdbResults = $state(null);
|
||||||
let manualSearched = $state(false); // true after user clicked Search manually
|
let manualSearched = $state(false);
|
||||||
let duplicateError = $state(""); // title of duplicate game if blocked
|
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;
|
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() {
|
async function loadGames() {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
@@ -42,8 +125,8 @@
|
|||||||
searching = true;
|
searching = true;
|
||||||
searchError = "";
|
searchError = "";
|
||||||
manualSearched = false;
|
manualSearched = false;
|
||||||
|
duplicateError = "";
|
||||||
fileInput.value = "";
|
fileInput.value = "";
|
||||||
// Auto-search IGDB with the filename
|
|
||||||
searchIGDB(name).then(data => {
|
searchIGDB(name).then(data => {
|
||||||
igdbResults = data.results || [];
|
igdbResults = data.results || [];
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
@@ -65,7 +148,7 @@
|
|||||||
const data = await searchIGDB(q);
|
const data = await searchIGDB(q);
|
||||||
igdbResults = data.results || [];
|
igdbResults = data.results || [];
|
||||||
if (igdbResults.length === 0) {
|
if (igdbResults.length === 0) {
|
||||||
searchError = "No matches found for \"" + q + "\" on IGDB.";
|
searchError = 'No matches found for "' + q + '" on IGDB.';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
searchError = "IGDB search failed: " + err.message;
|
searchError = "IGDB search failed: " + err.message;
|
||||||
@@ -87,7 +170,6 @@
|
|||||||
async function doUpload(title, igdbId) {
|
async function doUpload(title, igdbId) {
|
||||||
if (!pendingFile) return;
|
if (!pendingFile) return;
|
||||||
|
|
||||||
// Block duplicates: case-insensitive title match
|
|
||||||
const finalTitle = title || pendingFileName;
|
const finalTitle = title || pendingFileName;
|
||||||
if (finalTitle) {
|
if (finalTitle) {
|
||||||
const dup = games.find(g => g.title?.toLowerCase() === finalTitle.toLowerCase());
|
const dup = games.find(g => g.title?.toLowerCase() === finalTitle.toLowerCase());
|
||||||
@@ -115,7 +197,69 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</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">
|
<div class="toolbar">
|
||||||
<h1>Library</h1>
|
<h1>Library</h1>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
@@ -141,6 +285,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</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}
|
{#if pendingFile}
|
||||||
<div class="upload-overlay" onclick={cancelUpload} role="presentation">
|
<div class="upload-overlay" onclick={cancelUpload} role="presentation">
|
||||||
<div class="upload-dialog" onclick={(e) => e.stopPropagation()} role="dialog">
|
<div class="upload-dialog" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||||
@@ -208,7 +368,7 @@
|
|||||||
{searchError}
|
{searchError}
|
||||||
{#if manualSearched}
|
{#if manualSearched}
|
||||||
<button class="btn btn-sm btn-upload-anyway" onclick={() => doUpload(pendingTitle.trim())} disabled={uploading}>
|
<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>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -244,33 +404,180 @@
|
|||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading">Scanning library...</div>
|
<div class="loading">Scanning library...</div>
|
||||||
{:else if games.length === 0}
|
{:else if filteredGames.length === 0}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-icon">💾</div>
|
<div class="empty-icon">💾</div>
|
||||||
<h2>No games yet</h2>
|
<h2>{hasActiveFilters() ? "No matching games" : "No games yet"}</h2>
|
||||||
<p>Upload a DOS game ZIP to get started</p>
|
<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;">
|
<label class="btn btn-primary" style="margin-top: 16px;">
|
||||||
Upload your first game
|
Upload your first game
|
||||||
<input type="file" accept=".zip" class="hidden-input" onchange={handleFileSelected} />
|
<input type="file" accept=".zip" class="hidden-input" onchange={handleFileSelected} />
|
||||||
</label>
|
</label>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each games as game (game.id)}
|
{#each filteredGames as game (game.id)}
|
||||||
<GameCard {game} />
|
<GameCard {game} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="count">{games.length} game{games.length !== 1 ? "s" : ""}</div>
|
<div class="count">{filteredGames.length} game{filteredGames.length !== 1 ? "s" : ""}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.library {
|
/* ─── Layout ────────────────────────────────── */
|
||||||
|
.library-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 28px;
|
||||||
padding: 32px 0;
|
padding: 32px 0;
|
||||||
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 800px) {
|
||||||
.library { padding: 16px 0; }
|
.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 {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -356,7 +663,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Upload Dialog ─────────────────────────────── */
|
/* ─── Upload Dialog ─────────────────────────────── */
|
||||||
|
|
||||||
.upload-overlay {
|
.upload-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -516,7 +822,7 @@
|
|||||||
border-color: #ffaa44;
|
border-color: #ffaa44;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Upload Progress ──────────────────────────── */
|
/* Upload Progress spinner */
|
||||||
.upload-progress {
|
.upload-progress {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user