Compare commits

...

4 Commits

Author SHA1 Message Date
Admin
71a628673d feat(ui): homepage UX polish — headings, placeholders, genre highlight, view-all
All checks were successful
Release / Test backend (push) Successful in 55s
Release / Check ui (push) Successful in 1m3s
Release / Docker (push) Successful in 4m26s
Release / Deploy to prod (push) Successful in 2m38s
Release / Gitea Release (push) Successful in 27s
- Bump all section headings to text-lg for visual hierarchy
- Replace SVG book icon no-cover placeholders with first-letter avatars
  across Completed, Ready to Listen, Trending, Recommendations, Recently
  Updated, and From Following shelves
- Highlight user's top genre pill in Browse by Genre strip
- Add "View all → /catalogue?genre=…" link to Because You Read section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 13:00:50 +05:00
Admin
5f5aac5e3e fix(admin): UX and bug fixes across admin pages
All checks were successful
Release / Test backend (push) Successful in 54s
Release / Check ui (push) Successful in 1m1s
Release / Docker (push) Successful in 5m15s
Release / Deploy to prod (push) Successful in 2m0s
Release / Gitea Release (push) Successful in 26s
- Import: fix "1/1/1" date display (Go zero time.Time → show dash)
- AI Jobs: guard fmtDate against zero-time dates
- AI Jobs: add "Cancel all in-flight (N)" bulk action button
- AI Jobs sidebar: show live running+pending count badge from layout
- Notifications: add broadcast panel linking to push.libnovel.cc, relabel inbox section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 12:48:33 +05:00
Admin
e65883cc9e feat(catalogue): UX improvements and bug fixes
All checks were successful
Release / Test backend (push) Successful in 56s
Release / Check ui (push) Successful in 58s
Release / Docker (push) Successful in 8m2s
Release / Deploy to prod (push) Successful in 1m52s
Release / Gitea Release (push) Successful in 27s
- 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>
2026-04-16 12:34:55 +05:00
Admin
b19af1e8f3 fix: simplify Docker build workflow, remove PREBUILT artifact workaround
All checks were successful
Release / Test backend (push) Successful in 1m6s
Release / Check ui (push) Successful in 1m4s
Release / Docker (push) Successful in 8m43s
Release / Deploy to prod (push) Successful in 2m37s
Release / Gitea Release (push) Successful in 40s
- Remove UI build artifact upload/download steps (18 lines removed)
- Remove .dockerignore manipulation workaround
- Always build UI from source inside Docker (more reliable)
- Remove PREBUILT arg from ui/Dockerfile and docker-bake.hcl

This fixes the '/app/build not found' error in CI by eliminating the fragile
artifact-passing mechanism. UI now builds fresh in Docker using build cache,
same as local development.
2026-04-15 21:35:13 +05:00
11 changed files with 155 additions and 78 deletions

View File

@@ -55,13 +55,6 @@ jobs:
- name: Build
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v3
with:
name: ui-build
path: ui/build
retention-days: 1
# ── docker: build + push all images via docker bake ──────────────────────────
docker:
name: Docker
@@ -86,17 +79,6 @@ jobs:
echo "version=$VER" >> "$GITHUB_OUTPUT"
echo "major_minor=$(echo "$VER" | cut -d. -f1-2)" >> "$GITHUB_OUTPUT"
- name: Download ui build artifacts
uses: actions/download-artifact@v3
with:
name: ui-build
path: ui/build
- name: Allow build/ into Docker context (override .dockerignore)
run: |
grep -v '^build$' ui/.dockerignore > ui/.dockerignore.tmp
mv ui/.dockerignore.tmp ui/.dockerignore
- name: Build and push all images
uses: docker/bake-action@v6
with:

View File

