feat(ui): theme system — amber/slate/rose, profile picker, full token migration
Some checks failed
CI / Backend (pull_request) Successful in 44s
CI / UI (pull_request) Successful in 25s
CI / UI (push) Successful in 27s
Release / Test backend (push) Successful in 42s
CI / Backend (push) Successful in 44s
Release / Check ui (push) Successful in 24s
Release / Docker / caddy (push) Failing after 1m4s
Release / Docker / backend (push) Failing after 44s
Release / Docker / ui (push) Failing after 29s
Release / Docker / runner (push) Failing after 54s
Release / Gitea Release (push) Has been skipped
Some checks failed
CI / Backend (pull_request) Successful in 44s
CI / UI (pull_request) Successful in 25s
CI / UI (push) Successful in 27s
Release / Test backend (push) Successful in 42s
CI / Backend (push) Successful in 44s
Release / Check ui (push) Successful in 24s
Release / Docker / caddy (push) Failing after 1m4s
Release / Docker / backend (push) Failing after 44s
Release / Docker / ui (push) Failing after 29s
Release / Docker / runner (push) Failing after 54s
Release / Gitea Release (push) Has been skipped
- Add CSS custom property token system in app.css (@theme + [data-theme] overrides) - Three themes: amber (default), slate (indigo/dark), rose (dark pink) - Flash prevention via inline <script> in <svelte:head> sets data-theme before paint - Theme context (setContext/getContext) in +layout.svelte for live preview - Theme persisted via PocketBase user_settings (PBUserSettings.theme field) - /api/settings GET/PUT updated to handle theme field alongside existing settings - Profile page: new Appearance section with 3 colour-swatch theme picker - Full token migration across all 36 route/component files: zinc/amber hardcoded Tailwind classes → CSS var utilities (bg-(--color-surface), etc.) - UI primitives (Badge, Button, Card, Dialog, Separator, Textarea) migrated - accent-amber-400 replaced with inline style accent-color: var(--color-brand)
This commit is contained in:
@@ -675,7 +675,7 @@
|
||||
<!-- ── Voice row snippet (reused in both engine sections) ──────────────── -->
|
||||
{#snippet voiceRow(v: import('$lib/types').Voice)}
|
||||
<div
|
||||
class={cn('flex items-center gap-2 px-3 py-2 hover:bg-zinc-800 transition-colors cursor-pointer', audioStore.voice === v.id && 'bg-amber-400/10')}
|
||||
class={cn('flex items-center gap-2 px-3 py-2 hover:bg-(--color-surface-2) transition-colors cursor-pointer', audioStore.voice === v.id && 'bg-(--color-brand)/10')}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => selectVoice(v.id)}
|
||||
@@ -684,23 +684,23 @@
|
||||
<!-- Selected indicator -->
|
||||
<div class="w-4 flex-shrink-0">
|
||||
{#if audioStore.voice === v.id}
|
||||
<svg class="w-3.5 h-3.5 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-3.5 h-3.5 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Voice name -->
|
||||
<span class={cn('flex-1 text-xs', audioStore.voice === v.id ? 'text-amber-400 font-medium' : 'text-zinc-300')}>
|
||||
<span class={cn('flex-1 text-xs', audioStore.voice === v.id ? 'text-(--color-brand) font-medium' : 'text-(--color-text)')}>
|
||||
{voiceLabel(v)}
|
||||
</span>
|
||||
<span class="text-zinc-600 text-xs font-mono">{v.id}</span>
|
||||
<span class="text-(--color-muted) opacity-60 text-xs font-mono">{v.id}</span>
|
||||
|
||||
<!-- Sample play button -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={cn('h-6 w-6 flex-shrink-0', samplePlayingVoice === v.id ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : 'text-zinc-500 hover:text-zinc-200')}
|
||||
class={cn('h-6 w-6 flex-shrink-0', samplePlayingVoice === v.id ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
onclick={(e) => { e.stopPropagation(); playSample(v.id); }}
|
||||
title={samplePlayingVoice === v.id ? 'Stop sample' : 'Play sample'}
|
||||
aria-label={samplePlayingVoice === v.id ? `Stop ${v.id} sample` : `Play ${v.id} sample`}
|
||||
@@ -718,13 +718,13 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="mt-6 p-4 rounded-lg bg-zinc-800 border border-zinc-700">
|
||||
<div class="mt-6 p-4 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
|
||||
<div class="flex items-center justify-between gap-2 mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-4 h-4 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
|
||||
</svg>
|
||||
<span class="text-sm text-zinc-300 font-medium">Audio Narration</span>
|
||||
<span class="text-sm text-(--color-text) font-medium">Audio Narration</span>
|
||||
</div>
|
||||
|
||||
<!-- Voice selector button -->
|
||||
@@ -733,7 +733,7 @@
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; }}
|
||||
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : '')}
|
||||
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : '')}
|
||||
title="Change voice"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
@@ -749,13 +749,13 @@
|
||||
|
||||
<!-- ── Voice selector panel ──────────────────────────────────────────── -->
|
||||
{#if showVoicePanel && voices.length > 0}
|
||||
<div class="mb-3 rounded-lg border border-zinc-600 bg-zinc-900 overflow-hidden">
|
||||
<div class="px-3 py-2 border-b border-zinc-700 flex items-center justify-between">
|
||||
<span class="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Choose Voice</span>
|
||||
<div class="mb-3 rounded-lg border border-(--color-border) bg-(--color-surface) overflow-hidden">
|
||||
<div class="px-3 py-2 border-b border-(--color-border) flex items-center justify-between">
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Choose Voice</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 text-zinc-500 hover:text-zinc-300"
|
||||
class="h-6 w-6 text-(--color-muted) hover:text-(--color-text)"
|
||||
onclick={() => { stopSample(); showVoicePanel = false; }}
|
||||
aria-label="Close voice selector"
|
||||
>
|
||||
@@ -767,8 +767,8 @@
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
<!-- Kokoro (GPU) section -->
|
||||
{#if kokoroVoices.length > 0}
|
||||
<div class="px-3 py-1.5 bg-zinc-800/70 border-b border-zinc-700/50">
|
||||
<span class="text-[10px] font-semibold text-zinc-500 uppercase tracking-widest">Kokoro (GPU)</span>
|
||||
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50">
|
||||
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Kokoro (GPU)</span>
|
||||
</div>
|
||||
{#each kokoroVoices as v (v.id)}
|
||||
{@render voiceRow(v)}
|
||||
@@ -777,21 +777,21 @@
|
||||
|
||||
<!-- Pocket TTS (CPU) section -->
|
||||
{#if pocketVoices.length > 0}
|
||||
<div class="px-3 py-1.5 bg-zinc-800/70 border-b border-zinc-700/50 {kokoroVoices.length > 0 ? 'border-t border-zinc-700' : ''}">
|
||||
<span class="text-[10px] font-semibold text-zinc-500 uppercase tracking-widest">Pocket TTS (CPU)</span>
|
||||
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50 {kokoroVoices.length > 0 ? 'border-t border-(--color-border)' : ''}">
|
||||
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Pocket TTS (CPU)</span>
|
||||
</div>
|
||||
{#each pocketVoices as v (v.id)}
|
||||
{@render voiceRow(v)}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="px-3 py-2 border-t border-zinc-700 bg-zinc-800/50">
|
||||
<p class="text-xs text-zinc-500">
|
||||
<div class="px-3 py-2 border-t border-(--color-border) bg-(--color-surface-2)/50">
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
New voice applies on next "Play narration".
|
||||
{#if voices.length > 0}
|
||||
<a
|
||||
href="/api/audio/voice-samples"
|
||||
class="text-zinc-400 hover:text-amber-400 transition-colors underline"
|
||||
class="text-(--color-muted) hover:text-(--color-brand) transition-colors underline"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
fetch('/api/audio/voice-samples', { method: 'POST' }).catch(() => {});
|
||||
@@ -808,7 +808,7 @@
|
||||
|
||||
{#if audioStore.status === 'idle' || audioStore.status === 'error'}
|
||||
{#if audioStore.status === 'error'}
|
||||
<p class="text-red-400 text-sm mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
|
||||
<p class="text-(--color-danger) text-sm mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
|
||||
{/if}
|
||||
<Button variant="default" size="sm" onclick={handlePlay}>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
@@ -828,22 +828,22 @@
|
||||
|
||||
{:else if audioStore.status === 'generating'}
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-zinc-400">Generating narration…</p>
|
||||
<div class="w-full h-1.5 bg-zinc-700 rounded-full overflow-hidden">
|
||||
<p class="text-xs text-(--color-muted)">Generating narration…</p>
|
||||
<div class="w-full h-1.5 bg-(--color-surface-3) rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-amber-400 rounded-full transition-none"
|
||||
class="h-full bg-(--color-brand) rounded-full transition-none"
|
||||
style="width: {audioStore.progress}%"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-zinc-500 tabular-nums">{Math.round(audioStore.progress)}%</p>
|
||||
<p class="text-xs text-(--color-muted) opacity-60 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">
|
||||
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-3.5 h-3.5 text-amber-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-3.5 h-3.5 text-(--color-brand) flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||||
</svg>
|
||||
<span>Playing — controls below</span>
|
||||
@@ -853,7 +853,7 @@
|
||||
</svg>
|
||||
<span>Paused — controls below</span>
|
||||
{/if}
|
||||
<span class="tabular-nums text-zinc-500">
|
||||
<span class="tabular-nums text-(--color-muted) opacity-60">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -863,7 +863,7 @@
|
||||
<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')}
|
||||
class={cn('gap-1.5 text-xs flex-shrink-0', audioStore.autoNext ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
title={audioStore.autoNext ? `Auto-next on — will play Ch.${nextChapter} automatically` : 'Auto-next off'}
|
||||
aria-pressed={audioStore.autoNext}
|
||||
@@ -879,24 +879,24 @@
|
||||
<!-- 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}
|
||||
{#if audioStore.nextStatus === 'prefetching'}
|
||||
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
|
||||
<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-(--color-muted) flex items-center gap-1">
|
||||
<svg class="w-3 h-3 text-(--color-brand) 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-(--color-muted) opacity-60">Ch.{nextChapter} will generate on navigate</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -904,7 +904,7 @@
|
||||
{:else if audioStore.active}
|
||||
<!-- ── A different chapter is currently playing ── -->
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-xs text-zinc-400">
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
Now playing: {audioStore.chapterTitle || `Ch.${audioStore.chapter}`}
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" class="flex-shrink-0" onclick={startPlayback}>
|
||||
|
||||
@@ -93,14 +93,14 @@
|
||||
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;">
|
||||
<div class="rounded-xl bg-(--color-surface-2)" style="height: 300px; position: relative;">
|
||||
<img
|
||||
bind:this={imgEl}
|
||||
alt="Crop preview"
|
||||
style="display:block; max-width:100%; max-height:100%;"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-zinc-500 text-center mt-3">
|
||||
<p class="text-xs text-(--color-muted) text-center mt-3">
|
||||
Drag to reposition · pinch or scroll to zoom · drag corners to resize
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -243,26 +243,26 @@
|
||||
<div class="mt-10">
|
||||
<!-- Header + sort controls -->
|
||||
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
|
||||
<h2 class="text-base font-semibold text-zinc-200">
|
||||
<h2 class="text-base font-semibold text-(--color-text)">
|
||||
Comments
|
||||
{#if !loading && totalCount > 0}
|
||||
<span class="text-zinc-500 font-normal text-sm ml-1">({totalCount})</span>
|
||||
<span class="text-(--color-muted) font-normal text-sm ml-1">({totalCount})</span>
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
<!-- Sort tabs -->
|
||||
{#if !loading && comments.length > 0}
|
||||
<div class="flex items-center gap-1 text-xs rounded-lg bg-zinc-800/60 p-1">
|
||||
<div class="flex items-center gap-1 text-xs rounded-lg bg-(--color-surface-2)/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')}
|
||||
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'top' ? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
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')}
|
||||
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'new' ? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
onclick={() => (sort = 'new')}
|
||||
>New</Button>
|
||||
</div>
|
||||
@@ -279,12 +279,12 @@
|
||||
rows={3}
|
||||
/>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class={cn('text-xs tabular-nums', charOver ? 'text-red-400' : 'text-zinc-600')}>
|
||||
<span class={cn('text-xs tabular-nums', charOver ? 'text-(--color-danger)' : 'text-(--color-muted) opacity-60')}>
|
||||
{charCount}/2000
|
||||
</span>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if postError}
|
||||
<span class="text-xs text-red-400">{postError}</span>
|
||||
<span class="text-xs text-(--color-danger)">{postError}</span>
|
||||
{/if}
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -298,8 +298,8 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-zinc-500">
|
||||
<a href="/login" class="text-amber-400 hover:text-amber-300 transition-colors">Log in</a>
|
||||
<p class="text-sm text-(--color-muted)">
|
||||
<a href="/login" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">Log in</a>
|
||||
to leave a comment.
|
||||
</p>
|
||||
{/if}
|
||||
@@ -309,17 +309,17 @@
|
||||
{#if loading}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each Array(3) as _}
|
||||
<div class="rounded-lg bg-zinc-800/50 p-4 animate-pulse">
|
||||
<div class="h-3 w-24 bg-zinc-700 rounded mb-3"></div>
|
||||
<div class="h-3 w-full bg-zinc-700/60 rounded mb-2"></div>
|
||||
<div class="h-3 w-3/4 bg-zinc-700/60 rounded"></div>
|
||||
<div class="rounded-lg bg-(--color-surface-2)/50 p-4 animate-pulse">
|
||||
<div class="h-3 w-24 bg-(--color-surface-3) rounded mb-3"></div>
|
||||
<div class="h-3 w-full bg-(--color-surface-3)/60 rounded mb-2"></div>
|
||||
<div class="h-3 w-3/4 bg-(--color-surface-3)/60 rounded"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<p class="text-sm text-red-400">{loadError}</p>
|
||||
<p class="text-sm text-(--color-danger)">{loadError}</p>
|
||||
{:else if comments.length === 0}
|
||||
<p class="text-sm text-zinc-500">No comments yet. Be the first!</p>
|
||||
<p class="text-sm text-(--color-muted)">No comments yet. Be the first!</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each comments as comment (comment.id)}
|
||||
@@ -328,39 +328,39 @@
|
||||
{@const deleting = deletingIds.has(comment.id)}
|
||||
{@const isOwner = isLoggedIn && currentUserId === comment.user_id}
|
||||
|
||||
<div class="rounded-lg bg-zinc-800/50 border border-zinc-700/50 px-4 py-3 flex flex-col gap-2 {deleting ? 'opacity-50' : ''}">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{#if avatarUrls[comment.user_id]}
|
||||
<img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" />
|
||||
{:else}
|
||||
<div class="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-[9px] font-semibold text-zinc-300 leading-none">{initials(comment.username)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if comment.username}
|
||||
<a href="/users/{comment.username}" class="text-sm font-medium text-zinc-200 hover:text-amber-400 transition-colors">{comment.username}</a>
|
||||
<div class="rounded-lg bg-(--color-surface-2)/50 border border-(--color-border)/50 px-4 py-3 flex flex-col gap-2 {deleting ? 'opacity-50' : ''}">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{#if avatarUrls[comment.user_id]}
|
||||
<img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" />
|
||||
{:else}
|
||||
<span class="text-sm font-medium text-zinc-400">Anonymous</span>
|
||||
<div class="w-6 h-6 rounded-full bg-(--color-surface-3) flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-[9px] font-semibold text-(--color-text) leading-none">{initials(comment.username)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="text-zinc-600 text-xs">·</span>
|
||||
<span class="text-xs text-zinc-500">{formatDate(comment.created)}</span>
|
||||
</div>
|
||||
{#if comment.username}
|
||||
<a href="/users/{comment.username}" class="text-sm font-medium text-(--color-text) hover:text-(--color-brand) transition-colors">{comment.username}</a>
|
||||
{:else}
|
||||
<span class="text-sm font-medium text-(--color-muted)">Anonymous</span>
|
||||
{/if}
|
||||
<span class="text-(--color-muted) opacity-60 text-xs">·</span>
|
||||
<span class="text-xs text-(--color-muted)">{formatDate(comment.created)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
|
||||
<!-- Body -->
|
||||
<p class="text-sm text-(--color-text) leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
|
||||
|
||||
<!-- Actions row: votes + reply + delete -->
|
||||
<div class="flex items-center gap-3 pt-1 flex-wrap">
|
||||
<!-- Upvote -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300')}
|
||||
disabled={voting}
|
||||
onclick={() => vote(comment.id, 'up')}
|
||||
title="Upvote"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'up' ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
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>
|
||||
@@ -368,14 +368,14 @@
|
||||
</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"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'down' ? 'text-(--color-danger)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
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>
|
||||
@@ -384,11 +384,11 @@
|
||||
|
||||
<!-- 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={() => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyingTo === comment.id ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
onclick={() => {
|
||||
if (replyingTo === comment.id) {
|
||||
replyingTo = null;
|
||||
replyBody = '';
|
||||
@@ -409,14 +409,14 @@
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-auto px-1 py-0 gap-1 text-xs text-(--color-muted) hover:text-(--color-danger) ml-auto"
|
||||
disabled={deleting}
|
||||
onclick={() => deleteComment(comment.id)}
|
||||
title="Delete comment"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
@@ -427,26 +427,26 @@
|
||||
|
||||
<!-- 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>
|
||||
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-(--color-border)">
|
||||
<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-(--color-danger)' : 'text-(--color-muted) opacity-60')}>
|
||||
{replyCharCount}/2000
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if replyError}
|
||||
<span class="text-xs text-(--color-danger)">{replyError}</span>
|
||||
{/if}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-(--color-muted) hover:text-(--color-text)"
|
||||
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
|
||||
>Cancel</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
@@ -462,59 +462,59 @@
|
||||
|
||||
<!-- Replies -->
|
||||
{#if comment.replies && comment.replies.length > 0}
|
||||
<div class="mt-1 flex flex-col gap-2 pl-3 border-l-2 border-zinc-700/60">
|
||||
{#each comment.replies as reply (reply.id)}
|
||||
{@const replyVote = myVotes[reply.id]}
|
||||
{@const replyVoting = votingIds.has(reply.id)}
|
||||
{@const replyDeleting = deletingIds.has(reply.id)}
|
||||
{@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id}
|
||||
<div class="mt-1 flex flex-col gap-2 pl-3 border-l-2 border-(--color-border)/60">
|
||||
{#each comment.replies as reply (reply.id)}
|
||||
{@const replyVote = myVotes[reply.id]}
|
||||
{@const replyVoting = votingIds.has(reply.id)}
|
||||
{@const replyDeleting = deletingIds.has(reply.id)}
|
||||
{@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id}
|
||||
|
||||
<div class="rounded-md bg-zinc-800/30 px-3 py-2.5 flex flex-col gap-1.5 {replyDeleting ? 'opacity-50' : ''}">
|
||||
<!-- Reply header -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{#if avatarUrls[reply.user_id]}
|
||||
<img src={avatarUrls[reply.user_id]} alt={reply.username} class="w-5 h-5 rounded-full object-cover flex-shrink-0" />
|
||||
{:else}
|
||||
<div class="w-5 h-5 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-[8px] font-semibold text-zinc-300 leading-none">{initials(reply.username)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if reply.username}
|
||||
<a href="/users/{reply.username}" class="text-xs font-medium text-zinc-300 hover:text-amber-400 transition-colors">{reply.username}</a>
|
||||
<div class="rounded-md bg-(--color-surface-2)/30 px-3 py-2.5 flex flex-col gap-1.5 {replyDeleting ? 'opacity-50' : ''}">
|
||||
<!-- Reply header -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{#if avatarUrls[reply.user_id]}
|
||||
<img src={avatarUrls[reply.user_id]} alt={reply.username} class="w-5 h-5 rounded-full object-cover flex-shrink-0" />
|
||||
{:else}
|
||||
<span class="text-xs font-medium text-zinc-400">Anonymous</span>
|
||||
<div class="w-5 h-5 rounded-full bg-(--color-surface-3) flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-[8px] font-semibold text-(--color-text) leading-none">{initials(reply.username)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="text-zinc-600 text-xs">·</span>
|
||||
<span class="text-xs text-zinc-500">{formatDate(reply.created)}</span>
|
||||
</div>
|
||||
{#if reply.username}
|
||||
<a href="/users/{reply.username}" class="text-xs font-medium text-(--color-text) hover:text-(--color-brand) transition-colors">{reply.username}</a>
|
||||
{:else}
|
||||
<span class="text-xs font-medium text-(--color-muted)">Anonymous</span>
|
||||
{/if}
|
||||
<span class="text-(--color-muted) opacity-60 text-xs">·</span>
|
||||
<span class="text-xs text-(--color-muted)">{formatDate(reply.created)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Reply body -->
|
||||
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
|
||||
<!-- Reply body -->
|
||||
<p class="text-sm text-(--color-text) leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
|
||||
|
||||
<!-- Reply actions -->
|
||||
<div class="flex items-center gap-3 pt-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300')}
|
||||
disabled={replyVoting}
|
||||
onclick={() => vote(reply.id, 'up', comment.id)}
|
||||
title="Upvote"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'up' ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
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"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'down' ? 'text-(--color-danger)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
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>
|
||||
@@ -522,14 +522,14 @@
|
||||
</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"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-auto px-1 py-0 gap-1 text-xs text-(--color-muted) hover:text-(--color-danger) ml-auto"
|
||||
disabled={replyDeleting}
|
||||
onclick={() => deleteComment(reply.id, comment.id)}
|
||||
title="Delete reply"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
'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',
|
||||
default: 'border-transparent bg-(--color-brand) text-(--color-surface)',
|
||||
secondary: 'border-transparent bg-(--color-surface-3) text-(--color-text)',
|
||||
outline: 'border-(--color-border) text-(--color-muted)',
|
||||
destructive: 'border-transparent bg-(--color-danger)/20 text-(--color-danger)',
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -28,15 +28,15 @@
|
||||
}: 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';
|
||||
'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-(--color-brand) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-surface) 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',
|
||||
default: 'bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim)',
|
||||
secondary: 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-border)',
|
||||
outline: 'border border-(--color-border) bg-transparent text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)',
|
||||
ghost: 'bg-transparent text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)',
|
||||
destructive: 'bg-(--color-danger)/20 text-(--color-danger) hover:bg-(--color-danger)/30',
|
||||
link: 'text-(--color-brand) underline-offset-4 hover:underline bg-transparent p-0 h-auto',
|
||||
};
|
||||
|
||||
const sizes: Record<Size, string> = {
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('rounded-xl border border-zinc-700 bg-zinc-800/50', className)}>
|
||||
<div class={cn('rounded-xl border border-(--color-border) bg-(--color-surface-2)/50', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<p class={cn('text-sm text-zinc-400', className)}>
|
||||
<p class={cn('text-sm text-(--color-muted)', className)}>
|
||||
{@render children?.()}
|
||||
</p>
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<h3 class={cn('font-semibold leading-none tracking-tight text-zinc-100', className)}>
|
||||
<h3 class={cn('font-semibold leading-none tracking-tight text-(--color-text)', className)}>
|
||||
{@render children?.()}
|
||||
</h3>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
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)}>
|
||||
<div class={cn('bg-(--color-surface) rounded-2xl border border-(--color-border) shadow-2xl w-full max-w-sm', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<h2 class={cn('text-base font-semibold leading-none tracking-tight text-zinc-100', className)}>
|
||||
<h2 class={cn('text-base font-semibold leading-none tracking-tight text-(--color-text)', className)}>
|
||||
{@render children?.()}
|
||||
</h2>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div
|
||||
role="separator"
|
||||
class={cn(
|
||||
'shrink-0 bg-zinc-700',
|
||||
'shrink-0 bg-(--color-border)',
|
||||
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
{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',
|
||||
'flex w-full rounded-lg border border-(--color-border) bg-(--color-surface-2) px-3 py-2 text-sm text-(--color-text) placeholder-zinc-500 resize-none transition-colors',
|
||||
'focus:outline-none focus:border-(--color-brand)',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user