feat(ui): v0.16 features + dark mode across every page
Wires up everything from the previous commits into actual UI surfaces, and applies Tailwind dark: variants throughout. All pages now support the 'system' / 'light' / 'dark' preference set in the onboarding step or in Mein Konto → Design. Layout & nav: - routes/+layout.svelte: initTheme(), global pin-reset SSE handler that filters by user_id and calls clearPin(), one-shot /me/context fetch on boot to hydrate privacyNote + quota. - components/BottomNav.svelte: dark variants on the frosted-glass bar. - components/UploadSheet.svelte: dark variants on backdrop, sheet, source buttons. - components/OnboardingGuide.svelte: new "Helles oder dunkles Design?" step (3-option custom-radio grid), reactive currentStep with proper type narrowing, dark variants throughout. Privacy-note nudge appears on the PIN step only when one is configured. Feed: - routes/feed/+page.svelte: diashow entry icon (tablet/desktop only), long-press → ContextSheet (Löschen for own posts, Original anzeigen for all), upload-deleted + feed-delta SSE handlers, dark variants on header, search, autocomplete, filter chips, empty states. - components/FeedListCard.svelte: long-press wireup, double-tap-to-like, data-mode-aware mediaSrc via pickMediaUrl, kebab fallback for desktop, isOwn prop, dark variants. - components/FeedGrid.svelte: long-press wireup, dark variants. - components/LightboxModal.svelte: data-mode-aware src, double-tap heart burst, dark variants on card / comments / input. - components/HashtagChips.svelte: dark variants. Account: - routes/account/+page.svelte: theme picker (3-button radio grid), data mode picker (with confirm sheet for Original), live quota widget, preformatted Datenschutzhinweis block, diashow tile (mobile only), pin now sourced from the $currentPin store so a global pin-reset clears it live, clearQueue() on explicit logout, dark variants across every card + both bottom sheets. Upload: - routes/upload/+page.svelte: per-user quota progress bar above the submit button, dark variants. Host & Admin: - routes/host/+page.svelte: PIN-reset confirm + one-time PIN modal, hosts may demote other hosts, canResetPinFor() helper, dark variants on all cards, modals, stats, toast. - routes/admin/+page.svelte: Config form rebuilt as CONFIG_GROUPS with per-field kind (number / bool / text), renders toggles for the rate-limit + quota switches and a textarea for the privacy_note; Nutzer tab gains PIN reset + hosts-may-demote-hosts wiring; same one-time PIN modal; dark variants everywhere. - routes/admin/login/+page.svelte: dark variants. Join / Recover / Export: - routes/join/+page.svelte: rename inline link to "Ich habe bereits einen Account", dark variants. - routes/recover/+page.svelte: dark variants. - routes/export/+page.svelte: dark variants on status cards + HTML guide modal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
import { pendingFiles, pendingCaption, clearPending } from '$lib/pending-upload-store';
|
||||
import { get } from 'svelte/store';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { quotaStore, refreshQuota } from '$lib/quota-store';
|
||||
import type { PendingFile } from '$lib/pending-upload-store';
|
||||
|
||||
interface StagedFile extends PendingFile {
|
||||
@@ -17,6 +18,8 @@
|
||||
let submitting = $state(false);
|
||||
let captionEl: HTMLTextAreaElement;
|
||||
|
||||
const MAX_CAPTION_LENGTH = 2000;
|
||||
|
||||
// Quick-tag chips derived from caption as the user types
|
||||
let captionTags = $derived.by(() => {
|
||||
const matches = [...caption.matchAll(/#(\w+)/g)];
|
||||
@@ -30,6 +33,7 @@
|
||||
return;
|
||||
}
|
||||
loadQueue();
|
||||
void refreshQuota();
|
||||
|
||||
// Pull staged files from the pending store (written by UploadSheet)
|
||||
const pf = get(pendingFiles);
|
||||
@@ -39,6 +43,16 @@
|
||||
|
||||
// Auto-focus caption textarea after a short delay (let layout settle)
|
||||
setTimeout(() => captionEl?.focus(), 80);
|
||||
|
||||
// Revoke blob URLs if user abandons the upload page
|
||||
const handleBeforeUnload = () => {
|
||||
clearPending();
|
||||
};
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -58,9 +72,11 @@
|
||||
|
||||
async function handleSubmit() {
|
||||
if (stagedFiles.length === 0 || submitting) return;
|
||||
if (caption.length > MAX_CAPTION_LENGTH) return;
|
||||
submitting = true;
|
||||
const hashtagsString = captionTags.join(',');
|
||||
for (const sf of stagedFiles) {
|
||||
await addToQueue(sf.file, caption, '');
|
||||
await addToQueue(sf.file, caption, hashtagsString);
|
||||
}
|
||||
clearPending();
|
||||
goto('/feed');
|
||||
@@ -69,28 +85,42 @@
|
||||
function isVideo(file: File): boolean {
|
||||
return file.type.startsWith('video/');
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | null | undefined): string {
|
||||
if (bytes == null || bytes <= 0) return '0 MB';
|
||||
const mb = bytes / (1024 * 1024);
|
||||
if (mb < 1024) return `${mb.toFixed(mb < 10 ? 1 : 0)} MB`;
|
||||
return `${(mb / 1024).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
const totalStagedBytes = $derived(stagedFiles.reduce((sum, sf) => sum + sf.file.size, 0));
|
||||
const quotaPercent = $derived(
|
||||
$quotaStore.limit_bytes && $quotaStore.limit_bytes > 0
|
||||
? Math.min(100, (($quotaStore.used_bytes + totalStagedBytes) / $quotaStore.limit_bytes) * 100)
|
||||
: 0
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- Full-screen composer — bottom nav is suppressed -->
|
||||
<div class="flex min-h-screen flex-col bg-white">
|
||||
<div class="flex min-h-screen flex-col bg-white dark:bg-gray-950">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-3">
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-3 dark:border-gray-800">
|
||||
<button
|
||||
onclick={cancel}
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full text-gray-500 transition hover:bg-gray-100"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
aria-label="Abbrechen"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 class="text-base font-semibold text-gray-900">Neuer Beitrag</h1>
|
||||
<h1 class="text-base font-semibold text-gray-900 dark:text-gray-100">Neuer Beitrag</h1>
|
||||
<!-- Submit button in header for desktop convenience -->
|
||||
<button
|
||||
onclick={handleSubmit}
|
||||
disabled={stagedFiles.length === 0 || submitting}
|
||||
class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm font-semibold text-white transition
|
||||
hover:bg-blue-700 disabled:opacity-40"
|
||||
hover:bg-blue-700 disabled:opacity-40 dark:bg-blue-500 dark:hover:bg-blue-400"
|
||||
>
|
||||
{submitting ? 'Wird hochgeladen…' : 'Hochladen'}
|
||||
</button>
|
||||
@@ -101,9 +131,9 @@
|
||||
{#if stagedFiles.length > 0}
|
||||
<div class="flex gap-2 overflow-x-auto px-4 py-3 scrollbar-none">
|
||||
{#each stagedFiles as sf, i}
|
||||
<div class="relative h-20 w-20 shrink-0 overflow-hidden rounded-xl bg-gray-100">
|
||||
<div class="relative h-20 w-20 shrink-0 overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800">
|
||||
{#if isVideo(sf.file)}
|
||||
<div class="flex h-full w-full items-center justify-center bg-gray-800">
|
||||
<div class="flex h-full w-full items-center justify-center bg-gray-800 dark:bg-gray-700">
|
||||
<svg class="h-7 w-7 text-white/70" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
@@ -123,20 +153,20 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="border-b border-gray-100"></div>
|
||||
<div class="border-b border-gray-100 dark:border-gray-800"></div>
|
||||
{:else}
|
||||
<!-- No files: prompt to go back and pick some -->
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
|
||||
<svg class="h-16 w-16 text-gray-200" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<svg class="h-16 w-16 text-gray-200 dark:text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M9 9.75h.008v.008H9V9.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-medium text-gray-500">Keine Dateien ausgewählt</p>
|
||||
<p class="mt-1 text-sm text-gray-400">Geh zurück und tippe auf den Plus-Button.</p>
|
||||
<p class="font-medium text-gray-500 dark:text-gray-400">Keine Dateien ausgewählt</p>
|
||||
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Geh zurück und tippe auf den Plus-Button.</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={cancel}
|
||||
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
@@ -148,34 +178,60 @@
|
||||
<textarea
|
||||
bind:this={captionEl}
|
||||
bind:value={caption}
|
||||
maxlength={MAX_CAPTION_LENGTH}
|
||||
placeholder="Beschreibung hinzufügen… (#hashtags möglich)"
|
||||
rows="4"
|
||||
class="w-full resize-none rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900
|
||||
placeholder-gray-400 focus:border-blue-400 focus:bg-white focus:outline-none focus:ring-1 focus:ring-blue-200"
|
||||
placeholder-gray-400 focus:border-blue-400 focus:bg-white focus:outline-none focus:ring-1 focus:ring-blue-200
|
||||
dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500 dark:focus:bg-gray-800"
|
||||
></textarea>
|
||||
<div class="mt-1 text-xs text-gray-500 text-right dark:text-gray-400">
|
||||
{caption.length} / {MAX_CAPTION_LENGTH}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick-tag chips (derived from typed caption) -->
|
||||
{#if captionTags.length > 0}
|
||||
<div class="flex flex-wrap gap-1.5 px-4 pt-2">
|
||||
{#each captionTags as tag}
|
||||
<span class="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-600">
|
||||
<span class="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-600 dark:bg-blue-950/40 dark:text-blue-300">
|
||||
#{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Per-user quota — hidden when admin disabled enforcement -->
|
||||
{#if $quotaStore.enabled && $quotaStore.limit_bytes != null}
|
||||
<div class="px-4 pt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Speicher: {formatBytes($quotaStore.used_bytes + totalStagedBytes)} / {formatBytes($quotaStore.limit_bytes)}</span>
|
||||
<span class:text-amber-600={quotaPercent >= 80} class:dark:text-amber-400={quotaPercent >= 80} class:text-red-600={quotaPercent >= 95} class:dark:text-red-400={quotaPercent >= 95}>
|
||||
{Math.round(quotaPercent)}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div
|
||||
class="h-full transition-all"
|
||||
class:bg-blue-500={quotaPercent < 80}
|
||||
class:bg-amber-500={quotaPercent >= 80 && quotaPercent < 95}
|
||||
class:bg-red-500={quotaPercent >= 95}
|
||||
style="width: {quotaPercent}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="h-8"></div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky submit button at bottom (mobile-primary) -->
|
||||
<div class="border-t border-gray-100 px-4 py-3">
|
||||
<div class="border-t border-gray-100 px-4 py-3 dark:border-gray-800">
|
||||
<button
|
||||
onclick={handleSubmit}
|
||||
disabled={stagedFiles.length === 0 || submitting}
|
||||
class="flex w-full items-center justify-center gap-2 rounded-xl bg-blue-600 py-3.5 text-sm font-semibold
|
||||
text-white transition hover:bg-blue-700 active:scale-[0.98] disabled:opacity-40"
|
||||
text-white transition hover:bg-blue-700 active:scale-[0.98] disabled:opacity-40 dark:bg-blue-500 dark:hover:bg-blue-400"
|
||||
>
|
||||
{#if submitting}
|
||||
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
|
||||
Reference in New Issue
Block a user