- Service worker (src/service-worker.ts) handles push events and notification clicks, navigating to the book page on tap - Web app manifest (manifest.webmanifest) linked in app.html - Profile page: push notification toggle (subscribe/unsubscribe) using the browser Notification + PushManager API with VAPID - API route POST/DELETE /api/push-subscription proxies to backend - Go backend: push_subscriptions PocketBase collection storage methods (SavePushSubscription, DeletePushSubscription, ListPushSubscriptionsByBook) in storage/store.go - handlers_push.go: GET vapid-public-key, POST/DELETE subscription - webpush package: VAPID-signed sends via webpush-go, SendToBook fans out to all users who have the book in their library - Runner fires push to subscribers whenever ChaptersScraped > 0 after a successful book scrape - Config: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT env vars - domain.ScrapeResult gets a Slug field; orchestrator populates it
956 lines
40 KiB
Svelte
956 lines
40 KiB
Svelte
<script lang="ts">
|
||
import { enhance } from '$app/forms';
|
||
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 { cn } from '$lib/utils';
|
||
import * as m from '$lib/paraglide/messages.js';
|
||
|
||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||
|
||
// ── Polar checkout ───────────────────────────────────────────────────────────
|
||
const manageUrl = `https://polar.sh/libnovel/portal`;
|
||
|
||
let checkoutLoading = $state<'monthly' | 'annual' | null>(null);
|
||
let checkoutError = $state('');
|
||
|
||
async function startCheckout(product: 'monthly' | 'annual') {
|
||
checkoutLoading = product;
|
||
checkoutError = '';
|
||
try {
|
||
const res = await fetch('/api/checkout', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ product })
|
||
});
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => ({})) as { message?: string };
|
||
checkoutError = body.message ?? `Checkout failed (${res.status}). Please try again.`;
|
||
return;
|
||
}
|
||
const { url } = await res.json() as { url: string };
|
||
window.location.href = url;
|
||
} catch {
|
||
checkoutError = 'Network error. Please try again.';
|
||
} finally {
|
||
checkoutLoading = null;
|
||
}
|
||
}
|
||
|
||
// ── Avatar ───────────────────────────────────────────────────────────────────
|
||
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) {
|
||
const input = e.target as HTMLInputElement;
|
||
const file = input.files?.[0];
|
||
if (!file) return;
|
||
if (fileInput) fileInput.value = '';
|
||
cropFile = file;
|
||
}
|
||
|
||
async function handleCropConfirm(blob: Blob, mimeType: string) {
|
||
cropFile = null;
|
||
avatarUploading = true;
|
||
avatarError = '';
|
||
try {
|
||
const res = await fetch('/api/profile/avatar', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': mimeType },
|
||
body: blob
|
||
});
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => ({})) as { message?: string };
|
||
avatarError = body.message ?? `Upload failed (${res.status})`;
|
||
return;
|
||
}
|
||
const result = await res.json() as { avatar_url: string | null };
|
||
avatarUrl = result.avatar_url;
|
||
} catch {
|
||
avatarError = 'Network error during upload';
|
||
} finally {
|
||
avatarUploading = false;
|
||
}
|
||
}
|
||
|
||
function handleCropCancel() { cropFile = 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 selectedFontFamily = $state(untrack(() => data.settings?.fontFamily ?? settingsCtx?.fontFamily ?? 'system'));
|
||
let selectedFontSize = $state(untrack(() => data.settings?.fontSize ?? settingsCtx?.fontSize ?? 1.0));
|
||
|
||
const THEMES: { id: string; label: () => string; swatch: string; light?: boolean }[] = [
|
||
{ id: 'amber', label: () => m.profile_theme_amber(), swatch: '#f59e0b' },
|
||
{ id: 'slate', label: () => m.profile_theme_slate(), swatch: '#818cf8' },
|
||
{ id: 'rose', label: () => m.profile_theme_rose(), swatch: '#fb7185' },
|
||
{ id: 'forest', label: () => m.profile_theme_forest(), swatch: '#4ade80' },
|
||
{ id: 'mono', label: () => m.profile_theme_mono(), swatch: '#f4f4f5' },
|
||
{ id: 'cyber', label: () => m.profile_theme_cyber(), swatch: '#22d3ee' },
|
||
{ id: 'light', label: () => m.profile_theme_light(), swatch: '#d97706', light: true },
|
||
{ id: 'light-slate', label: () => m.profile_theme_light_slate(), swatch: '#4f46e5', light: true },
|
||
{ id: 'light-rose', label: () => m.profile_theme_light_rose(), swatch: '#e11d48', light: true },
|
||
];
|
||
|
||
const FONTS = [
|
||
{ id: 'system', label: () => m.profile_font_system() },
|
||
{ id: 'serif', label: () => m.profile_font_serif() },
|
||
{ id: 'mono', label: () => m.profile_font_mono() },
|
||
];
|
||
|
||
const FONT_SIZES = [
|
||
{ value: 0.9, label: () => m.profile_text_size_sm() },
|
||
{ value: 1.0, label: () => m.profile_text_size_md() },
|
||
{ value: 1.15, label: () => m.profile_text_size_lg() },
|
||
{ value: 1.3, label: () => m.profile_text_size_xl() },
|
||
];
|
||
|
||
// Local save-status indicator — layout's effect does the actual debounced save.
|
||
type SaveStatus = 'idle' | 'saving' | 'saved';
|
||
let saveStatus = $state<SaveStatus>('idle');
|
||
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(() => {
|
||
const t = selectedTheme;
|
||
const ff = selectedFontFamily;
|
||
const fs = selectedFontSize;
|
||
const v = audioStore.voice;
|
||
const sp = audioStore.speed;
|
||
const an = audioStore.autoNext;
|
||
const ac = audioStore.announceChapter;
|
||
const am = audioStore.audioMode;
|
||
|
||
if (settingsCtx) {
|
||
settingsCtx.current = t;
|
||
settingsCtx.fontFamily = ff;
|
||
settingsCtx.fontSize = fs;
|
||
}
|
||
|
||
if (!initialized) { initialized = true; return; }
|
||
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');
|
||
|
||
// ── Sessions ─────────────────────────────────────────────────────────────────
|
||
type Session = {
|
||
id: string;
|
||
user_agent: string;
|
||
ip: string;
|
||
created_at: string;
|
||
last_seen: string;
|
||
is_current: boolean;
|
||
};
|
||
|
||
let sessions = $state<Session[]>(untrack(() => data.sessions ?? []));
|
||
let revokingId = $state<string | null>(null);
|
||
let revokeError = $state('');
|
||
|
||
async function revokeSession(session: Session) {
|
||
revokingId = session.id;
|
||
revokeError = '';
|
||
try {
|
||
const res = await fetch(`/api/sessions/${session.id}`, { method: 'DELETE' });
|
||
if (!res.ok) { revokeError = 'Failed to end session. Please try again.'; return; }
|
||
if (session.is_current) {
|
||
const logoutForm = document.getElementById('logout-form') as HTMLFormElement | null;
|
||
if (logoutForm) logoutForm.submit();
|
||
return;
|
||
}
|
||
sessions = sessions.filter((s) => s.id !== session.id);
|
||
} catch {
|
||
revokeError = 'Network error. Please try again.';
|
||
} finally {
|
||
revokingId = null;
|
||
}
|
||
}
|
||
|
||
// ── 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 {
|
||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
|
||
} catch { return iso; }
|
||
}
|
||
|
||
// ── Push notifications ────────────────────────────────────────────────────────
|
||
type PushState = 'unsupported' | 'default' | 'subscribed' | 'denied' | 'loading';
|
||
let pushState = $state<PushState>('unsupported');
|
||
let pushError = $state('');
|
||
|
||
$effect(() => {
|
||
if (!browser) return;
|
||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||
pushState = 'unsupported';
|
||
return;
|
||
}
|
||
// Check current permission / subscription state
|
||
(async () => {
|
||
const perm = Notification.permission;
|
||
if (perm === 'denied') { pushState = 'denied'; return; }
|
||
const reg = await navigator.serviceWorker.getRegistration('/');
|
||
if (!reg) { pushState = 'default'; return; }
|
||
const sub = await reg.pushManager.getSubscription();
|
||
pushState = sub ? 'subscribed' : 'default';
|
||
})();
|
||
});
|
||
|
||
async function subscribePush() {
|
||
pushError = '';
|
||
pushState = 'loading';
|
||
try {
|
||
// Fetch VAPID public key from backend
|
||
const keyRes = await fetch('/api/push-subscriptions/vapid-public-key');
|
||
if (!keyRes.ok) { pushError = 'Push notifications are not configured on this server.'; pushState = 'default'; return; }
|
||
const { public_key } = await keyRes.json() as { public_key: string };
|
||
|
||
// Register / get existing service worker
|
||
const reg = await navigator.serviceWorker.ready;
|
||
|
||
// Subscribe with VAPID
|
||
const sub = await reg.pushManager.subscribe({
|
||
userVisibleOnly: true,
|
||
applicationServerKey: urlBase64ToUint8Array(public_key),
|
||
});
|
||
|
||
const json = sub.toJSON() as {
|
||
endpoint: string;
|
||
keys: { p256dh: string; auth: string };
|
||
};
|
||
|
||
const saveRes = await fetch('/api/push-subscription', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(json),
|
||
});
|
||
if (!saveRes.ok) { pushError = 'Failed to save subscription. Please try again.'; pushState = 'default'; return; }
|
||
|
||
pushState = 'subscribed';
|
||
} catch (e) {
|
||
pushError = e instanceof Error ? e.message : 'Failed to enable notifications.';
|
||
pushState = Notification.permission === 'denied' ? 'denied' : 'default';
|
||
}
|
||
}
|
||
|
||
async function unsubscribePush() {
|
||
pushError = '';
|
||
pushState = 'loading';
|
||
try {
|
||
const reg = await navigator.serviceWorker.getRegistration('/');
|
||
const sub = await reg?.pushManager.getSubscription();
|
||
if (sub) {
|
||
await fetch('/api/push-subscription', {
|
||
method: 'DELETE',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||
});
|
||
await sub.unsubscribe();
|
||
}
|
||
pushState = 'default';
|
||
} catch (e) {
|
||
pushError = e instanceof Error ? e.message : 'Failed to disable notifications.';
|
||
pushState = 'subscribed';
|
||
}
|
||
}
|
||
|
||
/** Convert a base64url VAPID public key to a Uint8Array for PushManager.subscribe(). */
|
||
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
|
||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||
const raw = atob(base64);
|
||
const arr = new Uint8Array(raw.length);
|
||
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
|
||
return arr.buffer as ArrayBuffer;
|
||
}
|
||
|
||
function parseUA(ua: string): string {
|
||
if (!ua) return 'Unknown browser';
|
||
if (/Mobile/i.test(ua)) {
|
||
const match = ua.match(/\(([^)]+)\)/);
|
||
return match ? `Mobile — ${match[1].split(';')[0].trim()}` : 'Mobile device';
|
||
}
|
||
if (/Chrome\/(\d+)/i.test(ua)) return `Chrome ${ua.match(/Chrome\/(\d+)/i)![1]}`;
|
||
if (/Firefox\/(\d+)/i.test(ua)) return `Firefox ${ua.match(/Firefox\/(\d+)/i)![1]}`;
|
||
if (/Safari\/(\d+)/i.test(ua) && !/Chrome/i.test(ua)) return 'Safari';
|
||
if (/Edg\/(\d+)/i.test(ua)) return `Edge ${ua.match(/Edg\/(\d+)/i)![1]}`;
|
||
return ua.slice(0, 48) + (ua.length > 48 ? '…' : '');
|
||
}
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<title>{m.profile_page_title()}</title>
|
||
</svelte:head>
|
||
|
||
{#if cropFile && browser}
|
||
{#await import('$lib/components/AvatarCropModal.svelte') then { default: AvatarCropModal }}
|
||
<AvatarCropModal file={cropFile} onconfirm={handleCropConfirm} oncancel={handleCropCancel} />
|
||
{/await}
|
||
{/if}
|
||
|
||
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
|
||
|
||
<div class="max-w-2xl mx-auto space-y-6 pb-12">
|
||
|
||
<!-- ── Post-checkout success banner ──────────────────────────────────────── -->
|
||
{#if justSubscribed}
|
||
<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 ───────────────────────────────────────────────────── -->
|
||
<div class="flex items-center gap-5 pt-2">
|
||
<div class="relative shrink-0">
|
||
<button
|
||
onclick={() => fileInput?.click()}
|
||
class="group relative w-20 h-20 rounded-full overflow-hidden ring-2 ring-(--color-border) hover:ring-(--color-brand) transition-all focus:outline-none"
|
||
title={m.profile_change_avatar()}
|
||
disabled={avatarUploading}
|
||
>
|
||
{#if avatarUrl}
|
||
<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">
|
||
<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">
|
||
{#if avatarUploading}
|
||
<svg class="w-5 h-5 text-white animate-spin" fill="none" viewBox="0 0 24 24">
|
||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
||
</svg>
|
||
{:else}
|
||
<span class="text-xs font-semibold text-white tracking-wide">Edit</span>
|
||
{/if}
|
||
</div>
|
||
</button>
|
||
<input bind:this={fileInput} type="file" accept="image/jpeg,image/png,image/webp" class="hidden" onchange={handleAvatarChange} />
|
||
</div>
|
||
|
||
<div class="min-w-0">
|
||
<h1 class="text-2xl font-bold text-(--color-text) truncate">{data.user.username}</h1>
|
||
<div class="flex items-center gap-2 mt-1 flex-wrap">
|
||
<span class="text-xs font-semibold px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted) capitalize border border-(--color-border)">{data.user.role}</span>
|
||
{#if data.isPro}
|
||
<span class="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}
|
||
</div>
|
||
{#if avatarError}
|
||
<p class="text-(--color-danger) text-xs mt-1.5">{avatarError}</p>
|
||
{:else}
|
||
<p class="text-(--color-muted) text-xs mt-1.5">{m.profile_click_to_change()}</p>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tabs -->
|
||
<div class="flex gap-1 bg-(--color-surface-2) rounded-xl p-1 border border-(--color-border)">
|
||
{#each (['profile', 'stats', 'history'] as const) as tab}
|
||
<button
|
||
type="button"
|
||
onclick={() => (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)'
|
||
)}
|
||
>
|
||
{tab === 'profile' ? 'Profile' : tab === 'stats' ? 'Stats' : 'History'}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
|
||
{#if activeTab === 'profile'}
|
||
|
||
<!-- ── Subscription ──────────────────────────────────────────────────────── -->
|
||
{#if !data.isPro}
|
||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
|
||
<div class="flex items-start justify-between gap-4">
|
||
<div class="space-y-1">
|
||
<h2 class="text-base font-semibold text-(--color-text)">{m.profile_subscription_heading()}</h2>
|
||
<p class="text-sm text-(--color-muted)">{m.profile_free_limits()}</p>
|
||
</div>
|
||
<span class="shrink-0 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-(--color-surface-3) text-(--color-muted) border border-(--color-border) uppercase tracking-wide">
|
||
{m.profile_plan_free()}
|
||
</span>
|
||
</div>
|
||
<div class="mt-5 pt-5 border-t border-(--color-border)">
|
||
<p class="text-sm font-medium text-(--color-text) mb-1">{m.profile_upgrade_heading()}</p>
|
||
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()} <a href="/subscribe" class="text-(--color-brand) hover:underline">See plans →</a></p>
|
||
{#if checkoutError}
|
||
<p class="text-sm text-(--color-danger) mb-3">{checkoutError}</p>
|
||
{/if}
|
||
<div class="flex flex-wrap gap-3">
|
||
<button
|
||
type="button"
|
||
onclick={() => startCheckout('monthly')}
|
||
disabled={checkoutLoading !== null}
|
||
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>
|
||
{/if}
|
||
{m.profile_upgrade_monthly()}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onclick={() => startCheckout('annual')}
|
||
disabled={checkoutLoading !== null}
|
||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors disabled:opacity-60 disabled:cursor-wait">
|
||
{#if checkoutLoading === 'annual'}
|
||
<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}
|
||
{m.profile_upgrade_annual()}
|
||
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30">–33%</span>
|
||
{/if}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
{:else}
|
||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5 flex items-center justify-between gap-4">
|
||
<div>
|
||
<p class="text-sm font-medium text-(--color-text)">{m.profile_pro_active()}</p>
|
||
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_pro_perks()}</p>
|
||
</div>
|
||
<a href={manageUrl} target="_blank" rel="noopener noreferrer"
|
||
class="shrink-0 text-sm font-medium text-(--color-brand) hover:underline">
|
||
{m.profile_manage_subscription()} →
|
||
</a>
|
||
</section>
|
||
{/if}
|
||
|
||
<!-- ── Preferences ───────────────────────────────────────────────────────── -->
|
||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) divide-y divide-(--color-border)">
|
||
|
||
<!-- 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={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>
|
||
|
||
<!-- Theme -->
|
||
<div class="px-6 py-5 space-y-3">
|
||
<p class="text-sm font-medium text-(--color-text)">{m.profile_theme_label()}</p>
|
||
<div class="flex gap-2 flex-wrap items-center">
|
||
{#each THEMES as t, i}
|
||
{#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={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)'
|
||
)}
|
||
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>
|
||
{t.label()}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Font family -->
|
||
<div class="px-6 py-5 space-y-3">
|
||
<p class="text-sm font-medium text-(--color-text)">{m.profile_font_family()}</p>
|
||
<div class="flex gap-2 flex-wrap">
|
||
{#each FONTS as f}
|
||
<button
|
||
type="button"
|
||
onclick={() => (selectedFontFamily = f.id)}
|
||
class={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)'
|
||
)}
|
||
aria-pressed={selectedFontFamily === f.id}
|
||
>
|
||
{f.label()}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Text size -->
|
||
<div class="px-6 py-5 space-y-3">
|
||
<p class="text-sm font-medium text-(--color-text)">{m.profile_text_size()}</p>
|
||
<div class="flex gap-2 flex-wrap">
|
||
{#each FONT_SIZES as s}
|
||
<button
|
||
type="button"
|
||
onclick={() => (selectedFontSize = s.value)}
|
||
class={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)'
|
||
)}
|
||
aria-pressed={selectedFontSize === s.value}
|
||
>
|
||
{s.label()}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
</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)">{audioStore.speed.toFixed(1)}x</span>
|
||
</div>
|
||
<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>
|
||
|
||
<!-- 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>
|
||
</div>
|
||
|
||
</section>
|
||
|
||
<!-- ── 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>
|
||
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_session_unrecognised()}</p>
|
||
</div>
|
||
|
||
{#if revokeError}
|
||
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">{revokeError}</div>
|
||
{/if}
|
||
|
||
{#if sessions.length === 0}
|
||
<p class="text-sm text-(--color-muted) italic">{m.profile_no_sessions()}</p>
|
||
{:else}
|
||
<ul class="space-y-2">
|
||
{#each sessions as session (session.id)}
|
||
<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>
|
||
{#if session.is_current}
|
||
<span class="shrink-0 text-xs font-semibold px-1.5 py-0.5 rounded bg-(--color-brand)/20 text-(--color-brand-dim) border border-(--color-brand)/40">{m.profile_session_this()}</span>
|
||
{/if}
|
||
</div>
|
||
{#if session.ip}
|
||
<p class="text-xs text-(--color-muted) font-mono">{session.ip}</p>
|
||
{/if}
|
||
<p class="text-xs text-(--color-muted)">
|
||
{m.profile_session_signed_in({ date: formatDate(session.created_at) })}
|
||
{#if session.last_seen && session.last_seen !== session.created_at}
|
||
{m.profile_session_last_seen({ date: formatDate(session.last_seen) })}
|
||
{/if}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onclick={() => revokeSession(session)}
|
||
disabled={revokingId === session.id}
|
||
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)'
|
||
)}
|
||
>
|
||
{revokingId === session.id ? '…' : session.is_current ? m.profile_session_sign_out() : m.profile_session_end()}
|
||
</button>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
</section>
|
||
|
||
<!-- ── Push notifications ────────────────────────────────────────────────── -->
|
||
{#if pushState !== 'unsupported'}
|
||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
|
||
<div class="flex items-start justify-between gap-4">
|
||
<div class="min-w-0">
|
||
<h2 class="text-base font-semibold text-(--color-text)">Push notifications</h2>
|
||
<p class="text-sm text-(--color-muted) mt-0.5">
|
||
{#if pushState === 'subscribed'}
|
||
You'll receive a push notification when new chapters are added to books in your library.
|
||
{:else if pushState === 'denied'}
|
||
Notifications are blocked by your browser. Change the permission in your browser settings.
|
||
{:else}
|
||
Get notified when new chapters arrive for books in your library.
|
||
{/if}
|
||
</p>
|
||
{#if pushError}
|
||
<p class="text-sm text-(--color-danger) mt-1.5">{pushError}</p>
|
||
{/if}
|
||
</div>
|
||
<div class="shrink-0">
|
||
{#if pushState === 'subscribed'}
|
||
<button
|
||
type="button"
|
||
onclick={unsubscribePush}
|
||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-3) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) text-sm font-medium transition-colors"
|
||
>
|
||
<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="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-5-5.917V5a1 1 0 10-2 0v.083A6 6 0 006 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
|
||
</svg>
|
||
Turn off
|
||
</button>
|
||
{:else if pushState === 'denied'}
|
||
<span class="text-xs text-(--color-muted) italic">Blocked</span>
|
||
{:else}
|
||
<button
|
||
type="button"
|
||
onclick={subscribePush}
|
||
disabled={pushState === 'loading'}
|
||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) disabled:opacity-60 text-sm font-semibold transition-colors"
|
||
>
|
||
{#if pushState === 'loading'}
|
||
<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"></circle>
|
||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
||
</svg>
|
||
{: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="1.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-5-5.917V5a1 1 0 10-2 0v.083A6 6 0 006 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
|
||
</svg>
|
||
{/if}
|
||
Turn on
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</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">
|
||
|
||
<!-- Reading overview -->
|
||
<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">Reading Overview</h2>
|
||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||
{#each [
|
||
{ 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>
|
||
<p class="text-xs text-(--color-muted) mt-0.5">{stat.label}</p>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Streak + rating -->
|
||
<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="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="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>
|
||
|
||
<!-- Top genres -->
|
||
{#if data.stats.topGenres.length > 0}
|
||
<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-3">Favourite Genres</h2>
|
||
<div class="flex flex-wrap gap-2">
|
||
{#each data.stats.topGenres as genre, i}
|
||
<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}
|
||
</div>
|
||
</section>
|
||
{/if}
|
||
|
||
{#if data.stats.booksDropped > 0}
|
||
<p class="text-xs text-(--color-muted) text-center">
|
||
{data.stats.booksDropped} dropped book{data.stats.booksDropped !== 1 ? 's' : ''} —
|
||
<a href="/books" class="text-(--color-brand) hover:underline">revisit your library</a>
|
||
</p>
|
||
{/if}
|
||
|
||
</div>
|
||
{/if}
|
||
|
||
{#if activeTab === 'history'}
|
||
<div class="space-y-2">
|
||
{#if data.history.length === 0}
|
||
<div class="py-16 text-center text-(--color-muted)">
|
||
<p class="text-sm">No reading history yet.</p>
|
||
</div>
|
||
{:else}
|
||
{#each data.history as item}
|
||
<a
|
||
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"
|
||
>
|
||
<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 bg-(--color-surface-3)"></div>
|
||
{/if}
|
||
</div>
|
||
<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>
|
||
<p class="text-xs text-(--color-muted) shrink-0 tabular-nums">
|
||
{#if item.updated}
|
||
{(() => {
|
||
const ms = Date.now() - new Date(item.updated).getTime();
|
||
const mins = Math.floor(ms / 60000);
|
||
if (mins < 60) return mins <= 1 ? 'just now' : `${mins}m ago`;
|
||
const hrs = Math.floor(mins / 60);
|
||
if (hrs < 24) return `${hrs}h ago`;
|
||
const days = Math.floor(hrs / 24);
|
||
if (days < 30) return `${days}d ago`;
|
||
return new Date(item.updated).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||
})()}
|
||
{/if}
|
||
</p>
|
||
</a>
|
||
{/each}
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
</div>
|