feat(catalogue): UX improvements and bug fixes
All checks were successful
All checks were successful
- Persist audio filter in URL (?audio=1) via history.replaceState - Show total novel count in browse mode subtitle - Close filter panel automatically on Apply - Replace missing-cover SVG placeholder with styled first-letter avatar - Add forbidden scrape badge to list view (was missing, grid had it) - Carry audio param through applyFilters() navigation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
|||||||
const sort = url.searchParams.get('sort') ?? 'popular';
|
const sort = url.searchParams.get('sort') ?? 'popular';
|
||||||
const status = url.searchParams.get('status') ?? 'all';
|
const status = url.searchParams.get('status') ?? 'all';
|
||||||
const q = url.searchParams.get('q') ?? '';
|
const q = url.searchParams.get('q') ?? '';
|
||||||
|
const audioOnly = url.searchParams.get('audio') === '1';
|
||||||
|
|
||||||
const params = new URLSearchParams({ page, genre, sort, status });
|
const params = new URLSearchParams({ page, genre, sort, status });
|
||||||
if (q.trim().length >= 2) {
|
if (q.trim().length >= 2) {
|
||||||
@@ -64,7 +65,8 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
|||||||
isAdmin: locals.user?.role === 'admin',
|
isAdmin: locals.user?.role === 'admin',
|
||||||
searchQuery: q.trim().length >= 2 ? q.trim() : '',
|
searchQuery: q.trim().length >= 2 ? q.trim() : '',
|
||||||
searchLocalCount: 0,
|
searchLocalCount: 0,
|
||||||
searchRemoteCount: 0
|
searchRemoteCount: 0,
|
||||||
|
audioOnly
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
|
filtersOpen = false;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('sort', filterSort);
|
params.set('sort', filterSort);
|
||||||
params.set('genre', filterGenre);
|
params.set('genre', filterGenre);
|
||||||
params.set('status', filterStatus);
|
params.set('status', filterStatus);
|
||||||
params.set('page', '1');
|
params.set('page', '1');
|
||||||
|
if (filterAudioOnly) params.set('audio', '1');
|
||||||
goto(`/catalogue?${params.toString()}`);
|
goto(`/catalogue?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +217,7 @@
|
|||||||
|
|
||||||
// ── Audio-available set ───────────────────────────────────────────────────
|
// ── Audio-available set ───────────────────────────────────────────────────
|
||||||
let audioSlugs = $state<Set<string>>(new Set());
|
let audioSlugs = $state<Set<string>>(new Set());
|
||||||
let filterAudioOnly = $state(false);
|
let filterAudioOnly = $state(untrack(() => data.audioOnly));
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
fetch('/api/audio/slugs')
|
fetch('/api/audio/slugs')
|
||||||
@@ -224,6 +226,17 @@
|
|||||||
.catch(() => { /* non-critical */ });
|
.catch(() => { /* non-critical */ });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function toggleAudio() {
|
||||||
|
filterAudioOnly = !filterAudioOnly;
|
||||||
|
const u = new URL(window.location.href);
|
||||||
|
if (filterAudioOnly) {
|
||||||
|
u.searchParams.set('audio', '1');
|
||||||
|
} else {
|
||||||
|
u.searchParams.delete('audio');
|
||||||
|
}
|
||||||
|
history.replaceState({}, '', u.toString());
|
||||||
|
}
|
||||||
|
|
||||||
const displayedNovels = $derived(
|
const displayedNovels = $derived(
|
||||||
filterAudioOnly ? novels.filter((n) => audioSlugs.has(n.slug)) : novels
|
filterAudioOnly ? novels.filter((n) => audioSlugs.has(n.slug)) : novels
|
||||||
);
|
);
|
||||||
@@ -249,7 +262,7 @@
|
|||||||
{m.catalogue_rank_no_data_body()}
|
{m.catalogue_rank_no_data_body()}
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
{m.catalogue_browse_source()}
|
{m.catalogue_browse_source()}{#if data.total > 0} <span class="text-(--color-muted) text-xs">{data.total.toLocaleString()} novels</span>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -349,7 +362,7 @@
|
|||||||
<!-- Audio-only filter toggle -->
|
<!-- Audio-only filter toggle -->
|
||||||
{#if audioSlugs.size > 0}
|
{#if audioSlugs.size > 0}
|
||||||
<button
|
<button
|
||||||
onclick={() => (filterAudioOnly = !filterAudioOnly)}
|
onclick={toggleAudio}
|
||||||
title="Show only books with audio"
|
title="Show only books with audio"
|
||||||
class="flex items-center gap-1.5 px-2.5 py-2 rounded border text-sm transition-colors shrink-0
|
class="flex items-center gap-1.5 px-2.5 py-2 rounded border text-sm transition-colors shrink-0
|
||||||
{filterAudioOnly
|
{filterAudioOnly
|
||||||
@@ -503,7 +516,7 @@
|
|||||||
{m.catalogue_rank_run_scrape_user()}
|
{m.catalogue_rank_run_scrape_user()}
|
||||||
{/if}
|
{/if}
|
||||||
{:else if filterAudioOnly}
|
{:else if filterAudioOnly}
|
||||||
<button onclick={() => (filterAudioOnly = false)} class="text-(--color-brand) hover:underline">Clear audio filter</button>
|
<button onclick={toggleAudio} class="text-(--color-brand) hover:underline">Clear audio filter</button>
|
||||||
{:else}
|
{:else}
|
||||||
{m.catalogue_no_results_filters()}
|
{m.catalogue_no_results_filters()}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -531,11 +544,8 @@
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
|
<div class="w-full h-full flex items-center justify-center bg-(--color-surface-3)">
|
||||||
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<span class="text-5xl font-bold text-(--color-muted) select-none opacity-50">{novel.title.charAt(0).toUpperCase()}</span>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
|
||||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if novel.rank}
|
{#if novel.rank}
|
||||||
@@ -622,11 +632,8 @@
|
|||||||
{#if novel.cover}
|
{#if novel.cover}
|
||||||
<img src={novel.cover} alt={novel.title} class="w-full h-full object-cover" loading="lazy" />
|
<img src={novel.cover} alt={novel.title} class="w-full h-full object-cover" loading="lazy" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
|
<div class="w-full h-full flex items-center justify-center bg-(--color-surface-3)">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<span class="text-xl font-bold text-(--color-muted) select-none opacity-50">{novel.title.charAt(0).toUpperCase()}</span>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
|
||||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
@@ -688,6 +695,8 @@
|
|||||||
<span class="text-xs text-emerald-400 font-medium">{m.catalogue_scrape_queued_badge()}</span>
|
<span class="text-xs text-emerald-400 font-medium">{m.catalogue_scrape_queued_badge()}</span>
|
||||||
{:else if scrapeResult[novel.slug] === 'busy'}
|
{:else if scrapeResult[novel.slug] === 'busy'}
|
||||||
<span class="text-xs text-yellow-400 font-medium">{m.catalogue_scrape_busy_list()}</span>
|
<span class="text-xs text-yellow-400 font-medium">{m.catalogue_scrape_busy_list()}</span>
|
||||||
|
{:else if scrapeResult[novel.slug] === 'forbidden'}
|
||||||
|
<span class="text-xs text-(--color-danger) font-medium">{m.catalogue_scrape_forbidden_badge()}</span>
|
||||||
{:else if scrapeResult[novel.slug] === 'error'}
|
{:else if scrapeResult[novel.slug] === 'error'}
|
||||||
<span class="text-xs text-(--color-danger) font-medium">{m.common_error()}</span>
|
<span class="text-xs text-(--color-danger) font-medium">{m.common_error()}</span>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
Reference in New Issue
Block a user