|
|
|
|
@@ -6,7 +6,6 @@
|
|
|
|
|
import type { AudioMode } from '$lib/audio.svelte';
|
|
|
|
|
import { browser } from '$app/environment';
|
|
|
|
|
import { page } from '$app/state';
|
|
|
|
|
import type { Voice } from '$lib/types';
|
|
|
|
|
import { cn } from '$lib/utils';
|
|
|
|
|
import * as m from '$lib/paraglide/messages.js';
|
|
|
|
|
|
|
|
|
|
@@ -84,70 +83,6 @@
|
|
|
|
|
|
|
|
|
|
function handleCropCancel() { cropFile = null; }
|
|
|
|
|
|
|
|
|
|
// ── Voices ───────────────────────────────────────────────────────────────────
|
|
|
|
|
let voices = $state<Voice[]>([]);
|
|
|
|
|
let voicesLoaded = $state(false);
|
|
|
|
|
|
|
|
|
|
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
|
|
|
|
|
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
|
|
|
|
|
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
|
|
|
|
|
|
|
|
|
|
function voiceLabel(v: Voice): string {
|
|
|
|
|
if (v.engine === 'cfai') {
|
|
|
|
|
const speaker = v.id.startsWith('cfai:') ? v.id.slice(5) : v.id;
|
|
|
|
|
return speaker.replace(/\b\w/g, (c) => c.toUpperCase()) + (v.gender ? ` (EN ${v.gender.toUpperCase()})` : '');
|
|
|
|
|
}
|
|
|
|
|
if (v.engine === 'pocket-tts') {
|
|
|
|
|
const name = v.id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
|
|
|
return name + (v.gender ? ` (${v.lang?.toUpperCase().replace('-','')} ${v.gender.toUpperCase()})` : '');
|
|
|
|
|
}
|
|
|
|
|
// Kokoro: "af_bella" → "Bella (US F)"
|
|
|
|
|
const langMap: Record<string, string> = {
|
|
|
|
|
af:'US', am:'US', bf:'UK', bm:'UK',
|
|
|
|
|
ef:'ES', em:'ES', ff:'FR',
|
|
|
|
|
hf:'IN', hm:'IN', 'if':'IT', im:'IT',
|
|
|
|
|
jf:'JP', jm:'JP', pf:'PT', pm:'PT', zf:'ZH', zm:'ZH',
|
|
|
|
|
};
|
|
|
|
|
const prefix = v.id.slice(0, 2);
|
|
|
|
|
const name = v.id.slice(3).replace(/^v0/, '').replace(/^([a-z])/, (c) => c.toUpperCase());
|
|
|
|
|
const lang = langMap[prefix] ?? prefix.toUpperCase();
|
|
|
|
|
const gender = v.gender ? v.gender.toUpperCase() : '?';
|
|
|
|
|
return `${name} (${lang} ${gender})`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
fetch('/api/voices')
|
|
|
|
|
.then((r) => r.json())
|
|
|
|
|
.then((d: { voices: Voice[] }) => { voices = d.voices ?? []; voicesLoaded = true; })
|
|
|
|
|
.catch(() => { voicesLoaded = true; });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Voice sample playback
|
|
|
|
|
let samplePlayingVoice = $state<string | null>(null);
|
|
|
|
|
let sampleAudio = $state<HTMLAudioElement | null>(null);
|
|
|
|
|
|
|
|
|
|
function stopSample() {
|
|
|
|
|
if (sampleAudio) { sampleAudio.pause(); sampleAudio.src = ''; sampleAudio = null; }
|
|
|
|
|
samplePlayingVoice = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function toggleSample(voiceId: string) {
|
|
|
|
|
if (samplePlayingVoice === voiceId) { stopSample(); return; }
|
|
|
|
|
stopSample();
|
|
|
|
|
samplePlayingVoice = voiceId;
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/presign/voice-sample?voice=${encodeURIComponent(voiceId)}`);
|
|
|
|
|
if (res.status === 404) { samplePlayingVoice = null; return; }
|
|
|
|
|
if (!res.ok) throw new Error();
|
|
|
|
|
const { url } = await res.json() as { url: string };
|
|
|
|
|
const audio = new Audio(url);
|
|
|
|
|
sampleAudio = audio;
|
|
|
|
|
audio.onended = () => { if (samplePlayingVoice === voiceId) stopSample(); };
|
|
|
|
|
audio.onerror = () => { if (samplePlayingVoice === voiceId) stopSample(); };
|
|
|
|
|
await audio.play();
|
|
|
|
|
} catch { samplePlayingVoice = null; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Settings state ────────────────────────────────────────────────────────────
|
|
|
|
|
// All changes are written directly into audioStore / theme context.
|
|
|
|
|
// The layout's debounced $effect owns the single PUT /api/settings call.
|
|
|
|
|
@@ -192,7 +127,6 @@
|
|
|
|
|
function markSaved() {
|
|
|
|
|
saveStatus = 'saving';
|
|
|
|
|
clearTimeout(savedTimer);
|
|
|
|
|
// Give a tick for layout's effect to fire, then show ✓ Saved
|
|
|
|
|
savedTimer = setTimeout(() => {
|
|
|
|
|
saveStatus = 'saved';
|
|
|
|
|
savedTimer = setTimeout(() => (saveStatus = 'idle'), 2000) as unknown as number;
|
|
|
|
|
@@ -218,11 +152,10 @@
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!initialized) { initialized = true; return; }
|
|
|
|
|
void v; void sp; void an; void ac; void am; // keep subscriptions live
|
|
|
|
|
void v; void sp; void an; void ac; void am;
|
|
|
|
|
markSaved();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Keep theme/font writes flowing into layout context when changed from selectors
|
|
|
|
|
$effect(() => { if (settingsCtx) settingsCtx.current = selectedTheme; });
|
|
|
|
|
$effect(() => { if (settingsCtx) settingsCtx.fontFamily = selectedFontFamily; });
|
|
|
|
|
$effect(() => { if (settingsCtx) settingsCtx.fontSize = selectedFontSize; });
|
|
|
|
|
@@ -283,7 +216,6 @@
|
|
|
|
|
deleteError = body.message ?? `Delete failed (${res.status}). Please try again.`;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// Server deleted account — submit logout form to clear session cookie
|
|
|
|
|
const logoutForm = document.getElementById('logout-form') as HTMLFormElement | null;
|
|
|
|
|
if (logoutForm) logoutForm.submit();
|
|
|
|
|
} catch {
|
|
|
|
|
@@ -331,12 +263,9 @@
|
|
|
|
|
|
|
|
|
|
<!-- ── Post-checkout success banner ──────────────────────────────────────── -->
|
|
|
|
|
{#if justSubscribed}
|
|
|
|
|
<div class="rounded-xl bg-(--color-brand)/10 border border-(--color-brand)/40 px-5 py-4 flex items-start gap-3">
|
|
|
|
|
<svg class="w-5 h-5 text-(--color-brand) shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
|
|
|
|
|
<div>
|
|
|
|
|
<p class="text-sm font-semibold text-(--color-brand)">Welcome to Pro!</p>
|
|
|
|
|
<p class="text-sm text-(--color-muted) mt-0.5">Your subscription is being activated. Refresh the page in a moment if the Pro badge doesn't appear yet.</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="rounded-xl bg-(--color-brand)/10 border border-(--color-brand)/40 px-5 py-4">
|
|
|
|
|
<p class="text-sm font-semibold text-(--color-brand)">Welcome to Pro!</p>
|
|
|
|
|
<p class="text-sm text-(--color-muted) mt-0.5">Your subscription is being activated. Refresh the page in a moment if the Pro badge doesn't appear yet.</p>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
@@ -353,9 +282,9 @@
|
|
|
|
|
<img src={avatarUrl} alt="Profile" class="w-full h-full object-cover" />
|
|
|
|
|
{:else}
|
|
|
|
|
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
|
|
|
|
<svg class="w-10 h-10 text-(--color-muted)" fill="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
|
|
|
|
|
</svg>
|
|
|
|
|
<span class="text-3xl font-bold text-(--color-muted) select-none">
|
|
|
|
|
{data.user.username.slice(0, 1).toUpperCase()}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
|
|
|
|
@@ -365,10 +294,7 @@
|
|
|
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
|
|
|
|
</svg>
|
|
|
|
|
{:else}
|
|
|
|
|
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
|
|
|
</svg>
|
|
|
|
|
<span class="text-xs font-semibold text-white tracking-wide">Edit</span>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
@@ -380,8 +306,7 @@
|
|
|
|
|
<div class="flex items-center gap-2 mt-1 flex-wrap">
|
|
|
|
|
<span class="text-xs font-semibold px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted) capitalize border border-(--color-border)">{data.user.role}</span>
|
|
|
|
|
{#if data.isPro}
|
|
|
|
|
<span class="inline-flex items-center gap-1 text-xs font-bold px-2 py-0.5 rounded-full bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 uppercase tracking-wide">
|
|
|
|
|
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
|
|
|
|
|
<span class="text-xs font-bold px-2 py-0.5 rounded-full bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 uppercase tracking-wide">
|
|
|
|
|
{m.profile_plan_pro()}
|
|
|
|
|
</span>
|
|
|
|
|
{/if}
|
|
|
|
|
@@ -440,8 +365,6 @@
|
|
|
|
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60 disabled:cursor-wait">
|
|
|
|
|
{#if checkoutLoading === 'monthly'}
|
|
|
|
|
<svg class="w-4 h-4 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
|
|
|
|
|
{:else}
|
|
|
|
|
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
|
|
|
|
|
{/if}
|
|
|
|
|
{m.profile_upgrade_monthly()}
|
|
|
|
|
</button>
|
|
|
|
|
@@ -467,9 +390,8 @@
|
|
|
|
|
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_pro_perks()}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<a href={manageUrl} target="_blank" rel="noopener noreferrer"
|
|
|
|
|
class="shrink-0 inline-flex items-center gap-1.5 text-sm font-medium text-(--color-brand) hover:underline">
|
|
|
|
|
{m.profile_manage_subscription()}
|
|
|
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
|
|
|
|
class="shrink-0 text-sm font-medium text-(--color-brand) hover:underline">
|
|
|
|
|
{m.profile_manage_subscription()} →
|
|
|
|
|
</a>
|
|
|
|
|
</section>
|
|
|
|
|
{/if}
|
|
|
|
|
@@ -562,84 +484,6 @@
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- TTS voice — visual card picker grouped by engine -->
|
|
|
|
|
<div class="px-6 py-5 space-y-3">
|
|
|
|
|
<p class="text-sm font-medium text-(--color-text)">{m.profile_tts_voice()}</p>
|
|
|
|
|
{#if !voicesLoaded}
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
{#each [1,2,3] as _}
|
|
|
|
|
<div class="h-10 bg-(--color-surface-3) rounded-lg animate-pulse"></div>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
{:else if voices.length === 0}
|
|
|
|
|
<p class="text-sm text-(--color-muted) italic">No voices available.</p>
|
|
|
|
|
{:else}
|
|
|
|
|
<!-- Engine groups -->
|
|
|
|
|
{#each [
|
|
|
|
|
{ label: 'Kokoro (GPU)', voices: kokoroVoices },
|
|
|
|
|
{ label: 'Pocket TTS (CPU)', voices: pocketVoices },
|
|
|
|
|
{ label: 'Cloudflare AI', voices: cfaiVoices },
|
|
|
|
|
].filter(g => g.voices.length > 0) as group}
|
|
|
|
|
<div>
|
|
|
|
|
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest mb-2">{group.label}</p>
|
|
|
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
|
|
|
|
|
{#each group.voices as v (v.id)}
|
|
|
|
|
{@const isSelected = audioStore.voice === v.id}
|
|
|
|
|
{@const isPlaying = samplePlayingVoice === v.id}
|
|
|
|
|
<!-- Use role=option div to avoid nested <button> inside <button> -->
|
|
|
|
|
<div
|
|
|
|
|
role="option"
|
|
|
|
|
aria-selected={isSelected}
|
|
|
|
|
tabindex="0"
|
|
|
|
|
onclick={() => { audioStore.voice = v.id; }}
|
|
|
|
|
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); audioStore.voice = v.id; } }}
|
|
|
|
|
class={cn(
|
|
|
|
|
'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border text-sm transition-colors cursor-pointer select-none',
|
|
|
|
|
isSelected
|
|
|
|
|
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
|
|
|
|
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-text) hover:border-(--color-brand)/40'
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
|
|
|
{#if isSelected}
|
|
|
|
|
<svg class="w-3.5 h-3.5 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
|
|
|
|
</svg>
|
|
|
|
|
{:else}
|
|
|
|
|
<span class="w-3.5 h-3.5 shrink-0"></span>
|
|
|
|
|
{/if}
|
|
|
|
|
<span class="truncate font-medium">{voiceLabel(v)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<!-- Sample play button -->
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onclick={(e) => { e.stopPropagation(); toggleSample(v.id); }}
|
|
|
|
|
class={cn(
|
|
|
|
|
'shrink-0 w-6 h-6 rounded-full flex items-center justify-center transition-colors',
|
|
|
|
|
isPlaying
|
|
|
|
|
? 'bg-(--color-brand) text-(--color-surface)'
|
|
|
|
|
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)'
|
|
|
|
|
)}
|
|
|
|
|
title={isPlaying ? 'Stop sample' : 'Play sample'}
|
|
|
|
|
>
|
|
|
|
|
{#if isPlaying}
|
|
|
|
|
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
|
|
|
|
</svg>
|
|
|
|
|
{:else}
|
|
|
|
|
<svg class="w-3 h-3 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path d="M8 5v14l11-7z"/>
|
|
|
|
|
</svg>
|
|
|
|
|
{/if}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/each}
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Playback speed -->
|
|
|
|
|
<div class="px-6 py-5 space-y-3">
|
|
|
|
|
<div class="flex items-center justify-between">
|
|
|
|
|
@@ -654,15 +498,15 @@
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Playback toggles row -->
|
|
|
|
|
<div class="px-6 py-5 space-y-4">
|
|
|
|
|
<!-- 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">
|
|
|
|
|
<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">Automatically load the next chapter when audio finishes</p>
|
|
|
|
|
<p class="text-xs text-(--color-muted) mt-0.5">Load the next chapter automatically when audio ends</p>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
@@ -680,10 +524,10 @@
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Announce chapter -->
|
|
|
|
|
<div class="flex items-center justify-between">
|
|
|
|
|
<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 auto-advancing</p>
|
|
|
|
|
<p class="text-xs text-(--color-muted) mt-0.5">Read the chapter title aloud before advancing</p>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
@@ -701,22 +545,18 @@
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Audio mode -->
|
|
|
|
|
<div class="flex items-center justify-between">
|
|
|
|
|
<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">
|
|
|
|
|
{#if audioStore.audioMode === 'stream'}
|
|
|
|
|
<strong class="text-(--color-text)">Stream</strong> — audio starts within seconds, saved in background
|
|
|
|
|
{:else}
|
|
|
|
|
<strong class="text-(--color-text)">Generate</strong> — wait for full audio before playing
|
|
|
|
|
{/if}
|
|
|
|
|
{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"
|
|
|
|
|
onclick={() => {
|
|
|
|
|
audioStore.audioMode = audioStore.audioMode === 'stream' ? 'generate' : 'stream';
|
|
|
|
|
}}
|
|
|
|
|
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)',
|
|
|
|
|
@@ -726,7 +566,6 @@
|
|
|
|
|
? 'bg-(--color-brand)'
|
|
|
|
|
: 'bg-(--color-surface-3) border border-(--color-border)'
|
|
|
|
|
)}
|
|
|
|
|
title={audioStore.voice.startsWith('cfai:') ? 'CF AI voices always use generate mode' : undefined}
|
|
|
|
|
>
|
|
|
|
|
<span class={cn(
|
|
|
|
|
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
|
|
|
|
@@ -806,12 +645,7 @@
|
|
|
|
|
<p class="text-sm font-semibold text-red-400">Danger zone</p>
|
|
|
|
|
<p class="text-xs text-(--color-muted) mt-0.5">Irreversible actions — proceed with care</p>
|
|
|
|
|
</div>
|
|
|
|
|
<svg
|
|
|
|
|
class={cn('w-4 h-4 text-(--color-muted) transition-transform', deleteConfirmOpen && 'rotate-180')}
|
|
|
|
|
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
|
|
|
|
>
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
|
|
|
</svg>
|
|
|
|
|
<span class="text-xs text-(--color-muted)">{deleteConfirmOpen ? 'Close' : 'Open'}</span>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{#if deleteConfirmOpen}
|
|
|
|
|
@@ -851,9 +685,6 @@
|
|
|
|
|
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
|
|
|
|
|
Deleting…
|
|
|
|
|
{:else}
|
|
|
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
|
|
|
|
</svg>
|
|
|
|
|
Delete my account
|
|
|
|
|
{/if}
|
|
|
|
|
</button>
|
|
|
|
|
@@ -871,10 +702,10 @@
|
|
|
|
|
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-4">Reading Overview</h2>
|
|
|
|
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
|
|
|
{#each [
|
|
|
|
|
{ label: 'Chapters Read', value: data.stats.totalChaptersRead, icon: '📖' },
|
|
|
|
|
{ label: 'Completed', value: data.stats.booksCompleted, icon: '✅' },
|
|
|
|
|
{ label: 'Reading', value: data.stats.booksReading, icon: '📚' },
|
|
|
|
|
{ label: 'Plan to Read', value: data.stats.booksPlanToRead, icon: '🔖' },
|
|
|
|
|
{ label: 'Chapters Read', value: data.stats.totalChaptersRead },
|
|
|
|
|
{ label: 'Completed', value: data.stats.booksCompleted },
|
|
|
|
|
{ label: 'Reading', value: data.stats.booksReading },
|
|
|
|
|
{ label: 'Plan to Read', value: data.stats.booksPlanToRead },
|
|
|
|
|
] as stat}
|
|
|
|
|
<div class="bg-(--color-surface-3) rounded-lg p-3 text-center">
|
|
|
|
|
<p class="text-2xl font-bold text-(--color-text) tabular-nums">{stat.value}</p>
|
|
|
|
|
@@ -888,21 +719,15 @@
|
|
|
|
|
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5">
|
|
|
|
|
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-4">Activity</h2>
|
|
|
|
|
<div class="grid grid-cols-2 gap-3">
|
|
|
|
|
<div class="flex items-center gap-3 bg-(--color-surface-3) rounded-lg p-3">
|
|
|
|
|
<div class="w-9 h-9 rounded-full bg-orange-500/15 flex items-center justify-center text-lg flex-shrink-0">🔥</div>
|
|
|
|
|
<div>
|
|
|
|
|
<p class="text-xl font-bold text-(--color-text) tabular-nums">{data.stats.streak}</p>
|
|
|
|
|
<p class="text-xs text-(--color-muted)">day streak</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="bg-(--color-surface-3) rounded-lg p-4">
|
|
|
|
|
<p class="text-2xl font-bold text-(--color-text) tabular-nums">{data.stats.streak}</p>
|
|
|
|
|
<p class="text-xs text-(--color-muted) mt-1">day streak</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex items-center gap-3 bg-(--color-surface-3) rounded-lg p-3">
|
|
|
|
|
<div class="w-9 h-9 rounded-full bg-yellow-500/15 flex items-center justify-center text-lg flex-shrink-0">⭐</div>
|
|
|
|
|
<div>
|
|
|
|
|
<p class="text-xl font-bold text-(--color-text) tabular-nums">
|
|
|
|
|
{data.stats.avgRatingGiven > 0 ? data.stats.avgRatingGiven.toFixed(1) : '—'}
|
|
|
|
|
</p>
|
|
|
|
|
<p class="text-xs text-(--color-muted)">avg rating given</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="bg-(--color-surface-3) rounded-lg p-4">
|
|
|
|
|
<p class="text-2xl font-bold text-(--color-text) tabular-nums">
|
|
|
|
|
{data.stats.avgRatingGiven > 0 ? data.stats.avgRatingGiven.toFixed(1) : '—'}
|
|
|
|
|
</p>
|
|
|
|
|
<p class="text-xs text-(--color-muted) mt-1">avg rating given</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
@@ -914,12 +739,11 @@
|
|
|
|
|
<div class="flex flex-wrap gap-2">
|
|
|
|
|
{#each data.stats.topGenres as genre, i}
|
|
|
|
|
<span class={cn(
|
|
|
|
|
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium',
|
|
|
|
|
'px-3 py-1.5 rounded-full text-sm font-medium',
|
|
|
|
|
i === 0
|
|
|
|
|
? 'bg-(--color-brand)/20 text-(--color-brand) border border-(--color-brand)/30'
|
|
|
|
|
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border)'
|
|
|
|
|
)}>
|
|
|
|
|
{#if i === 0}<span class="text-xs">🏆</span>{/if}
|
|
|
|
|
{genre}
|
|
|
|
|
</span>
|
|
|
|
|
{/each}
|
|
|
|
|
@@ -940,10 +764,7 @@
|
|
|
|
|
{#if activeTab === 'history'}
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
{#if data.history.length === 0}
|
|
|
|
|
<div class="py-12 text-center text-(--color-muted)">
|
|
|
|
|
<svg class="w-10 h-10 mx-auto mb-3 text-(--color-border)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
|
|
</svg>
|
|
|
|
|
<div class="py-16 text-center text-(--color-muted)">
|
|
|
|
|
<p class="text-sm">No reading history yet.</p>
|
|
|
|
|
</div>
|
|
|
|
|
{:else}
|
|
|
|
|
@@ -956,11 +777,7 @@
|
|
|
|
|
{#if item.cover}
|
|
|
|
|
<img src={item.cover} alt={item.title} class="w-full h-full object-cover" loading="lazy" />
|
|
|
|
|
{:else}
|
|
|
|
|
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
|
|
|
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="w-full h-full bg-(--color-surface-3)"></div>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex-1 min-w-0">
|
|
|
|
|
|