@@ -1,13 +1,11 @@
# docker-bake.hcl — defines all five production images.
#
# Uses only plain variables for broad buildx compatibility (no locals/functions).
# CI pre-computes VERSION and MAJOR_MINOR from the git tag and passes them as
# env vars. Locally, everything gets a :dev tag.
# CI passes version info as environment variables; locally everything gets :dev tags.
#
# Local build (no push):
# docker buildx bake
#
# CI passes: DOCKER_USER, VERSION, MAJOR_MINOR, COMMIT, BUILD_TIME
# CI environment variables: VERSION, MAJOR_MINOR, COMMIT, BUILD_TIME
variable "DOCKER_USER" { default = "kalekber" }
variable "VERSION" { default = "dev" } # e.g. "4.1.6" (no leading v)
@@ -85,7 +83,6 @@ target "ui" {
BUILD_VERSION = VERSION
BUILD_COMMIT = COMMIT
BUILD_TIME = BUILD_TIME
PREBUILT = "1"
}
}

View File

@@ -21,11 +21,7 @@ ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
ENV PUBLIC_BUILD_TIME=$BUILD_TIME
# PREBUILT=1 skips npm run build — used in CI when the build/ directory has
# already been compiled (and debug IDs injected) by a prior job. The caller
# must copy the pre-built build/ into the Docker context before building.
ARG PREBUILT=0
RUN [ "$PREBUILT" = "1" ] || npm run build
RUN npm run build
# ── Runtime image ──────────────────────────────────────────────────────────────
# adapter-node bundles most server-side code, but packages with dynamic

View File

