feat(ui): theme system — amber/slate/rose, profile picker, full token migration
Some checks failed
CI / Backend (pull_request) Successful in 44s
CI / UI (pull_request) Successful in 25s
CI / UI (push) Successful in 27s
Release / Test backend (push) Successful in 42s
CI / Backend (push) Successful in 44s
Release / Check ui (push) Successful in 24s
Release / Docker / caddy (push) Failing after 1m4s
Release / Docker / backend (push) Failing after 44s
Release / Docker / ui (push) Failing after 29s
Release / Docker / runner (push) Failing after 54s
Release / Gitea Release (push) Has been skipped

- Add CSS custom property token system in app.css (@theme + [data-theme] overrides)
- Three themes: amber (default), slate (indigo/dark), rose (dark pink)
- Flash prevention via inline <script> in <svelte:head> sets data-theme before paint
- Theme context (setContext/getContext) in +layout.svelte for live preview
- Theme persisted via PocketBase user_settings (PBUserSettings.theme field)
- /api/settings GET/PUT updated to handle theme field alongside existing settings
- Profile page: new Appearance section with 3 colour-swatch theme picker
- Full token migration across all 36 route/component files:
  zinc/amber hardcoded Tailwind classes → CSS var utilities (bg-(--color-surface), etc.)
- UI primitives (Badge, Button, Card, Dialog, Separator, Textarea) migrated
- accent-amber-400 replaced with inline style accent-color: var(--color-brand)
This commit is contained in:
Admin
2026-03-28 23:57:16 +05:00
parent 801928aadf
commit 7a2a4fc755
36 changed files with 846 additions and 726 deletions

View File

