feat(v3): add v3 stack — backend rewrite, renamed env vars, docs
- New Go backend binary (backend + runner) replacing old scraper/ - Rename SCRAPER_API_URL → BACKEND_API_URL in UI env and docker-compose - Rename scraperFetch → backendFetch across all 19 UI server files - Remove SCRAPER_PROXY env var and proxy transport from browser.Config - Add Meilisearch, Valkey, Caddy to docker-compose - Add docs/: api-endpoints.md, request-flow.mermaid.md, data-flow.mermaid.md
This commit is contained in:
851
v3/ui/src/lib/components/AudioPlayer.svelte
Normal file
851
v3/ui/src/lib/components/AudioPlayer.svelte
Normal file
@@ -0,0 +1,851 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* AudioPlayer — controller component.
|
||||
*
|
||||
* Does NOT own an <audio> element. Instead it reads/writes `audioStore`,
|
||||
* which is shared with the layout's persistent <audio> element so audio
|
||||
* survives SvelteKit navigations.
|
||||
*
|
||||
* ── Play flow ────────────────────────────────────────────────────────────
|
||||
* On "Play narration" click / auto-start:
|
||||
* 1. Populate store metadata (slug, chapter, titles, voice, speed).
|
||||
* 2. If the pre-fetch already landed (nextStatus='prefetched' AND
|
||||
* nextChapterPrefetched === chapter), use the cached URL immediately.
|
||||
* 3. Otherwise try GET /api/presign/audio — if 200, set audioUrl → layout plays.
|
||||
* 4. If 404, POST /api/audio/:slug/:n to generate. Drive pseudo progress bar.
|
||||
* On success (200 or 202→done), presign and set audioUrl directly from MinIO.
|
||||
*
|
||||
* ── Voice selection ──────────────────────────────────────────────────────
|
||||
* A "Change voice" panel lets users pick from the available Kokoro voices.
|
||||
* Each voice shows a play button that streams a pre-generated sample from
|
||||
* MinIO (GET /api/presign/voice-sample?voice=...). Samples are generated
|
||||
* server-side via POST /api/audio/voice-samples.
|
||||
*
|
||||
* Changing voice updates audioStore.voice (saved to settings via layout).
|
||||
* The currently loaded chapter audio is NOT re-generated automatically —
|
||||
* the new voice takes effect on next "Play narration" click.
|
||||
*
|
||||
* ── Pre-fetch (immediate + 90% fallback) ────────────────────────────────
|
||||
* When autoNext is on, prefetchNext() is called as soon as the current
|
||||
* chapter starts playing (via maybeStartPrefetch() at the end of
|
||||
* startPlayback()). This gives the maximum lead time for Kokoro to
|
||||
* generate the next chapter so the transition is seamless.
|
||||
*
|
||||
* A $effect also watches currentTime/duration and fires prefetchNext() at
|
||||
* the 90% mark as a fallback — covering the case where autoNext was toggled
|
||||
* on mid-playback after startPlayback() had already returned.
|
||||
* The nextStatus !== 'none' guard prevents double-runs in all cases.
|
||||
*
|
||||
* prefetchNext():
|
||||
* • Calls POST /api/audio for next chapter (sets nextStatus='prefetching')
|
||||
* • On success, presigns and stores URL in audioStore.nextAudioUrl
|
||||
* (sets nextStatus='prefetched')
|
||||
* • On failure, sets nextStatus='failed'
|
||||
*
|
||||
* ── Auto-next ────────────────────────────────────────────────────────────
|
||||
* layout.svelte onended → sets autoStartPending=true → navigates.
|
||||
* New chapter's AudioPlayer mounts → sees autoStartPending → startPlayback()
|
||||
* which uses the prefetched URL if available.
|
||||
*/
|
||||
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
slug: string;
|
||||
chapter: number;
|
||||
chapterTitle?: string;
|
||||
bookTitle?: string;
|
||||
/** Cover image URL for the book (used in MediaSession for lock-screen art). */
|
||||
cover?: string;
|
||||
/** Next chapter number, or null/undefined if this is the last chapter. */
|
||||
nextChapter?: number | null;
|
||||
/** Full chapter list for the book (number + title). Written into the store. */
|
||||
chapters?: { number: number; title: string }[];
|
||||
/** List of available voices from the Kokoro API. */
|
||||
voices?: string[];
|
||||
}
|
||||
|
||||
let {
|
||||
slug,
|
||||
chapter,
|
||||
chapterTitle = '',
|
||||
bookTitle = '',
|
||||
cover = '',
|
||||
nextChapter = null,
|
||||
chapters = [],
|
||||
voices = []
|
||||
}: Props = $props();
|
||||
|
||||
// ── Voice selector state ────────────────────────────────────────────────
|
||||
let showVoicePanel = $state(false);
|
||||
/** Voice whose sample is currently being fetched or playing. */
|
||||
let samplePlayingVoice = $state<string | null>(null);
|
||||
/** Currently active sample <audio> element — one at a time. */
|
||||
let sampleAudio = $state<HTMLAudioElement | null>(null);
|
||||
|
||||
/**
|
||||
* Human-readable label for a voice ID.
|
||||
* e.g. "af_bella" → "Bella (US F)" | "bm_george" → "George (UK M)"
|
||||
*/
|
||||
function voiceLabel(v: string): string {
|
||||
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 genderMap: Record<string, string> = {
|
||||
af: 'F', am: 'M',
|
||||
bf: 'F', bm: 'M',
|
||||
ef: 'F', em: 'M',
|
||||
ff: 'F',
|
||||
hf: 'F', hm: 'M',
|
||||
'if': 'F', im: 'M',
|
||||
jf: 'F', jm: 'M',
|
||||
pf: 'F', pm: 'M',
|
||||
zf: 'F', zm: 'M',
|
||||
};
|
||||
const prefix = v.slice(0, 2);
|
||||
const name = v.slice(3);
|
||||
// Capitalise and strip legacy v0 prefix.
|
||||
const displayName = name
|
||||
.replace(/^v0/, '')
|
||||
.replace(/^([a-z])/, (c: string) => c.toUpperCase());
|
||||
const lang = langMap[prefix] ?? prefix.toUpperCase();
|
||||
const gender = genderMap[prefix] ?? '?';
|
||||
return `${displayName} (${lang} ${gender})`;
|
||||
}
|
||||
|
||||
/** Stop any currently playing sample. */
|
||||
function stopSample() {
|
||||
if (sampleAudio) {
|
||||
sampleAudio.pause();
|
||||
sampleAudio.src = '';
|
||||
sampleAudio = null;
|
||||
}
|
||||
samplePlayingVoice = null;
|
||||
}
|
||||
|
||||
/** Play a voice sample from MinIO. */
|
||||
async function playSample(voice: string) {
|
||||
// If this voice is already playing, stop it.
|
||||
if (samplePlayingVoice === voice) {
|
||||
stopSample();
|
||||
return;
|
||||
}
|
||||
stopSample();
|
||||
|
||||
samplePlayingVoice = voice;
|
||||
try {
|
||||
const res = await fetch(`/api/presign/voice-sample?voice=${encodeURIComponent(voice)}`);
|
||||
if (res.status === 404) {
|
||||
// Sample not generated yet — silently ignore
|
||||
samplePlayingVoice = null;
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`presign failed: ${res.status}`);
|
||||
const data = (await res.json()) as { url: string };
|
||||
|
||||
const audio = new Audio(data.url);
|
||||
sampleAudio = audio;
|
||||
audio.onended = () => {
|
||||
if (samplePlayingVoice === voice) stopSample();
|
||||
};
|
||||
audio.onerror = () => {
|
||||
if (samplePlayingVoice === voice) stopSample();
|
||||
};
|
||||
await audio.play();
|
||||
} catch {
|
||||
samplePlayingVoice = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Select a voice and close the panel. */
|
||||
function selectVoice(voice: string) {
|
||||
stopSample();
|
||||
audioStore.voice = voice;
|
||||
showVoicePanel = false;
|
||||
}
|
||||
|
||||
// Keep nextChapter in the store so the layout's onended can navigate.
|
||||
// NOTE: we do NOT clear on unmount here — the store retains the value so
|
||||
// onended (which may fire after {#key} unmounts this component) can still
|
||||
// read it. The value is superseded when the new chapter mounts.
|
||||
$effect(() => {
|
||||
audioStore.nextChapter = nextChapter ?? null;
|
||||
});
|
||||
|
||||
// Auto-start: if the layout navigated here via auto-next, kick off playback.
|
||||
// We match against the chapter prop so the outgoing chapter's AudioPlayer
|
||||
// (still mounted during the brief navigation window) never reacts to this.
|
||||
$effect(() => {
|
||||
if (audioStore.autoStartChapter === chapter) {
|
||||
audioStore.autoStartChapter = null;
|
||||
startPlayback();
|
||||
}
|
||||
});
|
||||
|
||||
// Reset next-chapter prefetch state when this chapter changes (new page).
|
||||
// Only reset if the prefetch belongs to neither the current chapter
|
||||
// (about to be consumed by startPlayback) nor the next chapter (still valid).
|
||||
// Any other value means stale data from a previous page.
|
||||
$effect(() => {
|
||||
const prefetchedFor = audioStore.nextChapterPrefetched;
|
||||
if (
|
||||
prefetchedFor !== null &&
|
||||
prefetchedFor !== chapter &&
|
||||
prefetchedFor !== (nextChapter ?? null)
|
||||
) {
|
||||
audioStore.resetNextPrefetch();
|
||||
}
|
||||
});
|
||||
|
||||
// Close voice panel when user clicks outside (escape key).
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
stopSample();
|
||||
showVoicePanel = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 90% pre-fetch trigger ─────────────────────────────────────────────────
|
||||
// Watch playback progress; when >= 90% of current chapter, pre-generate
|
||||
// the next chapter's audio so it's ready when we navigate.
|
||||
$effect(() => {
|
||||
const ct = audioStore.currentTime;
|
||||
const dur = audioStore.duration;
|
||||
const isCurrentlyPlaying = audioStore.isCurrentChapter(slug, chapter);
|
||||
|
||||
if (
|
||||
!isCurrentlyPlaying ||
|
||||
!audioStore.autoNext ||
|
||||
nextChapter === null ||
|
||||
nextChapter === undefined ||
|
||||
dur <= 0 ||
|
||||
ct / dur < 0.9 ||
|
||||
audioStore.nextStatus !== 'none'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Trigger exactly once (nextStatus transitions away from 'none')
|
||||
prefetchNext();
|
||||
});
|
||||
|
||||
// ── Pseudo progress helpers ────────────────────────────────────────────────
|
||||
let progressRafId = 0;
|
||||
|
||||
function startProgress() {
|
||||
audioStore.progress = 0;
|
||||
let last = performance.now();
|
||||
|
||||
function tick(now: number) {
|
||||
const dt = (now - last) / 1000;
|
||||
last = now;
|
||||
let rate: number;
|
||||
if (audioStore.progress < 30) rate = 4;
|
||||
else if (audioStore.progress < 60) rate = 12;
|
||||
else if (audioStore.progress < 80) rate = 4;
|
||||
else rate = 0.3;
|
||||
|
||||
audioStore.progress = Math.min(audioStore.progress + rate * dt, 99);
|
||||
if (audioStore.progress < 99) {
|
||||
progressRafId = requestAnimationFrame(tick);
|
||||
}
|
||||
}
|
||||
progressRafId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function stopProgress() {
|
||||
if (progressRafId) {
|
||||
cancelAnimationFrame(progressRafId);
|
||||
progressRafId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function finishProgress() {
|
||||
stopProgress();
|
||||
const step = () => {
|
||||
audioStore.progress = Math.min(audioStore.progress + 8, 100);
|
||||
if (audioStore.progress < 100) {
|
||||
progressRafId = requestAnimationFrame(step);
|
||||
}
|
||||
};
|
||||
progressRafId = requestAnimationFrame(step);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
stopProgress();
|
||||
}
|
||||
|
||||
// ── Next-chapter pseudo-progress helpers ──────────────────────────────────
|
||||
let nextProgressRafId = 0;
|
||||
|
||||
function startNextProgress() {
|
||||
audioStore.nextProgress = 0;
|
||||
let last = performance.now();
|
||||
|
||||
function tick(now: number) {
|
||||
const dt = (now - last) / 1000;
|
||||
last = now;
|
||||
let rate: number;
|
||||
if (audioStore.nextProgress < 30) rate = 4;
|
||||
else if (audioStore.nextProgress < 60) rate = 12;
|
||||
else if (audioStore.nextProgress < 80) rate = 4;
|
||||
else rate = 0.3;
|
||||
|
||||
audioStore.nextProgress = Math.min(audioStore.nextProgress + rate * dt, 99);
|
||||
if (audioStore.nextProgress < 99) {
|
||||
nextProgressRafId = requestAnimationFrame(tick);
|
||||
}
|
||||
}
|
||||
nextProgressRafId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function stopNextProgress() {
|
||||
if (nextProgressRafId) {
|
||||
cancelAnimationFrame(nextProgressRafId);
|
||||
nextProgressRafId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── API helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
async function tryPresign(
|
||||
targetSlug: string,
|
||||
targetChapter: number,
|
||||
targetVoice: string
|
||||
): Promise<string | null> {
|
||||
const params = new URLSearchParams({
|
||||
slug: targetSlug,
|
||||
n: String(targetChapter),
|
||||
voice: targetVoice
|
||||
});
|
||||
const res = await fetch(`/api/presign/audio?${params}`);
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`presign HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { url: string };
|
||||
return data.url;
|
||||
}
|
||||
|
||||
type AudioStatusResponse =
|
||||
| { status: 'done' }
|
||||
| { status: 'pending' | 'generating'; job_id: string }
|
||||
| { status: 'idle' }
|
||||
| { status: 'failed'; error?: string };
|
||||
|
||||
/**
|
||||
* Poll GET /api/audio/status/[slug]/[n]?voice=... every `intervalMs` ms
|
||||
* until status is "done" or "failed" (or the caller cancels via signal).
|
||||
*
|
||||
* Returns the final status response, or throws on network error / cancellation.
|
||||
*/
|
||||
async function pollAudioStatus(
|
||||
targetSlug: string,
|
||||
targetChapter: number,
|
||||
targetVoice: string,
|
||||
intervalMs = 2000,
|
||||
signal?: AbortSignal
|
||||
): Promise<AudioStatusResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (targetVoice) qs.set('voice', targetVoice);
|
||||
const url = `/api/audio/status/${targetSlug}/${targetChapter}?${qs.toString()}`;
|
||||
|
||||
while (true) {
|
||||
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
||||
|
||||
const res = await fetch(url, { signal });
|
||||
if (!res.ok) throw new Error(`Status poll HTTP ${res.status}`);
|
||||
const data = (await res.json()) as AudioStatusResponse;
|
||||
|
||||
if (data.status === 'done' || data.status === 'failed') {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Still pending/generating — wait then retry.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, intervalMs);
|
||||
signal?.addEventListener('abort', () => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pre-fetch next chapter ─────────────────────────────────────────────────
|
||||
|
||||
async function prefetchNext() {
|
||||
if (nextChapter === null || nextChapter === undefined) return;
|
||||
if (audioStore.nextStatus !== 'none') return; // already running or done
|
||||
|
||||
const voice = audioStore.voice;
|
||||
|
||||
audioStore.nextStatus = 'prefetching';
|
||||
audioStore.nextChapterPrefetched = nextChapter;
|
||||
startNextProgress();
|
||||
|
||||
try {
|
||||
// Fast path: already generated
|
||||
const url = await tryPresign(slug, nextChapter, voice);
|
||||
if (url) {
|
||||
stopNextProgress();
|
||||
audioStore.nextProgress = 100;
|
||||
audioStore.nextAudioUrl = url;
|
||||
audioStore.nextStatus = 'prefetched';
|
||||
return;
|
||||
}
|
||||
|
||||
// Slow path: trigger Kokoro generation (non-blocking POST), then poll.
|
||||
const res = await fetch(`/api/audio/${slug}/${nextChapter}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ voice })
|
||||
});
|
||||
if (!res.ok) throw new Error(`Prefetch generation failed: HTTP ${res.status}`);
|
||||
|
||||
// Whether the server returned 200 (already cached) or 202 (enqueued),
|
||||
// always presign — the status endpoint no longer returns a proxy URL.
|
||||
if (res.status === 200) {
|
||||
// Body is { status: 'done' } — audio confirmed in MinIO. Presign it.
|
||||
await res.body?.cancel();
|
||||
}
|
||||
// else 202: generation enqueued — fall through to poll.
|
||||
|
||||
if (res.status !== 200) {
|
||||
// 202: poll until done.
|
||||
const final = await pollAudioStatus(slug, nextChapter, voice);
|
||||
stopNextProgress();
|
||||
audioStore.nextProgress = 100;
|
||||
|
||||
if (final.status === 'failed') {
|
||||
throw new Error(`Prefetch failed: ${(final as { error?: string }).error ?? 'unknown'}`);
|
||||
}
|
||||
} else {
|
||||
stopNextProgress();
|
||||
audioStore.nextProgress = 100;
|
||||
}
|
||||
|
||||
// Audio is ready in MinIO — get a direct presigned URL.
|
||||
const doneUrl = await tryPresign(slug, nextChapter, voice);
|
||||
if (!doneUrl) throw new Error('Prefetch: audio done but presign returned 404');
|
||||
|
||||
audioStore.nextAudioUrl = doneUrl;
|
||||
audioStore.nextStatus = 'prefetched';
|
||||
} catch {
|
||||
stopNextProgress();
|
||||
audioStore.nextStatus = 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Media Session ──────────────────────────────────────────────────────────
|
||||
// Sets the OS-level media metadata so the book cover, title, and chapter
|
||||
// appear on the phone lock screen / notification center.
|
||||
|
||||
function setMediaSession() {
|
||||
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return;
|
||||
|
||||
const artwork: MediaImage[] = cover
|
||||
? [
|
||||
{ src: cover, sizes: '512x512', type: 'image/jpeg' },
|
||||
{ src: cover, sizes: '256x256', type: 'image/jpeg' }
|
||||
]
|
||||
: [];
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: chapterTitle || `Chapter ${chapter}`,
|
||||
artist: bookTitle,
|
||||
album: bookTitle,
|
||||
artwork
|
||||
});
|
||||
}
|
||||
|
||||
// ── Core play flow ─────────────────────────────────────────────────────────
|
||||
|
||||
async function startPlayback() {
|
||||
const voice = audioStore.voice;
|
||||
|
||||
// Populate store metadata so layout + mini-bar have track info.
|
||||
audioStore.slug = slug;
|
||||
audioStore.chapter = chapter;
|
||||
audioStore.chapterTitle = chapterTitle;
|
||||
audioStore.bookTitle = bookTitle;
|
||||
audioStore.cover = cover;
|
||||
audioStore.chapters = chapters;
|
||||
|
||||
// Update OS media session (lock screen / notification center).
|
||||
setMediaSession();
|
||||
|
||||
audioStore.status = 'loading';
|
||||
audioStore.errorMsg = '';
|
||||
|
||||
try {
|
||||
// Fast path A: pre-fetch already landed for THIS chapter.
|
||||
if (
|
||||
audioStore.nextStatus === 'prefetched' &&
|
||||
audioStore.nextChapterPrefetched === chapter &&
|
||||
audioStore.nextAudioUrl
|
||||
) {
|
||||
const url = audioStore.nextAudioUrl;
|
||||
// Consume the pre-fetch — reset so it doesn't carry over
|
||||
audioStore.resetNextPrefetch();
|
||||
audioStore.audioUrl = url;
|
||||
audioStore.status = 'ready';
|
||||
// Don't restore saved time for auto-next; position is 0
|
||||
// Immediately start pre-generating the chapter after this one.
|
||||
maybeStartPrefetch();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast path B: audio already in MinIO (presign check).
|
||||
const url = await tryPresign(slug, chapter, voice);
|
||||
if (url) {
|
||||
audioStore.audioUrl = url;
|
||||
audioStore.status = 'ready';
|
||||
// Restore last saved position after the audio element loads
|
||||
restoreSavedAudioTime();
|
||||
// Immediately start pre-generating the next chapter in background.
|
||||
maybeStartPrefetch();
|
||||
return;
|
||||
}
|
||||
|
||||
// Slow path: trigger Kokoro generation (non-blocking POST), then poll.
|
||||
audioStore.status = 'generating';
|
||||
startProgress();
|
||||
|
||||
const res = await fetch(`/api/audio/${slug}/${chapter}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ voice })
|
||||
});
|
||||
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
|
||||
|
||||
if (res.status !== 200) {
|
||||
// 202: generation enqueued — poll until done.
|
||||
const final = await pollAudioStatus(slug, chapter, voice);
|
||||
|
||||
if (final.status === 'failed') {
|
||||
throw new Error(
|
||||
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 200: already cached — body is { status: 'done' }, no url needed.
|
||||
await res.body?.cancel();
|
||||
}
|
||||
|
||||
await finishProgress();
|
||||
|
||||
// Audio is ready in MinIO — always use a presigned URL for direct playback.
|
||||
const doneUrl = await tryPresign(slug, chapter, voice);
|
||||
if (!doneUrl) throw new Error('Audio generated but presign returned 404');
|
||||
audioStore.audioUrl = doneUrl;
|
||||
audioStore.status = 'ready';
|
||||
// Don't restore time for freshly generated audio — position is 0
|
||||
// Immediately start pre-generating the next chapter in background.
|
||||
maybeStartPrefetch();
|
||||
} catch (e) {
|
||||
stopProgress();
|
||||
audioStore.progress = 0;
|
||||
audioStore.status = 'error';
|
||||
audioStore.errorMsg = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start pre-fetching the next chapter if autoNext is on, there is a next
|
||||
* chapter, and no prefetch is already running or completed.
|
||||
* Called as soon as current-chapter playback begins so that the next
|
||||
* chapter's audio is ready before we need it (seamless transition).
|
||||
* The 90%-mark $effect acts as a fallback for cases where autoNext is
|
||||
* toggled on mid-playback.
|
||||
*/
|
||||
function maybeStartPrefetch() {
|
||||
if (
|
||||
audioStore.autoNext &&
|
||||
nextChapter !== null &&
|
||||
nextChapter !== undefined &&
|
||||
audioStore.nextStatus === 'none'
|
||||
) {
|
||||
prefetchNext();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the saved audio time for this chapter and seek to it after a short
|
||||
* delay (to allow the audio element to load the source).
|
||||
*/
|
||||
async function restoreSavedAudioTime() {
|
||||
try {
|
||||
const params = new URLSearchParams({ slug, chapter: String(chapter) });
|
||||
const res = await fetch(`/api/progress/audio-time?${params}`);
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { audioTime: number | null };
|
||||
if (data.audioTime && data.audioTime > 5) {
|
||||
// Small delay to let the <audio> element fully load the src before seeking
|
||||
setTimeout(() => {
|
||||
audioStore.seekRequest = data.audioTime as number;
|
||||
}, 300);
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePlay() {
|
||||
const isCurrent = audioStore.isCurrentChapter(slug, chapter);
|
||||
|
||||
// Already loaded this chapter: toggle play/pause.
|
||||
if (isCurrent && audioStore.status === 'ready') {
|
||||
audioStore.toggleRequest = (audioStore.toggleRequest ?? 0) + 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Not yet loaded — start the full flow.
|
||||
await startPlayback();
|
||||
}
|
||||
|
||||
function formatTime(s: number): string {
|
||||
if (!isFinite(s) || s < 0) return '0:00';
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} />
|
||||
|
||||
<div class="mt-6 p-4 rounded-lg bg-zinc-800 border border-zinc-700">
|
||||
<div class="flex items-center justify-between gap-2 mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
|
||||
</svg>
|
||||
<span class="text-sm text-zinc-300 font-medium">Audio Narration</span>
|
||||
</div>
|
||||
|
||||
<!-- Voice selector button -->
|
||||
{#if voices.length > 0}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; }}
|
||||
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : '')}
|
||||
title="Change voice"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||
</svg>
|
||||
<span class="max-w-[80px] truncate">{voiceLabel(audioStore.voice)}</span>
|
||||
<svg class={cn('w-3 h-3 flex-shrink-0 transition-transform', showVoicePanel && 'rotate-180')} fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M7 10l5 5 5-5z"/>
|
||||
</svg>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Voice selector panel ──────────────────────────────────────────── -->
|
||||
{#if showVoicePanel && voices.length > 0}
|
||||
<div class="mb-3 rounded-lg border border-zinc-600 bg-zinc-900 overflow-hidden">
|
||||
<div class="px-3 py-2 border-b border-zinc-700 flex items-center justify-between">
|
||||
<span class="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Choose Voice</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 text-zinc-500 hover:text-zinc-300"
|
||||
onclick={() => { stopSample(); showVoicePanel = false; }}
|
||||
aria-label="Close voice selector"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
{#each voices as v (v)}
|
||||
<div
|
||||
class={cn('flex items-center gap-2 px-3 py-2 hover:bg-zinc-800 transition-colors cursor-pointer', audioStore.voice === v && 'bg-amber-400/10')}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => selectVoice(v)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectVoice(v)}
|
||||
>
|
||||
<!-- Selected indicator -->
|
||||
<div class="w-4 flex-shrink-0">
|
||||
{#if audioStore.voice === v}
|
||||
<svg class="w-3.5 h-3.5 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Voice name -->
|
||||
<span class={cn('flex-1 text-xs', audioStore.voice === v ? 'text-amber-400 font-medium' : 'text-zinc-300')}>
|
||||
{voiceLabel(v)}
|
||||
</span>
|
||||
<span class="text-zinc-600 text-xs font-mono">{v}</span>
|
||||
|
||||
<!-- Sample play button (stop propagation so click doesn't select) -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={cn('h-6 w-6 flex-shrink-0', samplePlayingVoice === v ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : 'text-zinc-500 hover:text-zinc-200')}
|
||||
onclick={(e) => { e.stopPropagation(); playSample(v); }}
|
||||
title={samplePlayingVoice === v ? 'Stop sample' : 'Play sample'}
|
||||
aria-label={samplePlayingVoice === v ? `Stop ${v} sample` : `Play ${v} sample`}
|
||||
>
|
||||
{#if samplePlayingVoice === v}
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 6h12v12H6z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="px-3 py-2 border-t border-zinc-700 bg-zinc-800/50">
|
||||
<p class="text-xs text-zinc-500">
|
||||
New voice applies on next "Play narration".
|
||||
{#if voices.length > 0}
|
||||
<a
|
||||
href="/api/audio/voice-samples"
|
||||
class="text-zinc-400 hover:text-amber-400 transition-colors underline"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
fetch('/api/audio/voice-samples', { method: 'POST' }).catch(() => {});
|
||||
}}
|
||||
>Generate missing samples</a>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if audioStore.isCurrentChapter(slug, chapter)}
|
||||
<!-- ── This chapter is the active one ── -->
|
||||
|
||||
{#if audioStore.status === 'idle' || audioStore.status === 'error'}
|
||||
{#if audioStore.status === 'error'}
|
||||
<p class="text-red-400 text-sm mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
|
||||
{/if}
|
||||
<Button variant="default" size="sm" onclick={handlePlay}>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
Play narration
|
||||
</Button>
|
||||
|
||||
{:else if audioStore.status === 'loading'}
|
||||
<Button variant="default" size="sm" disabled>
|
||||
<svg class="w-3.5 h-3.5 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-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Loading…
|
||||
</Button>
|
||||
|
||||
{:else if audioStore.status === 'generating'}
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-zinc-400">Generating narration…</p>
|
||||
<div class="w-full h-1.5 bg-zinc-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-amber-400 rounded-full transition-none"
|
||||
style="width: {audioStore.progress}%"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-zinc-500 tabular-nums">{Math.round(audioStore.progress)}%</p>
|
||||
</div>
|
||||
|
||||
{:else if audioStore.status === 'ready'}
|
||||
<!-- Mini-bar is the canonical control surface — show a compact indicator here -->
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 text-xs text-zinc-400">
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-3.5 h-3.5 text-amber-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||||
</svg>
|
||||
<span>Playing — controls below</span>
|
||||
{:else}
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
<span>Paused — controls below</span>
|
||||
{/if}
|
||||
<span class="tabular-nums text-zinc-500">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Auto-next toggle (keep here as useful context) -->
|
||||
{#if nextChapter !== null && nextChapter !== undefined}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('gap-1.5 text-xs flex-shrink-0', audioStore.autoNext ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : 'text-zinc-500')}
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
title={audioStore.autoNext ? `Auto-next on — will play Ch.${nextChapter} automatically` : 'Auto-next off'}
|
||||
aria-pressed={audioStore.autoNext}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
</svg>
|
||||
Auto
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Next chapter pre-fetch status (only when auto-next is on) -->
|
||||
{#if audioStore.autoNext && nextChapter !== null && nextChapter !== undefined}
|
||||
<div class="mt-2">
|
||||
{#if audioStore.nextStatus === 'prefetching'}
|
||||
<div class="flex items-center gap-2 text-xs text-zinc-500">
|
||||
<svg class="w-3 h-3 animate-spin flex-shrink-0" 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-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span>Preparing Ch.{nextChapter}… {Math.round(audioStore.nextProgress)}%</span>
|
||||
</div>
|
||||
{:else if audioStore.nextStatus === 'prefetched'}
|
||||
<p class="text-xs text-zinc-500 flex items-center gap-1">
|
||||
<svg class="w-3 h-3 text-amber-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
|
||||
</svg>
|
||||
Ch.{nextChapter} ready
|
||||
</p>
|
||||
{:else if audioStore.nextStatus === 'failed'}
|
||||
<p class="text-xs text-zinc-600">Ch.{nextChapter} will generate on navigate</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{:else if audioStore.active}
|
||||
<!-- ── A different chapter is currently playing ── -->
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-xs text-zinc-400">
|
||||
Now playing: {audioStore.chapterTitle || `Ch.${audioStore.chapter}`}
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" class="flex-shrink-0" onclick={startPlayback}>
|
||||
Load this chapter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- ── Idle — nothing playing ── -->
|
||||
<Button variant="default" size="sm" onclick={handlePlay}>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
Play narration
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
112
v3/ui/src/lib/components/AvatarCropModal.svelte
Normal file
112
v3/ui/src/lib/components/AvatarCropModal.svelte
Normal file
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import Cropper from 'cropperjs';
|
||||
import type { default as CropperType } from 'cropperjs';
|
||||
import 'cropperjs/dist/cropper.css';
|
||||
import { Dialog, DialogHeader, DialogTitle, DialogFooter } from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
file: File;
|
||||
onconfirm: (blob: Blob, mimeType: string) => void;
|
||||
oncancel: () => void;
|
||||
}
|
||||
|
||||
let { file, onconfirm, oncancel }: Props = $props();
|
||||
|
||||
let imgEl: HTMLImageElement | undefined = $state();
|
||||
let cropper: CropperType | null = null;
|
||||
let objectUrl = '';
|
||||
let open = $state(true);
|
||||
|
||||
// Initialize cropper once the img element is bound and the file is known.
|
||||
// Use a $effect so it runs after the DOM is ready (replaces onMount).
|
||||
$effect(() => {
|
||||
if (!imgEl || !file) return;
|
||||
|
||||
// Create the object URL and set src directly on the element (not via reactive
|
||||
// state) so cropperjs sees the correct src before the image load event fires.
|
||||
objectUrl = URL.createObjectURL(file);
|
||||
imgEl.src = objectUrl;
|
||||
|
||||
// Cropperjs must be initialised inside the image's load event so it can
|
||||
// measure the natural dimensions — if we call new Cropper() before the image
|
||||
// has loaded, the crop canvas is blank/invisible.
|
||||
const handleLoad = () => {
|
||||
cropper = new Cropper(imgEl!, {
|
||||
aspectRatio: 1,
|
||||
viewMode: 1,
|
||||
dragMode: 'move',
|
||||
autoCropArea: 0.8,
|
||||
restore: false,
|
||||
guides: false,
|
||||
center: true,
|
||||
highlight: false,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
toggleDragModeOnDblclick: false,
|
||||
background: false
|
||||
});
|
||||
};
|
||||
|
||||
imgEl.addEventListener('load', handleLoad, { once: true });
|
||||
|
||||
return () => {
|
||||
imgEl?.removeEventListener('load', handleLoad);
|
||||
cropper?.destroy();
|
||||
cropper = null;
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
objectUrl = '';
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cropper?.destroy();
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
});
|
||||
|
||||
function confirm() {
|
||||
if (!cropper) return;
|
||||
const canvas = cropper.getCroppedCanvas({ width: 400, height: 400 });
|
||||
const mimeType = file.type === 'image/webp' ? 'image/webp' : 'image/jpeg';
|
||||
canvas.toBlob(
|
||||
(blob: Blob | null) => {
|
||||
if (blob) onconfirm(blob, mimeType);
|
||||
},
|
||||
mimeType,
|
||||
0.9
|
||||
);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
open = false;
|
||||
oncancel();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog bind:open onclose={handleClose} class="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Crop profile picture</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<!-- Cropper image container — overflow must be visible so cropperjs can
|
||||
render the crop canvas outside the natural image bounds. The fixed
|
||||
height gives cropperjs a stable container to size itself against. -->
|
||||
<div class="px-5">
|
||||
<div class="rounded-xl bg-zinc-800" style="height: 300px; position: relative;">
|
||||
<img
|
||||
bind:this={imgEl}
|
||||
alt="Crop preview"
|
||||
style="display:block; max-width:100%; max-height:100%;"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-zinc-500 text-center mt-3">
|
||||
Drag to reposition · pinch or scroll to zoom · drag corners to resize
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={handleClose}>Cancel</Button>
|
||||
<Button variant="default" onclick={confirm}>Use photo</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
561
v3/ui/src/lib/components/CommentsSection.svelte
Normal file
561
v3/ui/src/lib/components/CommentsSection.svelte
Normal file
@@ -0,0 +1,561 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface BookComment {
|
||||
id: string;
|
||||
slug: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
body: string;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
created: string;
|
||||
parent_id?: string;
|
||||
replies?: BookComment[];
|
||||
}
|
||||
|
||||
let {
|
||||
slug,
|
||||
isLoggedIn = false,
|
||||
currentUserId = ''
|
||||
}: {
|
||||
slug: string;
|
||||
isLoggedIn?: boolean;
|
||||
currentUserId?: string;
|
||||
} = $props();
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
let comments = $state<BookComment[]>([]);
|
||||
let myVotes = $state<Record<string, 'up' | 'down'>>({});
|
||||
let avatarUrls = $state<Record<string, string>>({});
|
||||
let loading = $state(true);
|
||||
let loadError = $state('');
|
||||
|
||||
// Top-level new comment
|
||||
let newBody = $state('');
|
||||
let posting = $state(false);
|
||||
let postError = $state('');
|
||||
|
||||
// Sort
|
||||
let sort = $state<'new' | 'top'>('top');
|
||||
|
||||
// Reply state: which comment is being replied to
|
||||
let replyingTo = $state<string | null>(null); // comment id
|
||||
let replyBody = $state('');
|
||||
let replyPosting = $state(false);
|
||||
let replyError = $state('');
|
||||
|
||||
// Delete in-flight set
|
||||
let deletingIds = $state(new Set<string>());
|
||||
|
||||
// Per-comment vote inflight set (prevents double-clicks)
|
||||
let votingIds = $state(new Set<string>());
|
||||
|
||||
// ── Load comments ─────────────────────────────────────────────────────────
|
||||
async function loadComments() {
|
||||
loading = true;
|
||||
loadError = '';
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/comments/${encodeURIComponent(slug)}?sort=${sort}`
|
||||
);
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const data = await res.json();
|
||||
comments = data.comments ?? [];
|
||||
myVotes = data.myVotes ?? {};
|
||||
avatarUrls = data.avatarUrls ?? {};
|
||||
} catch (e) {
|
||||
loadError = 'Failed to load comments.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadComments();
|
||||
});
|
||||
|
||||
// Re-load when sort changes (after initial mount)
|
||||
let firstLoad = true;
|
||||
$effect(() => {
|
||||
// Read sort to create a dependency
|
||||
const _ = sort;
|
||||
if (firstLoad) { firstLoad = false; return; }
|
||||
loadComments();
|
||||
});
|
||||
|
||||
// ── Post top-level comment ────────────────────────────────────────────────
|
||||
async function postComment() {
|
||||
const text = newBody.trim();
|
||||
if (!text || posting) return;
|
||||
if (text.length > 2000) { postError = 'Comment is too long (max 2000 characters).'; return; }
|
||||
posting = true;
|
||||
postError = '';
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: text })
|
||||
});
|
||||
if (res.status === 401) { postError = 'You must be logged in to comment.'; return; }
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
postError = err.message ?? 'Failed to post comment.';
|
||||
return;
|
||||
}
|
||||
const created: BookComment = await res.json();
|
||||
created.replies = [];
|
||||
// Prepend for 'new', or re-sort for 'top'
|
||||
if (sort === 'new') {
|
||||
comments = [created, ...comments];
|
||||
} else {
|
||||
comments = [created, ...comments]; // new comment has 0 score, goes to end after sort would happen
|
||||
}
|
||||
newBody = '';
|
||||
} catch {
|
||||
postError = 'Failed to post comment.';
|
||||
} finally {
|
||||
posting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Post reply ────────────────────────────────────────────────────────────
|
||||
async function postReply(parentId: string) {
|
||||
const text = replyBody.trim();
|
||||
if (!text || replyPosting) return;
|
||||
if (text.length > 2000) { replyError = 'Reply is too long (max 2000 characters).'; return; }
|
||||
replyPosting = true;
|
||||
replyError = '';
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: text, parent_id: parentId })
|
||||
});
|
||||
if (res.status === 401) { replyError = 'You must be logged in to reply.'; return; }
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
replyError = err.message ?? 'Failed to post reply.';
|
||||
return;
|
||||
}
|
||||
const created: BookComment = await res.json();
|
||||
// Append to the parent's replies list
|
||||
comments = comments.map((c) => {
|
||||
if (c.id !== parentId) return c;
|
||||
return { ...c, replies: [...(c.replies ?? []), created] };
|
||||
});
|
||||
replyBody = '';
|
||||
replyingTo = null;
|
||||
} catch {
|
||||
replyError = 'Failed to post reply.';
|
||||
} finally {
|
||||
replyPosting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────
|
||||
async function deleteComment(commentId: string, parentId?: string) {
|
||||
if (deletingIds.has(commentId)) return;
|
||||
deletingIds = new Set([...deletingIds, commentId]);
|
||||
try {
|
||||
const res = await fetch(`/api/comment/${commentId}`, { method: 'DELETE' });
|
||||
if (!res.ok) return;
|
||||
if (parentId) {
|
||||
// Remove reply from parent
|
||||
comments = comments.map((c) => {
|
||||
if (c.id !== parentId) return c;
|
||||
return { ...c, replies: (c.replies ?? []).filter((r) => r.id !== commentId) };
|
||||
});
|
||||
} else {
|
||||
// Remove top-level comment
|
||||
comments = comments.filter((c) => c.id !== commentId);
|
||||
}
|
||||
} finally {
|
||||
const next = new Set(deletingIds);
|
||||
next.delete(commentId);
|
||||
deletingIds = next;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vote ──────────────────────────────────────────────────────────────────
|
||||
async function vote(commentId: string, v: 'up' | 'down', parentId?: string) {
|
||||
if (votingIds.has(commentId)) return;
|
||||
votingIds = new Set([...votingIds, commentId]);
|
||||
try {
|
||||
const res = await fetch(`/api/comment/${commentId}/vote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vote: v })
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const updated: BookComment = await res.json();
|
||||
// Update comment in list (handle both top-level and replies)
|
||||
if (parentId) {
|
||||
comments = comments.map((c) => {
|
||||
if (c.id !== parentId) return c;
|
||||
return {
|
||||
...c,
|
||||
replies: (c.replies ?? []).map((r) => (r.id === commentId ? updated : r))
|
||||
};
|
||||
});
|
||||
} else {
|
||||
comments = comments.map((c) => (c.id === commentId ? { ...updated, replies: c.replies } : c));
|
||||
}
|
||||
// Update myVotes: toggle off if same, else set new vote
|
||||
const prev = myVotes[commentId];
|
||||
if (prev === v) {
|
||||
const next = { ...myVotes };
|
||||
delete next[commentId];
|
||||
myVotes = next;
|
||||
} else {
|
||||
myVotes = { ...myVotes, [commentId]: v };
|
||||
}
|
||||
} finally {
|
||||
const next = new Set(votingIds);
|
||||
next.delete(commentId);
|
||||
votingIds = next;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function initials(username: string): string {
|
||||
const name = username.trim() || '?';
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
const date = new Date(iso);
|
||||
const now = Date.now();
|
||||
const diffMs = now - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60_000);
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 30) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
const charCount = $derived(newBody.length);
|
||||
const charOver = $derived(charCount > 2000);
|
||||
const replyCharCount = $derived(replyBody.length);
|
||||
const replyCharOver = $derived(replyCharCount > 2000);
|
||||
|
||||
const totalCount = $derived(
|
||||
comments.reduce((n, c) => n + 1 + (c.replies?.length ?? 0), 0)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="mt-10">
|
||||
<!-- Header + sort controls -->
|
||||
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
|
||||
<h2 class="text-base font-semibold text-zinc-200">
|
||||
Comments
|
||||
{#if !loading && totalCount > 0}
|
||||
<span class="text-zinc-500 font-normal text-sm ml-1">({totalCount})</span>
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
<!-- Sort tabs -->
|
||||
{#if !loading && comments.length > 0}
|
||||
<div class="flex items-center gap-1 text-xs rounded-lg bg-zinc-800/60 p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'top' ? 'bg-zinc-700 text-zinc-100 hover:bg-zinc-700' : 'text-zinc-500 hover:text-zinc-300')}
|
||||
onclick={() => (sort = 'top')}
|
||||
>Top</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'new' ? 'bg-zinc-700 text-zinc-100 hover:bg-zinc-700' : 'text-zinc-500 hover:text-zinc-300')}
|
||||
onclick={() => (sort = 'new')}
|
||||
>New</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Post form -->
|
||||
<div class="mb-6">
|
||||
{#if isLoggedIn}
|
||||
<div class="flex flex-col gap-2">
|
||||
<Textarea
|
||||
bind:value={newBody}
|
||||
placeholder="Write a comment…"
|
||||
rows={3}
|
||||
/>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class={cn('text-xs tabular-nums', charOver ? 'text-red-400' : 'text-zinc-600')}>
|
||||
{charCount}/2000
|
||||
</span>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if postError}
|
||||
<span class="text-xs text-red-400">{postError}</span>
|
||||
{/if}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={posting || !newBody.trim() || charOver}
|
||||
onclick={postComment}
|
||||
>
|
||||
{posting ? 'Posting…' : 'Post'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-zinc-500">
|
||||
<a href="/auth/login" class="text-amber-400 hover:text-amber-300 transition-colors">Log in</a>
|
||||
to leave a comment.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Comment list -->
|
||||
{#if loading}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each Array(3) as _}
|
||||
<div class="rounded-lg bg-zinc-800/50 p-4 animate-pulse">
|
||||
<div class="h-3 w-24 bg-zinc-700 rounded mb-3"></div>
|
||||
<div class="h-3 w-full bg-zinc-700/60 rounded mb-2"></div>
|
||||
<div class="h-3 w-3/4 bg-zinc-700/60 rounded"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<p class="text-sm text-red-400">{loadError}</p>
|
||||
{:else if comments.length === 0}
|
||||
<p class="text-sm text-zinc-500">No comments yet. Be the first!</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each comments as comment (comment.id)}
|
||||
{@const myVote = myVotes[comment.id]}
|
||||
{@const voting = votingIds.has(comment.id)}
|
||||
{@const deleting = deletingIds.has(comment.id)}
|
||||
{@const isOwner = isLoggedIn && currentUserId === comment.user_id}
|
||||
|
||||
<div class="rounded-lg bg-zinc-800/50 border border-zinc-700/50 px-4 py-3 flex flex-col gap-2 {deleting ? 'opacity-50' : ''}">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{#if avatarUrls[comment.user_id]}
|
||||
<img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" />
|
||||
{:else}
|
||||
<div class="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-[9px] font-semibold text-zinc-300 leading-none">{initials(comment.username)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if comment.username}
|
||||
<a href="/users/{comment.username}" class="text-sm font-medium text-zinc-200 hover:text-amber-400 transition-colors">{comment.username}</a>
|
||||
{:else}
|
||||
<span class="text-sm font-medium text-zinc-400">Anonymous</span>
|
||||
{/if}
|
||||
<span class="text-zinc-600 text-xs">·</span>
|
||||
<span class="text-xs text-zinc-500">{formatDate(comment.created)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
|
||||
|
||||
<!-- Actions row: votes + reply + delete -->
|
||||
<div class="flex items-center gap-3 pt-1 flex-wrap">
|
||||
<!-- Upvote -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300')}
|
||||
disabled={voting}
|
||||
onclick={() => vote(comment.id, 'up')}
|
||||
title="Upvote"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
|
||||
</svg>
|
||||
<span class="tabular-nums">{comment.upvotes ?? 0}</span>
|
||||
</Button>
|
||||
|
||||
<!-- Downvote -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300')}
|
||||
disabled={voting}
|
||||
onclick={() => vote(comment.id, 'down')}
|
||||
title="Downvote"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
|
||||
</svg>
|
||||
<span class="tabular-nums">{comment.downvotes ?? 0}</span>
|
||||
</Button>
|
||||
|
||||
<!-- Reply button -->
|
||||
{#if isLoggedIn}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyingTo === comment.id ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300')}
|
||||
onclick={() => {
|
||||
if (replyingTo === comment.id) {
|
||||
replyingTo = null;
|
||||
replyBody = '';
|
||||
replyError = '';
|
||||
} else {
|
||||
replyingTo = comment.id;
|
||||
replyBody = '';
|
||||
replyError = '';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
|
||||
</svg>
|
||||
Reply
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<!-- Delete (owner only) -->
|
||||
{#if isOwner}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-auto px-1 py-0 gap-1 text-xs text-zinc-600 hover:text-red-400 ml-auto"
|
||||
disabled={deleting}
|
||||
onclick={() => deleteComment(comment.id)}
|
||||
title="Delete comment"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" 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
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Inline reply form -->
|
||||
{#if replyingTo === comment.id}
|
||||
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-zinc-700">
|
||||
<Textarea
|
||||
bind:value={replyBody}
|
||||
placeholder="Write a reply…"
|
||||
rows={2}
|
||||
/>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class={cn('text-xs tabular-nums', replyCharOver ? 'text-red-400' : 'text-zinc-600')}>
|
||||
{replyCharCount}/2000
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if replyError}
|
||||
<span class="text-xs text-red-400">{replyError}</span>
|
||||
{/if}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-zinc-400 hover:text-zinc-200"
|
||||
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
|
||||
>Cancel</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={replyPosting || !replyBody.trim() || replyCharOver}
|
||||
onclick={() => postReply(comment.id)}
|
||||
>
|
||||
{replyPosting ? 'Posting…' : 'Reply'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Replies -->
|
||||
{#if comment.replies && comment.replies.length > 0}
|
||||
<div class="mt-1 flex flex-col gap-2 pl-3 border-l-2 border-zinc-700/60">
|
||||
{#each comment.replies as reply (reply.id)}
|
||||
{@const replyVote = myVotes[reply.id]}
|
||||
{@const replyVoting = votingIds.has(reply.id)}
|
||||
{@const replyDeleting = deletingIds.has(reply.id)}
|
||||
{@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id}
|
||||
|
||||
<div class="rounded-md bg-zinc-800/30 px-3 py-2.5 flex flex-col gap-1.5 {replyDeleting ? 'opacity-50' : ''}">
|
||||
<!-- Reply header -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{#if avatarUrls[reply.user_id]}
|
||||
<img src={avatarUrls[reply.user_id]} alt={reply.username} class="w-5 h-5 rounded-full object-cover flex-shrink-0" />
|
||||
{:else}
|
||||
<div class="w-5 h-5 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-[8px] font-semibold text-zinc-300 leading-none">{initials(reply.username)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if reply.username}
|
||||
<a href="/users/{reply.username}" class="text-xs font-medium text-zinc-300 hover:text-amber-400 transition-colors">{reply.username}</a>
|
||||
{:else}
|
||||
<span class="text-xs font-medium text-zinc-400">Anonymous</span>
|
||||
{/if}
|
||||
<span class="text-zinc-600 text-xs">·</span>
|
||||
<span class="text-xs text-zinc-500">{formatDate(reply.created)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Reply body -->
|
||||
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
|
||||
|
||||
<!-- Reply actions -->
|
||||
<div class="flex items-center gap-3 pt-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300')}
|
||||
disabled={replyVoting}
|
||||
onclick={() => vote(reply.id, 'up', comment.id)}
|
||||
title="Upvote"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
|
||||
</svg>
|
||||
<span class="tabular-nums">{reply.upvotes ?? 0}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300')}
|
||||
disabled={replyVoting}
|
||||
onclick={() => vote(reply.id, 'down', comment.id)}
|
||||
title="Downvote"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
|
||||
</svg>
|
||||
<span class="tabular-nums">{reply.downvotes ?? 0}</span>
|
||||
</Button>
|
||||
|
||||
{#if replyIsOwner}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-auto px-1 py-0 gap-1 text-xs text-zinc-600 hover:text-red-400 ml-auto"
|
||||
disabled={replyDeleting}
|
||||
onclick={() => deleteComment(reply.id, comment.id)}
|
||||
title="Delete reply"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" 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
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
28
v3/ui/src/lib/components/ui/badge/Badge.svelte
Normal file
28
v3/ui/src/lib/components/ui/badge/Badge.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type Variant = 'default' | 'secondary' | 'outline' | 'destructive';
|
||||
|
||||
interface Props {
|
||||
variant?: Variant;
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { variant = 'default', class: className = '', children }: Props = $props();
|
||||
|
||||
const base =
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none';
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
default: 'border-transparent bg-amber-400 text-zinc-900',
|
||||
secondary: 'border-transparent bg-zinc-700 text-zinc-200',
|
||||
outline: 'border-zinc-600 text-zinc-300',
|
||||
destructive: 'border-transparent bg-red-500/20 text-red-400',
|
||||
};
|
||||
</script>
|
||||
|
||||
<span class={cn(base, variants[variant], className)}>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
1
v3/ui/src/lib/components/ui/badge/index.ts
Normal file
1
v3/ui/src/lib/components/ui/badge/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Badge } from './Badge.svelte';
|
||||
58
v3/ui/src/lib/components/ui/button/Button.svelte
Normal file
58
v3/ui/src/lib/components/ui/button/Button.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type Variant = 'default' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'link';
|
||||
type Size = 'default' | 'sm' | 'lg' | 'icon';
|
||||
|
||||
interface Props {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
disabled?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
class?: string;
|
||||
onclick?: (e: MouseEvent) => void;
|
||||
children?: Snippet;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
disabled = false,
|
||||
type = 'button',
|
||||
class: className = '',
|
||||
onclick,
|
||||
children,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const base =
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900 disabled:pointer-events-none disabled:opacity-50';
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
default: 'bg-amber-400 text-zinc-900 hover:bg-amber-300',
|
||||
secondary: 'bg-zinc-700 text-zinc-200 hover:bg-zinc-600',
|
||||
outline: 'border border-zinc-600 bg-transparent text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100',
|
||||
ghost: 'bg-transparent text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100',
|
||||
destructive: 'bg-red-500/20 text-red-400 hover:bg-red-500/30 hover:text-red-300',
|
||||
link: 'text-amber-400 underline-offset-4 hover:underline bg-transparent p-0 h-auto',
|
||||
};
|
||||
|
||||
const sizes: Record<Size, string> = {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
};
|
||||
</script>
|
||||
|
||||
<button
|
||||
{type}
|
||||
{disabled}
|
||||
class={cn(base, variants[variant], sizes[size], className)}
|
||||
{onclick}
|
||||
{...rest}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
1
v3/ui/src/lib/components/ui/button/index.ts
Normal file
1
v3/ui/src/lib/components/ui/button/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Button } from './Button.svelte';
|
||||
15
v3/ui/src/lib/components/ui/card/Card.svelte
Normal file
15
v3/ui/src/lib/components/ui/card/Card.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('rounded-xl border border-zinc-700 bg-zinc-800/50', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
15
v3/ui/src/lib/components/ui/card/CardContent.svelte
Normal file
15
v3/ui/src/lib/components/ui/card/CardContent.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('p-5 pt-0', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
15
v3/ui/src/lib/components/ui/card/CardDescription.svelte
Normal file
15
v3/ui/src/lib/components/ui/card/CardDescription.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<p class={cn('text-sm text-zinc-400', className)}>
|
||||
{@render children?.()}
|
||||
</p>
|
||||
15
v3/ui/src/lib/components/ui/card/CardFooter.svelte
Normal file
15
v3/ui/src/lib/components/ui/card/CardFooter.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('flex items-center p-5 pt-0', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
15
v3/ui/src/lib/components/ui/card/CardHeader.svelte
Normal file
15
v3/ui/src/lib/components/ui/card/CardHeader.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('flex flex-col space-y-1.5 p-5', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
15
v3/ui/src/lib/components/ui/card/CardTitle.svelte
Normal file
15
v3/ui/src/lib/components/ui/card/CardTitle.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<h3 class={cn('font-semibold leading-none tracking-tight text-zinc-100', className)}>
|
||||
{@render children?.()}
|
||||
</h3>
|
||||
6
v3/ui/src/lib/components/ui/card/index.ts
Normal file
6
v3/ui/src/lib/components/ui/card/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { default as Card } from './Card.svelte';
|
||||
export { default as CardHeader } from './CardHeader.svelte';
|
||||
export { default as CardTitle } from './CardTitle.svelte';
|
||||
export { default as CardDescription } from './CardDescription.svelte';
|
||||
export { default as CardContent } from './CardContent.svelte';
|
||||
export { default as CardFooter } from './CardFooter.svelte';
|
||||
43
v3/ui/src/lib/components/ui/dialog/Dialog.svelte
Normal file
43
v3/ui/src/lib/components/ui/dialog/Dialog.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
onclose?: () => void;
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), onclose, class: className = '', children }: Props = $props();
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
open = false;
|
||||
onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
open = false;
|
||||
onclose?.();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} />
|
||||
|
||||
{#if open}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onclick={handleBackdropClick}
|
||||
>
|
||||
<div class={cn('bg-zinc-900 rounded-2xl border border-zinc-700 shadow-2xl w-full max-w-sm', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
15
v3/ui/src/lib/components/ui/dialog/DialogContent.svelte
Normal file
15
v3/ui/src/lib/components/ui/dialog/DialogContent.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('flex flex-col gap-4 p-5', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
15
v3/ui/src/lib/components/ui/dialog/DialogFooter.svelte
Normal file
15
v3/ui/src/lib/components/ui/dialog/DialogFooter.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('flex items-center justify-end gap-2 px-5 pb-5', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
15
v3/ui/src/lib/components/ui/dialog/DialogHeader.svelte
Normal file
15
v3/ui/src/lib/components/ui/dialog/DialogHeader.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('flex flex-col space-y-1.5 px-5 pt-5', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
15
v3/ui/src/lib/components/ui/dialog/DialogTitle.svelte
Normal file
15
v3/ui/src/lib/components/ui/dialog/DialogTitle.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<h2 class={cn('text-base font-semibold leading-none tracking-tight text-zinc-100', className)}>
|
||||
{@render children?.()}
|
||||
</h2>
|
||||
5
v3/ui/src/lib/components/ui/dialog/index.ts
Normal file
5
v3/ui/src/lib/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as Dialog } from './Dialog.svelte';
|
||||
export { default as DialogContent } from './DialogContent.svelte';
|
||||
export { default as DialogHeader } from './DialogHeader.svelte';
|
||||
export { default as DialogTitle } from './DialogTitle.svelte';
|
||||
export { default as DialogFooter } from './DialogFooter.svelte';
|
||||
19
v3/ui/src/lib/components/ui/separator/Separator.svelte
Normal file
19
v3/ui/src/lib/components/ui/separator/Separator.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
let { class: className = '', orientation = 'horizontal' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="separator"
|
||||
class={cn(
|
||||
'shrink-0 bg-zinc-700',
|
||||
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||
className
|
||||
)}
|
||||
></div>
|
||||
1
v3/ui/src/lib/components/ui/separator/index.ts
Normal file
1
v3/ui/src/lib/components/ui/separator/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Separator } from './Separator.svelte';
|
||||
41
v3/ui/src/lib/components/ui/textarea/Textarea.svelte
Normal file
41
v3/ui/src/lib/components/ui/textarea/Textarea.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
onchange?: (e: Event) => void;
|
||||
oninput?: (e: Event) => void;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
placeholder = '',
|
||||
rows = 3,
|
||||
disabled = false,
|
||||
class: className = '',
|
||||
onchange,
|
||||
oninput,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<textarea
|
||||
bind:value
|
||||
{placeholder}
|
||||
{rows}
|
||||
{disabled}
|
||||
class={cn(
|
||||
'flex w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 resize-none transition-colors',
|
||||
'focus:outline-none focus:border-amber-400',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{onchange}
|
||||
{oninput}
|
||||
{...rest}
|
||||
></textarea>
|
||||
1
v3/ui/src/lib/components/ui/textarea/index.ts
Normal file
1
v3/ui/src/lib/components/ui/textarea/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Textarea } from './Textarea.svelte';
|
||||
Reference in New Issue
Block a user