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

- 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:
Admin
2026-03-23 17:21:12 +05:00
parent 1118392811
commit 59e8cdb19a
522 changed files with 5259 additions and 80365 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -0,0 +1 @@
export { default as Badge } from './Badge.svelte';

View 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>

View File

@@ -0,0 +1 @@
export { default as Button } from './Button.svelte';

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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';

View 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}

View 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>

View 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>

View 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>

View 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>

View 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';

View 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>

View File

@@ -0,0 +1 @@
export { default as Separator } from './Separator.svelte';

View 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>

View File

@@ -0,0 +1 @@
export { default as Textarea } from './Textarea.svelte';