|
|
|
|
@@ -3,15 +3,16 @@
|
|
|
|
|
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 +42,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,9 +82,7 @@
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleCropCancel() {
|
|
|
|
|
cropFile = null;
|
|
|
|
|
}
|
|
|
|
|
function handleCropCancel() { cropFile = null; }
|
|
|
|
|
|
|
|
|
|
// ── Voices ───────────────────────────────────────────────────────────────────
|
|
|
|
|
let voices = $state<Voice[]>([]);
|
|
|
|
|
@@ -93,7 +90,7 @@
|
|
|
|
|
|
|
|
|
|
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'));
|
|
|
|
|
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
|
|
|
|
|
|
|
|
|
|
function voiceLabel(v: Voice): string {
|
|
|
|
|
if (v.engine === 'cfai') {
|
|
|
|
|
@@ -102,14 +99,14 @@
|
|
|
|
|
}
|
|
|
|
|
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()})` : '');
|
|
|
|
|
return name + (v.gender ? ` (${v.lang?.toUpperCase().replace('-','')} ${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',
|
|
|
|
|
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());
|
|
|
|
|
@@ -125,21 +122,41 @@
|
|
|
|
|
.catch(() => { voicesLoaded = true; });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Settings state ───────────────────────────────────────────────────────────
|
|
|
|
|
let voice = $state(audioStore.voice);
|
|
|
|
|
let speed = $state(audioStore.speed);
|
|
|
|
|
let autoNext = $state(audioStore.autoNext);
|
|
|
|
|
// Voice sample playback
|
|
|
|
|
let samplePlayingVoice = $state<string | null>(null);
|
|
|
|
|
let sampleAudio = $state<HTMLAudioElement | null>(null);
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
voice = audioStore.voice;
|
|
|
|
|
speed = audioStore.speed;
|
|
|
|
|
autoNext = audioStore.autoNext;
|
|
|
|
|
});
|
|
|
|
|
function stopSample() {
|
|
|
|
|
if (sampleAudio) { sampleAudio.pause(); sampleAudio.src = ''; sampleAudio = null; }
|
|
|
|
|
samplePlayingVoice = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function toggleSample(voiceId: string) {
|
|
|
|
|
if (samplePlayingVoice === voiceId) { stopSample(); return; }
|
|
|
|
|
stopSample();
|
|
|
|
|
samplePlayingVoice = voiceId;
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/presign/voice-sample?voice=${encodeURIComponent(voiceId)}`);
|
|
|
|
|
if (res.status === 404) { samplePlayingVoice = null; return; }
|
|
|
|
|
if (!res.ok) throw new Error();
|
|
|
|
|
const { url } = await res.json() as { url: string };
|
|
|
|
|
const audio = new Audio(url);
|
|
|
|
|
sampleAudio = audio;
|
|
|
|
|
audio.onended = () => { if (samplePlayingVoice === voiceId) stopSample(); };
|
|
|
|
|
audio.onerror = () => { if (samplePlayingVoice === voiceId) stopSample(); };
|
|
|
|
|
await audio.play();
|
|
|
|
|
} catch { samplePlayingVoice = null; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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 +183,50 @@
|
|
|
|
|
{ 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);
|
|
|
|
|
// Give a tick for layout's effect to fire, then show ✓ Saved
|
|
|
|
|
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; // keep subscriptions live
|
|
|
|
|
markSaved();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Keep theme/font writes flowing into layout context when changed from selectors
|
|
|
|
|
$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 +240,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 +263,37 @@
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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;
|
|
|
|
|
}
|
|
|
|
|
// Server deleted account — submit logout form to clear session cookie
|
|
|
|
|
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 {
|
|
|
|
|
@@ -293,7 +340,7 @@
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<!-- ── Profile header ──────────────────────────────────────────────────────── -->
|
|
|
|
|
<!-- ── Profile header ───────────────────────────────────────────────────── -->
|
|
|
|
|
<div class="flex items-center gap-5 pt-2">
|
|
|
|
|
<div class="relative shrink-0">
|
|
|
|
|
<button
|
|
|
|
|
@@ -353,10 +400,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 +413,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">
|
|
|
|
|
@@ -424,20 +474,21 @@
|
|
|
|
|
</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 +497,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 +526,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 +548,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,34 +562,81 @@
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- TTS voice -->
|
|
|
|
|
<!-- TTS voice — visual card picker grouped by engine -->
|
|
|
|
|
<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>
|
|
|
|
|
<p class="text-sm font-medium text-(--color-text)">{m.profile_tts_voice()}</p>
|
|
|
|
|
{#if !voicesLoaded}
|
|
|
|
|
<div class="h-9 bg-(--color-surface-3) rounded-lg animate-pulse"></div>
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
{#each [1,2,3] as _}
|
|
|
|
|
<div class="h-10 bg-(--color-surface-3) rounded-lg animate-pulse"></div>
|
|
|
|
|
{/each}
|
|
|
|
|
</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>
|
|
|
|
|
<p class="text-sm text-(--color-muted) italic">No voices available.</p>
|
|
|
|
|
{: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>
|
|
|
|
|
<!-- Engine groups -->
|
|
|
|
|
{#each [
|
|
|
|
|
{ label: 'Kokoro (GPU)', voices: kokoroVoices },
|
|
|
|
|
{ label: 'Pocket TTS (CPU)', voices: pocketVoices },
|
|
|
|
|
{ label: 'Cloudflare AI', voices: cfaiVoices },
|
|
|
|
|
].filter(g => g.voices.length > 0) as group}
|
|
|
|
|
<div>
|
|
|
|
|
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest mb-2">{group.label}</p>
|
|
|
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
|
|
|
|
|
{#each group.voices as v (v.id)}
|
|
|
|
|
{@const isSelected = audioStore.voice === v.id}
|
|
|
|
|
{@const isPlaying = samplePlayingVoice === v.id}
|
|
|
|
|
<!-- Use role=option div to avoid nested <button> inside <button> -->
|
|
|
|
|
<div
|
|
|
|
|
role="option"
|
|
|
|
|
aria-selected={isSelected}
|
|
|
|
|
tabindex="0"
|
|
|
|
|
onclick={() => { audioStore.voice = v.id; }}
|
|
|
|
|
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); audioStore.voice = v.id; } }}
|
|
|
|
|
class={cn(
|
|
|
|
|
'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border text-sm transition-colors cursor-pointer select-none',
|
|
|
|
|
isSelected
|
|
|
|
|
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
|
|
|
|
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-text) hover:border-(--color-brand)/40'
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
|
|
|
{#if isSelected}
|
|
|
|
|
<svg class="w-3.5 h-3.5 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
|
|
|
|
</svg>
|
|
|
|
|
{:else}
|
|
|
|
|
<span class="w-3.5 h-3.5 shrink-0"></span>
|
|
|
|
|
{/if}
|
|
|
|
|
<span class="truncate font-medium">{voiceLabel(v)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<!-- Sample play button -->
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onclick={(e) => { e.stopPropagation(); toggleSample(v.id); }}
|
|
|
|
|
class={cn(
|
|
|
|
|
'shrink-0 w-6 h-6 rounded-full flex items-center justify-center transition-colors',
|
|
|
|
|
isPlaying
|
|
|
|
|
? 'bg-(--color-brand) text-(--color-surface)'
|
|
|
|
|
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)'
|
|
|
|
|
)}
|
|
|
|
|
title={isPlaying ? 'Stop sample' : 'Play sample'}
|
|
|
|
|
>
|
|
|
|
|
{#if isPlaying}
|
|
|
|
|
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
|
|
|
|
</svg>
|
|
|
|
|
{:else}
|
|
|
|
|
<svg class="w-3 h-3 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path d="M8 5v14l11-7z"/>
|
|
|
|
|
</svg>
|
|
|
|
|
{/if}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/each}
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
@@ -540,36 +644,101 @@
|
|
|
|
|
<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 row -->
|
|
|
|
|
<div class="px-6 py-5 space-y-4">
|
|
|
|
|
<p class="text-sm font-medium text-(--color-text)">Playback</p>
|
|
|
|
|
|
|
|
|
|
<!-- Auto-advance -->
|
|
|
|
|
<div class="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<p class="text-sm 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={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">
|
|
|
|
|
<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 auto-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">
|
|
|
|
|
<div>
|
|
|
|
|
<p class="text-sm text-(--color-text)">Audio mode</p>
|
|
|
|
|
<p class="text-xs text-(--color-muted) mt-0.5">
|
|
|
|
|
{#if audioStore.audioMode === 'stream'}
|
|
|
|
|
<strong class="text-(--color-text)">Stream</strong> — audio starts within seconds, saved in background
|
|
|
|
|
{:else}
|
|
|
|
|
<strong class="text-(--color-text)">Generate</strong> — wait for full audio before playing
|
|
|
|
|
{/if}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
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)'
|
|
|
|
|
)}
|
|
|
|
|
title={audioStore.voice.startsWith('cfai:') ? 'CF AI voices always use generate mode' : undefined}
|
|
|
|
|
>
|
|
|
|
|
<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 +754,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 +780,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 +794,74 @@
|
|
|
|
|
</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>
|
|
|
|
|
<svg
|
|
|
|
|
class={cn('w-4 h-4 text-(--color-muted) transition-transform', deleteConfirmOpen && 'rotate-180')}
|
|
|
|
|
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>
|
|
|
|
|
|
|
|
|
|
{#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}
|
|
|
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
|
|
|
|
</svg>
|
|
|
|
|
Delete my account
|
|
|
|
|
{/if}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{/if} <!-- end profile tab -->
|
|
|
|
|
|
|
|
|
|
{#if activeTab === 'stats'}
|
|
|
|
|
<div class="space-y-4">
|
|
|
|
|
@@ -629,9 +872,9 @@
|
|
|
|
|
<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: 'Completed', value: data.stats.booksCompleted, icon: '✅' },
|
|
|
|
|
{ label: 'Reading', value: data.stats.booksReading, icon: '📚' },
|
|
|
|
|
{ label: 'Plan to Read', value: data.stats.booksPlanToRead, icon: '🔖' },
|
|
|
|
|
] 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>
|
|
|
|
|
@@ -670,8 +913,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)'}">
|
|
|
|
|
<span class={cn(
|
|
|
|
|
'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}
|
|
|
|
|
{genre}
|
|
|
|
|
</span>
|
|
|
|
|
@@ -680,7 +927,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' : ''} —
|
|
|
|
|
@@ -706,7 +952,6 @@
|
|
|
|
|
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" />
|
|
|
|
|
@@ -718,14 +963,10 @@
|
|
|
|
|
</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}
|
|
|
|
|
{(() => {
|
|
|
|
|
|