refactor: profile page grouped menu layout inspired by iOS settings style
This commit is contained in:
@@ -84,10 +84,6 @@
|
|||||||
function handleCropCancel() { cropFile = null; }
|
function handleCropCancel() { cropFile = null; }
|
||||||
|
|
||||||
// ── Settings state ────────────────────────────────────────────────────────────
|
// ── 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');
|
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 selectedFontFamily = $state(untrack(() => data.settings?.fontFamily ?? settingsCtx?.fontFamily ?? 'system'));
|
||||||
@@ -118,7 +114,6 @@
|
|||||||
{ value: 1.3, label: () => m.profile_text_size_xl() },
|
{ 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';
|
type SaveStatus = 'idle' | 'saving' | 'saved';
|
||||||
let saveStatus = $state<SaveStatus>('idle');
|
let saveStatus = $state<SaveStatus>('idle');
|
||||||
let savedTimer = 0;
|
let savedTimer = 0;
|
||||||
@@ -133,8 +128,6 @@
|
|||||||
}, 900) 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(() => {
|
$effect(() => {
|
||||||
const t = selectedTheme;
|
const t = selectedTheme;
|
||||||
const ff = selectedFontFamily;
|
const ff = selectedFontFamily;
|
||||||
@@ -160,8 +153,13 @@
|
|||||||
$effect(() => { if (settingsCtx) settingsCtx.fontFamily = selectedFontFamily; });
|
$effect(() => { if (settingsCtx) settingsCtx.fontFamily = selectedFontFamily; });
|
||||||
$effect(() => { if (settingsCtx) settingsCtx.fontSize = selectedFontSize; });
|
$effect(() => { if (settingsCtx) settingsCtx.fontSize = selectedFontSize; });
|
||||||
|
|
||||||
// ── Tab ──────────────────────────────────────────────────────────────────────
|
// ── Expanded section state ────────────────────────────────────────────────────
|
||||||
let activeTab = $state<'profile' | 'stats' | 'history'>('profile');
|
type Section = 'history' | 'stats' | 'appearance' | 'playback' | 'notifications' | 'subscription' | 'sessions' | 'danger' | null;
|
||||||
|
let expanded = $state<Section>(null);
|
||||||
|
|
||||||
|
function toggle(section: Section) {
|
||||||
|
expanded = expanded === section ? null : section;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Sessions ─────────────────────────────────────────────────────────────────
|
// ── Sessions ─────────────────────────────────────────────────────────────────
|
||||||
type Session = {
|
type Session = {
|
||||||
@@ -197,7 +195,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Danger zone ──────────────────────────────────────────────────────────────
|
// ── Danger zone ──────────────────────────────────────────────────────────────
|
||||||
let deleteConfirmOpen = $state(false);
|
|
||||||
let deleteConfirmText = $state('');
|
let deleteConfirmText = $state('');
|
||||||
let deleting = $state(false);
|
let deleting = $state(false);
|
||||||
let deleteError = $state('');
|
let deleteError = $state('');
|
||||||
@@ -265,7 +262,6 @@
|
|||||||
pushState = 'unsupported';
|
pushState = 'unsupported';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Check current permission / subscription state
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const perm = Notification.permission;
|
const perm = Notification.permission;
|
||||||
if (perm === 'denied') { pushState = 'denied'; return; }
|
if (perm === 'denied') { pushState = 'denied'; return; }
|
||||||
@@ -280,15 +276,12 @@
|
|||||||
pushError = '';
|
pushError = '';
|
||||||
pushState = 'loading';
|
pushState = 'loading';
|
||||||
try {
|
try {
|
||||||
// Fetch VAPID public key from backend
|
|
||||||
const keyRes = await fetch('/api/push-subscriptions/vapid-public-key');
|
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; }
|
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 };
|
const { public_key } = await keyRes.json() as { public_key: string };
|
||||||
|
|
||||||
// Register / get existing service worker
|
|
||||||
const reg = await navigator.serviceWorker.ready;
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
|
||||||
// Subscribe with VAPID
|
|
||||||
const sub = await reg.pushManager.subscribe({
|
const sub = await reg.pushManager.subscribe({
|
||||||
userVisibleOnly: true,
|
userVisibleOnly: true,
|
||||||
applicationServerKey: urlBase64ToUint8Array(public_key),
|
applicationServerKey: urlBase64ToUint8Array(public_key),
|
||||||
@@ -334,7 +327,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convert a base64url VAPID public key to a Uint8Array for PushManager.subscribe(). */
|
|
||||||
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
|
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
|
||||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
@@ -356,6 +348,9 @@
|
|||||||
if (/Edg\/(\d+)/i.test(ua)) return `Edge ${ua.match(/Edg\/(\d+)/i)![1]}`;
|
if (/Edg\/(\d+)/i.test(ua)) return `Edge ${ua.match(/Edg\/(\d+)/i)![1]}`;
|
||||||
return ua.slice(0, 48) + (ua.length > 48 ? '…' : '');
|
return ua.slice(0, 48) + (ua.length > 48 ? '…' : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Chevron helper ────────────────────────────────────────────────────────────
|
||||||
|
const chevronClass = 'w-4 h-4 text-(--color-muted) shrink-0 transition-transform duration-200';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -370,9 +365,9 @@
|
|||||||
|
|
||||||
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
|
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
|
||||||
|
|
||||||
<div class="max-w-2xl mx-auto space-y-6 pb-12">
|
<div class="max-w-lg mx-auto space-y-5 pb-16">
|
||||||
|
|
||||||
<!-- ── Post-checkout success banner ──────────────────────────────────────── -->
|
<!-- ── Post-checkout success banner ─────────────────────────────────────── -->
|
||||||
{#if justSubscribed}
|
{#if justSubscribed}
|
||||||
<div class="rounded-xl bg-(--color-brand)/10 border border-(--color-brand)/40 px-5 py-4">
|
<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 font-semibold text-(--color-brand)">Welcome to Pro!</p>
|
||||||
@@ -381,11 +376,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- ── Profile header ───────────────────────────────────────────────────── -->
|
<!-- ── Profile header ───────────────────────────────────────────────────── -->
|
||||||
<div class="flex items-center gap-5 pt-2">
|
<div class="flex items-center gap-4 pt-2 px-1">
|
||||||
<div class="relative shrink-0">
|
<div class="relative shrink-0">
|
||||||
<button
|
<button
|
||||||
onclick={() => fileInput?.click()}
|
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"
|
class="group relative w-18 h-18 rounded-full overflow-hidden ring-2 ring-(--color-border) hover:ring-(--color-brand) transition-all focus:outline-none"
|
||||||
title={m.profile_change_avatar()}
|
title={m.profile_change_avatar()}
|
||||||
disabled={avatarUploading}
|
disabled={avatarUploading}
|
||||||
>
|
>
|
||||||
@@ -405,16 +400,19 @@
|
|||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-xs font-semibold text-white tracking-wide">Edit</span>
|
<svg class="w-4 h-4 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>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<input bind:this={fileInput} type="file" accept="image/jpeg,image/png,image/webp" class="hidden" onchange={handleAvatarChange} />
|
<input bind:this={fileInput} type="file" accept="image/jpeg,image/png,image/webp" class="hidden" onchange={handleAvatarChange} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-w-0">
|
<div class="min-w-0 flex-1">
|
||||||
<h1 class="text-2xl font-bold text-(--color-text) truncate">{data.user.username}</h1>
|
<h1 class="text-xl font-bold text-(--color-text) truncate">{data.user.username}</h1>
|
||||||
<div class="flex items-center gap-2 mt-1 flex-wrap">
|
<div class="flex items-center gap-2 mt-0.5 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>
|
<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}
|
{#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">
|
<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">
|
||||||
@@ -423,128 +421,212 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if avatarError}
|
{#if avatarError}
|
||||||
<p class="text-(--color-danger) text-xs mt-1.5">{avatarError}</p>
|
<p class="text-(--color-danger) text-xs mt-1">{avatarError}</p>
|
||||||
{:else}
|
|
||||||
<p class="text-(--color-muted) text-xs mt-1.5">{m.profile_click_to_change()}</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- ── Library group ─────────────────────────────────────────────────────── -->
|
||||||
<div class="flex gap-1 bg-(--color-surface-2) rounded-xl p-1 border border-(--color-border)">
|
<div class="bg-(--color-surface-2) rounded-xl border border-(--color-border) divide-y divide-(--color-border) overflow-hidden">
|
||||||
{#each (['profile', 'stats', 'history'] as const) as tab}
|
|
||||||
|
<!-- Favourites -->
|
||||||
|
<a
|
||||||
|
href="/books?status=reading"
|
||||||
|
class="flex items-center gap-3.5 px-5 py-4 hover:bg-(--color-surface-3)/60 transition-colors group"
|
||||||
|
>
|
||||||
|
<span class="shrink-0 w-8 h-8 rounded-lg bg-(--color-surface-3) border border-(--color-border) flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-(--color-muted) group-hover:text-(--color-brand) transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 text-sm font-medium text-(--color-text)">Library</span>
|
||||||
|
<svg class={chevronClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (activeTab = tab)}
|
onclick={() => toggle('stats')}
|
||||||
class={cn(
|
class="w-full flex items-center gap-3.5 px-5 py-4 hover:bg-(--color-surface-3)/60 transition-colors group text-left"
|
||||||
'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'}
|
<span class="shrink-0 w-8 h-8 rounded-lg bg-(--color-surface-3) border border-(--color-border) flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-(--color-muted) group-hover:text-(--color-brand) transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 text-sm font-medium text-(--color-text)">Stats</span>
|
||||||
|
<svg class={cn(chevronClass, expanded === 'stats' ? 'rotate-90' : '')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{#if expanded === 'stats'}
|
||||||
|
<div class="px-5 pb-5 pt-3 space-y-4 bg-(--color-surface-3)/30">
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
|
{#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-2) rounded-lg p-3 text-center border border-(--color-border)">
|
||||||
|
<p class="text-xl 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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
{#if activeTab === 'profile'}
|
<div class="bg-(--color-surface-2) rounded-lg p-3 border border-(--color-border)">
|
||||||
|
<p class="text-xl font-bold text-(--color-text) tabular-nums">{data.stats.streak}</p>
|
||||||
<!-- ── Subscription ──────────────────────────────────────────────────────── -->
|
<p class="text-xs text-(--color-muted) mt-0.5">day streak</p>
|
||||||
{#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>
|
</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">
|
<div class="bg-(--color-surface-2) rounded-lg p-3 border border-(--color-border)">
|
||||||
{m.profile_plan_free()}
|
<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) mt-0.5">avg rating</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if data.stats.topGenres.length > 0}
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
{#each data.stats.topGenres as genre, i}
|
||||||
|
<span class={cn(
|
||||||
|
'px-2.5 py-1 rounded-full text-xs font-medium',
|
||||||
|
i === 0
|
||||||
|
? 'bg-(--color-brand)/20 text-(--color-brand) border border-(--color-brand)/30'
|
||||||
|
: 'bg-(--color-surface-2) text-(--color-text) border border-(--color-border)'
|
||||||
|
)}>
|
||||||
|
{genre}
|
||||||
</span>
|
</span>
|
||||||
|
{/each}
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
<div class="flex flex-wrap gap-3">
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- History -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => startCheckout('monthly')}
|
onclick={() => toggle('history')}
|
||||||
disabled={checkoutLoading !== null}
|
class="w-full flex items-center gap-3.5 px-5 py-4 hover:bg-(--color-surface-3)/60 transition-colors group text-left"
|
||||||
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'}
|
<span class="shrink-0 w-8 h-8 rounded-lg bg-(--color-surface-3) border border-(--color-border) flex items-center justify-center">
|
||||||
<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>
|
<svg class="w-4 h-4 text-(--color-muted) group-hover:text-(--color-brand) transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
{/if}
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
{m.profile_upgrade_monthly()}
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 text-sm font-medium text-(--color-text)">Reading History</span>
|
||||||
|
<svg class={cn(chevronClass, expanded === 'history' ? 'rotate-90' : '')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
{#if expanded === 'history'}
|
||||||
onclick={() => startCheckout('annual')}
|
<div class="bg-(--color-surface-3)/30">
|
||||||
disabled={checkoutLoading !== null}
|
{#if data.history.length === 0}
|
||||||
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">
|
<p class="px-5 py-6 text-sm text-(--color-muted) text-center">No reading history yet.</p>
|
||||||
{#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}
|
{:else}
|
||||||
{m.profile_upgrade_annual()}
|
<ul class="divide-y divide-(--color-border)">
|
||||||
<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>
|
{#each data.history as item}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/books/{item.slug}/chapters/{item.chapter}"
|
||||||
|
class="flex items-center gap-3 px-5 py-3 hover:bg-(--color-surface-3)/60 transition-colors group"
|
||||||
|
>
|
||||||
|
<div class="w-7 h-10 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}
|
{/if}
|
||||||
</button>
|
|
||||||
</div>
|
</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">Ch. {item.chapter}</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<p class="text-xs text-(--color-muted) shrink-0 tabular-nums">
|
||||||
{:else}
|
{#if item.updated}
|
||||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5 flex items-center justify-between gap-4">
|
{(() => {
|
||||||
<div>
|
const ms = Date.now() - new Date(item.updated).getTime();
|
||||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_pro_active()}</p>
|
const mins = Math.floor(ms / 60000);
|
||||||
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_pro_perks()}</p>
|
if (mins < 60) return mins <= 1 ? 'just now' : `${mins}m ago`;
|
||||||
</div>
|
const hrs = Math.floor(mins / 60);
|
||||||
<a href={manageUrl} target="_blank" rel="noopener noreferrer"
|
if (hrs < 24) return `${hrs}h ago`;
|
||||||
class="shrink-0 text-sm font-medium text-(--color-brand) hover:underline">
|
const days = Math.floor(hrs / 24);
|
||||||
{m.profile_manage_subscription()} →
|
if (days < 30) return `${days}d ago`;
|
||||||
|
return new Date(item.updated).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
|
})()}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
</a>
|
</a>
|
||||||
</section>
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- ── Preferences ───────────────────────────────────────────────────────── -->
|
</div>
|
||||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) divide-y divide-(--color-border)">
|
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- ── Settings group ────────────────────────────────────────────────────── -->
|
||||||
<div class="flex items-center justify-between px-6 py-4">
|
<div class="bg-(--color-surface-2) rounded-xl border border-(--color-border) divide-y divide-(--color-border) overflow-hidden">
|
||||||
<h2 class="text-base font-semibold text-(--color-text)">Preferences</h2>
|
|
||||||
|
<!-- Appearance -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => toggle('appearance')}
|
||||||
|
class="w-full flex items-center gap-3.5 px-5 py-4 hover:bg-(--color-surface-3)/60 transition-colors group text-left"
|
||||||
|
>
|
||||||
|
<span class="shrink-0 w-8 h-8 rounded-lg bg-(--color-surface-3) border border-(--color-border) flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-(--color-muted) group-hover:text-(--color-brand) transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 text-sm font-medium text-(--color-text)">{m.profile_appearance_heading()}</span>
|
||||||
|
<span class="text-xs text-(--color-muted) mr-2 capitalize hidden sm:inline">{selectedTheme.replace('-', ' ')}</span>
|
||||||
|
<svg class={cn(chevronClass, expanded === 'appearance' ? 'rotate-90' : '')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if expanded === 'appearance'}
|
||||||
|
<div class="px-5 py-5 space-y-5 bg-(--color-surface-3)/30">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Appearance</span>
|
||||||
<span class={cn(
|
<span class={cn(
|
||||||
'text-xs transition-all duration-300',
|
'text-xs transition-all duration-300',
|
||||||
saveStatus === 'saving' ? 'text-(--color-muted)' :
|
saveStatus === 'saving' ? 'text-(--color-muted)' :
|
||||||
saveStatus === 'saved' ? 'text-green-400' :
|
saveStatus === 'saved' ? 'text-green-400' :
|
||||||
'opacity-0 pointer-events-none'
|
'opacity-0 pointer-events-none'
|
||||||
)}>
|
)}>
|
||||||
{#if saveStatus === 'saving'}{m.profile_saving()}…
|
{#if saveStatus === 'saving'}{m.profile_saving()}
|
||||||
{:else if saveStatus === 'saved'}✓ {m.profile_saved()}
|
{:else if saveStatus === 'saved'}✓ {m.profile_saved()}
|
||||||
{:else}{m.profile_saved()}{/if}
|
{:else}{m.profile_saved()}{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Theme -->
|
<!-- Theme -->
|
||||||
<div class="px-6 py-5 space-y-3">
|
<div class="space-y-2.5">
|
||||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_theme_label()}</p>
|
<p class="text-sm font-medium text-(--color-text)">{m.profile_theme_label()}</p>
|
||||||
<div class="flex gap-2 flex-wrap items-center">
|
<div class="flex gap-2 flex-wrap items-center">
|
||||||
{#each THEMES as t, i}
|
{#each THEMES as t, i}
|
||||||
{#if i === 6}
|
{#if i === 6}
|
||||||
<span class="w-px h-6 bg-(--color-border) mx-1 self-center"></span>
|
<span class="w-px h-5 bg-(--color-border) mx-0.5 self-center"></span>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (selectedTheme = t.id)}
|
onclick={() => (selectedTheme = t.id)}
|
||||||
class={cn(
|
class={cn(
|
||||||
'flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors',
|
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-colors',
|
||||||
selectedTheme === t.id
|
selectedTheme === t.id
|
||||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
? '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-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'
|
||||||
)}
|
)}
|
||||||
aria-pressed={selectedTheme === t.id}
|
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>
|
<span class="w-2.5 h-2.5 rounded-full shrink-0 {t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
|
||||||
{t.label()}
|
{t.label()}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -552,7 +634,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Font family -->
|
<!-- Font family -->
|
||||||
<div class="px-6 py-5 space-y-3">
|
<div class="space-y-2.5">
|
||||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_font_family()}</p>
|
<p class="text-sm font-medium text-(--color-text)">{m.profile_font_family()}</p>
|
||||||
<div class="flex gap-2 flex-wrap">
|
<div class="flex gap-2 flex-wrap">
|
||||||
{#each FONTS as f}
|
{#each FONTS as f}
|
||||||
@@ -560,10 +642,10 @@
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={() => (selectedFontFamily = f.id)}
|
onclick={() => (selectedFontFamily = f.id)}
|
||||||
class={cn(
|
class={cn(
|
||||||
'px-3 py-2 rounded-lg border text-sm font-medium transition-colors',
|
'px-3 py-1.5 rounded-lg border text-sm font-medium transition-colors',
|
||||||
selectedFontFamily === f.id
|
selectedFontFamily === f.id
|
||||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
? '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-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'
|
||||||
)}
|
)}
|
||||||
aria-pressed={selectedFontFamily === f.id}
|
aria-pressed={selectedFontFamily === f.id}
|
||||||
>
|
>
|
||||||
@@ -574,7 +656,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Text size -->
|
<!-- Text size -->
|
||||||
<div class="px-6 py-5 space-y-3">
|
<div class="space-y-2.5">
|
||||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_text_size()}</p>
|
<p class="text-sm font-medium text-(--color-text)">{m.profile_text_size()}</p>
|
||||||
<div class="flex gap-2 flex-wrap">
|
<div class="flex gap-2 flex-wrap">
|
||||||
{#each FONT_SIZES as s}
|
{#each FONT_SIZES as s}
|
||||||
@@ -582,10 +664,10 @@
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={() => (selectedFontSize = s.value)}
|
onclick={() => (selectedFontSize = s.value)}
|
||||||
class={cn(
|
class={cn(
|
||||||
'px-3 py-2 rounded-lg border text-sm font-medium transition-colors',
|
'px-3 py-1.5 rounded-lg border text-sm font-medium transition-colors',
|
||||||
selectedFontSize === s.value
|
selectedFontSize === s.value
|
||||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
? '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-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'
|
||||||
)}
|
)}
|
||||||
aria-pressed={selectedFontSize === s.value}
|
aria-pressed={selectedFontSize === s.value}
|
||||||
>
|
>
|
||||||
@@ -594,9 +676,34 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Playback speed -->
|
<!-- Playback -->
|
||||||
<div class="px-6 py-5 space-y-3">
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => toggle('playback')}
|
||||||
|
class="w-full flex items-center gap-3.5 px-5 py-4 hover:bg-(--color-surface-3)/60 transition-colors group text-left"
|
||||||
|
>
|
||||||
|
<span class="shrink-0 w-8 h-8 rounded-lg bg-(--color-surface-3) border border-(--color-border) flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-(--color-muted) group-hover:text-(--color-brand) transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M15.536 8.464a5 5 0 010 7.072M12 18.364a9 9 0 000-12.728M9 10a3 3 0 000 4"/>
|
||||||
|
<circle cx="7" cy="12" r="1.5" fill="currentColor" stroke="none"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 text-sm font-medium text-(--color-text)">Playback</span>
|
||||||
|
<span class="text-xs text-(--color-muted) mr-2 hidden sm:inline">{audioStore.speed.toFixed(1)}x</span>
|
||||||
|
<svg class={cn(chevronClass, expanded === 'playback' ? 'rotate-90' : '')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if expanded === 'playback'}
|
||||||
|
<div class="px-5 py-5 space-y-5 bg-(--color-surface-3)/30">
|
||||||
|
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Playback</span>
|
||||||
|
|
||||||
|
<!-- Speed -->
|
||||||
|
<div class="space-y-2">
|
||||||
<div class="flex items-center justify-between">
|
<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>
|
<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>
|
<span class="text-sm font-mono text-(--color-brand)">{audioStore.speed.toFixed(1)}x</span>
|
||||||
@@ -609,10 +716,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Auto-advance -->
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -685,31 +788,231 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
</section>
|
<!-- Notifications -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => toggle('notifications')}
|
||||||
|
class="w-full flex items-center gap-3.5 px-5 py-4 hover:bg-(--color-surface-3)/60 transition-colors group text-left"
|
||||||
|
>
|
||||||
|
<span class="shrink-0 w-8 h-8 rounded-lg bg-(--color-surface-3) border border-(--color-border) flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-(--color-muted) group-hover:text-(--color-brand) transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" 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>
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 text-sm font-medium text-(--color-text)">Notifications</span>
|
||||||
|
<span class="text-xs mr-2 hidden sm:inline {notifyNewChapters ? 'text-(--color-brand)' : 'text-(--color-muted)'}">
|
||||||
|
{notifyNewChapters ? 'On' : 'Off'}
|
||||||
|
</span>
|
||||||
|
<svg class={cn(chevronClass, expanded === 'notifications' ? 'rotate-90' : '')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- ── Active sessions ───────────────────────────────────────────────────── -->
|
{#if expanded === 'notifications'}
|
||||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
|
<div class="px-5 py-5 space-y-5 bg-(--color-surface-3)/30">
|
||||||
<div>
|
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Notifications</span>
|
||||||
<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>
|
<!-- In-app -->
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-(--color-text)">In-app notifications</p>
|
||||||
|
<p class="text-sm text-(--color-muted) mt-0.5">
|
||||||
|
{#if notifyNewChapters}
|
||||||
|
Notified when new chapters arrive in your library.
|
||||||
|
{:else}
|
||||||
|
In-app new-chapter notifications are disabled.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={toggleNotifyNewChapters}
|
||||||
|
disabled={notifyNewChaptersSaving}
|
||||||
|
class={cn(
|
||||||
|
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none disabled:opacity-50',
|
||||||
|
notifyNewChapters ? 'bg-(--color-brand)' : 'bg-(--color-surface-3)'
|
||||||
|
)}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={notifyNewChapters}
|
||||||
|
title={notifyNewChapters ? 'Turn off in-app notifications' : 'Turn on in-app notifications'}
|
||||||
|
>
|
||||||
|
<span class={cn('inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', notifyNewChapters ? 'translate-x-6' : 'translate-x-1')}></span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Push -->
|
||||||
|
{#if pushState !== 'unsupported'}
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-(--color-text)">Push notifications</p>
|
||||||
|
<p class="text-sm text-(--color-muted) mt-0.5">
|
||||||
|
{#if pushState === 'subscribed'}
|
||||||
|
Push enabled for new chapters in your library.
|
||||||
|
{:else if pushState === 'denied'}
|
||||||
|
Blocked by your browser. Change in browser settings.
|
||||||
|
{:else}
|
||||||
|
Get notified when new chapters arrive.
|
||||||
|
{/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"
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
{/if}
|
||||||
|
Turn on
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Account group ─────────────────────────────────────────────────────── -->
|
||||||
|
<div class="bg-(--color-surface-2) rounded-xl border border-(--color-border) divide-y divide-(--color-border) overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Subscription -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => toggle('subscription')}
|
||||||
|
class="w-full flex items-center gap-3.5 px-5 py-4 hover:bg-(--color-surface-3)/60 transition-colors group text-left"
|
||||||
|
>
|
||||||
|
<span class="shrink-0 w-8 h-8 rounded-lg bg-(--color-surface-3) border border-(--color-border) flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-(--color-muted) group-hover:text-(--color-brand) transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 text-sm font-medium text-(--color-text)">{m.profile_subscription_heading()}</span>
|
||||||
|
<span class={cn(
|
||||||
|
'text-xs font-semibold mr-2 px-2 py-0.5 rounded-full border hidden sm:inline-flex items-center',
|
||||||
|
data.isPro
|
||||||
|
? 'bg-(--color-brand)/15 text-(--color-brand) border-(--color-brand)/30'
|
||||||
|
: 'bg-(--color-surface-3) text-(--color-muted) border-(--color-border)'
|
||||||
|
)}>
|
||||||
|
{data.isPro ? m.profile_plan_pro() : m.profile_plan_free()}
|
||||||
|
</span>
|
||||||
|
<svg class={cn(chevronClass, expanded === 'subscription' ? 'rotate-90' : '')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if expanded === 'subscription'}
|
||||||
|
<div class="px-5 py-5 bg-(--color-surface-3)/30">
|
||||||
|
{#if data.isPro}
|
||||||
|
<div class="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 whitespace-nowrap">
|
||||||
|
{m.profile_manage_subscription()} →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-sm text-(--color-muted)">{m.profile_free_limits()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pt-1 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>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Active sessions -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => toggle('sessions')}
|
||||||
|
class="w-full flex items-center gap-3.5 px-5 py-4 hover:bg-(--color-surface-3)/60 transition-colors group text-left"
|
||||||
|
>
|
||||||
|
<span class="shrink-0 w-8 h-8 rounded-lg bg-(--color-surface-3) border border-(--color-border) flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-(--color-muted) group-hover:text-(--color-brand) transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17H3a2 2 0 01-2-2V5a2 2 0 012-2h14a2 2 0 012 2v10a2 2 0 01-2 2h-2"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 text-sm font-medium text-(--color-text)">{m.profile_sessions_heading()}</span>
|
||||||
|
{#if sessions.length > 0}
|
||||||
|
<span class="text-xs text-(--color-muted) mr-2 hidden sm:inline">{sessions.length} device{sessions.length !== 1 ? 's' : ''}</span>
|
||||||
|
{/if}
|
||||||
|
<svg class={cn(chevronClass, expanded === 'sessions' ? 'rotate-90' : '')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if expanded === 'sessions'}
|
||||||
|
<div class="bg-(--color-surface-3)/30">
|
||||||
|
<p class="px-5 pt-4 pb-2 text-xs text-(--color-muted)">{m.profile_session_unrecognised()}</p>
|
||||||
|
|
||||||
{#if revokeError}
|
{#if revokeError}
|
||||||
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">{revokeError}</div>
|
<div class="mx-5 mb-3 rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">{revokeError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if sessions.length === 0}
|
{#if sessions.length === 0}
|
||||||
<p class="text-sm text-(--color-muted) italic">{m.profile_no_sessions()}</p>
|
<p class="px-5 pb-5 text-sm text-(--color-muted) italic">{m.profile_no_sessions()}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="space-y-2">
|
<ul class="divide-y divide-(--color-border) pb-2">
|
||||||
{#each sessions as session (session.id)}
|
{#each sessions as session (session.id)}
|
||||||
<li class={cn(
|
<li class="flex items-start justify-between gap-3 px-5 py-3">
|
||||||
'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="min-w-0 space-y-0.5">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<span class="text-sm font-medium text-(--color-text) truncate">{parseUA(session.user_agent)}</span>
|
<span class="text-sm font-medium text-(--color-text) truncate">{parseUA(session.user_agent)}</span>
|
||||||
@@ -743,115 +1046,31 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- ── In-app notifications ──────────────────────────────────────────────── -->
|
|
||||||
<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)">In-app notifications</h2>
|
|
||||||
<p class="text-sm text-(--color-muted) mt-0.5">
|
|
||||||
{#if notifyNewChapters}
|
|
||||||
You'll receive a notification when new chapters are added to books in your library.
|
|
||||||
{:else}
|
|
||||||
In-app new-chapter notifications are disabled.
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={toggleNotifyNewChapters}
|
|
||||||
disabled={notifyNewChaptersSaving}
|
|
||||||
class={cn(
|
|
||||||
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none disabled:opacity-50',
|
|
||||||
notifyNewChapters ? 'bg-(--color-brand)' : 'bg-(--color-surface-3)'
|
|
||||||
)}
|
|
||||||
role="switch"
|
|
||||||
aria-checked={notifyNewChapters}
|
|
||||||
title={notifyNewChapters ? 'Turn off in-app notifications' : 'Turn on in-app notifications'}
|
|
||||||
>
|
|
||||||
<span class={cn(
|
|
||||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
|
||||||
notifyNewChapters ? 'translate-x-6' : 'translate-x-1'
|
|
||||||
)}></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</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 ───────────────────────────────────────────────────────── -->
|
<!-- ── Danger zone ───────────────────────────────────────────────────────── -->
|
||||||
<section class="rounded-xl border border-red-500/30 bg-red-500/5 overflow-hidden">
|
<div class="rounded-xl border border-red-500/30 bg-red-500/5 overflow-hidden">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => { deleteConfirmOpen = !deleteConfirmOpen; deleteConfirmText = ''; deleteError = ''; }}
|
onclick={() => { toggle('danger'); deleteConfirmText = ''; deleteError = ''; }}
|
||||||
class="w-full flex items-center justify-between px-6 py-4 text-left hover:bg-red-500/5 transition-colors"
|
class="w-full flex items-center gap-3.5 px-5 py-4 hover:bg-red-500/5 transition-colors text-left"
|
||||||
>
|
>
|
||||||
<div>
|
<span class="shrink-0 w-8 h-8 rounded-lg bg-red-500/10 border border-red-500/30 flex items-center justify-center">
|
||||||
<p class="text-sm font-semibold text-red-400">Danger zone</p>
|
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<p class="text-xs text-(--color-muted) mt-0.5">Irreversible actions — proceed with care</p>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" 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"/>
|
||||||
</div>
|
</svg>
|
||||||
<span class="text-xs text-(--color-muted)">{deleteConfirmOpen ? 'Close' : 'Open'}</span>
|
</span>
|
||||||
|
<span class="flex-1 text-sm font-medium text-red-400">Danger zone</span>
|
||||||
|
<svg class={cn('w-4 h-4 text-red-400/60 shrink-0 transition-transform duration-200', expanded === 'danger' ? 'rotate-90' : '')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if deleteConfirmOpen}
|
{#if expanded === 'danger'}
|
||||||
<div class="px-6 pb-6 space-y-4 border-t border-red-500/20">
|
<div class="px-5 pb-6 space-y-4 border-t border-red-500/20">
|
||||||
<div class="pt-4">
|
<div class="pt-4">
|
||||||
<p class="text-sm font-medium text-(--color-text)">Delete account</p>
|
<p class="text-sm font-medium text-(--color-text)">Delete account</p>
|
||||||
<p class="text-xs text-(--color-muted) mt-1">
|
<p class="text-xs text-(--color-muted) mt-1">
|
||||||
@@ -892,118 +1111,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
</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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user