From ec66e86a1821af1218cc1eba5de518563744a5df Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 6 Mar 2026 19:12:04 +0500 Subject: [PATCH] Persist browse view preference in localStorage; collapsible filter panel on discover page --- ui/src/routes/browse/+page.svelte | 392 +++++++++++++++++++----------- 1 file changed, 251 insertions(+), 141 deletions(-) diff --git a/ui/src/routes/browse/+page.svelte b/ui/src/routes/browse/+page.svelte index 5f7ac67..14503fe 100644 --- a/ui/src/routes/browse/+page.svelte +++ b/ui/src/routes/browse/+page.svelte @@ -119,12 +119,27 @@ const isSearchView = $derived(!!data.searchQuery); - // View toggle: 'grid' | 'list'. Default to 'list' when sort=rank (more detail). - let view = $state<'grid' | 'list'>(data.sort === 'rank' ? 'list' : 'grid'); - // Keep view in sync when sort changes via filter form. + // View toggle: 'grid' | 'list'. Persisted in localStorage. + // Rank view always uses list; otherwise restore saved preference (default: grid). + const VIEW_KEY = 'libnovel:browse:view'; + function savedView(): 'grid' | 'list' { + if (data.sort === 'rank') return 'list'; + if (typeof localStorage !== 'undefined') { + const v = localStorage.getItem(VIEW_KEY); + if (v === 'grid' || v === 'list') return v; + } + return 'grid'; + } + let view = $state<'grid' | 'list'>(savedView()); + // Keep view in sync when sort changes via filter form, and persist changes. $effect(() => { if (data.sort === 'rank' && view === 'grid') view = 'list'; }); + $effect(() => { + if (typeof localStorage !== 'undefined' && data.sort !== 'rank') { + localStorage.setItem(VIEW_KEY, view); + } + }); // Admin: per-novel scrape state (grid view) let scraping: Record = $state({}); @@ -153,6 +168,32 @@ // Admin: refresh catalogue let refreshing = $state(false); + // ── Collapsible filters panel ──────────────────────────────────────────── + let filtersOpen = $state(false); + + // Human-readable summary of active filters shown on the toggle button + const filterSummary = $derived(() => { + const parts: string[] = []; + const sortLabel = sorts.find((s) => s.value === data.sort)?.label ?? data.sort; + parts.push(sortLabel); + if (data.genre && data.genre !== 'all') { + const genreLabel = genres.find((g) => g.value === data.genre)?.label ?? data.genre; + parts.push(genreLabel); + } + if (data.status && data.status !== 'all') { + const statusLabel = statuses.find((s) => s.value === data.status)?.label ?? data.status; + parts.push(statusLabel); + } + return parts.join(' · '); + }); + + // Whether any non-default filter is active (used to show a dot indicator) + const hasActiveFilters = $derived( + (data.genre && data.genre !== 'all') || + (data.status && data.status !== 'all') || + (data.sort && data.sort !== 'popular') + ); + // ── Scroll-to-top button ───────────────────────────────────────────────── let showScrollTop = $state(false); $effect(() => { @@ -168,81 +209,25 @@ Discover — libnovel - -
-
-

Discover

-

- {#if isSearchView} - {novels.length} result{novels.length !== 1 ? 's' : ''} for "{data.searchQuery}" - {#if data.searchLocalCount > 0 || data.searchRemoteCount > 0} - ({data.searchLocalCount} local, {data.searchRemoteCount} from novelfire) - {/if} - {:else if isRankView} - {#if novels.length > 0} - {novels.length} novels ranked from last catalogue scrape - {:else} - No ranking data — run a full catalogue scrape to populate - {/if} - {:else} - Browse novels from novelfire.net + +

+

Discover

+

+ {#if isSearchView} + {novels.length} result{novels.length !== 1 ? 's' : ''} for "{data.searchQuery}" + {#if data.searchLocalCount > 0 || data.searchRemoteCount > 0} + ({data.searchLocalCount} local, {data.searchRemoteCount} from novelfire) {/if} -

- -
- -
- - -
- - - {#if data.isAdmin} -
{ - refreshing = true; - return async ({ update }) => { - await update(); - refreshing = false; - }; - }} - > - -
+ {:else if isRankView} + {#if novels.length > 0} + {novels.length} novels ranked from last catalogue scrape + {:else} + No ranking data — run a full catalogue scrape to populate + {/if} + {:else} + Browse novels from novelfire.net {/if} -
+

@@ -262,78 +247,203 @@ {/if} {/if} - -
- - - {#if data.searchQuery} - +
+ + + + + {#if data.searchQuery} + + Clear + + {/if} + + - {#if isRankView} - Genre & status filters apply to Browse only + +
+ + +
+ + + {#if data.isAdmin} +
{ + refreshing = true; + return async ({ update }) => { + await update(); + refreshing = false; + }; + }} + > + +
{/if} - +
+ + +{#if !filtersOpen && hasActiveFilters} +

+ {filterSummary()} + clear +

+{/if} + + +{#if filtersOpen} +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {#if isRankView} +

Genre & status filters apply to Browse only

+ {/if} + +
+ + {#if data.isAdmin} + { + refreshing = true; + return async ({ update }) => { + await update(); + refreshing = false; + }; + }} + class="sm:hidden mr-auto" + > + + + {/if} + + Reset + + +
+ +{/if} {#if novels.length === 0}