Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6776d9106f | ||
|
|
ada7de466a | ||
|
|
c91dd20c8c | ||
|
|
3b24f4560f | ||
|
|
973e639274 | ||
|
|
e78c44459e |
@@ -505,7 +505,11 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
|
||||
log.Warn("runner: unknown task kind")
|
||||
}
|
||||
|
||||
if err := r.deps.Consumer.FinishScrapeTask(ctx, task.ID, result); err != nil {
|
||||
// Use a fresh context for the final write so a cancelled task context doesn't
|
||||
// prevent the result counters from being persisted to PocketBase.
|
||||
finishCtx, finishCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer finishCancel()
|
||||
if err := r.deps.Consumer.FinishScrapeTask(finishCtx, task.ID, result); err != nil {
|
||||
log.Error("runner: FinishScrapeTask failed", "err", err)
|
||||
}
|
||||
|
||||
@@ -551,7 +555,7 @@ func (r *Runner) runCatalogueTask(ctx context.Context, task domain.ScrapeTask, o
|
||||
TargetURL: entry.URL,
|
||||
}
|
||||
bookResult := o.RunBook(ctx, bookTask)
|
||||
result.BooksFound += bookResult.BooksFound + 1
|
||||
result.BooksFound += bookResult.BooksFound
|
||||
result.ChaptersScraped += bookResult.ChaptersScraped
|
||||
result.ChaptersSkipped += bookResult.ChaptersSkipped
|
||||
result.Errors += bookResult.Errors
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
import { cn } from '$lib/utils';
|
||||
import type { Voice } from '$lib/types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import ChapterPickerOverlay from '$lib/components/ChapterPickerOverlay.svelte';
|
||||
|
||||
interface Props {
|
||||
slug: string;
|
||||
@@ -107,22 +108,10 @@
|
||||
|
||||
// ── Chapter picker state ─────────────────────────────────────────────────
|
||||
let showChapterPanel = $state(false);
|
||||
let chapterSearch = $state('');
|
||||
const filteredChapters = $derived(
|
||||
chapterSearch.trim() === ''
|
||||
? audioStore.chapters
|
||||
: audioStore.chapters.filter((ch) =>
|
||||
(ch.title || `Chapter ${ch.number}`)
|
||||
.toLowerCase()
|
||||
.includes(chapterSearch.toLowerCase()) ||
|
||||
String(ch.number).includes(chapterSearch)
|
||||
)
|
||||
);
|
||||
|
||||
function playChapter(chapterNumber: number) {
|
||||
audioStore.autoStartChapter = chapterNumber;
|
||||
showChapterPanel = false;
|
||||
chapterSearch = '';
|
||||
goto(`/books/${slug}/chapters/${chapterNumber}`);
|
||||
}
|
||||
|
||||
@@ -293,7 +282,7 @@
|
||||
// Close panels on Escape.
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (showChapterPanel) { showChapterPanel = false; chapterSearch = ''; }
|
||||
if (showChapterPanel) { showChapterPanel = false; }
|
||||
else { stopSample(); showVoicePanel = false; }
|
||||
}
|
||||
}
|
||||
@@ -1055,7 +1044,7 @@
|
||||
{#if voices.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; }}
|
||||
class={cn('flex items-center gap-1 text-xs transition-colors leading-none', showVoicePanel ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
title={m.reader_change_voice()}
|
||||
>
|
||||
@@ -1200,7 +1189,7 @@
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; }}
|
||||
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : '')}
|
||||
title={m.reader_change_voice()}
|
||||
>
|
||||
@@ -1389,75 +1378,13 @@
|
||||
the fixed inset-0 positioning is never clipped by overflow-hidden or
|
||||
border-radius on any ancestor wrapping the AudioPlayer component. -->
|
||||
{#if showChapterPanel && audioStore.chapters.length > 0}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[60] flex flex-col"
|
||||
style="background: var(--color-surface);"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
|
||||
<span class="text-sm font-semibold text-(--color-text) flex-1">Chapters</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showChapterPanel = false; chapterSearch = ''; }}
|
||||
class="w-9 h-9 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close chapter picker"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Search -->
|
||||
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search chapters…"
|
||||
bind:value={chapterSearch}
|
||||
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Chapter list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#each filteredChapters as ch (ch.number)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(ch.number)}
|
||||
class={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
|
||||
ch.number === chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
>
|
||||
<!-- Chapter number badge -->
|
||||
<span class={cn(
|
||||
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
|
||||
ch.number === chapter
|
||||
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
|
||||
: 'border-(--color-border) text-(--color-muted)'
|
||||
)}>{ch.number}</span>
|
||||
<!-- Title -->
|
||||
<span class={cn(
|
||||
'flex-1 text-sm truncate',
|
||||
ch.number === chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
|
||||
)}>{ch.title || `Chapter ${ch.number}`}</span>
|
||||
<!-- Now-playing indicator -->
|
||||
{#if ch.number === chapter}
|
||||
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if filteredChapters.length === 0}
|
||||
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<ChapterPickerOverlay
|
||||
chapters={audioStore.chapters}
|
||||
activeChapter={chapter}
|
||||
zIndex="z-[60]"
|
||||
onselect={playChapter}
|
||||
onclose={() => { showChapterPanel = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- ── Float player overlay ──────────────────────────────────────────────────
|
||||
|
||||
139
ui/src/lib/components/ChapterPickerOverlay.svelte
Normal file
139
ui/src/lib/components/ChapterPickerOverlay.svelte
Normal file
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface ChapterMeta {
|
||||
number: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Full chapter list to render and filter. */
|
||||
chapters: ChapterMeta[];
|
||||
/** Number of the currently-active chapter (highlighted + auto-scrolled). */
|
||||
activeChapter: number;
|
||||
/** z-index class, e.g. "z-[60]" or "z-[80]". Defaults to "z-[60]". */
|
||||
zIndex?: string;
|
||||
/** Called when a chapter row is tapped. The overlay does NOT close itself. */
|
||||
onselect: (chapterNumber: number) => void;
|
||||
/** Called when the close / chevron-down button is tapped. */
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
chapters,
|
||||
activeChapter,
|
||||
zIndex = 'z-[60]',
|
||||
onselect,
|
||||
onclose
|
||||
}: Props = $props();
|
||||
|
||||
let search = $state('');
|
||||
|
||||
const filtered = $derived(
|
||||
search.trim() === ''
|
||||
? chapters
|
||||
: chapters.filter((ch) =>
|
||||
(ch.title || `Chapter ${ch.number}`)
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase()) ||
|
||||
String(ch.number).includes(search)
|
||||
)
|
||||
);
|
||||
|
||||
/** Scroll the active chapter into view instantly (no animation) when the
|
||||
* list is first rendered so the user never has to hunt for their position. */
|
||||
function scrollIfActive(node: HTMLElement, isActive: boolean) {
|
||||
if (isActive) node.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
search = '';
|
||||
onclose();
|
||||
}
|
||||
|
||||
function handleSelect(n: number) {
|
||||
search = '';
|
||||
onselect(n);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 flex flex-col {zIndex}"
|
||||
style="background: var(--color-surface);"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0"
|
||||
style="padding-top: max(0.75rem, env(safe-area-inset-top));"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleClose}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close chapter picker"
|
||||
>
|
||||
<!-- chevron-down -->
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search chapters…"
|
||||
bind:value={search}
|
||||
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapter list -->
|
||||
<div
|
||||
class="flex-1 overflow-y-auto overscroll-contain"
|
||||
style="padding-bottom: env(safe-area-inset-bottom);"
|
||||
>
|
||||
{#each filtered as ch (ch.number)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelect(ch.number)}
|
||||
use:scrollIfActive={ch.number === activeChapter}
|
||||
class={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
|
||||
ch.number === activeChapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
>
|
||||
<span class={cn(
|
||||
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
|
||||
ch.number === activeChapter
|
||||
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
|
||||
: 'border-(--color-border) text-(--color-muted)'
|
||||
)}>{ch.number}</span>
|
||||
|
||||
<span class={cn(
|
||||
'flex-1 text-sm truncate',
|
||||
ch.number === activeChapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
|
||||
)}>{ch.title || `Chapter ${ch.number}`}</span>
|
||||
|
||||
{#if ch.number === activeChapter}
|
||||
<!-- play icon -->
|
||||
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{search}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -3,6 +3,7 @@
|
||||
import { cn } from '$lib/utils';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Voice } from '$lib/types';
|
||||
import ChapterPickerOverlay from '$lib/components/ChapterPickerOverlay.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Called when the user closes the overlay. */
|
||||
@@ -92,25 +93,14 @@
|
||||
const filteredCfai = $derived(cfaiVoices.filter((v) => voiceLabel(v).toLowerCase().includes(voiceSearchLower)));
|
||||
|
||||
// ── Chapter search ────────────────────────────────────────────────────────
|
||||
let chapterSearch = $state('');
|
||||
// (search state is managed internally by ChapterPickerOverlay)
|
||||
|
||||
// Scroll the current chapter into view instantly (no animation) when the
|
||||
// chapter modal opens. Applied to every chapter button; only scrolls when
|
||||
// the chapter number matches the currently playing one. Runs once on mount
|
||||
// before the browser paints so no scroll animation is ever visible.
|
||||
function scrollIfActive(node: HTMLElement, isActive: boolean) {
|
||||
if (isActive) node.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||
// ── Chapter click-to-play ─────────────────────────────────────────────────
|
||||
function playChapter(chapterNumber: number) {
|
||||
audioStore.autoStartChapter = chapterNumber;
|
||||
onclose();
|
||||
goto(`/books/${audioStore.slug}/chapters/${chapterNumber}`);
|
||||
}
|
||||
const filteredChapters = $derived(
|
||||
chapterSearch.trim() === ''
|
||||
? audioStore.chapters
|
||||
: audioStore.chapters.filter((ch) =>
|
||||
(ch.title || `Chapter ${ch.number}`)
|
||||
.toLowerCase()
|
||||
.includes(chapterSearch.toLowerCase()) ||
|
||||
String(ch.number).includes(chapterSearch)
|
||||
)
|
||||
);
|
||||
|
||||
function voiceLabel(v: Voice | string): string {
|
||||
if (typeof v === 'string') {
|
||||
@@ -156,13 +146,6 @@
|
||||
voiceSearch = '';
|
||||
}
|
||||
|
||||
// ── Chapter click-to-play ─────────────────────────────────────────────────
|
||||
function playChapter(chapterNumber: number) {
|
||||
audioStore.autoStartChapter = chapterNumber;
|
||||
onclose();
|
||||
goto(`/books/${audioStore.slug}/chapters/${chapterNumber}`);
|
||||
}
|
||||
|
||||
// ── Speed ────────────────────────────────────────────────────────────────
|
||||
const SPEED_OPTIONS = [0.75, 1, 1.25, 1.5, 2] as const;
|
||||
|
||||
@@ -453,63 +436,13 @@
|
||||
|
||||
<!-- Chapter modal (full-screen overlay) -->
|
||||
{#if showChapterModal && audioStore.chapters.length > 0}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[80] flex flex-col"
|
||||
style="background: var(--color-surface);"
|
||||
>
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0" style="padding-top: max(0.75rem, env(safe-area-inset-top));">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showChapterModal = false; }}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close chapter picker"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
|
||||
</div>
|
||||
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search chapters…"
|
||||
bind:value={chapterSearch}
|
||||
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto overscroll-contain" style="padding-bottom: env(safe-area-inset-bottom);">
|
||||
{#each filteredChapters as ch (ch.number)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(ch.number)}
|
||||
use:scrollIfActive={ch.number === audioStore.chapter}
|
||||
class={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
|
||||
ch.number === audioStore.chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
>
|
||||
<span class={cn(
|
||||
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
|
||||
ch.number === audioStore.chapter ? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)' : 'border-(--color-border) text-(--color-muted)'
|
||||
)}>{ch.number}</span>
|
||||
<span class={cn('flex-1 text-sm truncate', ch.number === audioStore.chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)')}>{ch.title || `Chapter ${ch.number}`}</span>
|
||||
{#if ch.number === audioStore.chapter}
|
||||
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if filteredChapters.length === 0}
|
||||
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<ChapterPickerOverlay
|
||||
chapters={audioStore.chapters}
|
||||
activeChapter={audioStore.chapter}
|
||||
zIndex="z-[80]"
|
||||
onselect={playChapter}
|
||||
onclose={() => { showChapterModal = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- ── Controls area (bottom half) ───────────────────────────────────── -->
|
||||
|
||||
@@ -1415,6 +1415,56 @@ export async function revokeAllUserSessions(userId: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all data associated with a user account:
|
||||
* - user_settings, user_library, progress, comment_votes, book_ratings,
|
||||
* user_subscriptions, user_sessions, notifications rows owned by the user
|
||||
* - the app_users record itself
|
||||
*
|
||||
* Does NOT delete audio files from MinIO (shared cache) or book comments
|
||||
* (anonymised to preserve discussion threads).
|
||||
*/
|
||||
export async function deleteUserAccount(userId: string, sessionId: string): Promise<void> {
|
||||
const collections = [
|
||||
{ name: 'user_settings', filter: `(user_id="${userId}" || session_id="${sessionId}")` },
|
||||
{ name: 'user_library', filter: `(user_id="${userId}" || session_id="${sessionId}")` },
|
||||
{ name: 'progress', filter: `(user_id="${userId}" || session_id="${sessionId}")` },
|
||||
{ name: 'comment_votes', filter: `user_id="${userId}"` },
|
||||
{ name: 'book_ratings', filter: `user_id="${userId}"` },
|
||||
{ name: 'user_subscriptions', filter: `(follower_id="${userId}" || followee_id="${userId}")` },
|
||||
{ name: 'notifications', filter: `user_id="${userId}"` },
|
||||
{ name: 'user_sessions', filter: `user_id="${userId}"` },
|
||||
];
|
||||
|
||||
const token = await getToken();
|
||||
|
||||
for (const { name, filter } of collections) {
|
||||
try {
|
||||
const rows = await listAll<{ id: string }>(name, filter);
|
||||
await Promise.all(
|
||||
rows.map((r) =>
|
||||
fetch(`${PB_URL}/api/collections/${name}/records/${r.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).catch(() => {})
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
// Best-effort: log and continue so one failure doesn't abort the rest
|
||||
log.warn('pocketbase', `deleteUserAccount: failed to purge ${name}`, { userId });
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the user record last
|
||||
const res = await pbDelete(`/api/collections/app_users/records/${userId}`);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'deleteUserAccount: failed to delete app_users record', { userId, status: res.status, body });
|
||||
throw new Error(`Failed to delete user record (${res.status})`);
|
||||
}
|
||||
log.info('pocketbase', 'deleteUserAccount: account deleted', { userId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the avatar_url field for a user record.
|
||||
*/
|
||||
|
||||
26
ui/src/routes/api/profile/+server.ts
Normal file
26
ui/src/routes/api/profile/+server.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { deleteUserAccount } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* DELETE /api/profile
|
||||
*
|
||||
* Permanently deletes the authenticated user's account and all associated data:
|
||||
* settings, library, progress, votes, ratings, sessions, notifications.
|
||||
*
|
||||
* The app_users record is removed last. The caller should immediately log the
|
||||
* user out (submit the logout form) to clear the session cookie.
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.user) error(401, 'Not authenticated');
|
||||
|
||||
try {
|
||||
await deleteUserAccount(locals.user.id, locals.sessionId);
|
||||
} catch (e) {
|
||||
log.error('profile', 'DELETE /api/profile failed', { userId: locals.user.id, err: String(e) });
|
||||
error(500, { message: 'Failed to delete account. Please try again or contact support.' });
|
||||
}
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import type { PageData } from './$types';
|
||||
import CommentsSection from '$lib/components/CommentsSection.svelte';
|
||||
import StarRating from '$lib/components/StarRating.svelte';
|
||||
@@ -660,6 +661,28 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.scraping ? m.book_detail_scraping() : (data.book?.title ?? 'Book')} — libnovel</title>
|
||||
{#if data.book && !data.scraping}
|
||||
{@const ogTitle = `${data.book.title} — libnovel`}
|
||||
{@const ogDesc = data.book.summary ? (data.book.summary.length > 200 ? data.book.summary.slice(0, 200) + '…' : data.book.summary) : `${data.book.total_chapters} chapters · ${data.book.author}`}
|
||||
{@const ogUrl = `${page.url.origin}/books/${data.book.slug}`}
|
||||
<link rel="canonical" href={ogUrl} />
|
||||
<meta property="og:type" content="book" />
|
||||
<meta property="og:site_name" content="libnovel" />
|
||||
<meta property="og:url" content={ogUrl} />
|
||||
<meta property="og:title" content={ogTitle} />
|
||||
<meta property="og:description" content={ogDesc} />
|
||||
{#if data.book.cover}
|
||||
<meta property="og:image" content={data.book.cover} />
|
||||
<meta property="og:image:width" content="300" />
|
||||
<meta property="og:image:height" content="450" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:image" content={data.book.cover} />
|
||||
{:else}
|
||||
<meta name="twitter:card" content="summary" />
|
||||
{/if}
|
||||
<meta name="twitter:title" content={ogTitle} />
|
||||
<meta name="twitter:description" content={ogDesc} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
{#if data.scraping}
|
||||
|
||||
@@ -332,6 +332,23 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>{cleanTitle} — {data.book.title} — libnovel</title>
|
||||
<link rel="canonical" href="{page.url.origin}/books/{data.book.slug}/chapters/{data.chapter.number}" />
|
||||
<meta property="og:type" content="book" />
|
||||
<meta property="og:site_name" content="libnovel" />
|
||||
<meta property="og:url" content="{page.url.origin}/books/{data.book.slug}/chapters/{data.chapter.number}" />
|
||||
<meta property="og:title" content="{cleanTitle} — {data.book.title} — libnovel" />
|
||||
<meta property="og:description" content="Chapter {data.chapter.number} of {data.book.title}" />
|
||||
{#if data.book.cover}
|
||||
<meta property="og:image" content={data.book.cover} />
|
||||
<meta property="og:image:width" content="300" />
|
||||
<meta property="og:image:height" content="450" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:image" content={data.book.cover} />
|
||||
{:else}
|
||||
<meta name="twitter:card" content="summary" />
|
||||
{/if}
|
||||
<meta name="twitter:title" content="{cleanTitle} — {data.book.title} — libnovel" />
|
||||
<meta name="twitter:description" content="Chapter {data.chapter.number} of {data.book.title}" />
|
||||
</svelte:head>
|
||||
|
||||
<!-- Reading progress bar (scroll mode, fixed at top of viewport) -->
|
||||
|
||||
@@ -15,49 +15,58 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
redirect(302, '/login');
|
||||
}
|
||||
|
||||
let sessions: Awaited<ReturnType<typeof listUserSessions>> = [];
|
||||
let email: string | null = null;
|
||||
let polarCustomerId: string | null = null;
|
||||
let stats: Awaited<ReturnType<typeof getUserStats>> | null = null;
|
||||
|
||||
// Fetch avatar — MinIO first, fall back to OAuth provider picture
|
||||
let avatarUrl: string | null = null;
|
||||
try {
|
||||
const record = await getUserByUsername(locals.user.username);
|
||||
avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url);
|
||||
email = record?.email ?? null;
|
||||
polarCustomerId = record?.polar_customer_id ?? null;
|
||||
} catch (e) {
|
||||
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(e) });
|
||||
}
|
||||
|
||||
try {
|
||||
[sessions, stats] = await Promise.all([
|
||||
listUserSessions(locals.user.id),
|
||||
getUserStats(locals.sessionId, locals.user.id)
|
||||
]);
|
||||
} catch (e) {
|
||||
log.warn('profile', 'load failed (non-fatal)', { err: String(e) });
|
||||
}
|
||||
|
||||
// Reading history — last 50 progress entries with book metadata
|
||||
let history: { slug: string; chapter: number; updated: string; title: string; cover: string | null }[] = [];
|
||||
try {
|
||||
const progress = await allProgress(locals.sessionId, locals.user.id);
|
||||
// Helper: fetch reading history (progress → books, sequential by necessity)
|
||||
async function fetchHistory() {
|
||||
const progress = await allProgress(locals.sessionId, locals.user!.id);
|
||||
const recent = progress.slice(0, 50);
|
||||
const books = await getBooksBySlugs(new Set(recent.map((p) => p.slug)));
|
||||
const bookMap = new Map(books.map((b) => [b.slug, b]));
|
||||
history = recent.map((p) => ({
|
||||
return recent.map((p) => ({
|
||||
slug: p.slug,
|
||||
chapter: p.chapter,
|
||||
updated: p.updated,
|
||||
title: bookMap.get(p.slug)?.title ?? p.slug,
|
||||
cover: bookMap.get(p.slug)?.cover ?? null
|
||||
}));
|
||||
} catch (e) {
|
||||
log.warn('profile', 'history fetch failed (non-fatal)', { err: String(e) });
|
||||
}
|
||||
|
||||
// Helper: fetch avatar/email/polarCustomerId (getUserByUsername → resolveAvatarUrl)
|
||||
async function fetchUserRecord() {
|
||||
const record = await getUserByUsername(locals.user!.username);
|
||||
const avatarUrl = await resolveAvatarUrl(locals.user!.id, record?.avatar_url);
|
||||
return {
|
||||
avatarUrl,
|
||||
email: record?.email ?? null,
|
||||
polarCustomerId: record?.polar_customer_id ?? null
|
||||
};
|
||||
}
|
||||
|
||||
// Run all three independent groups concurrently
|
||||
const [userRecord, sessionsResult, statsResult, historyResult] = await Promise.allSettled([
|
||||
fetchUserRecord(),
|
||||
listUserSessions(locals.user.id),
|
||||
getUserStats(locals.sessionId, locals.user.id),
|
||||
fetchHistory()
|
||||
]);
|
||||
|
||||
if (userRecord.status === 'rejected')
|
||||
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(userRecord.reason) });
|
||||
if (sessionsResult.status === 'rejected')
|
||||
log.warn('profile', 'sessions fetch failed (non-fatal)', { err: String(sessionsResult.reason) });
|
||||
if (statsResult.status === 'rejected')
|
||||
log.warn('profile', 'stats fetch failed (non-fatal)', { err: String(statsResult.reason) });
|
||||
if (historyResult.status === 'rejected')
|
||||
log.warn('profile', 'history fetch failed (non-fatal)', { err: String(historyResult.reason) });
|
||||
|
||||
const { avatarUrl = null, email = null, polarCustomerId = null } =
|
||||
userRecord.status === 'fulfilled' ? userRecord.value : {};
|
||||
const sessions =
|
||||
sessionsResult.status === 'fulfilled' ? sessionsResult.value : [];
|
||||
const stats =
|
||||
statsResult.status === 'fulfilled' ? statsResult.value : null;
|
||||
const history =
|
||||
historyResult.status === 'fulfilled' ? historyResult.value : [];
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
avatarUrl,
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
import { untrack, getContext } from 'svelte';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import type { AudioMode } from '$lib/audio.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { page } from '$app/state';
|
||||
import type { Voice } from '$lib/types';
|
||||
import { cn } from '$lib/utils';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
// ── Polar checkout ───────────────────────────────────────────────────────────
|
||||
// Customer portal: always link to the org portal
|
||||
const manageUrl = `https://polar.sh/libnovel/portal`;
|
||||
|
||||
let checkoutLoading = $state<'monthly' | 'annual' | null>(null);
|
||||
@@ -41,14 +41,12 @@
|
||||
}
|
||||
|
||||
// ── Avatar ───────────────────────────────────────────────────────────────────
|
||||
// Show a welcome banner when Polar redirects back with ?subscribed=1
|
||||
const justSubscribed = $derived(browser && page.url.searchParams.get('subscribed') === '1');
|
||||
|
||||
let avatarUrl = $state<string | null>(untrack(() => data.avatarUrl ?? null));
|
||||
let avatarUploading = $state(false);
|
||||
let avatarError = $state('');
|
||||
let fileInput: HTMLInputElement | null = null;
|
||||
|
||||
let cropFile = $state<File | null>(null);
|
||||
|
||||
function handleAvatarChange(e: Event) {
|
||||
@@ -83,63 +81,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleCropCancel() {
|
||||
cropFile = null;
|
||||
}
|
||||
function handleCropCancel() { cropFile = null; }
|
||||
|
||||
// ── Voices ───────────────────────────────────────────────────────────────────
|
||||
let voices = $state<Voice[]>([]);
|
||||
let voicesLoaded = $state(false);
|
||||
|
||||
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
|
||||
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
|
||||
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
|
||||
|
||||
function voiceLabel(v: Voice): string {
|
||||
if (v.engine === 'cfai') {
|
||||
const speaker = v.id.startsWith('cfai:') ? v.id.slice(5) : v.id;
|
||||
return speaker.replace(/\b\w/g, (c) => c.toUpperCase()) + (v.gender ? ` (EN ${v.gender.toUpperCase()})` : '');
|
||||
}
|
||||
if (v.engine === 'pocket-tts') {
|
||||
const name = v.id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
return name + (v.gender ? ` (EN ${v.gender.toUpperCase()})` : '');
|
||||
}
|
||||
// Kokoro: "af_bella" → "Bella (US F)"
|
||||
const langMap: Record<string, string> = {
|
||||
af: 'US', am: 'US', bf: 'UK', bm: 'UK',
|
||||
ef: 'ES', em: 'ES', ff: 'FR',
|
||||
hf: 'IN', hm: 'IN', 'if': 'IT', im: 'IT',
|
||||
jf: 'JP', jm: 'JP', pf: 'PT', pm: 'PT', zf: 'ZH', zm: 'ZH',
|
||||
};
|
||||
const prefix = v.id.slice(0, 2);
|
||||
const name = v.id.slice(3).replace(/^v0/, '').replace(/^([a-z])/, (c) => c.toUpperCase());
|
||||
const lang = langMap[prefix] ?? prefix.toUpperCase();
|
||||
const gender = v.gender ? v.gender.toUpperCase() : '?';
|
||||
return `${name} (${lang} ${gender})`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
fetch('/api/voices')
|
||||
.then((r) => r.json())
|
||||
.then((d: { voices: Voice[] }) => { voices = d.voices ?? []; voicesLoaded = true; })
|
||||
.catch(() => { voicesLoaded = true; });
|
||||
});
|
||||
|
||||
// ── Settings state ───────────────────────────────────────────────────────────
|
||||
let voice = $state(audioStore.voice);
|
||||
let speed = $state(audioStore.speed);
|
||||
let autoNext = $state(audioStore.autoNext);
|
||||
|
||||
$effect(() => {
|
||||
voice = audioStore.voice;
|
||||
speed = audioStore.speed;
|
||||
autoNext = audioStore.autoNext;
|
||||
});
|
||||
// ── Settings state ────────────────────────────────────────────────────────────
|
||||
// All changes are written directly into audioStore / theme context.
|
||||
// The layout's debounced $effect owns the single PUT /api/settings call.
|
||||
// We only maintain a local saveStatus indicator here.
|
||||
|
||||
const settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme');
|
||||
let selectedTheme = $state(untrack(() => data.settings?.theme ?? settingsCtx?.current ?? 'amber'));
|
||||
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));
|
||||
let selectedFontSize = $state(untrack(() => data.settings?.fontSize ?? settingsCtx?.fontSize ?? 1.0));
|
||||
|
||||
const THEMES: { id: string; label: () => string; swatch: string; light?: boolean }[] = [
|
||||
{ id: 'amber', label: () => m.profile_theme_amber(), swatch: '#f59e0b' },
|
||||
@@ -166,51 +118,48 @@
|
||||
{ value: 1.3, label: () => m.profile_text_size_xl() },
|
||||
];
|
||||
|
||||
// ── Auto-save ────────────────────────────────────────────────────────────────
|
||||
// Local save-status indicator — layout's effect does the actual debounced save.
|
||||
type SaveStatus = 'idle' | 'saving' | 'saved';
|
||||
let saveStatus = $state<SaveStatus>('idle');
|
||||
let saveTimer = 0;
|
||||
let savedTimer = 0;
|
||||
let initialized = false;
|
||||
|
||||
function markSaved() {
|
||||
saveStatus = 'saving';
|
||||
clearTimeout(savedTimer);
|
||||
savedTimer = setTimeout(() => {
|
||||
saveStatus = 'saved';
|
||||
savedTimer = setTimeout(() => (saveStatus = 'idle'), 2000) as unknown as number;
|
||||
}, 900) as unknown as number;
|
||||
}
|
||||
|
||||
// Propagate all settings changes into audioStore / context immediately.
|
||||
// Layout effect watches these and persists to the server (debounced 800ms).
|
||||
$effect(() => {
|
||||
// Read all settings deps to subscribe
|
||||
const t = selectedTheme;
|
||||
const t = selectedTheme;
|
||||
const ff = selectedFontFamily;
|
||||
const fs = selectedFontSize;
|
||||
const v = voice;
|
||||
const sp = speed;
|
||||
const an = autoNext;
|
||||
const v = audioStore.voice;
|
||||
const sp = audioStore.speed;
|
||||
const an = audioStore.autoNext;
|
||||
const ac = audioStore.announceChapter;
|
||||
const am = audioStore.audioMode;
|
||||
|
||||
// Apply context immediately (font/theme previews live without waiting for save)
|
||||
if (settingsCtx) {
|
||||
settingsCtx.current = t;
|
||||
settingsCtx.current = t;
|
||||
settingsCtx.fontFamily = ff;
|
||||
settingsCtx.fontSize = fs;
|
||||
settingsCtx.fontSize = fs;
|
||||
}
|
||||
audioStore.voice = v;
|
||||
audioStore.autoNext = an;
|
||||
|
||||
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;
|
||||
void v; void sp; void an; void ac; void am;
|
||||
markSaved();
|
||||
});
|
||||
|
||||
$effect(() => { if (settingsCtx) settingsCtx.current = selectedTheme; });
|
||||
$effect(() => { if (settingsCtx) settingsCtx.fontFamily = selectedFontFamily; });
|
||||
$effect(() => { if (settingsCtx) settingsCtx.fontSize = selectedFontSize; });
|
||||
|
||||
// ── Tab ──────────────────────────────────────────────────────────────────────
|
||||
let activeTab = $state<'profile' | 'stats' | 'history'>('profile');
|
||||
|
||||
@@ -224,12 +173,12 @@
|
||||
is_current: boolean;
|
||||
};
|
||||
|
||||
let sessions = $state<Session[]>(untrack(() => data.sessions ?? []));
|
||||
let revokingId = $state<string | null>(null);
|
||||
let sessions = $state<Session[]>(untrack(() => data.sessions ?? []));
|
||||
let revokingId = $state<string | null>(null);
|
||||
let revokeError = $state('');
|
||||
|
||||
async function revokeSession(session: Session) {
|
||||
revokingId = session.id;
|
||||
revokingId = session.id;
|
||||
revokeError = '';
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${session.id}`, { method: 'DELETE' });
|
||||
@@ -247,6 +196,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Danger zone ──────────────────────────────────────────────────────────────
|
||||
let deleteConfirmOpen = $state(false);
|
||||
let deleteConfirmText = $state('');
|
||||
let deleting = $state(false);
|
||||
let deleteError = $state('');
|
||||
|
||||
const DELETE_KEYWORD = untrack(() => data.user.username);
|
||||
const deleteReady = $derived(deleteConfirmText.trim() === DELETE_KEYWORD);
|
||||
|
||||
async function deleteAccount() {
|
||||
if (!deleteReady) return;
|
||||
deleting = true;
|
||||
deleteError = '';
|
||||
try {
|
||||
const res = await fetch('/api/profile', { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as { message?: string };
|
||||
deleteError = body.message ?? `Delete failed (${res.status}). Please try again.`;
|
||||
return;
|
||||
}
|
||||
const logoutForm = document.getElementById('logout-form') as HTMLFormElement | null;
|
||||
if (logoutForm) logoutForm.submit();
|
||||
} catch {
|
||||
deleteError = 'Network error. Please try again.';
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────────────────────
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
@@ -284,16 +263,13 @@
|
||||
|
||||
<!-- ── Post-checkout success banner ──────────────────────────────────────── -->
|
||||
{#if justSubscribed}
|
||||
<div class="rounded-xl bg-(--color-brand)/10 border border-(--color-brand)/40 px-5 py-4 flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-(--color-brand) shrink-0 mt-0.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>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-(--color-brand)">Welcome to Pro!</p>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">Your subscription is being activated. Refresh the page in a moment if the Pro badge doesn't appear yet.</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-(--color-brand)/10 border border-(--color-brand)/40 px-5 py-4">
|
||||
<p class="text-sm font-semibold text-(--color-brand)">Welcome to Pro!</p>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">Your subscription is being activated. Refresh the page in a moment if the Pro badge doesn't appear yet.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Profile header ──────────────────────────────────────────────────────── -->
|
||||
<!-- ── Profile header ───────────────────────────────────────────────────── -->
|
||||
<div class="flex items-center gap-5 pt-2">
|
||||
<div class="relative shrink-0">
|
||||
<button
|
||||
@@ -306,9 +282,9 @@
|
||||
<img src={avatarUrl} alt="Profile" class="w-full h-full object-cover" />
|
||||
{: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="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
|
||||
</svg>
|
||||
<span class="text-3xl font-bold text-(--color-muted) select-none">
|
||||
{data.user.username.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
@@ -318,10 +294,7 @@
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-semibold text-white tracking-wide">Edit</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
@@ -333,8 +306,7 @@
|
||||
<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>
|
||||
<span class="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">
|
||||
{m.profile_plan_pro()}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -353,10 +325,12 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTab = tab)}
|
||||
class="flex-1 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
{activeTab === tab
|
||||
class={cn(
|
||||
'flex-1 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
activeTab === tab
|
||||
? 'bg-(--color-surface-3) text-(--color-text) shadow-sm'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
>
|
||||
{tab === 'profile' ? 'Profile' : tab === 'stats' ? 'Stats' : 'History'}
|
||||
</button>
|
||||
@@ -364,7 +338,8 @@
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'profile'}
|
||||
<!-- ── Subscription ─────────────────────────────────────────────────────────── -->
|
||||
|
||||
<!-- ── 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">
|
||||
@@ -390,8 +365,6 @@
|
||||
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 disabled:opacity-60 disabled:cursor-wait">
|
||||
{#if checkoutLoading === 'monthly'}
|
||||
<svg class="w-4 h-4 shrink-0 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>
|
||||
{:else}
|
||||
<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>
|
||||
{/if}
|
||||
{m.profile_upgrade_monthly()}
|
||||
</button>
|
||||
@@ -417,27 +390,27 @@
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_pro_perks()}</p>
|
||||
</div>
|
||||
<a href={manageUrl} 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>
|
||||
class="shrink-0 text-sm font-medium text-(--color-brand) hover:underline">
|
||||
{m.profile_manage_subscription()} →
|
||||
</a>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Preferences ──────────────────────────────────────────────────────────── -->
|
||||
<!-- ── Preferences ───────────────────────────────────────────────────────── -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) divide-y divide-(--color-border)">
|
||||
|
||||
<!-- Section header with auto-save indicator -->
|
||||
<!-- Header -->
|
||||
<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 class={cn(
|
||||
'text-xs transition-all duration-300',
|
||||
saveStatus === 'saving' ? 'text-(--color-muted)' :
|
||||
saveStatus === 'saved' ? 'text-green-400' :
|
||||
'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>
|
||||
|
||||
@@ -446,16 +419,18 @@
|
||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_theme_label()}</p>
|
||||
<div class="flex gap-2 flex-wrap items-center">
|
||||
{#each THEMES as t, i}
|
||||
{#if i === 3}
|
||||
{#if i === 6}
|
||||
<span class="w-px h-6 bg-(--color-border) mx-1 self-center"></span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedTheme = t.id)}
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors
|
||||
{selectedTheme === t.id
|
||||
class={cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors',
|
||||
selectedTheme === t.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)'}"
|
||||
: '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 h-3 rounded-full shrink-0 {t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
|
||||
@@ -473,10 +448,12 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedFontFamily = f.id)}
|
||||
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors
|
||||
{selectedFontFamily === f.id
|
||||
class={cn(
|
||||
'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)'}"
|
||||
: '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()}
|
||||
@@ -493,10 +470,12 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedFontSize = s.value)}
|
||||
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors
|
||||
{selectedFontSize === s.value
|
||||
class={cn(
|
||||
'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)'}"
|
||||
: '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()}
|
||||
@@ -505,71 +484,100 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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-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)">
|
||||
{#if kokoroVoices.length > 0}
|
||||
<optgroup label="Kokoro (GPU)">
|
||||
{#each kokoroVoices as v}<option value={v.id}>{voiceLabel(v)}</option>{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if pocketVoices.length > 0}
|
||||
<optgroup label="Pocket TTS (CPU)">
|
||||
{#each pocketVoices as v}<option value={v.id}>{voiceLabel(v)}</option>{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if cfaiVoices.length > 0}
|
||||
<optgroup label="Cloudflare AI">
|
||||
{#each cfaiVoices as v}<option value={v.id}>{voiceLabel(v)}</option>{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<span class="text-sm font-mono text-(--color-brand)">{audioStore.speed.toFixed(1)}x</span>
|
||||
</div>
|
||||
<input id="speed-range" type="range" min="0.5" max="3.0" step="0.1" bind:value={speed}
|
||||
<input id="speed-range" type="range" min="0.5" max="3.0" step="0.1"
|
||||
bind:value={audioStore.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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<!-- Playback toggles -->
|
||||
<div class="px-6 py-5 space-y-5">
|
||||
<p class="text-sm font-medium text-(--color-text)">Playback</p>
|
||||
|
||||
<!-- Auto-advance -->
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-(--color-text)">{m.profile_auto_advance()}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">Load the next chapter automatically when audio ends</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={audioStore.autoNext}
|
||||
aria-label="Auto-advance to next chapter"
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
class={cn(
|
||||
'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)',
|
||||
audioStore.autoNext ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'
|
||||
)}
|
||||
>
|
||||
<span class={cn('inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', audioStore.autoNext ? 'translate-x-6' : 'translate-x-1')}></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Announce chapter -->
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-(--color-text)">Announce chapter</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">Read the chapter title aloud before advancing</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={audioStore.announceChapter}
|
||||
aria-label="Announce chapter title before auto-advance"
|
||||
onclick={() => (audioStore.announceChapter = !audioStore.announceChapter)}
|
||||
class={cn(
|
||||
'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)',
|
||||
audioStore.announceChapter ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'
|
||||
)}
|
||||
>
|
||||
<span class={cn('inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', audioStore.announceChapter ? 'translate-x-6' : 'translate-x-1')}></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Audio mode -->
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-(--color-text)">Audio mode</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">
|
||||
{audioStore.audioMode === 'stream' ? 'Stream — starts within seconds' : 'Generate — waits for full audio'}
|
||||
{#if audioStore.voice.startsWith('cfai:')} <span class="text-(--color-border)">(not available for CF AI)</span>{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle audio mode"
|
||||
onclick={() => { audioStore.audioMode = audioStore.audioMode === 'stream' ? 'generate' : 'stream'; }}
|
||||
disabled={audioStore.voice.startsWith('cfai:')}
|
||||
class={cn(
|
||||
'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)',
|
||||
audioStore.voice.startsWith('cfai:')
|
||||
? 'opacity-40 cursor-not-allowed bg-(--color-surface-3) border border-(--color-border)'
|
||||
: audioStore.audioMode === 'stream'
|
||||
? 'bg-(--color-brand)'
|
||||
: 'bg-(--color-surface-3) border border-(--color-border)'
|
||||
)}
|
||||
>
|
||||
<span class={cn(
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||
audioStore.audioMode === 'stream' && !audioStore.voice.startsWith('cfai:') ? 'translate-x-6' : 'translate-x-1'
|
||||
)}></span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={autoNext}
|
||||
aria-label="Auto-advance to next chapter"
|
||||
onclick={() => (autoNext = !autoNext)}
|
||||
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>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- ── Active sessions ──────────────────────────────────────────────────────── -->
|
||||
<!-- ── Active sessions ───────────────────────────────────────────────────── -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-(--color-text)">{m.profile_sessions_heading()}</h2>
|
||||
@@ -585,7 +593,12 @@
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each sessions as session (session.id)}
|
||||
<li class="flex items-start justify-between gap-3 rounded-lg px-4 py-3 {session.is_current ? 'bg-(--color-brand)/10 border border-(--color-brand)/30' : 'bg-(--color-surface-3)/50 border border-(--color-border)/50'}">
|
||||
<li class={cn(
|
||||
'flex items-start justify-between gap-3 rounded-lg px-4 py-3',
|
||||
session.is_current
|
||||
? 'bg-(--color-brand)/10 border border-(--color-brand)/30'
|
||||
: 'bg-(--color-surface-3)/50 border border-(--color-border)/50'
|
||||
)}>
|
||||
<div class="min-w-0 space-y-0.5">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium text-(--color-text) truncate">{parseUA(session.user_agent)}</span>
|
||||
@@ -606,10 +619,12 @@
|
||||
<button
|
||||
onclick={() => revokeSession(session)}
|
||||
disabled={revokingId === session.id}
|
||||
class="shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
|
||||
{session.is_current
|
||||
class={cn(
|
||||
'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-2)'}"
|
||||
: '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>
|
||||
@@ -618,7 +633,66 @@
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Danger zone ───────────────────────────────────────────────────────── -->
|
||||
<section class="rounded-xl border border-red-500/30 bg-red-500/5 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { deleteConfirmOpen = !deleteConfirmOpen; deleteConfirmText = ''; deleteError = ''; }}
|
||||
class="w-full flex items-center justify-between px-6 py-4 text-left hover:bg-red-500/5 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-red-400">Danger zone</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">Irreversible actions — proceed with care</p>
|
||||
</div>
|
||||
<span class="text-xs text-(--color-muted)">{deleteConfirmOpen ? 'Close' : 'Open'}</span>
|
||||
</button>
|
||||
|
||||
{#if deleteConfirmOpen}
|
||||
<div class="px-6 pb-6 space-y-4 border-t border-red-500/20">
|
||||
<div class="pt-4">
|
||||
<p class="text-sm font-medium text-(--color-text)">Delete account</p>
|
||||
<p class="text-xs text-(--color-muted) mt-1">
|
||||
This permanently deletes your account, reading history, settings, and all associated data. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="delete-confirm" class="text-xs text-(--color-muted)">
|
||||
Type <strong class="text-(--color-text) font-mono">{DELETE_KEYWORD}</strong> to confirm
|
||||
</label>
|
||||
<input
|
||||
id="delete-confirm"
|
||||
type="text"
|
||||
bind:value={deleteConfirmText}
|
||||
placeholder={DELETE_KEYWORD}
|
||||
autocomplete="off"
|
||||
class="w-full bg-(--color-surface-3) border border-red-500/40 rounded-lg px-3 py-2 text-sm text-(--color-text) placeholder:text-(--color-border) focus:outline-none focus:ring-2 focus:ring-red-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if deleteError}
|
||||
<p class="text-sm text-red-400">{deleteError}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={deleteAccount}
|
||||
disabled={!deleteReady || deleting}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-red-500/10 text-red-400 border border-red-500/40 text-sm font-semibold transition-colors hover:bg-red-500/20 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if deleting}
|
||||
<svg class="w-4 h-4 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>
|
||||
Deleting…
|
||||
{:else}
|
||||
Delete my account
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{/if} <!-- end profile tab -->
|
||||
|
||||
{#if activeTab === 'stats'}
|
||||
<div class="space-y-4">
|
||||
@@ -628,10 +702,10 @@
|
||||
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-4">Reading Overview</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{#each [
|
||||
{ label: 'Chapters Read', value: data.stats.totalChaptersRead, icon: '📖' },
|
||||
{ label: 'Completed', value: data.stats.booksCompleted, icon: '✅' },
|
||||
{ label: 'Reading', value: data.stats.booksReading, icon: '📚' },
|
||||
{ label: 'Plan to Read', value: data.stats.booksPlanToRead, icon: '🔖' },
|
||||
{ label: 'Chapters Read', value: data.stats.totalChaptersRead },
|
||||
{ label: 'Completed', value: data.stats.booksCompleted },
|
||||
{ label: 'Reading', value: data.stats.booksReading },
|
||||
{ label: 'Plan to Read', value: data.stats.booksPlanToRead },
|
||||
] as stat}
|
||||
<div class="bg-(--color-surface-3) rounded-lg p-3 text-center">
|
||||
<p class="text-2xl font-bold text-(--color-text) tabular-nums">{stat.value}</p>
|
||||
@@ -645,21 +719,15 @@
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5">
|
||||
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-4">Activity</h2>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="flex items-center gap-3 bg-(--color-surface-3) rounded-lg p-3">
|
||||
<div class="w-9 h-9 rounded-full bg-orange-500/15 flex items-center justify-center text-lg flex-shrink-0">🔥</div>
|
||||
<div>
|
||||
<p class="text-xl font-bold text-(--color-text) tabular-nums">{data.stats.streak}</p>
|
||||
<p class="text-xs text-(--color-muted)">day streak</p>
|
||||
</div>
|
||||
<div class="bg-(--color-surface-3) rounded-lg p-4">
|
||||
<p class="text-2xl font-bold text-(--color-text) tabular-nums">{data.stats.streak}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-1">day streak</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 bg-(--color-surface-3) rounded-lg p-3">
|
||||
<div class="w-9 h-9 rounded-full bg-yellow-500/15 flex items-center justify-center text-lg flex-shrink-0">⭐</div>
|
||||
<div>
|
||||
<p class="text-xl font-bold text-(--color-text) tabular-nums">
|
||||
{data.stats.avgRatingGiven > 0 ? data.stats.avgRatingGiven.toFixed(1) : '—'}
|
||||
</p>
|
||||
<p class="text-xs text-(--color-muted)">avg rating given</p>
|
||||
</div>
|
||||
<div class="bg-(--color-surface-3) rounded-lg p-4">
|
||||
<p class="text-2xl font-bold text-(--color-text) tabular-nums">
|
||||
{data.stats.avgRatingGiven > 0 ? data.stats.avgRatingGiven.toFixed(1) : '—'}
|
||||
</p>
|
||||
<p class="text-xs text-(--color-muted) mt-1">avg rating given</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -670,9 +738,12 @@
|
||||
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-3">Favourite Genres</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each data.stats.topGenres as genre, i}
|
||||
<span class="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium
|
||||
{i === 0 ? 'bg-(--color-brand)/20 text-(--color-brand) border border-(--color-brand)/30' : 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border)'}">
|
||||
{#if i === 0}<span class="text-xs">🏆</span>{/if}
|
||||
<span class={cn(
|
||||
'px-3 py-1.5 rounded-full text-sm font-medium',
|
||||
i === 0
|
||||
? 'bg-(--color-brand)/20 text-(--color-brand) border border-(--color-brand)/30'
|
||||
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border)'
|
||||
)}>
|
||||
{genre}
|
||||
</span>
|
||||
{/each}
|
||||
@@ -680,7 +751,6 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Dropped books (only if any) -->
|
||||
{#if data.stats.booksDropped > 0}
|
||||
<p class="text-xs text-(--color-muted) text-center">
|
||||
{data.stats.booksDropped} dropped book{data.stats.booksDropped !== 1 ? 's' : ''} —
|
||||
@@ -694,10 +764,7 @@
|
||||
{#if activeTab === 'history'}
|
||||
<div class="space-y-2">
|
||||
{#if data.history.length === 0}
|
||||
<div class="py-12 text-center text-(--color-muted)">
|
||||
<svg class="w-10 h-10 mx-auto mb-3 text-(--color-border)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="py-16 text-center text-(--color-muted)">
|
||||
<p class="text-sm">No reading history yet.</p>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -706,26 +773,17 @@
|
||||
href="/books/{item.slug}/chapters/{item.chapter}"
|
||||
class="flex items-center gap-3 px-4 py-3 bg-(--color-surface-2) rounded-xl border border-(--color-border) hover:border-zinc-500 transition-colors group"
|
||||
>
|
||||
<!-- Cover thumbnail -->
|
||||
<div class="w-8 h-11 rounded overflow-hidden bg-(--color-surface-3) flex-shrink-0">
|
||||
{#if item.cover}
|
||||
<img src={item.cover} alt={item.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-4 h-4" 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>
|
||||
<div class="w-full h-full bg-(--color-surface-3)"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Title + chapter -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-(--color-text) truncate group-hover:text-(--color-brand) transition-colors">{item.title}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">Chapter {item.chapter}</p>
|
||||
</div>
|
||||
|
||||
<!-- Relative time -->
|
||||
<p class="text-xs text-(--color-muted) shrink-0 tabular-nums">
|
||||
{#if item.updated}
|
||||
{(() => {
|
||||
|
||||
Reference in New Issue
Block a user