@@ -24,18 +24,18 @@
<div class="flex min-h-[calc(100vh-4rem)] gap-0">
<!-- Sidebar -->
<aside class="w-48 shrink-0 border-r border-zinc-800 px-3 py-6 flex flex-col gap-6">
<aside class="w-48 shrink-0 border-r border-(--color-border) px-3 py-6 flex flex-col gap-6">
<!-- Internal pages -->
<div>
<p class="px-2 mb-2 text-xs font-semibold text-zinc-600 uppercase tracking-widest">Pages</p>
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Pages</p>
<nav class="flex flex-col gap-0.5">
{#each internalLinks as link}
<a
href={link.href}
class="px-2 py-1.5 rounded-md text-sm font-medium transition-colors
{page.url.pathname.startsWith(link.href)
? 'bg-zinc-800 text-zinc-100'
: 'text-zinc-400 hover:bg-zinc-800/60 hover:text-zinc-200'}"
? 'bg-(--color-surface-2) text-(--color-text)'
: 'text-(--color-muted) hover:bg-(--color-surface-2)/60 hover:text-(--color-text)'}"
>
{link.label}
</a>
@@ -45,14 +45,14 @@
<!-- External tools -->
<div>
<p class="px-2 mb-2 text-xs font-semibold text-zinc-600 uppercase tracking-widest">Tools</p>
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Tools</p>
<nav class="flex flex-col gap-0.5">
{#each externalLinks as link}
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
class="px-2 py-1.5 rounded-md text-sm font-medium text-zinc-400 hover:bg-zinc-800/60 hover:text-zinc-200 transition-colors flex items-center justify-between"
class="px-2 py-1.5 rounded-md text-sm font-medium text-(--color-muted) hover:bg-(--color-surface-2)/60 hover:text-(--color-text) transition-colors flex items-center justify-between"
>
{link.label}
<svg class="w-3 h-3 shrink-0 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -33,10 +33,10 @@
// ── Helpers ──────────────────────────────────────────────────────────────────
function jobStatusColor(status: string) {
if (status === 'done') return 'text-green-400';
if (status === 'generating') return 'text-amber-400 animate-pulse';
if (status === 'generating') return 'text-(--color-brand) animate-pulse';
if (status === 'pending') return 'text-sky-400 animate-pulse';
if (status === 'failed') return 'text-red-400';
return 'text-zinc-300';
if (status === 'failed') return 'text-(--color-danger)';
return 'text-(--color-text)';
}
function fmtDate(s: string) {
@@ -100,30 +100,30 @@
<div class="space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-zinc-100">Audio</h1>
<p class="text-zinc-400 text-sm mt-1">
<h1 class="text-2xl font-bold text-(--color-text)">Audio</h1>
<p class="text-(--color-muted) text-sm mt-1">
{stats.total} job{stats.total !== 1 ? 's' : ''} &middot;
<span class="text-green-400">{stats.done} done</span>
{#if stats.failed > 0}
&middot; <span class="text-red-400">{stats.failed} failed</span>
&middot; <span class="text-(--color-danger)">{stats.failed} failed</span>
{/if}
{#if stats.inFlight > 0}
&middot; <span class="text-amber-400 animate-pulse">{stats.inFlight} in-flight</span>
&middot; <span class="text-(--color-brand) animate-pulse">{stats.inFlight} in-flight</span>
{/if}
&middot; {entries.length} cached file{entries.length !== 1 ? 's' : ''}
</p>
</div>
<!-- Tabs -->
<div class="flex gap-1 bg-zinc-800 rounded-lg p-1 w-fit border border-zinc-700">
<div class="flex gap-1 bg-(--color-surface-2) rounded-lg p-1 w-fit border border-(--color-border)">
<button
onclick={() => (activeTab = 'jobs')}
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
{activeTab === 'jobs' ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'}"
{activeTab === 'jobs' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Jobs
{#if stats.inFlight > 0}
<span class="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-amber-400 text-zinc-900 text-[10px] font-bold">
<span class="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-(--color-brand) text-(--color-surface) text-[10px] font-bold">
{stats.inFlight}
</span>
{/if}
@@ -131,7 +131,7 @@
<button
onclick={() => (activeTab = 'cache')}
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
{activeTab === 'cache' ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'}"
{activeTab === 'cache' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Cache
</button>
@@ -143,18 +143,18 @@
type="search"
bind:value={jobsQ}
placeholder="Filter by slug, voice or status…"
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"
class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
{#if filteredJobs.length === 0}
<p class="text-zinc-500 text-sm py-8 text-center">
<p class="text-(--color-muted) text-sm py-8 text-center">
{jobsQ.trim() ? 'No matching jobs.' : 'No audio jobs yet.'}
</p>
{:else}
<!-- Desktop table -->
<div class="hidden sm:block overflow-x-auto rounded-xl border border-zinc-700">
<div class="hidden sm:block overflow-x-auto rounded-xl border border-(--color-border)">
<table class="w-full text-sm">
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
<thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
<tr>
<th class="px-4 py-3 text-left">Book</th>
<th class="px-4 py-3 text-right">Ch.</th>
@@ -164,23 +164,23 @@
<th class="px-4 py-3 text-left">Duration</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-700/50">
<tbody class="divide-y divide-(--color-border)/50">
{#each filteredJobs as job}
<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/{job.slug}" class="hover:text-amber-400 transition-colors">{job.slug}</a>
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
<td class="px-4 py-3 text-(--color-text) font-medium">
<a href="/books/{job.slug}" class="hover:text-(--color-brand) transition-colors">{job.slug}</a>
</td>
<td class="px-4 py-3 text-right text-zinc-400">{job.chapter}</td>
<td class="px-4 py-3 text-zinc-400 font-mono text-xs">{job.voice}</td>
<td class="px-4 py-3 text-right text-(--color-muted)">{job.chapter}</td>
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs">{job.voice}</td>
<td class="px-4 py-3">
<span class="font-medium {jobStatusColor(job.status)}">{job.status}</span>
</td>
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{fmtDate(job.started)}</td>
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{duration(job.started, job.finished)}</td>
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(job.started)}</td>
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{duration(job.started, job.finished)}</td>
</tr>
{#if job.error_message}
<tr class="bg-red-950/20">
<td colspan="6" class="px-4 py-2 text-xs text-red-400 font-mono">{job.error_message}</td>
<tr class="bg-(--color-danger)/10">
<td colspan="6" class="px-4 py-2 text-xs text-(--color-danger) font-mono">{job.error_message}</td>
</tr>
{/if}
{/each}
@@ -191,21 +191,21 @@
<!-- Mobile cards -->
<div class="sm:hidden space-y-3">
{#each filteredJobs as job}
<div class="bg-zinc-900 rounded-xl border border-zinc-700 p-4 space-y-2">
<div class="bg-(--color-surface) rounded-xl border border-(--color-border) p-4 space-y-2">
<div class="flex items-start justify-between gap-2">
<a href="/books/{job.slug}" class="text-zinc-200 font-medium hover:text-amber-400 transition-colors truncate">
<a href="/books/{job.slug}" class="text-(--color-text) font-medium hover:text-(--color-brand) transition-colors truncate">
{job.slug}
</a>
<span class="shrink-0 text-xs font-semibold {jobStatusColor(job.status)}">{job.status}</span>
</div>
<div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-zinc-500">Chapter</span><span class="text-zinc-400 text-right">{job.chapter}</span>
<span class="text-zinc-500">Voice</span><span class="text-zinc-400 font-mono text-right truncate">{job.voice}</span>
<span class="text-zinc-500">Started</span><span class="text-zinc-400 text-right">{fmtDate(job.started)}</span>
<span class="text-zinc-500">Duration</span><span class="text-zinc-400 text-right">{duration(job.started, job.finished)}</span>
<span class="text-(--color-muted)">Chapter</span><span class="text-(--color-muted) text-right">{job.chapter}</span>
<span class="text-(--color-muted)">Voice</span><span class="text-(--color-muted) font-mono text-right truncate">{job.voice}</span>
<span class="text-(--color-muted)">Started</span><span class="text-(--color-muted) text-right">{fmtDate(job.started)}</span>
<span class="text-(--color-muted)">Duration</span><span class="text-(--color-muted) text-right">{duration(job.started, job.finished)}</span>
</div>
{#if job.error_message}
<p class="text-xs text-red-400 font-mono break-all">{job.error_message}</p>
<p class="text-xs text-(--color-danger) font-mono break-all">{job.error_message}</p>
{/if}
</div>
{/each}
@@ -219,18 +219,18 @@
type="search"
bind:value={cacheQ}
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"
class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
{#if filteredCache.length === 0}
<p class="text-zinc-500 text-sm py-8 text-center">
<p class="text-(--color-muted) text-sm py-8 text-center">
{cacheQ.trim() ? 'No results.' : 'Audio cache is empty.'}
</p>
{:else}
<!-- Desktop table -->
<div class="hidden sm:block overflow-x-auto rounded-xl border border-zinc-700">
<div class="hidden sm:block overflow-x-auto rounded-xl border border-(--color-border)">
<table class="w-full text-sm">
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
<thead class="bg-(--color-surface-2) text-(--color-muted) 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>
@@ -239,19 +239,19 @@
<th class="px-4 py-3 text-left">Updated</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-700/50">
<tbody class="divide-y divide-(--color-border)/50">
{#each filteredCache as entry}
{@const parts = parseCacheKey(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>
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
<td class="px-4 py-3 text-(--color-text) font-medium">
<a href="/books/{parts.slug}" class="hover:text-(--color-brand) 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}>
<td class="px-4 py-3 text-(--color-muted)">{parts.chapter}</td>
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs">{parts.voice}</td>
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs truncate max-w-[14rem]" title={entry.filename}>
{entry.filename}
</td>
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{fmtDate(entry.updated)}</td>
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(entry.updated)}</td>
</tr>
{/each}
</tbody>
@@ -262,17 +262,17 @@
<div class="sm:hidden space-y-3">
{#each filteredCache as entry}
{@const parts = parseCacheKey(entry.cache_key)}
<div class="bg-zinc-900 rounded-xl border border-zinc-700 p-4 space-y-2">
<a href="/books/{parts.slug}" class="text-zinc-200 font-medium hover:text-amber-400 transition-colors block truncate">
<div class="bg-(--color-surface) rounded-xl border border-(--color-border) p-4 space-y-2">
<a href="/books/{parts.slug}" class="text-(--color-text) font-medium hover:text-(--color-brand) transition-colors block truncate">
{parts.slug}
</a>
<div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-zinc-500">Chapter</span><span class="text-zinc-400 text-right">{parts.chapter}</span>
<span class="text-zinc-500">Voice</span><span class="text-zinc-400 font-mono text-right truncate">{parts.voice}</span>
<span class="text-zinc-500">Updated</span><span class="text-zinc-400 text-right">{fmtDate(entry.updated)}</span>
<span class="text-(--color-muted)">Chapter</span><span class="text-(--color-muted) text-right">{parts.chapter}</span>
<span class="text-(--color-muted)">Voice</span><span class="text-(--color-muted) font-mono text-right truncate">{parts.voice}</span>
<span class="text-(--color-muted)">Updated</span><span class="text-(--color-muted) text-right">{fmtDate(entry.updated)}</span>
</div>
{#if entry.filename}
<p class="text-xs text-zinc-500 font-mono truncate" title={entry.filename}>{entry.filename}</p>
<p class="text-xs text-(--color-muted) font-mono truncate" title={entry.filename}>{entry.filename}</p>
{/if}
</div>
{/each}

View File

@@ -16,12 +16,12 @@
<div class="space-y-6 max-w-2xl">
<div class="flex items-center gap-3">
<h1 class="text-xl font-semibold text-zinc-100 flex-1">Changelog</h1>
<h1 class="text-xl font-semibold text-(--color-text) flex-1">Changelog</h1>
<a
href="https://gitea.kalekber.cc/kamil/libnovel/releases"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-zinc-500 hover:text-zinc-300 transition-colors flex items-center gap-1"
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors flex items-center gap-1"
>
Gitea releases
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -32,25 +32,25 @@
</div>
{#if data.error}
<p class="text-sm text-red-400">Could not load releases: {data.error}</p>
<p class="text-sm text-(--color-danger)">Could not load releases: {data.error}</p>
{:else if data.releases.length === 0}
<p class="text-sm text-zinc-500 py-8 text-center">No releases found.</p>
<p class="text-sm text-(--color-muted) py-8 text-center">No releases found.</p>
{:else}
<div class="space-y-0 divide-y divide-zinc-800 border border-zinc-800 rounded-xl overflow-hidden">
<div class="space-y-0 divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
{#each data.releases as release}
<div class="px-5 py-4 bg-zinc-900 space-y-2">
<div class="px-5 py-4 bg-(--color-surface) space-y-2">
<div class="flex items-baseline gap-3 flex-wrap">
<span class="font-mono text-sm font-semibold text-amber-400">{release.tag_name}</span>
<span class="font-mono text-sm font-semibold text-(--color-brand)">{release.tag_name}</span>
{#if release.name && release.name !== release.tag_name}
<span class="text-sm text-zinc-300">{release.name}</span>
<span class="text-sm text-(--color-text)">{release.name}</span>
{/if}
{#if release.prerelease}
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-400">pre-release</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">pre-release</span>
{/if}
<span class="text-xs text-zinc-600 ml-auto">{fmtDate(release.published_at)}</span>
<span class="text-xs text-(--color-muted) ml-auto">{fmtDate(release.published_at)}</span>
</div>
{#if release.body.trim()}
<p class="text-sm text-zinc-400 leading-relaxed whitespace-pre-wrap">{release.body.trim()}</p>
<p class="text-sm text-(--color-muted) leading-relaxed whitespace-pre-wrap">{release.body.trim()}</p>
{/if}
</div>
{/each}

View File

@@ -193,10 +193,10 @@
// ── Helpers ─────────────────────────────────────────────────────────────────
function statusColor(status: string) {
if (status === 'done') return 'text-green-400';
if (status === 'running') return 'text-amber-400 animate-pulse';
if (status === 'failed') return 'text-red-400';
if (status === 'cancelled') return 'text-zinc-400';
return 'text-zinc-300';
if (status === 'running') return 'text-(--color-brand) animate-pulse';
if (status === 'failed') return 'text-(--color-danger)';
if (status === 'cancelled') return 'text-(--color-muted)';
return 'text-(--color-text)';
}
function fmtDate(s: string) {
@@ -234,87 +234,87 @@
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-xl font-semibold text-zinc-100 flex-1">Scrape</h1>
<span class="text-xs {running ? 'text-amber-400 animate-pulse' : 'text-green-500'}">
<h1 class="text-xl font-semibold text-(--color-text) flex-1">Scrape</h1>
<span class="text-xs {running ? 'text-(--color-brand) animate-pulse' : 'text-green-500'}">
{running ? 'Running' : 'Idle'}
</span>
</div>
<!-- Compact controls -->
<div class="divide-y divide-zinc-800 border border-zinc-800 rounded-xl overflow-hidden">
<div class="divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
<!-- Full catalogue -->
<div class="flex items-center gap-4 px-4 py-3 bg-zinc-900">
<span class="text-sm text-zinc-400 w-36 shrink-0">Full catalogue</span>
<div class="flex items-center gap-4 px-4 py-3 bg-(--color-surface)">
<span class="text-sm text-(--color-muted) w-36 shrink-0">Full catalogue</span>
<button
onclick={triggerCatalogueScrape}
disabled={running || cataloguing}
class="px-3 py-1.5 rounded-md bg-amber-600 text-zinc-900 font-semibold text-xs hover:bg-amber-500 transition-colors disabled:opacity-50"
class="px-3 py-1.5 rounded-md bg-(--color-brand) text-(--color-surface) font-semibold text-xs hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50"
>
{cataloguing ? 'Queuing…' : running ? 'Running…' : 'Start scrape'}
</button>
{#if catalogueError}<span class="text-xs text-red-400">{catalogueError}</span>{/if}
{#if catalogueError}<span class="text-xs text-(--color-danger)">{catalogueError}</span>{/if}
</div>
<!-- Single book -->
<div id="book-form" class="flex items-center gap-3 px-4 py-3 bg-zinc-900">
<span class="text-sm text-zinc-400 w-36 shrink-0">Single book</span>
<div id="book-form" class="flex items-center gap-3 px-4 py-3 bg-(--color-surface)">
<span class="text-sm text-(--color-muted) w-36 shrink-0">Single book</span>
<input
type="url"
bind:value={scrapeUrl}
placeholder="https://novelfire.net/book/…"
class="flex-1 min-w-0 bg-zinc-800 border border-zinc-700 rounded-md px-3 py-1.5 text-zinc-100 text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-amber-400"
class="flex-1 min-w-0 bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
/>
<button
onclick={() => triggerBookScrape(scrapeUrl)}
disabled={!scrapeUrl.trim() || running || scraping}
class="shrink-0 px-3 py-1.5 rounded-md bg-zinc-700 text-zinc-100 font-medium text-xs hover:bg-zinc-600 transition-colors disabled:opacity-50"
class="shrink-0 px-3 py-1.5 rounded-md bg-(--color-surface-3) text-(--color-text) font-medium text-xs hover:bg-zinc-600 transition-colors disabled:opacity-50"
>
{scraping ? 'Queuing…' : 'Scrape'}
</button>
{#if scrapeError}<span class="text-xs text-red-400">{scrapeError}</span>{/if}
{#if scrapeError}<span class="text-xs text-(--color-danger)">{scrapeError}</span>{/if}
</div>
<!-- Range scrape -->
<div id="range-form" class="flex items-center gap-3 px-4 py-3 bg-zinc-900 flex-wrap">
<span class="text-sm text-zinc-400 w-36 shrink-0">Chapter range</span>
<div id="range-form" class="flex items-center gap-3 px-4 py-3 bg-(--color-surface) flex-wrap">
<span class="text-sm text-(--color-muted) w-36 shrink-0">Chapter range</span>
<input
type="url"
bind:value={rangeUrl}
placeholder="https://novelfire.net/book/…"
class="flex-1 min-w-0 bg-zinc-800 border border-zinc-700 rounded-md px-3 py-1.5 text-zinc-100 text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-amber-400"
class="flex-1 min-w-0 bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
/>
<input
type="number"
bind:value={rangeFrom}
min="1"
placeholder="From"
class="w-20 bg-zinc-800 border border-zinc-700 rounded-md px-3 py-1.5 text-zinc-100 text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-amber-400"
class="w-20 bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
/>
<input
type="number"
bind:value={rangeTo}
min="1"
placeholder="To"
class="w-20 bg-zinc-800 border border-zinc-700 rounded-md px-3 py-1.5 text-zinc-100 text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-amber-400"
class="w-20 bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
/>
<button
onclick={triggerRangeScrape}
disabled={!rangeUrl.trim() || rangeFrom === null || running || ranging}
class="shrink-0 px-3 py-1.5 rounded-md bg-zinc-700 text-zinc-100 font-medium text-xs hover:bg-zinc-600 transition-colors disabled:opacity-50"
class="shrink-0 px-3 py-1.5 rounded-md bg-(--color-surface-3) text-(--color-text) font-medium text-xs hover:bg-zinc-600 transition-colors disabled:opacity-50"
>
{ranging ? 'Queuing…' : 'Go'}
</button>
{#if rangeError}<span class="text-xs text-red-400 w-full pl-40">{rangeError}</span>{/if}
{#if rangeError}<span class="text-xs text-(--color-danger) w-full pl-40">{rangeError}</span>{/if}
</div>
<!-- Quick genre chips -->
<div class="flex items-center gap-3 px-4 py-3 bg-zinc-900 flex-wrap">
<span class="text-sm text-zinc-400 w-36 shrink-0">Quick genres</span>
<div class="flex items-center gap-3 px-4 py-3 bg-(--color-surface) flex-wrap">
<span class="text-sm text-(--color-muted) w-36 shrink-0">Quick genres</span>
<div class="flex flex-wrap gap-1.5">
{#each quickScrapes as qs}
<button
onclick={() => { scrapeUrl = qs.url; }}
class="px-2.5 py-1 rounded text-xs font-medium bg-zinc-800 text-zinc-400 border border-zinc-700 hover:border-amber-400/50 hover:text-amber-300 transition-colors"
class="px-2.5 py-1 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) border border-(--color-border) hover:border-(--color-brand)/50 hover:text-(--color-brand-dim) transition-colors"
>
{qs.label}
</button>
@@ -323,7 +323,7 @@
href="https://novelfire.net"
target="_blank"
rel="noopener noreferrer"
class="px-2.5 py-1 rounded text-xs font-medium text-zinc-500 border border-zinc-700/50 hover:text-amber-300 hover:border-amber-400/40 transition-colors"
class="px-2.5 py-1 rounded text-xs font-medium text-(--color-muted) border border-(--color-border)/50 hover:text-(--color-brand-dim) hover:border-(--color-brand)/40 transition-colors"
>
novelfire.net ↗
</a>
@@ -334,24 +334,24 @@
<!-- Tasks table -->
<div class="space-y-3">
<div class="flex items-center gap-3 flex-wrap">
<h2 class="text-sm font-semibold text-zinc-400 flex-1 uppercase tracking-widest">Task history</h2>
<h2 class="text-sm font-semibold text-(--color-muted) flex-1 uppercase tracking-widest">Task history</h2>
<input
type="search"
bind:value={q}
placeholder="Filter by kind, status or URL…"
class="w-full max-w-xs 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"
class="w-full max-w-xs bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
</div>
{#if filtered.length === 0}
<p class="text-zinc-500 text-sm py-8 text-center">
<p class="text-(--color-muted) text-sm py-8 text-center">
{q.trim() ? 'No matching tasks.' : 'No scrape tasks yet.'}
</p>
{:else}
<!-- Desktop table -->
<div class="hidden sm:block overflow-x-auto rounded-xl border border-zinc-700">
<div class="hidden sm:block overflow-x-auto rounded-xl border border-(--color-border)">
<table class="w-full text-sm">
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
<thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
<tr>
<th class="px-4 py-3 text-left">Kind / URL</th>
<th class="px-4 py-3 text-left">Status</th>
@@ -364,14 +364,14 @@
<th class="px-4 py-3 text-left">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-700/50">
<tbody class="divide-y divide-(--color-border)/50">
{#each filtered as task}
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
<td class="px-4 py-3 font-mono text-xs text-zinc-300">
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
<td class="px-4 py-3 font-mono text-xs text-(--color-text)">
{task.kind}
{#if task.target_url}
<br />
<span class="text-zinc-500 truncate max-w-[16rem] block" title={task.target_url}>
<span class="text-(--color-muted) truncate max-w-[16rem] block" title={task.target_url}>
{task.target_url.replace('https://novelfire.net/book/', '')}
</span>
{/if}
@@ -379,19 +379,19 @@
<td class="px-4 py-3">
<span class="font-medium {statusColor(task.status)}">{task.status}</span>
</td>
<td class="px-4 py-3 text-right text-zinc-300">{task.books_found ?? 0}</td>
<td class="px-4 py-3 text-right text-zinc-300">{task.chapters_scraped ?? 0}</td>
<td class="px-4 py-3 text-right text-zinc-400">{task.chapters_skipped ?? 0}</td>
<td class="px-4 py-3 text-right {task.errors > 0 ? 'text-red-400' : 'text-zinc-400'}">{task.errors ?? 0}</td>
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{fmtDate(task.started)}</td>
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{duration(task.started, task.finished)}</td>
<td class="px-4 py-3 text-right text-(--color-text)">{task.books_found ?? 0}</td>
<td class="px-4 py-3 text-right text-(--color-text)">{task.chapters_scraped ?? 0}</td>
<td class="px-4 py-3 text-right text-(--color-muted)">{task.chapters_skipped ?? 0}</td>
<td class="px-4 py-3 text-right {task.errors > 0 ? 'text-(--color-danger)' : 'text-(--color-muted)'}">{task.errors ?? 0}</td>
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(task.started)}</td>
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{duration(task.started, task.finished)}</td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1.5">
{#if task.status === 'pending'}
<button
onclick={() => cancelTask(task.id)}
disabled={cancellingIds.has(task.id)}
class="px-2 py-1 rounded text-xs font-medium bg-zinc-700 text-zinc-300 hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
class="px-2 py-1 rounded text-xs font-medium bg-(--color-surface-3) text-(--color-text) hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
>
{cancellingIds.has(task.id) ? 'Cancelling…' : 'Cancel'}
</button>
@@ -413,14 +413,14 @@
</button>
{/if}
{#if cancelErrors[task.id]}
<p class="text-xs text-red-400 mt-1 w-full">{cancelErrors[task.id]}</p>
<p class="text-xs text-(--color-danger) mt-1 w-full">{cancelErrors[task.id]}</p>
{/if}
</div>
</td>
</tr>
{#if task.error_message}
<tr class="bg-red-950/20">
<td colspan="9" class="px-4 py-2 text-xs text-red-400 font-mono">{task.error_message}</td>
<tr class="bg-(--color-danger)/10">
<td colspan="9" class="px-4 py-2 text-xs text-(--color-danger) font-mono">{task.error_message}</td>
</tr>
{/if}
{/each}
@@ -431,12 +431,12 @@
<!-- Mobile cards -->
<div class="sm:hidden space-y-3">
{#each filtered as task}
<div class="bg-zinc-900 rounded-xl border border-zinc-700 p-4 space-y-2">
<div class="bg-(--color-surface) rounded-xl border border-(--color-border) p-4 space-y-2">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<span class="font-mono text-xs text-zinc-300">{task.kind}</span>
<span class="font-mono text-xs text-(--color-text)">{task.kind}</span>
{#if task.target_url}
<p class="text-xs text-zinc-500 truncate mt-0.5" title={task.target_url}>
<p class="text-xs text-(--color-muted) truncate mt-0.5" title={task.target_url}>
{task.target_url.replace('https://novelfire.net/book/', '')}
</p>
{/if}
@@ -444,22 +444,22 @@
<span class="shrink-0 text-xs font-semibold {statusColor(task.status)}">{task.status}</span>
</div>
<div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-zinc-500">Books</span><span class="text-zinc-300 text-right">{task.books_found ?? 0}</span>
<span class="text-zinc-500">Chapters</span><span class="text-zinc-300 text-right">{task.chapters_scraped ?? 0}</span>
<span class="text-zinc-500">Skipped</span><span class="text-zinc-400 text-right">{task.chapters_skipped ?? 0}</span>
<span class="text-zinc-500">Errors</span><span class="{task.errors > 0 ? 'text-red-400' : 'text-zinc-400'} text-right">{task.errors ?? 0}</span>
<span class="text-zinc-500">Started</span><span class="text-zinc-400 text-right">{fmtDate(task.started)}</span>
<span class="text-zinc-500">Duration</span><span class="text-zinc-400 text-right">{duration(task.started, task.finished)}</span>
<span class="text-(--color-muted)">Books</span><span class="text-(--color-text) text-right">{task.books_found ?? 0}</span>
<span class="text-(--color-muted)">Chapters</span><span class="text-(--color-text) text-right">{task.chapters_scraped ?? 0}</span>
<span class="text-(--color-muted)">Skipped</span><span class="text-(--color-muted) text-right">{task.chapters_skipped ?? 0}</span>
<span class="text-(--color-muted)">Errors</span><span class="{task.errors > 0 ? 'text-(--color-danger)' : 'text-(--color-muted)'} text-right">{task.errors ?? 0}</span>
<span class="text-(--color-muted)">Started</span><span class="text-(--color-muted) text-right">{fmtDate(task.started)}</span>
<span class="text-(--color-muted)">Duration</span><span class="text-(--color-muted) text-right">{duration(task.started, task.finished)}</span>
</div>
{#if task.error_message}
<p class="text-xs text-red-400 font-mono break-all">{task.error_message}</p>
<p class="text-xs text-(--color-danger) font-mono break-all">{task.error_message}</p>
{/if}
<div class="flex flex-wrap gap-2">
{#if task.status === 'pending'}
<button
onclick={() => cancelTask(task.id)}
disabled={cancellingIds.has(task.id)}
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-700 text-zinc-300 hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium bg-(--color-surface-3) text-(--color-text) hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
>
{cancellingIds.has(task.id) ? 'Cancelling…' : 'Cancel task'}
</button>
@@ -481,7 +481,7 @@
</button>
{/if}
{#if cancelErrors[task.id]}
<p class="text-xs text-red-400 w-full">{cancelErrors[task.id]}</p>
<p class="text-xs text-(--color-danger) w-full">{cancelErrors[task.id]}</p>
{/if}
</div>
</div>