Compare commits

...

4 Commits

Author SHA1 Message Date
Admin
76de5eb491 feat(reader): chapter comments + readers-this-week count
Some checks failed
CI / Backend (push) Successful in 48s
CI / UI (push) Failing after 22s
Release / Check ui (push) Failing after 33s
Release / Docker / ui (push) Has been skipped
Release / Test backend (push) Successful in 53s
Release / Docker / caddy (push) Successful in 48s
CI / Backend (pull_request) Successful in 44s
CI / UI (pull_request) Failing after 44s
Release / Docker / runner (push) Failing after 46s
Release / Docker / backend (push) Successful in 1m54s
Release / Gitea Release (push) Has been skipped
- CommentsSection now accepts a chapter prop and scopes comments to that chapter
- Chapter reader page mounts CommentsSection with current chapter number
- Book detail page shows rolling 7-day unique reader count badge
- API GET/POST pass chapter param; pocketbase listComments filters by chapter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:34:12 +05:00
Admin
c6597c8d19 feat(home): hero resume card, horizontal scroll rows, genre strip, dedup
Some checks failed
CI / Backend (push) Successful in 1m0s
CI / UI (push) Failing after 26s
Release / Test backend (push) Successful in 53s
CI / Backend (pull_request) Successful in 45s
Release / Docker / caddy (push) Successful in 1m13s
CI / UI (pull_request) Failing after 33s
Release / Docker / runner (push) Failing after 1m27s
Release / Docker / backend (push) Successful in 3m35s
Release / Check ui (push) Failing after 31s
Release / Docker / ui (push) Has been skipped
Release / Gitea Release (push) Has been skipped
- First continue-reading book becomes a wide hero card with title,
  description, genre tags, and a prominent Resume ch.N CTA
- Remaining in-progress books move to a horizontal scroll shelf
- Recently Updated deduplicates by slug; books with multiple new
  chapters show a green "+N ch." badge
- Genre discovery strip (horizontal scroll) links to /catalogue?genre=X
- Stats demoted to a subtle two-number footer bar
- All rows use horizontal scroll instead of fixed grids

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:20:28 +05:00
Admin
e8d7108753 feat(themes): add light, light-slate, and light-rose themes
Some checks failed
CI / Backend (push) Successful in 30s
Release / Test backend (push) Failing after 11s
Release / Docker / backend (push) Has been skipped
Release / Docker / runner (push) Has been skipped
CI / UI (push) Successful in 35s
Release / Docker / caddy (push) Successful in 58s
Release / Check ui (push) Successful in 1m7s
CI / Backend (pull_request) Successful in 31s
CI / UI (pull_request) Successful in 1m1s
Release / Docker / ui (push) Failing after 2m43s
Release / Gitea Release (push) Has been skipped
Three light variants mirroring the existing dark set. All use CSS custom
properties so no component changes are needed. Theme dots in the header
and mobile drawer show a separator between dark/light groups; light-theme
swatches get a subtle ring so they're visible on light backgrounds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:13:25 +05:00
Admin
90dbecfa17 feat(profile): auto-save settings, merge sections, fix font/size preview
Some checks failed
CI / Backend (push) Successful in 30s
Release / Test backend (push) Successful in 33s
Release / Docker / caddy (push) Failing after 10s
CI / UI (push) Successful in 52s
Release / Check ui (push) Successful in 1m7s
CI / Backend (pull_request) Successful in 40s
CI / UI (pull_request) Successful in 53s
Release / Docker / runner (push) Failing after 46s
Release / Docker / backend (push) Successful in 3m16s
Release / Docker / ui (push) Successful in 5m2s
Release / Gitea Release (push) Has been skipped
- Remove both "Save settings" buttons; all settings now auto-save with
  800ms debounce and show a transient "✓ Saved" indicator
- Apply theme, font family, and font size to context immediately on
  change so the preview is live without waiting for the save
- Merge Appearance + Reading settings into a single Preferences card
  with dividers — fewer sections, less visual noise
- Pro users see a compact subscription row; free users see upgrade CTAs
- Speed label splits value and units (shows "1.5x" separately in brand
  color) for cleaner readout
- Auto-advance toggle gains a subtitle description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:07:51 +05:00
16 changed files with 541 additions and 455 deletions

View File

@@ -160,6 +160,9 @@
"profile_theme_amber": "Amber",
"profile_theme_slate": "Slate",
"profile_theme_rose": "Rose",
"profile_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",
"profile_reading_heading": "Reading settings",
"profile_voice_label": "Default voice",
"profile_speed_label": "Playback speed",

View File

@@ -160,6 +160,9 @@
"profile_theme_amber": "Ambre",
"profile_theme_slate": "Ardoise",
"profile_theme_rose": "Rose",
"profile_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",
"profile_reading_heading": "Paramètres de lecture",
"profile_voice_label": "Voix par défaut",
"profile_speed_label": "Vitesse de lecture",

View File

@@ -160,6 +160,9 @@
"profile_theme_amber": "Amber",
"profile_theme_slate": "Abu-abu",
"profile_theme_rose": "Mawar",
"profile_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",
"profile_reading_heading": "Pengaturan membaca",
"profile_voice_label": "Suara default",
"profile_speed_label": "Kecepatan pemutaran",

View File

@@ -160,6 +160,9 @@
"profile_theme_amber": "Âmbar",
"profile_theme_slate": "Ardósia",
"profile_theme_rose": "Rosa",
"profile_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",
"profile_reading_heading": "Configurações de leitura",
"profile_voice_label": "Voz padrão",
"profile_speed_label": "Velocidade de reprodução",

View File

@@ -160,6 +160,9 @@
"profile_theme_amber": "Янтарь",
"profile_theme_slate": "Сланец",
"profile_theme_rose": "Роза",
"profile_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",
"profile_reading_heading": "Настройки чтения",
"profile_voice_label": "Голос по умолчанию",
"profile_speed_label": "Скорость воспроизведения",

View File

