Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 16s
CI / Scraper / Lint (pull_request) Successful in 19s
CI / Scraper / Build (pull_request) Successful in 16s
- Add /profile page with reading settings (voice, speed, auto-next) and password change form - Add /admin/scrape page showing scraping task history with live status polling and trigger controls - Add /admin/audio page showing audio cache entries with client-side search filter - Add changePassword(), listAudioCache(), listScrapingTasks() to pocketbase.ts - Add /api/admin/scrape and /api/browse-page server-side proxy routes - Replace browse page pagination with IntersectionObserver infinite scroll - Update nav: username becomes a /profile link; admin users see Scrape and Audio cache links
93 lines
3.2 KiB
Svelte
93 lines
3.2 KiB
Svelte
<script lang="ts">
|
|
import type { PageData } from './$types';
|
|
|
|
let { data }: { data: PageData } = $props();
|
|
|
|
let entries = $state(data.entries);
|
|
|
|
// ── Parse cache_key ─────────────────────────────────────────────────────────
|
|
// cache_key format: "slug/chapter/voice"
|
|
function parseKey(key: string) {
|
|
const parts = key.split('/');
|
|
if (parts.length >= 3) {
|
|
return { slug: parts[0], chapter: parts[1], voice: parts.slice(2).join('/') };
|
|
}
|
|
return { slug: key, chapter: '—', voice: '—' };
|
|
}
|
|
|
|
function fmtDate(s: string) {
|
|
if (!s) return '—';
|
|
return new Date(s).toLocaleString(undefined, {
|
|
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
|
});
|
|
}
|
|
|
|
// ── Search ──────────────────────────────────────────────────────────────────
|
|
let q = $state('');
|
|
let filtered = $derived(
|
|
q.trim()
|
|
? entries.filter((e) => e.cache_key.toLowerCase().includes(q.toLowerCase().trim()))
|
|
: entries
|
|
);
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Audio cache — libnovel admin</title>
|
|
</svelte:head>
|
|
|
|
<div class="space-y-6">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-zinc-100">Audio cache</h1>
|
|
<p class="text-zinc-400 text-sm mt-1">{entries.length} cached audio file{entries.length !== 1 ? 's' : ''}</p>
|
|
</div>
|
|
|
|
<!-- Search -->
|
|
<input
|
|
type="search"
|
|
bind:value={q}
|
|
placeholder="Filter by slug, chapter or voice…"
|
|
class="w-full max-w-sm bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
|
/>
|
|
|
|
{#if filtered.length === 0}
|
|
<p class="text-zinc-500 text-sm py-8 text-center">
|
|
{q.trim() ? 'No results.' : 'Audio cache is empty.'}
|
|
</p>
|
|
{:else}
|
|
<div class="overflow-x-auto rounded-xl border border-zinc-700">
|
|
<table class="w-full text-sm">
|
|
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
|
|
<tr>
|
|
<th class="px-4 py-3 text-left">Book</th>
|
|
<th class="px-4 py-3 text-left">Chapter</th>
|
|
<th class="px-4 py-3 text-left">Voice</th>
|
|
<th class="px-4 py-3 text-left">Filename</th>
|
|
<th class="px-4 py-3 text-left">Updated</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-zinc-700/50">
|
|
{#each filtered as entry}
|
|
{@const parts = parseKey(entry.cache_key)}
|
|
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
|
|
<td class="px-4 py-3 text-zinc-200 font-medium">
|
|
<a
|
|
href="/books/{parts.slug}"
|
|
class="hover:text-amber-400 transition-colors"
|
|
>
|
|
{parts.slug}
|
|
</a>
|
|
</td>
|
|
<td class="px-4 py-3 text-zinc-400">{parts.chapter}</td>
|
|
<td class="px-4 py-3 text-zinc-400 font-mono text-xs">{parts.voice}</td>
|
|
<td class="px-4 py-3 text-zinc-500 font-mono text-xs truncate max-w-[14rem]" title={entry.filename}>
|
|
{entry.filename}
|
|
</td>
|
|
<td class="px-4 py-3 text-zinc-400">{fmtDate(entry.updated)}</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/if}
|
|
</div>
|