@@ -247,13 +247,16 @@
<!-- ── Streak widget ───────────────────────────────────────────────────────────── -->
{#if streak > 0}
<div class="mb-6 flex items-center gap-3 flex-wrap text-sm">
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
<span class="font-semibold text-(--color-text)">{streak}</span>
<span class="text-(--color-muted)">day{streak !== 1 ? 's' : ''} reading</span>
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-brand)/10 border border-(--color-brand)/30 text-(--color-brand) font-semibold">
<svg class="w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="currentColor">
<path d="M13.5 0.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"/>
</svg>
{streak} day{streak !== 1 ? 's' : ''}
</span>
{#if data.stats.booksInProgress > 0}
<span class="text-(--color-muted)">
<span class="font-semibold text-(--color-text)">{data.stats.booksInProgress}</span> {data.stats.booksInProgress === 1 ? 'book' : 'books'} in progress
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-muted)">
<span class="font-semibold text-(--color-text)">{data.stats.booksInProgress}</span>
{data.stats.booksInProgress === 1 ? 'book' : 'books'} in progress
</span>
{/if}
</div>
@@ -263,32 +266,39 @@
{#if shelfBooks.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">{m.home_continue_reading()}</h2>
<h2 class="text-lg font-bold text-(--color-text)">{m.home_continue_reading()}</h2>
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each shelfBooks as { book, chapter }}
<div class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-32 sm:w-36">
<div class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
<a href="/books/{book.slug}/chapters/{chapter}" class="block">
<div class="aspect-[2/3] overflow-hidden relative">
{#if book.cover}
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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>
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
</div>
{/if}
<!-- Chapter badge -->
<span class="absolute bottom-1.5 right-1.5 text-xs bg-(--color-brand) text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
{m.home_chapter_badge({ n: String(chapter) })}
</span>
<!-- Reading progress bar -->
{#if book.total_chapters > 0}
{@const pct = Math.min(100, Math.round((chapter / book.total_chapters) * 100))}
<div class="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
<div class="h-full bg-(--color-brand) transition-all" style="width: {pct}%"></div>
</div>
{/if}
</div>
</a>
<!-- Listen button (hover overlay) -->
<button
type="button"
onclick={() => playChapter(book.slug, chapter)}
class="absolute bottom-8 left-1.5 w-7 h-7 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
class="absolute bottom-9 left-1.5 w-7 h-7 rounded-full bg-black/60 text-white flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
title="Listen"
aria-label="Listen to chapter {chapter}"
>
@@ -296,6 +306,9 @@
</button>
<a href="/books/{book.slug}/chapters/{chapter}" class="p-2 block">
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
{#if book.author}
<p class="text-xs text-(--color-muted) truncate mt-0.5">{book.author}</p>
{/if}
</a>
</div>
{/each}
@@ -307,7 +320,7 @@
{#if data.continueCompleted.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">Completed</h2>
<h2 class="text-lg font-bold text-(--color-text)">Completed</h2>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each data.continueCompleted as { book, chapter }}
@@ -318,7 +331,7 @@
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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>
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
</div>
{/if}
<span class="absolute top-1.5 right-1.5 text-xs bg-green-600/90 text-white font-bold px-1.5 py-0.5 rounded">Done</span>
@@ -339,7 +352,7 @@
{#if data.readyToListen.length > 0 && !hidden.has('ready-to-listen')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">Ready to Listen</h2>
<h2 class="text-lg font-bold text-(--color-text)">Ready to Listen</h2>
<div class="flex items-center gap-3">
<a href="/listen" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">View all</a>
<button type="button" onclick={() => hide('ready-to-listen')} title="Hide section"
@@ -360,7 +373,7 @@
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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>
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
</div>
{/if}
<!-- Headphones badge -->
@@ -402,7 +415,7 @@
{#if !hidden.has('browse-genre')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">Browse by genre</h2>
<h2 class="text-lg font-bold text-(--color-text)">Browse by genre</h2>
<div class="flex items-center gap-3">
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<button type="button" onclick={() => hide('browse-genre')} title="Hide section"
@@ -415,8 +428,11 @@
</div>
<div class="flex gap-2 overflow-x-auto pb-1 scrollbar-none -mx-4 px-4">
{#each GENRES as genre}
{@const isTop = data.topGenre && genre.toLowerCase() === data.topGenre.toLowerCase()}
<a href="/catalogue?genre={encodeURIComponent(genre)}"
class="shrink-0 px-3.5 py-1.5 rounded-full border border-(--color-border) bg-(--color-surface-2) text-sm text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors whitespace-nowrap">
class="shrink-0 px-3.5 py-1.5 rounded-full border text-sm transition-colors whitespace-nowrap {isTop
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand) font-semibold'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text) hover:bg-(--color-surface-3)'}">
{genre}
</a>
{/each}
@@ -428,7 +444,7 @@
{#if data.trendingBooks.length > 0 && !hidden.has('trending')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">Trending Now</h2>
<h2 class="text-lg font-bold text-(--color-text)">Trending Now</h2>
<div class="flex items-center gap-3">
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<button type="button" onclick={() => hide('trending')} title="Hide section"
@@ -449,7 +465,7 @@
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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>
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
</div>
{/if}
<span class="absolute top-1.5 left-1.5 text-xs bg-(--color-brand)/80 text-(--color-surface) font-bold px-1.5 py-0.5 rounded">#{book.ranking}</span>
@@ -477,15 +493,18 @@
{#if data.recommendedBooks.length > 0 && data.topGenre && !hidden.has('because-you-read')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">
Because you read <span class="text-(--color-brand)">{data.topGenre}</span>
<h2 class="text-lg font-bold text-(--color-text)">
Because you read <span class="text-(--color-brand)">{data.topGenre ? data.topGenre.charAt(0).toUpperCase() + data.topGenre.slice(1) : ''}</span>
</h2>
<button type="button" onclick={() => hide('because-you-read')} title="Hide section"
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
<div class="flex items-center gap-3">
<a href="/catalogue?genre={encodeURIComponent(data.topGenre ?? '')}" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">View all</a>
<button type="button" onclick={() => hide('because-you-read')} title="Hide section"
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
</div>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each data.recommendedBooks as book}
@@ -497,7 +516,7 @@
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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>
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
</div>
{/if}
</div>
@@ -524,7 +543,7 @@
{#if dedupedRecent.length > 0 && !hidden.has('recently-updated')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
<h2 class="text-lg font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
<div class="flex items-center gap-3">
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<button type="button" onclick={() => hide('recently-updated')} title="Hide section"
@@ -545,7 +564,7 @@
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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>
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
</div>
{/if}
{#if count > 1}
@@ -577,7 +596,7 @@
{#if data.subscriptionFeed.length > 0 && !hidden.has('from-following')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">{m.home_from_following()}</h2>
<h2 class="text-lg font-bold text-(--color-text)">{m.home_from_following()}</h2>
<button type="button" onclick={() => hide('from-following')} title="Hide section"
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -594,7 +613,7 @@
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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>
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
</div>
{/if}
</div>

View File

@@ -1,8 +1,16 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { listAIJobs } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
export const load: LayoutServerLoad = async ({ locals }) => {
if (locals.user?.role !== 'admin') {
redirect(302, '/');
}
const jobs = await listAIJobs().catch((e) => {
log.warn('admin/layout', 'failed to load ai jobs for sidebar badge', { err: String(e) });
return [];
});
const runningAiJobs = jobs.filter((j) => j.status === 'running' || j.status === 'pending').length;
return { runningAiJobs };
};

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import * as m from '$lib/paraglide/messages.js';
import type { LayoutData } from './$types';
const internalLinks = [
{
@@ -105,8 +106,9 @@
interface Props {
children?: import('svelte').Snippet;
data: LayoutData;
}
let { children }: Props = $props();
let { children, data }: Props = $props();
let sidebarOpen = $state(false);
</script>
@@ -136,6 +138,7 @@
<nav class="flex flex-col gap-0.5">
{#each internalLinks as link}
{@const active = page.url.pathname.startsWith(link.href)}
{@const isAiJobs = link.href === '/admin/ai-jobs'}
<a
href={link.href}
onclick={() => (sidebarOpen = false)}
@@ -147,7 +150,12 @@
<svg class="w-3.5 h-3.5 shrink-0 {active ? 'text-(--color-brand)' : 'opacity-50'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{@html link.icon}
</svg>
{link.label()}
<span class="flex-1">{link.label()}</span>
{#if isAiJobs && data.runningAiJobs > 0}
<span class="text-[10px] font-bold tabular-nums px-1.5 py-0.5 rounded-full bg-(--color-brand) text-black leading-none">
{data.runningAiJobs}
</span>
{/if}
</a>
{/each}
</nav>

View File

@@ -57,6 +57,7 @@
// ── Cancel ────────────────────────────────────────────────────────────────────
let cancellingIds = $state(new Set<string>());
let cancelErrors: Record<string, string> = $state({});
let cancellingAll = $state(false);
async function cancelJob(id: string) {
if (cancellingIds.has(id)) return;
@@ -77,6 +78,14 @@
}
}
async function cancelAllRunning() {
if (cancellingAll) return;
cancellingAll = true;
const inFlight = jobs.filter((j) => j.status === 'running' || j.status === 'pending');
await Promise.all(inFlight.map((j) => cancelJob(j.id)));
cancellingAll = false;
}
// ── Review & Apply (chapter-names jobs) ──────────────────────────────────────
interface ProposedTitle {
@@ -411,7 +420,9 @@
function fmtDate(s: string | undefined) {
if (!s) return '—';
return new Date(s).toLocaleString(undefined, {
const d = new Date(s);
if (d.getFullYear() < 2000) return '—';
return d.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
@@ -482,6 +493,27 @@
{/each}
</div>
<!-- Bulk actions -->
{#if stats.running + stats.pending > 0}
<div class="flex justify-end">
<button
onclick={cancelAllRunning}
disabled={cancellingAll}
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
>
{#if cancellingAll}
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
Cancelling…
{:else}
Cancel all in-flight ({stats.running + stats.pending})
{/if}
</button>
</div>
{/if}
<!-- Filters -->
<div class="flex flex-wrap gap-3 items-center">
<input

View File

@@ -169,7 +169,9 @@
function formatDate(dateStr: string) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString();
const d = new Date(dateStr);
if (d.getFullYear() < 2000) return '-';
return d.toLocaleString();
}
function statusColor(status: string) {

View File

@@ -47,9 +47,31 @@
</svelte:head>
<div class="max-w-2xl mx-auto px-4 py-8">
<!-- Broadcast panel -->
<div class="mb-6 rounded-lg border border-(--color-border) bg-(--color-surface-2) p-4 flex items-start gap-3">
<svg class="w-5 h-5 text-(--color-brand) mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
<div class="min-w-0">
<p class="text-sm font-semibold text-(--color-text)">Broadcast to users</p>
<p class="text-sm text-(--color-muted) mt-0.5">To send push notifications or in-app messages to all subscribers, use the push dashboard.</p>
<a
href="https://push.libnovel.cc"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 mt-2 text-sm text-(--color-brand) hover:underline"
>
Open push.libnovel.cc
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
</div>
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-xl font-semibold">Notifications</h1>
<h1 class="text-xl font-semibold">Your Notification Inbox</h1>
{#if unreadCount > 0}
<p class="text-sm text-(--color-muted) mt-0.5">{unreadCount} unread</p>
{/if}

View File

@@ -16,6 +16,7 @@ export const load: PageServerLoad = async ({ url, locals }) => {
const sort = url.searchParams.get('sort') ?? 'popular';
const status = url.searchParams.get('status') ?? 'all';
const q = url.searchParams.get('q') ?? '';
const audioOnly = url.searchParams.get('audio') === '1';
const params = new URLSearchParams({ page, genre, sort, status });
if (q.trim().length >= 2) {
@@ -64,7 +65,8 @@ export const load: PageServerLoad = async ({ url, locals }) => {
isAdmin: locals.user?.role === 'admin',
searchQuery: q.trim().length >= 2 ? q.trim() : '',
searchLocalCount: 0,
searchRemoteCount: 0
searchRemoteCount: 0,
audioOnly
};
};

View File

@@ -21,11 +21,13 @@
});
function applyFilters() {
filtersOpen = false;
const params = new URLSearchParams();
params.set('sort', filterSort);
params.set('genre', filterGenre);
params.set('status', filterStatus);
params.set('page', '1');
if (filterAudioOnly) params.set('audio', '1');
goto(`/catalogue?${params.toString()}`);
}
@@ -215,7 +217,7 @@
// ── Audio-available set ───────────────────────────────────────────────────
let audioSlugs = $state<Set<string>>(new Set());
let filterAudioOnly = $state(false);
let filterAudioOnly = $state(untrack(() => data.audioOnly));
$effect(() => {
fetch('/api/audio/slugs')
@@ -224,6 +226,17 @@
.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(
filterAudioOnly ? novels.filter((n) => audioSlugs.has(n.slug)) : novels
);
@@ -249,7 +262,7 @@
{m.catalogue_rank_no_data_body()}
{/if}
{:else}
{m.catalogue_browse_source()}
{m.catalogue_browse_source()}{#if data.total > 0}&nbsp;<span class="text-(--color-muted) text-xs">{data.total.toLocaleString()} novels</span>{/if}
{/if}
</p>
</div>
@@ -349,7 +362,7 @@
<!-- Audio-only filter toggle -->
{#if audioSlugs.size > 0}
<button
onclick={() => (filterAudioOnly = !filterAudioOnly)}
onclick={toggleAudio}
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
{filterAudioOnly
@@ -503,7 +516,7 @@
{m.catalogue_rank_run_scrape_user()}
{/if}
{: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}
{m.catalogue_no_results_filters()}
{/if}
@@ -531,11 +544,8 @@
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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 class="w-full h-full flex items-center justify-center bg-(--color-surface-3)">
<span class="text-5xl font-bold text-(--color-muted) select-none opacity-50">{novel.title.charAt(0).toUpperCase()}</span>
</div>
{/if}
{#if novel.rank}
@@ -622,11 +632,8 @@
{#if novel.cover}
<img src={novel.cover} alt={novel.title} class="w-full h-full object-cover" loading="lazy" />
{:else}
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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 class="w-full h-full flex items-center justify-center bg-(--color-surface-3)">
<span class="text-xl font-bold text-(--color-muted) select-none opacity-50">{novel.title.charAt(0).toUpperCase()}</span>
</div>
{/if}
{#if isLoading}
@@ -688,6 +695,8 @@
<span class="text-xs text-emerald-400 font-medium">{m.catalogue_scrape_queued_badge()}</span>
{:else if scrapeResult[novel.slug] === 'busy'}
<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'}
<span class="text-xs text-(--color-danger) font-medium">{m.common_error()}</span>
{:else}