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:
MechaCat02
2026-05-16 14:33:30 +02:00
parent 8a769b52bf
commit e619a3bd64
17 changed files with 1295 additions and 438 deletions

View File

@@ -1,22 +1,32 @@
<script lang="ts">
import type { FeedUpload } from '$lib/types';
import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
import { longpress } from '$lib/actions/longpress';
import { doubletap } from '$lib/actions/doubletap';
interface Props {
upload: FeedUpload;
isOwn?: boolean;
onlike: (id: string) => void;
oncomment: (id: string) => void;
onselect: (upload: FeedUpload) => void;
oncontextmenu?: (upload: FeedUpload) => void;
}
let { upload, onlike, oncomment, onselect }: Props = $props();
let {
upload,
isOwn = false,
onlike,
oncomment,
onselect,
oncontextmenu
}: Props = $props();
function isVideo(mime: string): boolean {
return mime.startsWith('video/');
}
function mediaUrl(u: FeedUpload): string {
return u.preview_url ?? u.thumbnail_url ?? '';
}
const mediaSrc = $derived(pickMediaUrl($dataMode, upload));
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
@@ -40,40 +50,68 @@
'bg-green-100 text-green-700',
'bg-amber-100 text-amber-700',
'bg-rose-100 text-rose-700',
'bg-teal-100 text-teal-700',
'bg-teal-100 text-teal-700'
];
function avatarColor(name: string): string {
let hash = 0;
for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) & 0xff;
return COLORS[hash % COLORS.length];
}
function openContext() {
oncontextmenu?.(upload);
}
</script>
<article class="bg-white">
<article
class="bg-white dark:bg-gray-900"
use:longpress={{ duration: 500 }}
onlongpress={openContext}
>
<!-- Uploader row -->
<div class="flex items-center gap-3 px-4 py-3">
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-bold
{avatarColor(upload.uploader_name)}"
>
{initial(upload.uploader_name)}
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-gray-900">{upload.uploader_name}</p>
<p class="text-xs text-gray-400">{relativeTime(upload.created_at)}</p>
<div class="flex items-center justify-between gap-3 px-4 py-3">
<div class="flex min-w-0 items-center gap-3">
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-bold
{avatarColor(upload.uploader_name)}"
>
{initial(upload.uploader_name)}
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">{upload.uploader_name}</p>
<p class="text-xs text-gray-400 dark:text-gray-500">{relativeTime(upload.created_at)}</p>
</div>
</div>
{#if oncontextmenu}
<!-- Desktop kebab — same actions as the mobile long-press context sheet. -->
<button
type="button"
onclick={(e) => { e.stopPropagation(); openContext(); }}
class="rounded-full p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
aria-label="Mehr Aktionen"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</button>
{/if}
</div>
<!-- Media -->
<button
class="block w-full"
onclick={() => onselect(upload)}
use:doubletap
ondoubletap={() => onlike(upload.id)}
aria-label="Bild vergrößern"
>
{#if isVideo(upload.mime_type)}
<div class="relative aspect-video w-full bg-gray-900">
{#if mediaUrl(upload)}
<img src={mediaUrl(upload)} alt="" class="h-full w-full object-cover opacity-80" />
{#if upload.thumbnail_url || upload.preview_url}
<img
src={upload.thumbnail_url ?? upload.preview_url ?? ''}
alt=""
class="h-full w-full object-cover opacity-80"
/>
{/if}
<div class="absolute inset-0 flex items-center justify-center">
<span class="flex h-14 w-14 items-center justify-center rounded-full bg-black/50 text-white">
@@ -83,17 +121,17 @@
</span>
</div>
</div>
{:else if mediaUrl(upload)}
{:else if mediaSrc}
<img
src={mediaUrl(upload)}
src={mediaSrc}
alt=""
class="w-full object-cover"
style="max-height: 80svh"
loading="lazy"
/>
{:else}
<div class="flex aspect-square w-full items-center justify-center bg-gray-100">
<svg class="h-12 w-12 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<div class="flex aspect-square w-full items-center justify-center bg-gray-100 dark:bg-gray-800">
<svg class="h-12 w-12 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
@@ -105,7 +143,7 @@
<button
onclick={() => onlike(upload.id)}
class="flex items-center gap-1.5 text-sm font-medium transition-colors
{upload.liked_by_me ? 'text-red-500' : 'text-gray-500 hover:text-red-400'}"
{upload.liked_by_me ? 'text-red-500 dark:text-red-400' : 'text-gray-500 hover:text-red-400 dark:text-gray-400 dark:hover:text-red-400'}"
>
<svg
class="h-5 w-5 {upload.liked_by_me ? 'fill-red-500' : ''}"
@@ -120,21 +158,24 @@
</button>
<button
onclick={() => oncomment(upload.id)}
class="flex items-center gap-1.5 text-sm font-medium text-gray-500 transition-colors hover:text-blue-500"
class="flex items-center gap-1.5 text-sm font-medium text-gray-500 transition-colors hover:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400"
>
<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="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
{upload.comment_count}
</button>
{#if isOwn}
<span class="ml-auto text-xs text-gray-400 dark:text-gray-500">Eigener Beitrag</span>
{/if}
</div>
<!-- Caption -->
{#if upload.caption}
<p class="px-4 pb-3 text-sm text-gray-800 [overflow-wrap:anywhere]">
<p class="px-4 pb-3 text-sm text-gray-800 [overflow-wrap:anywhere] dark:text-gray-200">
{upload.caption}
</p>
{/if}
<div class="border-b border-gray-100"></div>
<div class="border-b border-gray-100 dark:border-gray-800"></div>
</article>