chore: migrate to v3, Doppler secrets, clean up legacy code
Some checks failed
CI / v3 / Check ui (pull_request) Failing after 15s
CI / v3 / Test backend (pull_request) Failing after 16s
CI / v3 / Docker / backend (pull_request) Has been skipped
CI / v3 / Docker / runner (pull_request) Has been skipped
CI / v3 / Docker / ui (pull_request) Has been skipped
Some checks failed
CI / v3 / Check ui (pull_request) Failing after 15s
CI / v3 / Test backend (pull_request) Failing after 16s
CI / v3 / Docker / backend (pull_request) Has been skipped
CI / v3 / Docker / runner (pull_request) Has been skipped
CI / v3 / Docker / ui (pull_request) Has been skipped
- Remove all pre-v3 code: scraper, ui-v2, backend v1, ios v1+v2, legacy CI workflows - Flatten v3/ contents to repo root - Add Doppler secrets management (project=libnovel, config=prd) - Add justfile with doppler run wrappers for all docker compose commands - Strip hardcoded env fallbacks from docker-compose.yml - Add minimal README.md - Clean up .gitignore
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
* 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, presign again and set audioUrl.
|
||||
* 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.
|
||||
@@ -49,6 +49,8 @@
|
||||
*/
|
||||
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
slug: string;
|
||||
@@ -325,14 +327,16 @@
|
||||
voice: targetVoice
|
||||
});
|
||||
const res = await fetch(`/api/presign/audio?${params}`);
|
||||
if (res.status === 404) return null;
|
||||
// 202: TTS was just enqueued by the presign endpoint — audio not ready yet.
|
||||
// 404: legacy fallback (should no longer occur after endpoint change).
|
||||
if (res.status === 202 || 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'; url: string; filename: string }
|
||||
| { status: 'done' }
|
||||
| { status: 'pending' | 'generating'; job_id: string }
|
||||
| { status: 'idle' }
|
||||
| { status: 'failed'; error?: string };
|
||||
@@ -407,29 +411,31 @@
|
||||
});
|
||||
if (!res.ok) throw new Error(`Prefetch generation failed: HTTP ${res.status}`);
|
||||
|
||||
// If the scraper returned cached audio immediately (200), use the url.
|
||||
// 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) {
|
||||
const cached = (await res.json()) as { url: string };
|
||||
// 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;
|
||||
audioStore.nextAudioUrl = cached.url;
|
||||
audioStore.nextStatus = 'prefetched';
|
||||
return;
|
||||
}
|
||||
|
||||
// 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'}`);
|
||||
}
|
||||
|
||||
// Use the URL from the status response, or fall back to presign.
|
||||
const doneUrl =
|
||||
(final as { url?: string }).url ?? (await tryPresign(slug, nextChapter, voice));
|
||||
if (!doneUrl) throw new Error('Prefetch: audio done but no URL available');
|
||||
// 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';
|
||||
@@ -521,31 +527,25 @@
|
||||
});
|
||||
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
|
||||
|
||||
// If the scraper returned cached audio immediately (200), use the url.
|
||||
if (res.status === 200) {
|
||||
const cached = (await res.json()) as { url: string };
|
||||
await finishProgress();
|
||||
audioStore.audioUrl = cached.url;
|
||||
audioStore.status = 'ready';
|
||||
maybeStartPrefetch();
|
||||
return;
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
// 202: generation enqueued — poll until done.
|
||||
const final = await pollAudioStatus(slug, chapter, voice);
|
||||
|
||||
// 202: 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'}`
|
||||
);
|
||||
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();
|
||||
|
||||
// Use the URL from the status response, or fall back to presign.
|
||||
const doneUrl =
|
||||
(final as { url?: string }).url ?? (await tryPresign(slug, chapter, voice));
|
||||
if (!doneUrl) throw new Error('Audio generated but no URL available');
|
||||
// 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
|
||||
@@ -610,6 +610,11 @@
|
||||
|
||||
// Not yet loaded — start the full flow.
|
||||
await startPlayback();
|
||||
|
||||
// Track audio play after successful load.
|
||||
if (audioStore.status === 'ready') {
|
||||
window.umami?.track('audio_played', { slug, chapter });
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(s: number): string {
|
||||
@@ -633,21 +638,21 @@
|
||||
|
||||
<!-- Voice selector button -->
|
||||
{#if voices.length > 0}
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; }}
|
||||
class="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors {showVoicePanel
|
||||
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
|
||||
: 'text-zinc-400 bg-zinc-700 hover:bg-zinc-600 hover:text-zinc-200'}"
|
||||
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="w-3 h-3 flex-shrink-0 transition-transform {showVoicePanel ? 'rotate-180' : ''}" fill="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -656,20 +661,22 @@
|
||||
<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
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 text-zinc-500 hover:text-zinc-300"
|
||||
onclick={() => { stopSample(); showVoicePanel = false; }}
|
||||
class="text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
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>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
{#each voices as v (v)}
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-zinc-800 transition-colors cursor-pointer {audioStore.voice === v ? 'bg-amber-400/10' : ''}"
|
||||
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)}
|
||||
@@ -685,32 +692,30 @@
|
||||
</div>
|
||||
|
||||
<!-- Voice name -->
|
||||
<span class="flex-1 text-xs {audioStore.voice === v ? 'text-amber-400 font-medium' : 'text-zinc-300'}">
|
||||
<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
|
||||
<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); }}
|
||||
class="p-1 rounded transition-colors flex-shrink-0 {samplePlayingVoice === v
|
||||
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
|
||||
: 'text-zinc-500 hover:text-zinc-200 hover:bg-zinc-700'}"
|
||||
title={samplePlayingVoice === v ? 'Stop sample' : 'Play sample'}
|
||||
aria-label={samplePlayingVoice === v ? `Stop ${v} sample` : `Play ${v} sample`}
|
||||
>
|
||||
{#if samplePlayingVoice === v}
|
||||
<!-- Stop icon -->
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 6h12v12H6z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Play icon -->
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -736,31 +741,24 @@
|
||||
<!-- ── This chapter is the active one ── -->
|
||||
|
||||
{#if audioStore.status === 'idle' || audioStore.status === 'error'}
|
||||
<!-- Should not normally reach here while current, but handle gracefully -->
|
||||
{#if audioStore.status === 'error'}
|
||||
<p class="text-red-400 text-sm mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
|
||||
{/if}
|
||||
<button
|
||||
onclick={handlePlay}
|
||||
class="px-4 py-1.5 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</Button>
|
||||
|
||||
{:else if audioStore.status === 'loading'}
|
||||
<button
|
||||
disabled
|
||||
class="px-4 py-1.5 rounded bg-amber-400 text-zinc-900 text-sm font-semibold opacity-50 cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<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>
|
||||
</Button>
|
||||
|
||||
{:else if audioStore.status === 'generating'}
|
||||
<div class="space-y-2">
|
||||
@@ -774,68 +772,68 @@
|
||||
<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>
|
||||
{: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}
|
||||
<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
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
class="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors flex-shrink-0 {audioStore.autoNext
|
||||
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
|
||||
: 'text-zinc-500 bg-zinc-700 hover:bg-zinc-600 hover:text-zinc-200'}"
|
||||
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>
|
||||
<!-- 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}
|
||||
</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 ── -->
|
||||
@@ -843,24 +841,18 @@
|
||||
<p class="text-xs text-zinc-400">
|
||||
Now playing: {audioStore.chapterTitle || `Ch.${audioStore.chapter}`}
|
||||
</p>
|
||||
<button
|
||||
onclick={startPlayback}
|
||||
class="px-3 py-1 rounded bg-zinc-700 text-zinc-200 text-xs font-medium hover:bg-zinc-600 transition-colors flex-shrink-0"
|
||||
>
|
||||
<Button variant="secondary" size="sm" class="flex-shrink-0" onclick={startPlayback}>
|
||||
Load this chapter
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- ── Idle — nothing playing ── -->
|
||||
<button
|
||||
onclick={handlePlay}
|
||||
class="px-4 py-1.5 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<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;
|
||||
@@ -12,8 +15,9 @@
|
||||
let { file, onconfirm, oncancel }: Props = $props();
|
||||
|
||||
let imgEl: HTMLImageElement | undefined = $state();
|
||||
let cropper: Cropper | null = null;
|
||||
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).
|
||||
@@ -73,21 +77,22 @@
|
||||
0.9
|
||||
);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
open = false;
|
||||
oncancel();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 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"
|
||||
aria-label="Crop profile picture"
|
||||
>
|
||||
<div class="bg-zinc-900 rounded-2xl border border-zinc-700 shadow-2xl w-full max-w-sm flex flex-col gap-4 p-5">
|
||||
<h2 class="text-base font-semibold text-zinc-100">Crop profile picture</h2>
|
||||
<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. -->
|
||||
<!-- 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}
|
||||
@@ -95,22 +100,13 @@
|
||||
style="display:block; max-width:100%; max-height:100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-zinc-500 text-center">Drag to reposition · pinch or scroll to zoom · drag corners to resize</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={oncancel}
|
||||
class="flex-1 py-2 rounded-lg border border-zinc-600 text-zinc-300 text-sm font-medium hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={confirm}
|
||||
class="flex-1 py-2 rounded-lg bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Use photo
|
||||
</button>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={handleClose}>Cancel</Button>
|
||||
<Button variant="default" onclick={confirm}>Use photo</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
<script lang="ts">
|
||||
interface BookComment {
|
||||
id: string;
|
||||
slug: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
body: string;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
created: string;
|
||||
parent_id?: string;
|
||||
replies?: BookComment[];
|
||||
}
|
||||
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { cn } from '$lib/utils';
|
||||
import type { BookComment } from '$lib/types';
|
||||
let {
|
||||
slug,
|
||||
isLoggedIn = false,
|
||||
@@ -259,63 +250,56 @@
|
||||
{/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
|
||||
onclick={() => (sort = 'top')}
|
||||
class="px-2.5 py-1 rounded-md transition-colors {sort === 'top'
|
||||
? 'bg-zinc-700 text-zinc-100'
|
||||
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
Top
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (sort = 'new')}
|
||||
class="px-2.5 py-1 rounded-md transition-colors {sort === 'new'
|
||||
? 'bg-zinc-700 text-zinc-100'
|
||||
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 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
|
||||
<Textarea
|
||||
bind:value={newBody}
|
||||
placeholder="Write a comment…"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-zinc-200 text-sm placeholder-zinc-500 resize-none focus:outline-none focus:border-amber-400 transition-colors"
|
||||
></textarea>
|
||||
rows={3}
|
||||
/>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-xs {charOver ? 'text-red-400' : 'text-zinc-600'} tabular-nums">
|
||||
<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
|
||||
onclick={postComment}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={posting || !newBody.trim() || charOver}
|
||||
class="px-4 py-1.5 rounded-lg text-sm font-medium transition-colors
|
||||
{posting || !newBody.trim() || charOver
|
||||
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
|
||||
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300'}"
|
||||
onclick={postComment}
|
||||
>
|
||||
{posting ? 'Posting…' : 'Post'}
|
||||
</button>
|
||||
</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>
|
||||
<a href="/login" class="text-amber-400 hover:text-amber-300 transition-colors">Log in</a>
|
||||
to leave a comment.
|
||||
</p>
|
||||
{/if}
|
||||
@@ -366,115 +350,115 @@
|
||||
<!-- 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
|
||||
onclick={() => vote(comment.id, 'up')}
|
||||
disabled={voting}
|
||||
title="Upvote"
|
||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||
{myVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||
<!-- 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="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"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
|
||||
</svg>
|
||||
<span class="tabular-nums">{comment.upvotes ?? 0}</span>
|
||||
</button>
|
||||
Reply
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<!-- Downvote -->
|
||||
<button
|
||||
onclick={() => vote(comment.id, 'down')}
|
||||
disabled={voting}
|
||||
title="Downvote"
|
||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||
{myVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||
<!-- 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="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"/>
|
||||
<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>
|
||||
<span class="tabular-nums">{comment.downvotes ?? 0}</span>
|
||||
</button>
|
||||
Delete
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Reply button -->
|
||||
{#if isLoggedIn}
|
||||
<button
|
||||
onclick={() => {
|
||||
if (replyingTo === comment.id) {
|
||||
replyingTo = null;
|
||||
replyBody = '';
|
||||
replyError = '';
|
||||
} else {
|
||||
replyingTo = comment.id;
|
||||
replyBody = '';
|
||||
replyError = '';
|
||||
}
|
||||
}}
|
||||
class="flex items-center gap-1 text-xs transition-colors
|
||||
{replyingTo === comment.id
|
||||
? 'text-amber-400'
|
||||
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<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
|
||||
onclick={() => deleteComment(comment.id)}
|
||||
disabled={deleting}
|
||||
class="flex items-center gap-1 text-xs text-zinc-600 hover:text-red-400 transition-colors disabled:opacity-50 ml-auto"
|
||||
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"
|
||||
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm placeholder-zinc-500 resize-none focus:outline-none focus:border-amber-400 transition-colors"
|
||||
></textarea>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs {replyCharOver ? 'text-red-400' : 'text-zinc-600'} tabular-nums">
|
||||
{replyCharCount}/2000
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if replyError}
|
||||
<span class="text-xs text-red-400">{replyError}</span>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
|
||||
class="px-3 py-1 rounded-lg text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={() => postReply(comment.id)}
|
||||
disabled={replyPosting || !replyBody.trim() || replyCharOver}
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors
|
||||
{replyPosting || !replyBody.trim() || replyCharOver
|
||||
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
|
||||
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300'}"
|
||||
>
|
||||
{replyPosting ? 'Posting…' : 'Reply'}
|
||||
</button>
|
||||
</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>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Replies -->
|
||||
{#if comment.replies && comment.replies.length > 0}
|
||||
@@ -507,48 +491,52 @@
|
||||
<!-- 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
|
||||
onclick={() => vote(reply.id, 'up', comment.id)}
|
||||
disabled={replyVoting}
|
||||
title="Upvote"
|
||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||
{replyVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||
<!-- 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="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"/>
|
||||
<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>
|
||||
<span class="tabular-nums">{reply.upvotes ?? 0}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => vote(reply.id, 'down', comment.id)}
|
||||
disabled={replyVoting}
|
||||
title="Downvote"
|
||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||
{replyVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<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
|
||||
onclick={() => deleteComment(reply.id, comment.id)}
|
||||
disabled={replyDeleting}
|
||||
class="flex items-center gap-1 text-xs text-zinc-600 hover:text-red-400 transition-colors disabled:opacity-50 ml-auto"
|
||||
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>
|
||||
Delete
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
28
ui/src/lib/components/ui/badge/Badge.svelte
Normal file
28
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
ui/src/lib/components/ui/badge/index.ts
Normal file
1
ui/src/lib/components/ui/badge/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Badge } from './Badge.svelte';
|
||||
58
ui/src/lib/components/ui/button/Button.svelte
Normal file
58
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
ui/src/lib/components/ui/button/index.ts
Normal file
1
ui/src/lib/components/ui/button/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Button } from './Button.svelte';
|
||||
15
ui/src/lib/components/ui/card/Card.svelte
Normal file
15
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
ui/src/lib/components/ui/card/CardContent.svelte
Normal file
15
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
ui/src/lib/components/ui/card/CardDescription.svelte
Normal file
15
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
ui/src/lib/components/ui/card/CardFooter.svelte
Normal file
15
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
ui/src/lib/components/ui/card/CardHeader.svelte
Normal file
15
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
ui/src/lib/components/ui/card/CardTitle.svelte
Normal file
15
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
ui/src/lib/components/ui/card/index.ts
Normal file
6
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
ui/src/lib/components/ui/dialog/Dialog.svelte
Normal file
43
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
ui/src/lib/components/ui/dialog/DialogContent.svelte
Normal file
15
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
ui/src/lib/components/ui/dialog/DialogFooter.svelte
Normal file
15
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
ui/src/lib/components/ui/dialog/DialogHeader.svelte
Normal file
15
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
ui/src/lib/components/ui/dialog/DialogTitle.svelte
Normal file
15
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
ui/src/lib/components/ui/dialog/index.ts
Normal file
5
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
ui/src/lib/components/ui/separator/Separator.svelte
Normal file
19
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
ui/src/lib/components/ui/separator/index.ts
Normal file
1
ui/src/lib/components/ui/separator/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Separator } from './Separator.svelte';
|
||||
41
ui/src/lib/components/ui/textarea/Textarea.svelte
Normal file
41
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
ui/src/lib/components/ui/textarea/index.ts
Normal file
1
ui/src/lib/components/ui/textarea/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Textarea } from './Textarea.svelte';
|
||||
71
ui/src/lib/server/catalogue.ts
Normal file
71
ui/src/lib/server/catalogue.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Shared types and helpers for the /api/catalogue backend response.
|
||||
*
|
||||
* Imported by both:
|
||||
* - src/routes/catalogue/+page.server.ts (SSR page load)
|
||||
* - src/routes/api/catalogue-page/+server.ts (infinite-scroll proxy)
|
||||
*/
|
||||
|
||||
/** Shape of a single book as returned by GET /api/catalogue on the Go backend. */
|
||||
export interface CatalogueBook {
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string;
|
||||
cover: string;
|
||||
status: string;
|
||||
genres: string[];
|
||||
summary: string;
|
||||
total_chapters: number;
|
||||
source_url: string;
|
||||
ranking: number;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
/** Facets returned alongside catalogue results for dynamic filter options. */
|
||||
export interface CatalogueFacets {
|
||||
genres: string[];
|
||||
statuses: string[];
|
||||
}
|
||||
|
||||
/** Full response shape from GET /api/catalogue. */
|
||||
export interface CatalogueResponse {
|
||||
books: CatalogueBook[];
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
has_next: boolean;
|
||||
facets?: CatalogueFacets;
|
||||
}
|
||||
|
||||
/** Normalised book shape consumed by the catalogue UI. */
|
||||
export interface NovelListing {
|
||||
slug: string;
|
||||
title: string;
|
||||
cover: string;
|
||||
rank: string;
|
||||
rating: string;
|
||||
chapters: string;
|
||||
url: string;
|
||||
// enriched fields
|
||||
author?: string;
|
||||
status?: string;
|
||||
genres?: string[];
|
||||
source_url?: string;
|
||||
}
|
||||
|
||||
/** Convert a raw CatalogueBook into the UI NovelListing shape. */
|
||||
export function bookToListing(book: CatalogueBook): NovelListing {
|
||||
return {
|
||||
slug: book.slug,
|
||||
title: book.title,
|
||||
cover: book.cover,
|
||||
rank: book.ranking > 0 ? `#${book.ranking}` : '',
|
||||
rating: book.rating > 0 ? String(book.rating) : '',
|
||||
chapters: book.total_chapters > 0 ? `${book.total_chapters} chapters` : '',
|
||||
url: book.source_url ?? '',
|
||||
author: book.author,
|
||||
status: book.status,
|
||||
genres: book.genres ?? [],
|
||||
source_url: book.source_url
|
||||
};
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
/**
|
||||
* Server-side MinIO presign helper.
|
||||
* Calls the scraper API to get presigned URLs, then optionally rewrites
|
||||
* Calls the backend API to get presigned URLs, then optionally rewrites
|
||||
* the MinIO host to the public-facing URL for browser use.
|
||||
*
|
||||
* Never import this from client-side code.
|
||||
*/
|
||||
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { env as pubEnv } from '$env/dynamic/public';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
// Public MinIO URL — used to rewrite presigned URLs so the browser can reach MinIO directly.
|
||||
// In docker-compose this would differ from the internal endpoint.
|
||||
const MINIO_PUBLIC_URL = pubEnv.PUBLIC_MINIO_PUBLIC_URL ?? 'http://localhost:9000';
|
||||
@@ -27,11 +26,11 @@ function extFromMime(mime: string): string {
|
||||
/**
|
||||
* Returns a short-lived presigned PUT URL for uploading an avatar directly to MinIO,
|
||||
* along with the object key to record in PocketBase after upload completes.
|
||||
* Routed through the Go scraper which holds MinIO credentials.
|
||||
* Routed through the Go backend which holds MinIO credentials.
|
||||
*/
|
||||
export async function presignAvatarUploadUrl(userId: string, mimeType: string): Promise<{ uploadUrl: string; key: string }> {
|
||||
const ext = extFromMime(mimeType);
|
||||
const res = await fetch(`${SCRAPER_URL}/api/presign/avatar-upload/${encodeURIComponent(userId)}?ext=${ext}`);
|
||||
const res = await backendFetch(`/api/presign/avatar-upload/${encodeURIComponent(userId)}?ext=${ext}`);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`presign avatar upload failed: ${res.status} ${body}`);
|
||||
@@ -45,26 +44,43 @@ export async function presignAvatarUploadUrl(userId: string, mimeType: string):
|
||||
* Returns null if no avatar exists.
|
||||
*/
|
||||
export async function presignAvatarUrl(userId: string): Promise<string | null> {
|
||||
const res = await fetch(`${SCRAPER_URL}/api/presign/avatar/${encodeURIComponent(userId)}`);
|
||||
const res = await backendFetch(`/api/presign/avatar/${encodeURIComponent(userId)}`);
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`presign avatar failed: ${res.status} ${body}`);
|
||||
}
|
||||
const data = (await res.json()) as { url: string };
|
||||
return data.url ?? null;
|
||||
return data.url ? rewriteHost(data.url) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the MinIO host in a presigned URL to the public-facing URL.
|
||||
* The presigned URL is signed against the internal endpoint (e.g. minio:9000),
|
||||
* but the browser needs the public URL (e.g. localhost:9000 in dev, or a CDN in prod).
|
||||
* Rewriting the host preserves all query params (signature, expiry, etc).
|
||||
*
|
||||
* The Go backend presigns URLs against its internal endpoint (e.g. minio:9000)
|
||||
* when PUBLIC_MINIO_PUBLIC_URL is not set or equals the internal endpoint.
|
||||
* In that case the browser must reach MinIO via the public URL (e.g.
|
||||
* localhost:9000 in dev), so we swap the origin.
|
||||
*
|
||||
* NOTE: AWS Signature V4 DOES include the Host header in the canonical request
|
||||
* (via X-Amz-SignedHeaders=host). Rewriting the host here would break the
|
||||
* signature. This function is therefore only a no-op safety net — in
|
||||
* production the Go backend is configured with MINIO_PUBLIC_ENDPOINT equal to
|
||||
* the externally-reachable hostname, so presigned URLs already carry the right
|
||||
* host and no rewrite is needed.
|
||||
*
|
||||
* For local dev: MINIO_PUBLIC_ENDPOINT=http://localhost:9000 and the backend
|
||||
* presigns with localhost:9000 (the public client), so this rewrite is again
|
||||
* a no-op (origins already match).
|
||||
*/
|
||||
function rewriteHost(presignedUrl: string): string {
|
||||
try {
|
||||
const u = new URL(presignedUrl);
|
||||
const pub = new URL(MINIO_PUBLIC_URL);
|
||||
// No-op if already pointing at the right origin.
|
||||
if (u.protocol === pub.protocol && u.hostname === pub.hostname && u.port === pub.port) {
|
||||
return presignedUrl;
|
||||
}
|
||||
u.protocol = pub.protocol;
|
||||
u.hostname = pub.hostname;
|
||||
u.port = pub.port;
|
||||
@@ -86,7 +102,7 @@ export async function presignChapter(slug: string, n: number, rewrite = false):
|
||||
log.debug('minio', 'presigning chapter', { slug, n });
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${SCRAPER_URL}/api/presign/chapter/${slug}/${n}`);
|
||||
res = await backendFetch(`/api/presign/chapter/${slug}/${n}`);
|
||||
} catch (e) {
|
||||
log.error('minio', 'presign chapter network error', { slug, n, err: String(e) });
|
||||
throw new Error(`presign chapter ${slug}/${n}: network error`);
|
||||
@@ -110,7 +126,7 @@ export async function presignVoiceSample(voice: string): Promise<string> {
|
||||
log.debug('minio', 'presigning voice sample', { voice });
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${SCRAPER_URL}/api/presign/voice-sample/${encodeURIComponent(voice)}`);
|
||||
res = await backendFetch(`/api/presign/voice-sample/${encodeURIComponent(voice)}`);
|
||||
} catch (e) {
|
||||
log.error('minio', 'presign voice sample network error', { voice, err: String(e) });
|
||||
throw new Error(`presign voice sample ${voice}: network error`);
|
||||
@@ -146,7 +162,7 @@ export async function presignAudio(
|
||||
log.debug('minio', 'presigning audio', { slug, n, voice });
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${SCRAPER_URL}/api/presign/audio/${slug}/${n}${qs}`);
|
||||
res = await backendFetch(`/api/presign/audio/${slug}/${n}${qs}`);
|
||||
} catch (e) {
|
||||
log.error('minio', 'presign audio network error', { slug, n, err: String(e) });
|
||||
throw new Error(`presign audio ${slug}/${n}: network error`);
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface Progress {
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface UserSettings {
|
||||
export interface PBUserSettings {
|
||||
id?: string;
|
||||
session_id: string;
|
||||
user_id?: string;
|
||||
@@ -212,10 +212,6 @@ export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
|
||||
return listN<Book>('books', limit, '', '-meta_updated');
|
||||
}
|
||||
|
||||
export async function recentlyUpdatedBooks(limit = 6): Promise<Book[]> {
|
||||
return listN<Book>('books', limit, '', '-meta_updated');
|
||||
}
|
||||
|
||||
export interface HomeStats {
|
||||
totalBooks: number;
|
||||
totalChapters: number;
|
||||
@@ -587,8 +583,8 @@ function settingsFilter(sessionId: string, userId?: string): string {
|
||||
export async function getSettings(
|
||||
sessionId: string,
|
||||
userId?: string
|
||||
): Promise<UserSettings | null> {
|
||||
return listOne<UserSettings>('user_settings', settingsFilter(sessionId, userId));
|
||||
): Promise<PBUserSettings | null> {
|
||||
return listOne<PBUserSettings>('user_settings', settingsFilter(sessionId, userId));
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
@@ -596,12 +592,12 @@ export async function saveSettings(
|
||||
settings: { autoNext: boolean; voice: string; speed: number },
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const existing = await listOne<UserSettings & { id: string }>(
|
||||
const existing = await listOne<PBUserSettings & { id: string }>(
|
||||
'user_settings',
|
||||
settingsFilter(sessionId, userId)
|
||||
);
|
||||
|
||||
const payload: Partial<UserSettings> = {
|
||||
const payload: Partial<PBUserSettings> = {
|
||||
session_id: sessionId,
|
||||
auto_next: settings.autoNext,
|
||||
voice: settings.voice,
|
||||
@@ -666,6 +662,8 @@ export async function setAudioTime(
|
||||
}
|
||||
|
||||
// ─── Audio cache ──────────────────────────────────────────────────────────────
|
||||
// There is no separate audio_cache collection — completed audio jobs in the
|
||||
// audio_jobs collection serve as the cache record. We project them here.
|
||||
|
||||
export interface AudioCacheEntry {
|
||||
id: string;
|
||||
@@ -675,7 +673,13 @@ export interface AudioCacheEntry {
|
||||
}
|
||||
|
||||
export async function listAudioCache(): Promise<AudioCacheEntry[]> {
|
||||
return listAll<AudioCacheEntry>('audio_cache', '', '-updated');
|
||||
const jobs = await listAll<AudioJob>('audio_jobs', 'status="done"', '-finished');
|
||||
return jobs.map((j) => ({
|
||||
id: j.id,
|
||||
cache_key: j.cache_key,
|
||||
filename: `${j.cache_key}.mp3`,
|
||||
updated: j.finished
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Scraping tasks ───────────────────────────────────────────────────────────
|
||||
@@ -688,6 +692,8 @@ export interface ScrapingTask {
|
||||
books_found: number;
|
||||
chapters_scraped: number;
|
||||
chapters_skipped: number;
|
||||
from_chapter: number;
|
||||
to_chapter: number;
|
||||
errors: number;
|
||||
started: string;
|
||||
finished: string;
|
||||
@@ -698,6 +704,10 @@ export async function listScrapingTasks(): Promise<ScrapingTask[]> {
|
||||
return listAll<ScrapingTask>('scraping_tasks', '', '-started');
|
||||
}
|
||||
|
||||
export async function getScrapingTask(id: string): Promise<ScrapingTask | null> {
|
||||
return listOne<ScrapingTask>('scraping_tasks', `id="${id}"`);
|
||||
}
|
||||
|
||||
// ─── Audio jobs ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AudioJob {
|
||||
@@ -854,7 +864,7 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr
|
||||
|
||||
// ─── Comments ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BookComment {
|
||||
export interface PBBookComment {
|
||||
id: string;
|
||||
slug: string;
|
||||
user_id: string;
|
||||
@@ -885,7 +895,7 @@ export type CommentSort = 'top' | 'new';
|
||||
export async function listComments(
|
||||
slug: string,
|
||||
sort: CommentSort = 'new'
|
||||
): Promise<BookComment[]> {
|
||||
): Promise<PBBookComment[]> {
|
||||
const token = await getToken();
|
||||
const slugEsc = slug.replace(/"/g, '\\"');
|
||||
// Only top-level comments (parent_id is empty or missing)
|
||||
@@ -900,7 +910,7 @@ export async function listComments(
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
let items = (data.items ?? []) as BookComment[];
|
||||
let items = (data.items ?? []) as PBBookComment[];
|
||||
if (sort === 'top') {
|
||||
items = items.sort((a, b) => {
|
||||
const scoreB = (b.upvotes ?? 0) - (b.downvotes ?? 0);
|
||||
@@ -917,7 +927,7 @@ export async function listComments(
|
||||
* List replies (1-level deep) for a single parent comment.
|
||||
* Always sorted oldest-first so the conversation reads naturally.
|
||||
*/
|
||||
export async function listReplies(parentId: string): Promise<BookComment[]> {
|
||||
export async function listReplies(parentId: string): Promise<PBBookComment[]> {
|
||||
const token = await getToken();
|
||||
const filter = encodeURIComponent(`parent_id="${parentId.replace(/"/g, '\\"')}"`);
|
||||
const res = await fetch(
|
||||
@@ -926,7 +936,7 @@ export async function listReplies(parentId: string): Promise<BookComment[]> {
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return (data.items ?? []) as BookComment[];
|
||||
return (data.items ?? []) as PBBookComment[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -939,7 +949,7 @@ export async function createComment(
|
||||
userId: string | undefined,
|
||||
username: string,
|
||||
parentId?: string
|
||||
): Promise<BookComment> {
|
||||
): Promise<PBBookComment> {
|
||||
const token = await getToken();
|
||||
const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, {
|
||||
method: 'POST',
|
||||
@@ -959,7 +969,7 @@ export async function createComment(
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`createComment failed: ${res.status} ${text}`);
|
||||
}
|
||||
return res.json() as Promise<BookComment>;
|
||||
return res.json() as Promise<PBBookComment>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -975,7 +985,7 @@ export async function deleteComment(commentId: string, userId: string): Promise<
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!getRes.ok) throw new Error(`Comment not found: ${commentId}`);
|
||||
const comment = (await getRes.json()) as BookComment;
|
||||
const comment = (await getRes.json()) as PBBookComment;
|
||||
if (comment.user_id !== userId) throw new Error('Not authorized to delete this comment');
|
||||
|
||||
// Delete any replies first
|
||||
@@ -986,7 +996,7 @@ export async function deleteComment(commentId: string, userId: string): Promise<
|
||||
);
|
||||
if (repliesRes.ok) {
|
||||
const repliesData = await repliesRes.json();
|
||||
const replies = (repliesData.items ?? []) as BookComment[];
|
||||
const replies = (repliesData.items ?? []) as PBBookComment[];
|
||||
await Promise.all(
|
||||
replies.map((r) =>
|
||||
fetch(`${PB_URL}/api/collections/book_comments/records/${r.id}`, {
|
||||
@@ -1039,7 +1049,7 @@ export async function voteComment(
|
||||
vote: 'up' | 'down',
|
||||
sessionId: string,
|
||||
userId?: string
|
||||
): Promise<BookComment> {
|
||||
): Promise<PBBookComment> {
|
||||
const token = await getToken();
|
||||
|
||||
// Fetch current comment
|
||||
@@ -1047,7 +1057,7 @@ export async function voteComment(
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!commentRes.ok) throw new Error(`Comment not found: ${commentId}`);
|
||||
const comment = (await commentRes.json()) as BookComment;
|
||||
const comment = (await commentRes.json()) as PBBookComment;
|
||||
|
||||
const existing = await getCommentVote(commentId, sessionId, userId);
|
||||
|
||||
@@ -1090,7 +1100,7 @@ export async function voteComment(
|
||||
})
|
||||
});
|
||||
if (!patchRes.ok) throw new Error(`Failed to update vote counts on comment ${commentId}`);
|
||||
return patchRes.json() as Promise<BookComment>;
|
||||
return patchRes.json() as Promise<PBBookComment>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
118
ui/src/lib/server/presignCache.ts
Normal file
118
ui/src/lib/server/presignCache.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Valkey-backed presign URL cache (v3).
|
||||
*
|
||||
* Replaces the in-process Map from v2. All presign URLs are stored in Valkey
|
||||
* (Redis-compatible) with native TTL, so:
|
||||
* - Cache survives UI process restarts.
|
||||
* - Cache is shared across multiple UI replicas (if scaled horizontally).
|
||||
* - No manual sweep timer needed — Valkey expires entries automatically.
|
||||
*
|
||||
* MinIO presigned audio URLs are valid for 1 hour (set by the backend).
|
||||
* We cache them for 50 minutes so the browser always gets a URL with at
|
||||
* least 10 minutes of remaining validity.
|
||||
*
|
||||
* Voice-sample URLs use the same cache with key "sample:<voice>".
|
||||
*
|
||||
* Connection:
|
||||
* VALKEY_URL env var (default: redis://valkey:6379)
|
||||
* ioredis handles reconnection automatically.
|
||||
*/
|
||||
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const AUDIO_TTL_S = 50 * 60; // 50 minutes in seconds (Valkey TTL is in seconds)
|
||||
|
||||
// Lazily-initialised singleton client.
|
||||
let _client: Redis | null = null;
|
||||
|
||||
function client(): Redis {
|
||||
if (!_client) {
|
||||
const url = process.env.VALKEY_URL ?? 'redis://valkey:6379';
|
||||
_client = new Redis(url, {
|
||||
// Reconnect automatically with exponential backoff (ioredis default).
|
||||
// lazyConnect: false means the connection is established immediately.
|
||||
lazyConnect: false,
|
||||
// Log connection errors to stderr; do not crash the process.
|
||||
enableOfflineQueue: true,
|
||||
maxRetriesPerRequest: 2,
|
||||
});
|
||||
_client.on('error', (err: Error) => {
|
||||
console.error('[presignCache] Valkey error:', err.message);
|
||||
});
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
|
||||
// ── Key helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Cache key for a chapter audio presigned URL. */
|
||||
export function audioKey(slug: string, n: number, voice: string): string {
|
||||
return `audio:${slug}:${n}:${voice}`;
|
||||
}
|
||||
|
||||
/** Cache key for a voice-sample presigned URL. */
|
||||
export function sampleKey(voice: string): string {
|
||||
return `sample:${voice}`;
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Return the cached URL for key, or null if absent / expired. */
|
||||
export async function get(key: string): Promise<string | null> {
|
||||
try {
|
||||
return await client().get(key);
|
||||
} catch (err) {
|
||||
console.error('[presignCache] get error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Store a presigned URL under key for ttlSeconds seconds. */
|
||||
export async function set(key: string, url: string, ttlSeconds = AUDIO_TTL_S): Promise<void> {
|
||||
try {
|
||||
await client().set(key, url, 'EX', ttlSeconds);
|
||||
} catch (err) {
|
||||
console.error('[presignCache] set error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Invalidate a specific key (e.g. after audio generation to force refresh). */
|
||||
export async function invalidate(key: string): Promise<void> {
|
||||
try {
|
||||
await client().del(key);
|
||||
} catch (err) {
|
||||
console.error('[presignCache] invalidate error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from Valkey — called on graceful shutdown.
|
||||
* ioredis queues commands during reconnects; calling quit() drains the queue
|
||||
* and closes the connection cleanly.
|
||||
*/
|
||||
export async function drain(): Promise<void> {
|
||||
if (_client) {
|
||||
try {
|
||||
await _client.quit();
|
||||
} catch {
|
||||
// ignore — process is exiting anyway
|
||||
}
|
||||
_client = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the approximate number of keys matching the libnovel presign prefix.
|
||||
* Used for health/debug only — not called in the hot path.
|
||||
*/
|
||||
export async function size(): Promise<number> {
|
||||
try {
|
||||
// DBSIZE returns the total key count in the current DB.
|
||||
// For a precise count of just our keys, use SCAN with a pattern.
|
||||
const keys = await client().keys('audio:*');
|
||||
const sampleKeys = await client().keys('sample:*');
|
||||
return keys.length + sampleKeys.length;
|
||||
} catch {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
60
ui/src/lib/server/scraper.ts
Normal file
60
ui/src/lib/server/scraper.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Backend API helper.
|
||||
*
|
||||
* Centralises the BACKEND_URL constant and provides a thin fetch wrapper that:
|
||||
* - Resolves paths relative to BACKEND_API_URL.
|
||||
* - Throws 502 on network errors (unreachable backend).
|
||||
* - Re-throws SvelteKit `error()` objects so callers can still short-circuit.
|
||||
* - Passes a RequestInit through verbatim so callers keep full control.
|
||||
*
|
||||
* Import only from server-side modules (`+server.ts`, `*.server.ts`).
|
||||
*/
|
||||
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
export const BACKEND_URL = env.BACKEND_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
/**
|
||||
* Fetch a path on the backend, throwing a 502 on network failures.
|
||||
*
|
||||
* The `path` must start with `/` (e.g. `/api/voices`).
|
||||
*
|
||||
* SvelteKit `error()` exceptions are always re-thrown so callers can
|
||||
* short-circuit correctly inside their own catch blocks.
|
||||
*/
|
||||
export async function backendFetch(path: string, init?: RequestInit): Promise<Response> {
|
||||
try {
|
||||
return await fetch(`${BACKEND_URL}${path}`, init);
|
||||
} catch (e) {
|
||||
// Re-throw SvelteKit HTTP errors so they propagate to the framework.
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Response types ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Metadata shape returned inside the 200 response from GET /api/book-preview/{slug}.
|
||||
* Used in both the SSR page load and the API proxy to avoid duplicating the inline type.
|
||||
*/
|
||||
export interface BookPreviewMeta {
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string;
|
||||
cover: string;
|
||||
status: string;
|
||||
genres: string[];
|
||||
summary: string;
|
||||
total_chapters: number;
|
||||
source_url: string;
|
||||
}
|
||||
|
||||
/** Full 200 response from GET /api/book-preview/{slug}. */
|
||||
export interface BookPreviewResponse {
|
||||
in_lib: boolean;
|
||||
meta: BookPreviewMeta;
|
||||
chapters: { number: number; title: string; date?: string }[];
|
||||
}
|
||||
|
||||
30
ui/src/lib/types.ts
Normal file
30
ui/src/lib/types.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Shared domain types for the LibNovel UI.
|
||||
*
|
||||
* Server-only types (full PocketBase record shapes) live in
|
||||
* src/lib/server/pocketbase.ts. This file holds the types that are
|
||||
* safe to import in both server and client code.
|
||||
*/
|
||||
|
||||
// ── Comments ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BookComment {
|
||||
id: string;
|
||||
slug: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
body: string;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
created: string;
|
||||
parent_id?: string;
|
||||
replies?: BookComment[];
|
||||
}
|
||||
|
||||
// ── User settings ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UserSettings {
|
||||
voice: string;
|
||||
speed: number;
|
||||
autoNext: boolean;
|
||||
}
|
||||
13
ui/src/lib/utils.ts
Normal file
13
ui/src/lib/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* Merge Tailwind classes safely, resolving conflicts via tailwind-merge
|
||||
* and collapsing falsy values via clsx.
|
||||
*
|
||||
* Usage:
|
||||
* cn('px-4 py-2', isActive && 'bg-brand', className)
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user