@@ -55,6 +55,48 @@
--color-success: #4ade80; /* green-400 */
}
/* ── Light amber theme ────────────────────────────────────────────────── */
[data-theme="light"] {
--color-brand: #d97706; /* amber-600 */
--color-brand-dim: #b45309; /* amber-700 */
--color-surface: #ffffff;
--color-surface-2: #f4f4f5; /* zinc-100 */
--color-surface-3: #e4e4e7; /* zinc-200 */
--color-muted: #71717a; /* zinc-500 */
--color-text: #18181b; /* zinc-900 */
--color-border: #d4d4d8; /* zinc-300 */
--color-danger: #dc2626; /* red-600 */
--color-success: #16a34a; /* green-600 */
}
/* ── Light slate theme ────────────────────────────────────────────────── */
[data-theme="light-slate"] {
--color-brand: #4f46e5; /* indigo-600 */
--color-brand-dim: #4338ca; /* indigo-700 */
--color-surface: #f8fafc; /* slate-50 */
--color-surface-2: #f1f5f9; /* slate-100 */
--color-surface-3: #e2e8f0; /* slate-200 */
--color-muted: #64748b; /* slate-500 */
--color-text: #0f172a; /* slate-900 */
--color-border: #cbd5e1; /* slate-300 */
--color-danger: #dc2626; /* red-600 */
--color-success: #16a34a; /* green-600 */
}
/* ── Light rose theme ─────────────────────────────────────────────────── */
[data-theme="light-rose"] {
--color-brand: #e11d48; /* rose-600 */
--color-brand-dim: #be123c; /* rose-700 */
--color-surface: #fff1f2; /* rose-50 */
--color-surface-2: #ffe4e6; /* rose-100 */
--color-surface-3: #fecdd3; /* rose-200 */
--color-muted: #9f1239; /* rose-800 at 60% */
--color-text: #0f0a0b; /* near black */
--color-border: #fda4af; /* rose-300 */
--color-danger: #dc2626; /* red-600 */
--color-success: #16a34a; /* green-600 */
}
html {
background-color: var(--color-surface);
color: var(--color-text);

View File

@@ -6,10 +6,12 @@
import * as m from '$lib/paraglide/messages.js';
let {
slug,
chapter = 0,
isLoggedIn = false,
currentUserId = ''
}: {
slug: string;
chapter?: number; // 0 = book-level, N = chapter N
isLoggedIn?: boolean;
currentUserId?: string;
} = $props();
@@ -47,7 +49,7 @@
loadError = '';
try {
const res = await fetch(
`/api/comments/${encodeURIComponent(slug)}?sort=${sort}`
`/api/comments/${encodeURIComponent(slug)}?sort=${sort}${chapter > 0 ? `&chapter=${chapter}` : ''}`
);
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
@@ -85,7 +87,7 @@
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: text })
body: JSON.stringify({ body: text, ...(chapter > 0 ? { chapter } : {}) })
});
if (res.status === 401) { postError = 'You must be logged in to comment.'; return; }
if (!res.ok) {

View File

@@ -1130,6 +1130,7 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr
export interface PBBookComment {
id: string;
slug: string;
chapter?: number; // 0 or absent = book-level; N = chapter N
user_id: string;
username: string;
body: string;
@@ -1150,25 +1151,26 @@ export interface CommentVote {
export type CommentSort = 'top' | 'new';
/**
* List top-level comments for a book.
* List top-level comments for a book or a specific chapter.
* chapter=0 (default) → book-level comments only
* chapter=N → comments for chapter N only
* sort='top' → by net score (upvotes downvotes) desc, then newest
* sort='new' → newest first (default)
* Replies (parent_id != "") are NOT included — fetch them separately.
*/
export async function listComments(
slug: string,
sort: CommentSort = 'new'
sort: CommentSort = 'new',
chapter = 0
): Promise<PBBookComment[]> {
const token = await getToken();
const slugEsc = slug.replace(/"/g, '\\"');
// Only top-level comments (parent_id is empty or missing)
const filter = encodeURIComponent(`slug="${slugEsc}"&&(parent_id=""||parent_id=null)`);
// PocketBase sorts: for 'top' we still fetch all and re-sort in JS because
// PocketBase doesn't support computed sort fields. For 'new' we push the
// sort down to the DB so large result sets are still paged correctly.
const pbSort = sort === 'new' ? '&sort=-created' : '&sort=-created';
const chapterFilter = chapter > 0
? `&&chapter=${chapter}`
: `&&(chapter=0||chapter=null)`;
const filter = encodeURIComponent(`slug="${slugEsc}"${chapterFilter}&&(parent_id=""||parent_id=null)`);
const res = await fetch(
`${PB_URL}/api/collections/book_comments/records?filter=${filter}${pbSort}&perPage=200`,
`${PB_URL}/api/collections/book_comments/records?filter=${filter}&sort=-created&perPage=200`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) return [];
@@ -1179,13 +1181,32 @@ export async function listComments(
const scoreB = (b.upvotes ?? 0) - (b.downvotes ?? 0);
const scoreA = (a.upvotes ?? 0) - (a.downvotes ?? 0);
if (scoreB !== scoreA) return scoreB - scoreA;
// tie-break: newest first
return new Date(b.created).getTime() - new Date(a.created).getTime();
});
}
return items;
}
/**
* Count unique readers for a book in the last 7 days.
* Uses progress.updated timestamp; counts both session-based and user-based.
*/
export async function countReadersThisWeek(slug: string): Promise<number> {
const token = await getToken();
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const filter = encodeURIComponent(`slug="${slug.replace(/"/g, '\\"')}"&&updated>"${cutoff}"`);
const res = await fetch(
`${PB_URL}/api/collections/progress/records?filter=${filter}&perPage=500&fields=user_id,session_id`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) return 0;
const data = await res.json();
const items = (data.items ?? []) as { user_id?: string; session_id?: string }[];
// Deduplicate: prefer user_id when present, fall back to session_id
const unique = new Set(items.map((r) => r.user_id || r.session_id || '').filter(Boolean));
return unique.size;
}
/**
* List replies (1-level deep) for a single parent comment.
* Always sorted oldest-first so the conversation reads naturally.
@@ -1211,7 +1232,8 @@ export async function createComment(
body: string,
userId: string | undefined,
username: string,
parentId?: string
parentId?: string,
chapter = 0
): Promise<PBBookComment> {
const token = await getToken();
const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, {
@@ -1219,6 +1241,7 @@ export async function createComment(
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
slug,
chapter,
body,
user_id: userId ?? '',
username,

View File

@@ -22,9 +22,12 @@
let langMenuOpen = $state(false);
const THEMES = [
{ id: 'amber', color: '#f59e0b' },
{ id: 'slate', color: '#818cf8' },
{ id: 'rose', color: '#fb7185' },
{ id: 'amber', color: '#f59e0b' },
{ id: 'slate', color: '#818cf8' },
{ id: 'rose', color: '#fb7185' },
{ id: 'light', color: '#d97706', light: true },
{ id: 'light-slate', color: '#4f46e5', light: true },
{ id: 'light-rose', color: '#e11d48', light: true },
];
// Chapter list drawer state for the mini-player
@@ -316,12 +319,15 @@
<div class="ml-auto flex items-center gap-2">
<!-- Theme dots (desktop) -->
<div class="hidden sm:flex items-center gap-1 mr-1">
{#each THEMES as t}
{#each THEMES as t, i}
{#if i === 3}
<span class="w-px h-3 bg-(--color-border) mx-0.5"></span>
{/if}
<button
type="button"
onclick={() => { currentTheme = t.id; }}
title={t.id}
class="w-3.5 h-3.5 rounded-full border-2 transition-all {currentTheme === t.id ? 'border-(--color-text) scale-110' : 'border-transparent opacity-50 hover:opacity-100'}"
class="w-3.5 h-3.5 rounded-full border-2 transition-all {currentTheme === t.id ? 'border-(--color-text) scale-110' : t.light ? 'border-(--color-border) opacity-70 hover:opacity-100' : 'border-transparent opacity-50 hover:opacity-100'}"
style="background: {t.color};"
></button>
{/each}
@@ -498,13 +504,16 @@
<div class="my-1 border-t border-(--color-border)/60"></div>
<div class="px-3 py-2.5 flex items-center justify-between">
<span class="text-xs text-(--color-muted) uppercase tracking-widest">{m.profile_theme_label()}</span>
<div class="flex items-center gap-2">
{#each THEMES as t}
<div class="flex items-center gap-1.5">
{#each THEMES as t, i}
{#if i === 3}
<span class="w-px h-4 bg-(--color-border) mx-0.5"></span>
{/if}
<button
type="button"
onclick={() => { currentTheme = t.id; }}
title={t.id}
class="w-5 h-5 rounded-full border-2 transition-all {currentTheme === t.id ? 'border-(--color-text) scale-110' : 'border-transparent opacity-50 hover:opacity-100'}"
class="w-5 h-5 rounded-full border-2 transition-all {currentTheme === t.id ? 'border-(--color-text) scale-110' : t.light ? 'border-(--color-border) opacity-70 hover:opacity-100' : 'border-transparent opacity-50 hover:opacity-100'}"
style="background: {t.color};"
></button>
{/each}

View File

@@ -10,194 +10,220 @@
try {
const parsed = JSON.parse(genres);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
} catch { return []; }
}
// Deduplicate recentlyUpdated by slug, keeping the first occurrence and
// counting how many times the same book appears (= new chapters added).
const dedupedRecent = $derived.by(() => {
const seen = new Map<string, { book: (typeof data.recentlyUpdated)[0]; count: number }>();
for (const book of data.recentlyUpdated) {
if (seen.has(book.slug)) {
seen.get(book.slug)!.count++;
} else {
seen.set(book.slug, { book, count: 1 });
}
}
return [...seen.values()];
});
const GENRES = [
'Action', 'Fantasy', 'Romance', 'Cultivation', 'System',
'Reincarnation', 'Sci-Fi', 'Horror', 'Slice of Life', 'Adventure',
];
// Hero = first continue-reading item; shelf = the rest
const heroBook = $derived(data.continueReading[0] ?? null);
const shelfBooks = $derived(data.continueReading.slice(1));
</script>
<svelte:head>
<title>{m.home_title()}</title>
</svelte:head>
<!-- Stats bar -->
<div class="flex gap-6 mb-8 text-center">
<div class="flex-1 rounded-lg bg-(--color-surface-2) border border-(--color-border) py-4 px-6">
<p class="text-2xl font-bold text-(--color-brand)">{data.stats.totalBooks}</p>
<p class="text-xs text-(--color-muted) mt-0.5">{m.home_stat_books()}</p>
</div>
<div class="flex-1 rounded-lg bg-(--color-surface-2) border border-(--color-border) py-4 px-6">
<p class="text-2xl font-bold text-(--color-brand)">{data.stats.totalChapters.toLocaleString()}</p>
<p class="text-xs text-(--color-muted) mt-0.5">{m.home_stat_chapters()}</p>
</div>
<div class="flex-1 rounded-lg bg-(--color-surface-2) border border-(--color-border) py-4 px-6">
<p class="text-2xl font-bold text-(--color-brand)">{data.stats.booksInProgress}</p>
<p class="text-xs text-(--color-muted) mt-0.5">{m.home_stat_in_progress()}</p>
</div>
</div>
<!-- Continue Reading -->
{#if data.continueReading.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<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>
<!-- ── Hero resume card ──────────────────────────────────────────────────────── -->
{#if heroBook}
<section class="mb-10">
<a
href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all"
>
<!-- Cover -->
<div class="w-32 sm:w-44 shrink-0 aspect-[2/3] overflow-hidden">
{#if heroBook.book.cover}
<img src={heroBook.book.cover} alt={heroBook.book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" loading="eager" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-10 h-10 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>
</div>
{/if}
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each data.continueReading as { book, chapter }}
<a
href="/books/{book.slug}/chapters/{chapter}"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-(--color-surface) 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 flex items-center justify-center text-(--color-muted)">
<svg class="w-10 h-10" 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>
{/if}
<!-- Chapter badge overlay -->
<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) })}
<!-- Info -->
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0">
<div>
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-2">{m.home_continue_reading()}</p>
<h2 class="text-xl sm:text-2xl font-bold text-(--color-text) leading-snug line-clamp-2 mb-1">{heroBook.book.title}</h2>
{#if heroBook.book.author}
<p class="text-sm text-(--color-muted)">{heroBook.book.author}</p>
{/if}
{#if heroBook.book.description}
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-2 max-w-prose">{heroBook.book.description}</p>
{/if}
</div>
<div class="flex items-center gap-3 mt-4 flex-wrap">
<span class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm group-hover:bg-(--color-brand-dim) transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{m.home_chapter_badge({ n: String(heroBook.chapter) })}
</span>
{#each parseGenres(heroBook.book.genres).slice(0, 2) as genre}
<span class="text-xs px-2 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
{/each}
</div>
</div>
</a>
</section>
{/if}
<!-- ── Continue Reading shelf (remaining books) ──────────────────────────────── -->
{#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>
<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 }}
<a href="/books/{book.slug}/chapters/{chapter}"
class="group 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="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>
</div>
{/if}
<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>
</div>
<div class="p-2">
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- ── Genre discovery strip ─────────────────────────────────────────────────── -->
<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>
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
</div>
<div class="flex gap-2 overflow-x-auto pb-1 scrollbar-none -mx-4 px-4">
{#each GENRES as genre}
<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">
{genre}
</a>
{/each}
</div>
</section>
<!-- ── Recently Updated ──────────────────────────────────────────────────────── -->
{#if dedupedRecent.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_recently_updated()}</h2>
<a href="/catalogue" 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 dedupedRecent as { book, count }}
{@const genres = parseGenres(book.genres)}
<a href="/books/{book.slug}"
class="group 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">
<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>
</div>
{/if}
{#if count > 1}
<span class="absolute top-1.5 left-1.5 text-xs bg-(--color-success)/90 text-black font-bold px-1.5 py-0.5 rounded">
+{count} ch.
</span>
</div>
<div class="p-2">
<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}
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- Recently Updated -->
{#if data.recentlyUpdated.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-lg font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each data.recentlyUpdated as book}
{@const genres = parseGenres(book.genres)}
<a
href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden">
{#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 flex items-center justify-center text-(--color-muted)">
<svg class="w-10 h-10" 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>
{/if}
</div>
<div class="p-2 flex flex-col gap-1">
<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">{book.author}</p>
{/if}
{#if book.status}
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-text) self-start">{book.status}</span>
{/if}
{#if genres.length > 0}
<div class="flex flex-wrap gap-1 mt-auto pt-1">
{#each genres.slice(0, 2) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
{/each}
</div>
{/if}
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- Empty state -->
{#if data.continueReading.length === 0 && data.recentlyUpdated.length === 0}
<div class="text-center py-20 text-(--color-muted)">
<p class="text-lg font-semibold text-(--color-text) mb-2">{m.home_empty_title()}</p>
<p class="text-sm mb-6">{m.home_empty_body()}</p>
<a
href="/catalogue"
class="inline-block px-6 py-3 bg-(--color-brand) text-(--color-surface) font-semibold rounded hover:bg-(--color-brand-dim) transition-colors"
>
{m.home_discover_novels()}
</a>
{/if}
</div>
<div class="p-2 flex flex-col gap-1">
<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">{book.author}</p>
{/if}
{#if genres.length > 0}
<div class="flex flex-wrap gap-1 mt-auto pt-0.5">
{#each genres.slice(0, 2) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
{/each}
</div>
{/if}
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- From Subscriptions -->
<!-- ── From Following ────────────────────────────────────────────────────────── -->
{#if data.subscriptionFeed.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-lg font-bold text-(--color-text)">{m.home_from_following()}</h2>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each data.subscriptionFeed as { book, readerUsername }}
{@const genres = parseGenres(book.genres)}
<a
href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden">
{#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 flex items-center justify-center text-(--color-muted)">
<svg class="w-10 h-10" 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>
{/if}
</div>
<div class="p-2 flex flex-col gap-1">
<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">{book.author}</p>
{/if}
<!-- Reader attribution -->
<p class="text-xs text-(--color-muted) truncate mt-0.5">
{m.home_via_reader({ username: readerUsername })}
</p>
{#if genres.length > 0}
<div class="flex flex-wrap gap-1 mt-auto pt-1">
{#each genres.slice(0, 1) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
{/each}
</div>
{/if}
</div>
</a>
{/each}
</div>
</section>
<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>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each data.subscriptionFeed as { book, readerUsername }}
<a href="/books/{book.slug}"
class="group 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">
<div class="aspect-[2/3] overflow-hidden">
{#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>
</div>
{/if}
</div>
<div class="p-2 flex flex-col gap-0.5">
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
<p class="text-xs text-(--color-muted) truncate">{m.home_via_reader({ username: readerUsername })}</p>
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- ── Empty state (no content at all) ──────────────────────────────────────── -->
{#if data.continueReading.length === 0 && dedupedRecent.length === 0}
<div class="text-center py-20 text-(--color-muted)">
<p class="text-lg font-semibold text-(--color-text) mb-2">{m.home_empty_title()}</p>
<p class="text-sm mb-6">{m.home_empty_body()}</p>
<a href="/catalogue" class="inline-block px-6 py-3 bg-(--color-brand) text-(--color-surface) font-semibold rounded-lg hover:bg-(--color-brand-dim) transition-colors">
{m.home_discover_novels()}
</a>
</div>
{/if}
<!-- ── Stats footer ──────────────────────────────────────────────────────────── -->
<div class="mt-6 pt-6 border-t border-(--color-border) flex items-center justify-center gap-6 text-sm text-(--color-muted)">
<span><span class="font-semibold text-(--color-text)">{data.stats.totalBooks.toLocaleString()}</span> {m.home_stat_books()}</span>
<span class="w-px h-4 bg-(--color-border)"></span>
<span><span class="font-semibold text-(--color-text)">{data.stats.totalChapters.toLocaleString()}</span> {m.home_stat_chapters()}</span>
</div>

View File

@@ -21,9 +21,10 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
const { slug } = params;
const sortParam = url.searchParams.get('sort') ?? 'new';
const sort: CommentSort = sortParam === 'top' ? 'top' : 'new';
const chapter = parseInt(url.searchParams.get('chapter') ?? '0', 10) || 0;
try {
const topLevel = await listComments(slug, sort);
const topLevel = await listComments(slug, sort, chapter);
// Fetch replies for all top-level comments in parallel
const repliesPerComment = await Promise.all(topLevel.map((c) => listReplies(c.id)));
@@ -75,7 +76,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
if (!locals.user) error(401, 'Login required to comment');
const { slug } = params;
let body: { body?: string; parent_id?: string };
let body: { body?: string; parent_id?: string; chapter?: number };
try {
body = await request.json();
} catch {
@@ -86,8 +87,8 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
if (!text) error(400, 'Comment body is required');
if (text.length > 2000) error(400, 'Comment is too long (max 2000 characters)');
// Enforce 1-level depth: parent_id must be a top-level comment
const parentId = body.parent_id?.trim() || undefined;
const chapter = typeof body.chapter === 'number' ? body.chapter : 0;
try {
const comment = await createComment(
@@ -95,7 +96,8 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
text,
locals.user.id,
locals.user.username,
parentId
parentId,
chapter
);
return json(comment, { status: 201 });
} catch (e) {

View File

@@ -44,7 +44,7 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
}
// theme is optional — if provided it must be a known value
const validThemes = ['amber', 'slate', 'rose'];
const validThemes = ['amber', 'slate', 'rose', 'light', 'light-slate', 'light-rose'];
if (body.theme !== undefined && !validThemes.includes(body.theme)) {
error(400, `Invalid theme — must be one of: ${validThemes.join(', ')}`);
}

View File

@@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getBook, listChapterIdx, getProgress, isBookSaved } from '$lib/server/pocketbase';
import { getBook, listChapterIdx, getProgress, isBookSaved, countReadersThisWeek } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import { backendFetch, type BookPreviewResponse } from '$lib/server/scraper';
@@ -15,12 +15,13 @@ export const load: PageServerLoad = async ({ params, locals }) => {
if (book) {
// Book is in the library — normal path
let chapters, progress, saved;
let chapters, progress, saved, readersThisWeek;
try {
[chapters, progress, saved] = await Promise.all([
[chapters, progress, saved, readersThisWeek] = await Promise.all([
listChapterIdx(slug),
getProgress(locals.sessionId, slug, locals.user?.id),
isBookSaved(locals.sessionId, slug, locals.user?.id)
isBookSaved(locals.sessionId, slug, locals.user?.id),
countReadersThisWeek(slug)
]);
} catch (e) {
log.error('books', 'failed to load book page data', { slug, err: String(e) });
@@ -33,6 +34,7 @@ export const load: PageServerLoad = async ({ params, locals }) => {
inLib: true,
saved,
lastChapter: progress?.chapter ?? null,
readersThisWeek,
isAdmin: locals.user?.role === 'admin',
isLoggedIn: !!locals.user,
currentUserId: locals.user?.id ?? '',

View File

@@ -203,6 +203,12 @@
{#each genres as genre}
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border)">{genre}</span>
{/each}
{#if data.readersThisWeek && data.readersThisWeek > 0}
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border) flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/></svg>
{data.readersThisWeek} reading this week
</span>
{/if}
</div>
<!-- Summary with expand toggle -->

View File

@@ -3,6 +3,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
import CommentsSection from '$lib/components/CommentsSection.svelte';
import type { PageData } from './$types';
import * as m from '$lib/paraglide/messages.js';
@@ -337,3 +338,13 @@
</a>
{/if}
</div>
<!-- Chapter comments -->
<div class="mt-12">
<CommentsSection
slug={data.book.slug}
chapter={data.chapter.number}
isLoggedIn={!!page.data.user}
currentUserId={page.data.user?.id ?? ''}
/>
</div>

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { untrack, getContext } from 'svelte';
import type { PageData, ActionData } from './$types';
import { audioStore } from '$lib/audio.svelte';
@@ -16,14 +15,12 @@
let avatarError = $state('');
let fileInput: HTMLInputElement | null = null;
// Crop modal state
let cropFile = $state<File | null>(null);
function handleAvatarChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// Reset input so the same file can be re-selected after cancel
if (fileInput) fileInput.value = '';
cropFile = file;
}
@@ -33,7 +30,6 @@
avatarUploading = true;
avatarError = '';
try {
// POST raw bytes to the SvelteKit server, which proxies to MinIO internally.
const res = await fetch('/api/profile/avatar', {
method: 'POST',
headers: { 'Content-Type': mimeType },
@@ -57,95 +53,104 @@
cropFile = null;
}
// ── Settings ────────────────────────────────────────────────────────────────
// ── Voices ───────────────────────────────────────────────────────────────────
let voices = $state<Voice[]>([]);
let voicesLoaded = $state(false);
// Derived: voices grouped by engine
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
// Load voices on mount
$effect(() => {
fetch('/api/voices')
.then((r) => r.json())
.then((d: { voices: Voice[] }) => {
voices = d.voices ?? [];
voicesLoaded = true;
})
.catch(() => {
voicesLoaded = true;
});
.then((d: { voices: Voice[] }) => { voices = d.voices ?? []; voicesLoaded = true; })
.catch(() => { voicesLoaded = true; });
});
// Mirror from audioStore so sliders feel live
// ── Settings state ───────────────────────────────────────────────────────────
let voice = $state(audioStore.voice);
let speed = $state(audioStore.speed);
let autoNext = $state(audioStore.autoNext);
// Keep in sync when layout changes them externally
$effect(() => {
voice = audioStore.voice;
speed = audioStore.speed;
autoNext = audioStore.autoNext;
});
// ── Theme + Font ─────────────────────────────────────────────────────────────
const settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme');
let selectedTheme = $state(untrack(() => data.settings?.theme ?? settingsCtx?.current ?? 'amber'));
let selectedFontFamily = $state(untrack(() => data.settings?.fontFamily ?? settingsCtx?.fontFamily ?? 'system'));
let selectedFontSize = $state(untrack(() => data.settings?.fontSize ?? settingsCtx?.fontSize ?? 1.0));
const THEMES: { id: string; label: () => string; swatch: string }[] = [
{ id: 'amber', label: () => m.profile_theme_amber(), swatch: '#f59e0b' },
{ id: 'slate', label: () => m.profile_theme_slate(), swatch: '#818cf8' },
{ id: 'rose', label: () => m.profile_theme_rose(), swatch: '#fb7185' },
const THEMES: { id: string; label: () => string; swatch: string; light?: boolean }[] = [
{ id: 'amber', label: () => m.profile_theme_amber(), swatch: '#f59e0b' },
{ id: 'slate', label: () => m.profile_theme_slate(), swatch: '#818cf8' },
{ id: 'rose', label: () => m.profile_theme_rose(), swatch: '#fb7185' },
{ id: 'light', label: () => m.profile_theme_light(), swatch: '#d97706', light: true },
{ id: 'light-slate', label: () => m.profile_theme_light_slate(), swatch: '#4f46e5', light: true },
{ id: 'light-rose', label: () => m.profile_theme_light_rose(), swatch: '#e11d48', light: true },
];
const FONTS = [
{ id: 'system', label: () => m.profile_font_system() },
{ id: 'serif', label: () => m.profile_font_serif() },
{ id: 'mono', label: () => m.profile_font_mono() },
{ id: 'serif', label: () => m.profile_font_serif() },
{ id: 'mono', label: () => m.profile_font_mono() },
];
const FONT_SIZES = [
{ value: 0.9, label: () => m.profile_text_size_sm() },
{ value: 1.0, label: () => m.profile_text_size_md() },
{ value: 0.9, label: () => m.profile_text_size_sm() },
{ value: 1.0, label: () => m.profile_text_size_md() },
{ value: 1.15, label: () => m.profile_text_size_lg() },
{ value: 1.3, label: () => m.profile_text_size_xl() },
{ value: 1.3, label: () => m.profile_text_size_xl() },
];
let settingsSaving = $state(false);
let settingsSaved = $state(false);
// ── Auto-save ────────────────────────────────────────────────────────────────
type SaveStatus = 'idle' | 'saving' | 'saved';
let saveStatus = $state<SaveStatus>('idle');
let saveTimer = 0;
let savedTimer = 0;
let initialized = false;
async function saveSettings() {
settingsSaving = true;
settingsSaved = false;
try {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext, voice, speed, theme: selectedTheme, fontFamily: selectedFontFamily, fontSize: selectedFontSize })
});
// Sync to audioStore so the player picks up changes immediately
audioStore.autoNext = autoNext;
audioStore.voice = voice;
audioStore.speed = speed;
// Apply theme + font live via context
if (settingsCtx) {
settingsCtx.current = selectedTheme;
settingsCtx.fontFamily = selectedFontFamily;
settingsCtx.fontSize = selectedFontSize;
}
await invalidateAll();
settingsSaved = true;
setTimeout(() => (settingsSaved = false), 2500);
} finally {
settingsSaving = false;
$effect(() => {
// Read all settings deps to subscribe
const t = selectedTheme;
const ff = selectedFontFamily;
const fs = selectedFontSize;
const v = voice;
const sp = speed;
const an = autoNext;
// Apply context immediately (font/theme previews live without waiting for save)
if (settingsCtx) {
settingsCtx.current = t;
settingsCtx.fontFamily = ff;
settingsCtx.fontSize = fs;
}
}
audioStore.voice = v;
audioStore.autoNext = an;
// ── Sessions ────────────────────────────────────────────────────────────────
if (!initialized) { initialized = true; return; }
clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
saveStatus = 'saving';
try {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext: an, voice: v, speed: sp, theme: t, fontFamily: ff, fontSize: fs })
});
saveStatus = 'saved';
clearTimeout(savedTimer);
savedTimer = setTimeout(() => (saveStatus = 'idle'), 2000) as unknown as number;
} catch {
saveStatus = 'idle';
}
}, 800) as unknown as number;
});
// ── Sessions ─────────────────────────────────────────────────────────────────
type Session = {
id: string;
user_agent: string;
@@ -164,19 +169,12 @@
revokeError = '';
try {
const res = await fetch(`/api/sessions/${session.id}`, { method: 'DELETE' });
if (!res.ok) {
revokeError = 'Failed to end session. Please try again.';
return;
}
if (!res.ok) { revokeError = 'Failed to end session. Please try again.'; return; }
if (session.is_current) {
// Ended our own session — submit the logout form to clear the cookie
const logoutForm = document.getElementById('logout-form') as HTMLFormElement | null;
if (logoutForm) {
logoutForm.submit();
}
if (logoutForm) logoutForm.submit();
return;
}
// Remove from local list
sessions = sessions.filter((s) => s.id !== session.id);
} catch {
revokeError = 'Network error. Please try again.';
@@ -188,18 +186,12 @@
function formatDate(iso: string): string {
if (!iso) return '—';
try {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(iso));
} catch {
return iso;
}
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
} catch { return iso; }
}
function parseUA(ua: string): string {
if (!ua) return 'Unknown browser';
// Very lightweight UA display — just show the most meaningful part
if (/Mobile/i.test(ua)) {
const match = ua.match(/\(([^)]+)\)/);
return match ? `Mobile — ${match[1].split(';')[0].trim()}` : 'Mobile device';
@@ -218,24 +210,20 @@
{#if cropFile && browser}
{#await import('$lib/components/AvatarCropModal.svelte') then { default: AvatarCropModal }}
<AvatarCropModal
file={cropFile}
onconfirm={handleCropConfirm}
oncancel={handleCropCancel}
/>
<AvatarCropModal file={cropFile} onconfirm={handleCropConfirm} oncancel={handleCropCancel} />
{/await}
{/if}
<!-- Hidden logout form used when user ends their own session -->
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
<div class="max-w-xl mx-auto space-y-10">
<div class="flex items-center gap-5">
<!-- Avatar -->
<div class="max-w-2xl mx-auto space-y-6 pb-12">
<!-- ── Profile header ──────────────────────────────────────────────────────── -->
<div class="flex items-center gap-5 pt-2">
<div class="relative shrink-0">
<button
onclick={() => fileInput?.click()}
class="group relative w-20 h-20 rounded-full overflow-hidden ring-2 ring-(--color-border) hover:ring-(--color-brand) transition-all focus:outline-none focus:ring-(--color-brand)"
class="group relative w-20 h-20 rounded-full overflow-hidden ring-2 ring-(--color-border) hover:ring-(--color-brand) transition-all focus:outline-none"
title={m.profile_change_avatar()}
disabled={avatarUploading}
>
@@ -248,7 +236,6 @@
</svg>
</div>
{/if}
<!-- Hover overlay -->
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
{#if avatarUploading}
<svg class="w-5 h-5 text-white animate-spin" fill="none" viewBox="0 0 24 24">
@@ -263,97 +250,96 @@
{/if}
</div>
</button>
<input
bind:this={fileInput}
type="file"
accept="image/jpeg,image/png,image/webp"
class="hidden"
onchange={handleAvatarChange}
/>
</div>
<div>
<h1 class="text-2xl font-bold text-(--color-text)">{data.user.username}</h1>
<p class="text-(--color-muted) text-sm mt-0.5 capitalize">{data.user.role}</p>
{#if avatarError}
<p class="text-(--color-danger) text-xs mt-1">{avatarError}</p>
{:else}
<p class="text-(--color-muted) text-xs mt-1">{m.profile_click_to_change()}</p>
{/if}
</div>
</div>
<!-- ── Subscription ─────────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
<div class="flex items-center justify-between gap-3 flex-wrap">
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_subscription_heading()}</h2>
{#if data.isPro}
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 tracking-wide uppercase">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/>
</svg>
{m.profile_plan_pro()}
</span>
{:else}
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-(--color-surface-3) text-(--color-muted) border border-(--color-border) uppercase tracking-wide">
{m.profile_plan_free()}
</span>
{/if}
<input bind:this={fileInput} type="file" accept="image/jpeg,image/png,image/webp" class="hidden" onchange={handleAvatarChange} />
</div>
{#if data.isPro}
<p class="text-sm text-(--color-text)">{m.profile_pro_active()}</p>
<p class="text-sm text-(--color-muted)">{m.profile_pro_perks()}</p>
<a
href="https://polar.sh/libnovel"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-sm font-medium text-(--color-brand) hover:underline"
>
{m.profile_manage_subscription()}
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
</a>
{:else}
<p class="text-sm text-(--color-muted)">{m.profile_free_limits()}</p>
<div>
<p class="text-sm font-semibold text-(--color-text) mb-3">{m.profile_upgrade_heading()}</p>
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()}</p>
<div class="flex flex-wrap gap-3">
<a
href="https://buy.polar.sh/libnovel/1376fdf5-b6a9-492b-be70-7c905131c0f9"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors"
>
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/>
</svg>
{m.profile_upgrade_monthly()}
</a>
<a
href="https://buy.polar.sh/libnovel/b6190307-79aa-4905-80c8-9ed941378d21"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors"
>
{m.profile_upgrade_annual()}
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30">33%</span>
</a>
</div>
<div class="min-w-0">
<h1 class="text-2xl font-bold text-(--color-text) truncate">{data.user.username}</h1>
<div class="flex items-center gap-2 mt-1 flex-wrap">
<span class="text-xs font-semibold px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted) capitalize border border-(--color-border)">{data.user.role}</span>
{#if data.isPro}
<span class="inline-flex items-center gap-1 text-xs font-bold px-2 py-0.5 rounded-full bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 uppercase tracking-wide">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
{m.profile_plan_pro()}
</span>
{/if}
</div>
{/if}
{#if avatarError}
<p class="text-(--color-danger) text-xs mt-1.5">{avatarError}</p>
{:else}
<p class="text-(--color-muted) text-xs mt-1.5">{m.profile_click_to_change()}</p>
{/if}
</div>
</div>
<!-- ── Subscription ─────────────────────────────────────────────────────────── -->
{#if !data.isPro}
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
<div class="flex items-start justify-between gap-4">
<div class="space-y-1">
<h2 class="text-base font-semibold text-(--color-text)">{m.profile_subscription_heading()}</h2>
<p class="text-sm text-(--color-muted)">{m.profile_free_limits()}</p>
</div>
<span class="shrink-0 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-(--color-surface-3) text-(--color-muted) border border-(--color-border) uppercase tracking-wide">
{m.profile_plan_free()}
</span>
</div>
<div class="mt-5 pt-5 border-t border-(--color-border)">
<p class="text-sm font-medium text-(--color-text) mb-1">{m.profile_upgrade_heading()}</p>
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()}</p>
<div class="flex flex-wrap gap-3">
<a href="https://buy.polar.sh/libnovel/1376fdf5-b6a9-492b-be70-7c905131c0f9" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors">
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
{m.profile_upgrade_monthly()}
</a>
<a href="https://buy.polar.sh/libnovel/b6190307-79aa-4905-80c8-9ed941378d21" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors">
{m.profile_upgrade_annual()}
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30">33%</span>
</a>
</div>
</div>
</section>
{:else}
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5 flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-(--color-text)">{m.profile_pro_active()}</p>
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_pro_perks()}</p>
</div>
<a href="https://polar.sh/libnovel" target="_blank" rel="noopener noreferrer"
class="shrink-0 inline-flex items-center gap-1.5 text-sm font-medium text-(--color-brand) hover:underline">
{m.profile_manage_subscription()}
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
</section>
{/if}
<!-- ── Appearance ────────────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-5">
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_appearance_heading()}</h2>
<!-- ── Preferences ──────────────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) divide-y divide-(--color-border)">
<div class="space-y-2">
<!-- Section header with auto-save indicator -->
<div class="flex items-center justify-between px-6 py-4">
<h2 class="text-base font-semibold text-(--color-text)">Preferences</h2>
<span class="text-xs transition-all duration-300 {saveStatus === 'saving' ? 'text-(--color-muted)' : saveStatus === 'saved' ? 'text-(--color-success)' : 'opacity-0 pointer-events-none'}">
{#if saveStatus === 'saving'}
{m.profile_saving()}
{:else if saveStatus === 'saved'}
{m.profile_saved()}
{:else}
{m.profile_saved()}
{/if}
</span>
</div>
<!-- Theme -->
<div class="px-6 py-5 space-y-3">
<p class="text-sm font-medium text-(--color-text)">{m.profile_theme_label()}</p>
<div class="flex gap-3 flex-wrap">
{#each THEMES as t}
<div class="flex gap-2 flex-wrap items-center">
{#each THEMES as t, i}
{#if i === 3}
<span class="w-px h-6 bg-(--color-border) mx-1 self-center"></span>
{/if}
<button
type="button"
onclick={() => (selectedTheme = t.id)}
@@ -363,26 +349,25 @@
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={selectedTheme === t.id}
>
<span class="w-3.5 h-3.5 rounded-full flex-shrink-0" style="background: {t.swatch};"></span>
<span class="w-3 h-3 rounded-full shrink-0 {t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
{t.label()}
{#if selectedTheme === t.id}
<svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
</svg>
{/if}
</button>
{/each}
</div>
</div>
<div class="space-y-2">
<!-- Font family -->
<div class="px-6 py-5 space-y-3">
<p class="text-sm font-medium text-(--color-text)">{m.profile_font_family()}</p>
<div class="flex gap-2 flex-wrap">
{#each FONTS as f}
<button
type="button"
onclick={() => (selectedFontFamily = f.id)}
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors {selectedFontFamily === f.id ? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)' : 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors
{selectedFontFamily === f.id
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={selectedFontFamily === f.id}
>
{f.label()}
@@ -391,14 +376,18 @@
</div>
</div>
<div class="space-y-2">
<!-- Text size -->
<div class="px-6 py-5 space-y-3">
<p class="text-sm font-medium text-(--color-text)">{m.profile_text_size()}</p>
<div class="flex gap-2 flex-wrap">
{#each FONT_SIZES as s}
<button
type="button"
onclick={() => (selectedFontSize = s.value)}
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors {selectedFontSize === s.value ? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)' : 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors
{selectedFontSize === s.value
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={selectedFontSize === s.value}
>
{s.label()}
@@ -407,115 +396,73 @@
</div>
</div>
<div class="flex items-center gap-3 pt-1">
<button
onclick={saveSettings}
disabled={settingsSaving}
class="px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60"
>
{settingsSaving ? m.profile_saving() : m.profile_save_settings()}
</button>
{#if settingsSaved}
<span class="text-sm text-(--color-success)">{m.profile_saved()}</span>
{/if}
</div>
</section>
<!-- ── Reading settings ─────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-5">
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_reading_heading()}</h2>
<!-- Voice -->
<div class="space-y-1.5">
<!-- TTS voice -->
<div class="px-6 py-5 space-y-3">
<label class="block text-sm font-medium text-(--color-text)" for="voice-select">{m.profile_tts_voice()}</label>
{#if !voicesLoaded}
<div class="h-9 bg-(--color-surface-3) rounded animate-pulse"></div>
<div class="h-9 bg-(--color-surface-3) rounded-lg animate-pulse"></div>
{:else if voices.length === 0}
<select id="voice-select" disabled class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-muted) text-sm cursor-not-allowed">
<option>{m.common_loading()}</option>
</select>
{:else}
<select
id="voice-select"
bind:value={voice}
class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
>
<select id="voice-select" bind:value={voice}
class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)">
{#if kokoroVoices.length > 0}
<optgroup label="Kokoro (GPU)">
{#each kokoroVoices as v}
<option value={v.id}>{v.id}</option>
{/each}
{#each kokoroVoices as v}<option value={v.id}>{v.id}</option>{/each}
</optgroup>
{/if}
{#if pocketVoices.length > 0}
<optgroup label="Pocket TTS (CPU)">
{#each pocketVoices as v}
<option value={v.id}>{v.id}</option>
{/each}
{#each pocketVoices as v}<option value={v.id}>{v.id}</option>{/each}
</optgroup>
{/if}
</select>
{/if}
</div>
<!-- Speed -->
<div class="space-y-1.5">
<label class="block text-sm font-medium text-(--color-text)" for="speed-range">
{m.profile_playback_speed({ speed: speed.toFixed(1) })}
</label>
<input
id="speed-range"
type="range"
min="0.5"
max="3.0"
step="0.1"
bind:value={speed}
style="accent-color: var(--color-brand);"
class="w-full"
/>
<!-- Playback speed -->
<div class="px-6 py-5 space-y-3">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-(--color-text)" for="speed-range">{m.profile_playback_speed({ speed: '' })}</label>
<span class="text-sm font-mono text-(--color-brand)">{speed.toFixed(1)}x</span>
</div>
<input id="speed-range" type="range" min="0.5" max="3.0" step="0.1" bind:value={speed}
style="accent-color: var(--color-brand);" class="w-full" />
<div class="flex justify-between text-xs text-(--color-muted)">
<span>0.5x</span>
<span>3.0x</span>
<span>0.5x</span><span>3.0x</span>
</div>
</div>
<!-- Auto-next toggle -->
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-(--color-text)">{m.profile_auto_advance()}</span>
<!-- Auto-advance -->
<div class="px-6 py-5 flex items-center justify-between">
<div>
<p class="text-sm font-medium text-(--color-text)">{m.profile_auto_advance()}</p>
<p class="text-xs text-(--color-muted) mt-0.5">Automatically load the next chapter when audio finishes</p>
</div>
<button
type="button"
role="switch"
aria-checked={autoNext}
onclick={() => (autoNext = !autoNext)}
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface) {autoNext ? 'bg-(--color-brand)' : 'bg-(--color-surface-3)'}"
class="shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface) {autoNext ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'}"
>
<span class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform {autoNext ? 'translate-x-6' : 'translate-x-1'}"></span>
</button>
</div>
<div class="flex items-center gap-3 pt-1">
<button
onclick={saveSettings}
disabled={settingsSaving}
class="px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60"
>
{settingsSaving ? m.profile_saving() : m.profile_save_settings()}
</button>
{#if settingsSaved}
<span class="text-sm text-(--color-success)">{m.profile_saved()}</span>
{/if}
</div>
</section>
<!-- ── Active sessions ──────────────────────────────────────────────────── -->
<!-- ── Active sessions ──────────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_sessions_heading()}</h2>
<p class="text-sm text-(--color-muted)">{m.profile_session_unrecognised()}</p>
<div>
<h2 class="text-base font-semibold text-(--color-text)">{m.profile_sessions_heading()}</h2>
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_session_unrecognised()}</p>
</div>
{#if revokeError}
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">
{revokeError}
</div>
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">{revokeError}</div>
{/if}
{#if sessions.length === 0}
@@ -547,7 +494,7 @@
class="shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
{session.is_current
? 'bg-(--color-danger)/10 text-(--color-danger) border border-(--color-danger)/60 hover:bg-(--color-danger)/20'
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border) hover:bg-(--color-surface-3)'}"
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border) hover:bg-(--color-surface-2)'}"
>
{revokingId === session.id ? '…' : session.is_current ? m.profile_session_sign_out() : m.profile_session_end()}
</button>
@@ -556,4 +503,5 @@
</ul>
{/if}
</section>
</div>