feat(ui): elegant wedding white/silver/gold redesign

Reskin the whole app via design tokens in tailwind-theme.css (remapped
color ramps) plus a define-once component layer in lib/styles/components.css
(.btn, .card, .input, .chip, .badge, .sheet, …). Buttons are muted/outlined
gold rather than flat fills. Self-host Inter + Fraunces (woff2) under the
existing font-src 'self' CSP. Restyle the shared components and the account,
admin, host, join, recover and upload screens against the new tokens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-18 17:28:42 +02:00
parent 5546fb82e6
commit f243bfe89a
22 changed files with 696 additions and 545 deletions

View File

@@ -1,4 +1,5 @@
@import './tailwind-theme.css'; @import './tailwind-theme.css';
@import './lib/styles/components.css';
/* Respect the OS "reduce motion" setting. Collapses every animation/transition /* Respect the OS "reduce motion" setting. Collapses every animation/transition
* (HeartBurst, Skeleton pulse, diashow cross-fades, sheet/FAB slides) to a * (HeartBurst, Skeleton pulse, diashow cross-fades, sheet/FAB slides) to a

View File

@@ -7,8 +7,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="text-scale" content="scale" /> <meta name="text-scale" content="scale" />
<!-- Light/dark theme-color so the browser chrome matches the active theme. --> <!-- Light/dark theme-color so the browser chrome matches the active theme. -->
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#faf9f7" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#030712" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#100f0f" media="(prefers-color-scheme: dark)" />
<!-- Installable PWA keepsake: manifest + Apple home-screen metadata. --> <!-- Installable PWA keepsake: manifest + Apple home-screen metadata. -->
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" /> <link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<link rel="icon" href="%sveltekit.assets%/icon.svg" type="image/svg+xml" /> <link rel="icon" href="%sveltekit.assets%/icon.svg" type="image/svg+xml" />
@@ -61,31 +61,29 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.75rem; gap: 0.75rem;
background: #f9fafb; background: #faf9f7;
} }
html.dark #app-boot { html.dark #app-boot {
background: #030712; background: #100f0f;
} }
.app-boot__spinner { .app-boot__spinner {
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
border-radius: 9999px; border-radius: 9999px;
border: 2px solid #d1d5db; border: 2px solid #e5e4e1;
border-top-color: #2563eb; border-top-color: #8a6a2b;
animation: app-boot-spin 0.6s linear infinite; animation: app-boot-spin 0.6s linear infinite;
} }
html.dark .app-boot__spinner { html.dark .app-boot__spinner {
border-color: #374151; border-color: #3b3a38;
border-top-color: #60a5fa; border-top-color: #c6a24a;
} }
.app-boot__label { .app-boot__label {
font-family: font-family: Georgia, 'Times New Roman', serif;
system-ui, font-size: 1rem;
-apple-system, font-weight: 600;
sans-serif; letter-spacing: 0.01em;
font-size: 0.875rem; color: #8a6a2b;
font-weight: 500;
color: #9ca3af;
} }
@keyframes app-boot-spin { @keyframes app-boot-spin {
to { to {

View File

@@ -49,7 +49,7 @@
<div class="relative -translate-y-3"> <div class="relative -translate-y-3">
<button <button
onclick={() => ($uploadSheetOpen = true)} onclick={() => ($uploadSheetOpen = true)}
class="relative flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-lg transition active:scale-95 hover:bg-blue-700" class="relative flex h-14 w-14 items-center justify-center rounded-full border-2 border-primary-500 bg-primary-100 text-primary-700 shadow-lg transition active:scale-95 hover:bg-primary-200 dark:border-primary-500 dark:bg-primary-950/60 dark:text-primary-300"
aria-label="Hochladen" aria-label="Hochladen"
> >
<!-- Camera + plus icon --> <!-- Camera + plus icon -->

View File

@@ -80,10 +80,7 @@
onclick={handleConfirm} onclick={handleConfirm}
disabled={busy} disabled={busy}
data-testid="confirm-sheet-confirm" data-testid="confirm-sheet-confirm"
class="mb-3 flex w-full items-center justify-center gap-2 rounded-xl py-3 text-sm font-semibold text-white transition disabled:opacity-60 {tone === class="btn btn-block mb-3 {tone === 'danger' ? 'btn-danger' : 'btn-primary'}"
'danger'
? 'bg-red-600 hover:bg-red-700 active:bg-red-700 dark:bg-red-500 dark:hover:bg-red-400 dark:active:bg-red-400'
: 'bg-blue-600 hover:bg-blue-700 active:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-400'}"
> >
{#if busy} {#if busy}
<span <span
@@ -98,7 +95,7 @@
onclick={onCancel} onclick={onCancel}
disabled={busy} disabled={busy}
data-testid="confirm-sheet-cancel" data-testid="confirm-sheet-cancel"
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-700 transition hover:bg-gray-50 active:bg-gray-100 disabled:opacity-60 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:active:bg-gray-800" class="btn btn-secondary btn-block"
> >
{cancelLabel} {cancelLabel}
</button> </button>

View File

@@ -147,11 +147,7 @@
</button> </button>
{/each} {/each}
<button <button type="button" onclick={onClose} class="btn btn-secondary btn-block mt-2">
type="button"
onclick={onClose}
class="mt-2 w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-600 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
Abbrechen Abbrechen
</button> </button>
</div> </div>

View File

@@ -66,170 +66,168 @@
<!-- Off-screen cards are removed from the DOM entirely by the parent VirtualFeed <!-- Off-screen cards are removed from the DOM entirely by the parent VirtualFeed
window-virtualizer, so this card no longer needs content-visibility — and must window-virtualizer, so this card no longer needs content-visibility — and must
not use it, since the virtualizer measures each card's real rendered height. --> not use it, since the virtualizer measures each card's real rendered height. -->
<article <article class="px-3 py-2" use:longpress={{ duration: 500 }} onlongpress={openContext}>
class="bg-white dark:bg-gray-900" <div
use:longpress={{ duration: 500 }} class="overflow-hidden rounded-2xl border border-gray-200/70 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900"
onlongpress={openContext} >
> <!-- Uploader row -->
<!-- Uploader row --> <div class="flex items-center justify-between gap-3 px-4 py-3">
<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 min-w-0 items-center gap-3"> <div
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-bold
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-bold
{avatarPalette(upload.uploader_name)}" {avatarPalette(upload.uploader_name)}"
> >
{initials(upload.uploader_name)} {initials(upload.uploader_name)}
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100"> <p class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
{upload.uploader_name} {upload.uploader_name}
</p> </p>
<p class="text-xs text-gray-400 dark:text-gray-500">{relTime}</p> <p class="text-xs text-gray-400 dark:text-gray-500">{relTime}</p>
</div>
</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 active:bg-gray-200 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-gray-800 dark:active: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> </div>
{#if oncontextmenu} <!-- Media -->
<!-- Desktop kebab — same actions as the mobile long-press context sheet. --> <button
<button class="relative block w-full"
type="button" use:doubletap
onclick={(e) => { onsingletap={() => onselect(upload)}
e.stopPropagation(); ondoubletap={handleDoubleTap}
openContext(); onclick={(e) => {
}} if (e.detail === 0) onselect(upload);
class="rounded-full p-1 text-gray-400 hover:bg-gray-100 active:bg-gray-200 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-gray-800 dark:active:bg-gray-800 dark:hover:text-gray-200" }}
aria-label="Mehr Aktionen" aria-label="Bild vergrößern"
> >
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" <HeartBurst active={heartBurst} />
><circle cx="5" cy="12" r="2" /><circle cx="12" cy="12" r="2" /><circle {#if isVideo(upload.mime_type)}
cx="19" <div class="relative aspect-video w-full bg-gray-900">
cy="12" {#if upload.thumbnail_url || upload.preview_url}
r="2" <img
/></svg src={upload.thumbnail_url ?? upload.preview_url ?? ''}
> alt=""
</button> class="h-full w-full object-cover opacity-80"
{/if} loading="lazy"
</div> decoding="async"
/>
<!-- Media --> {/if}
<button <div class="absolute inset-0 flex items-center justify-center">
class="relative block w-full" <span
use:doubletap class="flex h-14 w-14 items-center justify-center rounded-full bg-black/50 text-white"
onsingletap={() => onselect(upload)} >
ondoubletap={handleDoubleTap} <svg class="h-7 w-7 pl-0.5" fill="currentColor" viewBox="0 0 24 24">
onclick={(e) => { <path d="M8 5v14l11-7z" />
if (e.detail === 0) onselect(upload); </svg>
}} </span>
aria-label="Bild vergrößern" </div>
> </div>
<HeartBurst active={heartBurst} /> {:else if mediaSrc}
{#if isVideo(upload.mime_type)} <!-- Reserve the same 4/5 box the skeleton uses so the card doesn't collapse
<div class="relative aspect-video w-full bg-gray-900"> to height 0 and reflow as images stream in. The uncropped original is one
{#if upload.thumbnail_url || upload.preview_url} tap away in the lightbox. -->
<div class="aspect-[4/5] w-full bg-gray-100 dark:bg-gray-800">
<img <img
src={upload.thumbnail_url ?? upload.preview_url ?? ''} src={mediaSrc}
alt="" alt=""
class="h-full w-full object-cover opacity-80" class="h-full w-full object-cover"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
/> />
{/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"
>
<svg class="h-7 w-7 pl-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</span>
</div> </div>
</div> {:else}
{:else if mediaSrc} <div
<!-- Reserve the same 4/5 box the skeleton uses so the card doesn't collapse class="flex aspect-square w-full items-center justify-center bg-gray-100 dark:bg-gray-800"
to height 0 and reflow as images stream in. The uncropped original is one >
tap away in the lightbox. --> <svg
<div class="aspect-[4/5] w-full bg-gray-100 dark:bg-gray-800"> class="h-12 w-12 text-gray-300 dark:text-gray-600"
<img fill="none"
src={mediaSrc} viewBox="0 0 24 24"
alt="" stroke="currentColor"
class="h-full w-full object-cover" >
loading="lazy" <path
decoding="async" stroke-linecap="round"
/> stroke-linejoin="round"
</div> stroke-width="1.5"
{:else} 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"
<div />
class="flex aspect-square w-full items-center justify-center bg-gray-100 dark:bg-gray-800" </svg>
</div>
{/if}
</button>
<!-- Actions row -->
<div class="flex items-center gap-4 px-4 py-2">
<button
onclick={() => {
vibrate(10);
onlike(upload.id);
}}
aria-pressed={upload.liked_by_me}
aria-label={upload.liked_by_me ? 'Gefällt mir nicht mehr' : 'Gefällt mir'}
class="flex items-center gap-1.5 text-sm font-medium transition-colors
{upload.liked_by_me
? 'text-red-500 dark:text-red-400'
: 'text-gray-500 hover:text-red-400 active:text-red-400 dark:text-gray-400 dark:hover:text-red-400 dark:active:text-red-400'}"
> >
<svg <svg
class="h-12 w-12 text-gray-300 dark:text-gray-600" class="h-5 w-5 {upload.liked_by_me ? 'fill-red-500' : ''}"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
stroke-width="2"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="1.5" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
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> </svg>
</div> {upload.like_count}
{/if} </button>
</button> <button
onclick={() => oncomment(upload.id)}
<!-- Actions row --> class="flex items-center gap-1.5 text-sm font-medium text-gray-500 transition-colors hover:text-blue-500 active:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 dark:active:text-blue-400"
<div class="flex items-center gap-4 px-4 py-2">
<button
onclick={() => {
vibrate(10);
onlike(upload.id);
}}
aria-pressed={upload.liked_by_me}
aria-label={upload.liked_by_me ? 'Gefällt mir nicht mehr' : 'Gefällt mir'}
class="flex items-center gap-1.5 text-sm font-medium transition-colors
{upload.liked_by_me
? 'text-red-500 dark:text-red-400'
: 'text-gray-500 hover:text-red-400 active:text-red-400 dark:text-gray-400 dark:hover:text-red-400 dark:active:text-red-400'}"
>
<svg
class="h-5 w-5 {upload.liked_by_me ? 'fill-red-500' : ''}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
> >
<path <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
stroke-linecap="round" <path
stroke-linejoin="round" stroke-linecap="round"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" 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.like_count} </svg>
</button> {upload.comment_count}
<button </button>
onclick={() => oncomment(upload.id)} {#if isOwn}
class="flex items-center gap-1.5 text-sm font-medium text-gray-500 transition-colors hover:text-blue-500 active:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 dark:active:text-blue-400" <span class="ml-auto text-xs text-gray-400 dark:text-gray-500">Eigener Beitrag</span>
> {/if}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> </div>
<path
stroke-linecap="round" <!-- Caption -->
stroke-linejoin="round" {#if upload.caption}
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" <p class="px-4 pb-3 text-sm text-gray-800 [overflow-wrap:anywhere] dark:text-gray-200">
/> {upload.caption}
</svg> </p>
{upload.comment_count}
</button>
{#if isOwn}
<span class="ml-auto text-xs text-gray-400 dark:text-gray-500">Eigener Beitrag</span>
{/if} {/if}
</div> </div>
<!-- Caption -->
{#if upload.caption}
<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 dark:border-gray-800"></div>
</article> </article>

View File

@@ -18,10 +18,9 @@
<button <button
onclick={() => onselect(null)} onclick={() => onselect(null)}
aria-pressed={selected === null} aria-pressed={selected === null}
class="inline-flex min-h-11 shrink-0 items-center rounded-full px-4 py-2 text-sm font-medium transition {selected === class="chip inline-flex min-h-11 shrink-0 items-center {selected === null
null ? 'chip-active'
? 'bg-blue-600 text-white dark:bg-blue-500' : 'chip-inactive'}"
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 active:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 dark:active:bg-gray-700'}"
> >
Alle Alle
</button> </button>
@@ -29,10 +28,9 @@
<button <button
onclick={() => onselect(h.tag)} onclick={() => onselect(h.tag)}
aria-pressed={selected === h.tag} aria-pressed={selected === h.tag}
class="inline-flex min-h-11 shrink-0 items-center rounded-full px-4 py-2 text-sm font-medium transition {selected === class="chip inline-flex min-h-11 shrink-0 items-center {selected === h.tag
h.tag ? 'chip-active'
? 'bg-blue-600 text-white dark:bg-blue-500' : 'chip-inactive'}"
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 active:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 dark:active:bg-gray-700'}"
> >
#{h.tag} #{h.tag}
<span class="ml-1 text-xs opacity-70">{h.count}</span> <span class="ml-1 text-xs opacity-70">{h.count}</span>

View File

@@ -277,12 +277,12 @@
bind:value={newComment} bind:value={newComment}
placeholder="Kommentar schreiben..." placeholder="Kommentar schreiben..."
maxlength={COMMENT_MAX} maxlength={COMMENT_MAX}
class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500" class="input flex-1"
/> />
<button <button
type="submit" type="submit"
disabled={loading || !newComment.trim()} disabled={loading || !newComment.trim()}
class="rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white transition hover:bg-blue-700 active:bg-blue-700 disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-400" class="btn btn-primary btn-sm"
> >
Senden Senden
</button> </button>

View File

@@ -49,9 +49,7 @@
use:focusTrap={{ onclose: onClose }} use:focusTrap={{ onclose: onClose }}
use:scrollLock use:scrollLock
> >
<div <div class="card pointer-events-auto w-full max-w-sm p-6 shadow-xl">
class="pointer-events-auto w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900"
>
{@render children()} {@render children()}
</div> </div>
</div> </div>

View File

@@ -130,8 +130,17 @@
<!-- Content --> <!-- Content -->
<div class="mb-6 text-center"> <div class="mb-6 text-center">
<div class="mb-3 text-5xl">{currentStep.icon}</div> <div class="mb-4 flex justify-center">
<h2 id="onboarding-title" class="mb-2 text-xl font-bold text-gray-900 dark:text-gray-100"> <div
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary-50 text-3xl dark:bg-primary-900/30"
>
{currentStep.icon}
</div>
</div>
<h2
id="onboarding-title"
class="mb-2 text-xl font-semibold text-gray-900 dark:text-gray-100"
>
{currentStep.title} {currentStep.title}
</h2> </h2>
@@ -193,10 +202,7 @@
> >
Überspringen Überspringen
</button> </button>
<button <button onclick={next} class="btn btn-primary flex-1">
onclick={next}
class="flex-1 rounded-xl bg-blue-600 py-3 text-sm font-semibold text-white hover:bg-blue-700 active:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-400"
>
{step < steps.length - 1 ? 'Weiter' : "Los geht's!"} {step < steps.length - 1 ? 'Weiter' : "Los geht's!"}
</button> </button>
</div> </div>

View File

@@ -167,12 +167,7 @@
liken und kommentieren. liken und kommentieren.
</p> </p>
</div> </div>
<button <button onclick={close} class="btn btn-secondary btn-block"> Schließen </button>
onclick={close}
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-600 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
Schließen
</button>
{:else} {:else}
<!-- Gallery option --> <!-- Gallery option -->
<button <button
@@ -236,12 +231,7 @@
</button> </button>
<!-- Cancel --> <!-- Cancel -->
<button <button onclick={close} class="btn btn-secondary btn-block"> Abbrechen </button>
onclick={close}
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-600 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
Abbrechen
</button>
{/if} {/if}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,120 @@
/* ─────────────────────────────────────────────────────────────────────────
* EventSnap component layer — the shared, "define-once" building blocks.
*
* Colours/type/spacing tokens live in `tailwind-theme.css`; this file turns the
* *recurring UI patterns* (buttons, inputs, cards, badges, chips, sheets) into
* named classes so every page consumes the same look. Change a button here and
* it changes everywhere.
*
* These live in the `components` layer, which Tailwind emits BEFORE `utilities`
* — so a one-off inline utility (e.g. `rounded-lg`, `w-full`) on an element
* still overrides the component class. Reach for a component class first; drop
* to inline utilities only for genuinely one-off tweaks.
* ───────────────────────────────────────────────────────────────────────── */
@layer components {
/* ── Buttons ──────────────────────────────────────────────────────────
* Base + intent modifier (+ optional size). e.g. `class="btn btn-primary"`,
* `class="btn btn-secondary btn-sm"`, `class="btn btn-primary btn-block"`. */
.btn {
@apply inline-flex select-none items-center justify-center gap-2 rounded-xl border border-transparent px-4 py-3 text-sm font-semibold transition;
@apply focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40;
@apply active:scale-[0.99] disabled:cursor-not-allowed;
}
.btn-lg {
@apply px-5 py-3.5 text-base;
}
.btn-sm {
@apply rounded-lg px-3 py-2;
}
.btn-block {
@apply w-full;
}
/* Primary = muted champagne fill + gold border + gold text (an elegant,
* outlined "signal" rather than an aggressive solid gold block). */
.btn-primary {
@apply border-primary-400 bg-primary-100 text-primary-800 shadow-sm hover:border-primary-500 hover:bg-primary-200;
@apply disabled:border-gray-200 disabled:bg-gray-100 disabled:text-gray-400 disabled:shadow-none;
@apply dark:border-primary-500/50 dark:bg-primary-950/40 dark:text-primary-200 dark:hover:border-primary-500 dark:hover:bg-primary-900/40;
}
.btn-secondary {
@apply border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 disabled:opacity-50;
@apply dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700;
}
.btn-ghost {
@apply text-gray-600 hover:bg-gray-100 hover:text-gray-900 disabled:opacity-50;
@apply dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100;
}
.btn-danger {
@apply bg-red-600 text-white shadow-sm hover:bg-red-700 disabled:opacity-50;
@apply dark:bg-red-600 dark:hover:bg-red-500;
}
/* ── Form inputs ──────────────────────────────────────────────────────
* Covers text/number inputs and textareas. */
.input {
@apply w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 placeholder-gray-400 transition;
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/25;
@apply dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500;
}
/* ── Surfaces ─────────────────────────────────────────────────────────
* `card` — the standard raised surface (page → card → surface-muted is the
* three-level elevation ramp).
* `surface-muted` — a nested/inset panel inside a card. */
.card {
@apply rounded-2xl border border-gray-200/70 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900;
}
.surface-muted {
@apply rounded-xl bg-gray-50 dark:bg-gray-800/60;
}
/* ── Badges (status / role pills) ─────────────────────────────────────
* `class="badge badge-gold"` etc. A leading dot is optional in markup. */
.badge {
@apply inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold;
}
.badge-primary {
@apply bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-200;
}
.badge-gold {
@apply bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200;
}
.badge-gray {
@apply bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300;
}
.badge-success {
@apply bg-green-100 text-green-700 dark:bg-green-950/50 dark:text-green-300;
}
.badge-danger {
@apply bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300;
}
/* ── Chips (filter / toggle) ──────────────────────────────────────────
* `class="chip chip-active"` / `class="chip chip-inactive"`. */
.chip {
@apply rounded-full border border-transparent px-4 py-2 text-sm font-medium transition;
}
.chip-inactive {
@apply bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700;
}
/* Active = gold outline + soft champagne fill (matches .btn-primary). */
.chip-active {
@apply border-primary-400 bg-primary-100 text-primary-800 dark:border-primary-500/50 dark:bg-primary-950/40 dark:text-primary-200;
}
/* ── Bottom-sheet surface ─────────────────────────────────────────────
* The rounded top panel shared by upload / context / confirm sheets. */
.sheet {
@apply rounded-t-3xl bg-white p-6 shadow-2xl dark:bg-gray-900 sm:rounded-2xl;
}
/* ── Page scaffold + section heading ──────────────────────────────────*/
.page {
@apply min-h-screen bg-gray-50 dark:bg-gray-950;
}
.section-title {
@apply font-display text-lg font-semibold text-gray-900 dark:text-gray-100;
}
}

View File

@@ -163,9 +163,7 @@
<div class="mx-auto max-w-lg space-y-3 p-4"> <div class="mx-auto max-w-lg space-y-3 p-4">
<!-- Profile card --> <!-- Profile card -->
<div <div class="card p-5">
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div <div
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full text-xl font-bold class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full text-xl font-bold
@@ -195,9 +193,7 @@
<!-- Dashboards section (host + admin only) --> <!-- Dashboards section (host + admin only) -->
{#if role === 'host' || role === 'admin'} {#if role === 'host' || role === 'admin'}
<div <div class="card overflow-hidden">
class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
>
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700"> <div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
<h2 <h2
class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400" class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
@@ -211,7 +207,7 @@
> >
<!-- Star icon --> <!-- Star icon -->
<svg <svg
class="h-5 w-5 text-amber-500" class="h-5 w-5 text-primary-500"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -270,10 +266,12 @@
<!-- PIN card --> <!-- PIN card -->
<div <div
class="rounded-xl border border-amber-200 bg-amber-50 p-5 dark:border-amber-800/60 dark:bg-amber-950/30" class="rounded-xl border border-primary-200 bg-primary-50 p-5 dark:border-primary-800/60 dark:bg-primary-950/30"
> >
<h2 class="mb-1 font-semibold text-amber-900 dark:text-amber-200">Wiederherstellungs-PIN</h2> <h2 class="mb-1 font-semibold text-primary-800 dark:text-primary-200">
<p class="mb-3 text-sm text-amber-700 dark:text-amber-300/90"> Wiederherstellungs-PIN
</h2>
<p class="mb-3 text-sm text-primary-700 dark:text-primary-300/90">
Du brauchst diesen PIN, um dein Konto auf einem anderen Gerät wiederherzustellen. Schreib Du brauchst diesen PIN, um dein Konto auf einem anderen Gerät wiederherzustellen. Schreib
ihn auf! ihn auf!
</p> </p>
@@ -287,7 +285,7 @@
> >
<button <button
onclick={copyPin} onclick={copyPin}
class="inline-flex min-h-11 items-center rounded-md bg-amber-100 px-4 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60" class="inline-flex min-h-11 items-center rounded-md bg-primary-100 px-4 py-2 text-sm font-medium text-primary-800 transition hover:bg-primary-200 dark:bg-primary-900/40 dark:text-primary-200 dark:hover:bg-primary-900/60"
> >
{pinCopied ? 'Kopiert!' : 'Kopieren'} {pinCopied ? 'Kopiert!' : 'Kopieren'}
</button> </button>
@@ -337,9 +335,7 @@
</a> </a>
<!-- Theme / Design --> <!-- Theme / Design -->
<div <div class="card overflow-hidden">
class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
>
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700"> <div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
<h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Design Design
@@ -394,9 +390,7 @@
</div> </div>
<!-- Data mode --> <!-- Data mode -->
<div <div class="card overflow-hidden">
class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
>
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700"> <div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
<h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Datennutzung Datennutzung
@@ -439,9 +433,7 @@
<!-- Per-user quota widget --> <!-- Per-user quota widget -->
{#if $quotaStore.enabled && $quotaStore.limit_bytes != null} {#if $quotaStore.enabled && $quotaStore.limit_bytes != null}
<div <div class="card p-5">
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<h2 <h2
class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400" class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
> >
@@ -472,9 +464,7 @@
<!-- Datenschutzhinweis (preformatted, plain text) --> <!-- Datenschutzhinweis (preformatted, plain text) -->
{#if $privacyNote.trim()} {#if $privacyNote.trim()}
<div <div class="card p-5">
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<h2 <h2
class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400" class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
> >
@@ -486,9 +476,7 @@
{/if} {/if}
<!-- Konto section --> <!-- Konto section -->
<div <div class="card overflow-hidden">
class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
>
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700"> <div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
<h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Konto Konto
@@ -611,16 +599,10 @@
<p class="mb-6 text-center text-sm text-gray-500 dark:text-gray-400"> <p class="mb-6 text-center text-sm text-gray-500 dark:text-gray-400">
Original-Dateien können deutlich mehr Datenvolumen verbrauchen. Am besten im WLAN aktivieren. Original-Dateien können deutlich mehr Datenvolumen verbrauchen. Am besten im WLAN aktivieren.
</p> </p>
<button <button onclick={confirmOriginalMode} class="btn btn-primary btn-block mb-3">
onclick={confirmOriginalMode}
class="mb-3 w-full rounded-xl bg-blue-600 py-3 text-sm font-semibold text-white transition hover:bg-blue-700 active:bg-blue-700"
>
Aktivieren Aktivieren
</button> </button>
<button <button onclick={() => (dataModeWarningOpen = false)} class="btn btn-secondary btn-block">
onclick={() => (dataModeWarningOpen = false)}
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-700 transition hover:bg-gray-50 active:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:active:bg-gray-800"
>
Abbrechen Abbrechen
</button> </button>
</div> </div>

View File

@@ -453,10 +453,7 @@
Kopieren Kopieren
</button> </button>
</div> </div>
<button <button onclick={() => (pinModal = null)} class="btn btn-primary btn-block btn-sm">
onclick={() => (pinModal = null)}
class="w-full rounded-lg bg-blue-600 py-2 text-sm font-semibold text-white hover:bg-blue-700 active:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-400"
>
Schließen Schließen
</button> </button>
{/if} {/if}
@@ -473,16 +470,10 @@
Diashow und Export, und die Sitzung wird beendet. Rückgängig machbar über „Entsperren“. Diashow und Export, und die Sitzung wird beendet. Rückgängig machbar über „Entsperren“.
</p> </p>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button onclick={() => (banTarget = null)} class="btn btn-secondary btn-sm flex-1"
onclick={() => (banTarget = null)}
class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:active:bg-gray-800"
>Abbrechen</button >Abbrechen</button
> >
<button <button onclick={confirmBan} disabled={banSubmitting} class="btn btn-danger btn-sm flex-1">
onclick={confirmBan}
disabled={banSubmitting}
class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 active:bg-red-700 disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-400 dark:active:bg-red-400"
>
{banSubmitting ? 'Wird gesperrt…' : 'Sperren'} {banSubmitting ? 'Wird gesperrt…' : 'Sperren'}
</button> </button>
</div> </div>
@@ -543,33 +534,25 @@
<div class="space-y-3"> <div class="space-y-3">
{#if stats} {#if stats}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div <div class="card p-4 text-center">
class="rounded-xl bg-white border border-gray-200 p-4 text-center dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100"> <p class="text-3xl font-bold text-gray-900 dark:text-gray-100">
{stats.user_count} {stats.user_count}
</p> </p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Gäste</p> <p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Gäste</p>
</div> </div>
<div <div class="card p-4 text-center">
class="rounded-xl bg-white border border-gray-200 p-4 text-center dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100"> <p class="text-3xl font-bold text-gray-900 dark:text-gray-100">
{stats.upload_count} {stats.upload_count}
</p> </p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Uploads</p> <p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Uploads</p>
</div> </div>
<div <div class="card p-4 text-center">
class="rounded-xl bg-white border border-gray-200 p-4 text-center dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100"> <p class="text-3xl font-bold text-gray-900 dark:text-gray-100">
{stats.comment_count} {stats.comment_count}
</p> </p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Kommentare</p> <p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Kommentare</p>
</div> </div>
<div <div class="card p-4 text-center">
class="rounded-xl bg-white border border-gray-200 p-4 text-center dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100"> <p class="text-3xl font-bold text-gray-900 dark:text-gray-100">
{diskPct(stats)} % {diskPct(stats)} %
</p> </p>
@@ -577,9 +560,7 @@
</div> </div>
</div> </div>
<!-- Disk bar --> <!-- Disk bar -->
<div <div class="card p-5">
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<div <div
class="mb-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400" class="mb-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
> >
@@ -611,9 +592,7 @@
{:else if activeTab === 'config'} {:else if activeTab === 'config'}
<div class="relative space-y-3 pb-20"> <div class="relative space-y-3 pb-20">
{#each CONFIG_GROUPS as group (group.title)} {#each CONFIG_GROUPS as group (group.title)}
<div <div class="card">
class="rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
>
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700"> <div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
<h3 <h3
class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400" class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
@@ -652,7 +631,7 @@
id={field.key} id={field.key}
rows="6" rows="6"
bind:value={configDraft[field.key]} bind:value={configDraft[field.key]}
class="w-full resize-none rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100" class="input resize-none font-mono text-sm"
></textarea> ></textarea>
{#if field.hint} {#if field.hint}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{field.hint}</p> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{field.hint}</p>
@@ -670,7 +649,7 @@
min="0" min="0"
inputmode="decimal" inputmode="decimal"
bind:value={configDraft[field.key]} bind:value={configDraft[field.key]}
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100" class="input text-sm"
/> />
{#if field.hint} {#if field.hint}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{field.hint}</p> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{field.hint}</p>
@@ -686,11 +665,7 @@
<div <div
class="sticky bottom-0 -mx-4 border-t border-gray-100 bg-white px-5 py-3 dark:border-gray-800 dark:bg-gray-900 sm:mx-0 sm:rounded-b-xl" class="sticky bottom-0 -mx-4 border-t border-gray-100 bg-white px-5 py-3 dark:border-gray-800 dark:bg-gray-900 sm:mx-0 sm:rounded-b-xl"
> >
<button <button onclick={saveConfig} disabled={saving} class="btn btn-primary btn-block">
onclick={saveConfig}
disabled={saving}
class="w-full rounded-xl bg-blue-600 py-3 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400"
>
{saving ? 'Wird gespeichert…' : 'Speichern'} {saving ? 'Wird gespeichert…' : 'Speichern'}
</button> </button>
</div> </div>
@@ -700,9 +675,7 @@
{:else if activeTab === 'export'} {:else if activeTab === 'export'}
<div class="space-y-3"> <div class="space-y-3">
<!-- Gallery release --> <!-- Gallery release -->
<div <div class="card p-5">
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<h3 class="mb-3 font-semibold text-gray-900 dark:text-gray-100">Galerie</h3> <h3 class="mb-3 font-semibold text-gray-900 dark:text-gray-100">Galerie</h3>
<button <button
onclick={() => onclick={() =>
@@ -714,16 +687,14 @@
tone: 'danger', tone: 'danger',
run: releaseGallery run: releaseGallery
})} })}
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400" class="btn btn-primary btn-sm"
> >
Galerie freigeben Galerie freigeben
</button> </button>
</div> </div>
<!-- Export jobs --> <!-- Export jobs -->
<div <div class="card p-5">
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Export-Jobs</h3> <h3 class="font-semibold text-gray-900 dark:text-gray-100">Export-Jobs</h3>
<button <button
@@ -781,9 +752,7 @@
<!-- ── Nutzer tab ───────────────────────────────────────────────── --> <!-- ── Nutzer tab ───────────────────────────────────────────────── -->
{:else if activeTab === 'users'} {:else if activeTab === 'users'}
<div <div class="card overflow-hidden">
class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
>
<!-- Search --> <!-- Search -->
<div class="p-4"> <div class="p-4">
<div <div
@@ -824,21 +793,12 @@
>{user.display_name}</span >{user.display_name}</span
> >
{#if user.role === 'host'} {#if user.role === 'host'}
<span <span class="badge badge-primary">Host</span>
class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
>Host</span
>
{:else if user.role === 'admin'} {:else if user.role === 'admin'}
<span <span class="badge badge-gold">Admin</span>
class="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-200"
>Admin</span
>
{/if} {/if}
{#if user.is_banned} {#if user.is_banned}
<span <span class="badge badge-danger">Gesperrt</span>
class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/40 dark:text-red-200"
>Gesperrt</span
>
{/if} {/if}
</div> </div>
<p class="text-xs text-gray-400 dark:text-gray-500"> <p class="text-xs text-gray-400 dark:text-gray-500">

View File

@@ -58,7 +58,7 @@
placeholder="Passwort" placeholder="Passwort"
autocomplete="current-password" autocomplete="current-password"
data-testid="admin-password-input" data-testid="admin-password-input"
class="mb-3 w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-lg text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500" class="input mb-3 text-lg"
/> />
{#if error} {#if error}
@@ -71,7 +71,7 @@
type="submit" type="submit"
disabled={loading || !password} disabled={loading || !password}
data-testid="admin-login-submit" data-testid="admin-login-submit"
class="w-full rounded-lg bg-blue-600 px-4 py-3 text-lg font-medium text-white transition hover:bg-blue-700 disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400" class="btn btn-primary btn-lg btn-block"
> >
{loading ? 'Wird angemeldet…' : 'Anmelden'} {loading ? 'Wird angemeldet…' : 'Anmelden'}
</button> </button>

View File

@@ -437,12 +437,7 @@
Kopieren Kopieren
</button> </button>
</div> </div>
<button <button onclick={() => (pinModal = null)} class="btn btn-primary btn-block"> Schließen </button>
onclick={() => (pinModal = null)}
class="w-full rounded-lg bg-blue-600 py-2 text-sm font-semibold text-white hover:bg-blue-700 active:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-400"
>
Schließen
</button>
{/if} {/if}
</Modal> </Modal>
@@ -458,17 +453,10 @@
ansehen, Keepsake herunterladen) bleibt bestehen. Rückgängig machbar über „Entsperren“. ansehen, Keepsake herunterladen) bleibt bestehen. Rückgängig machbar über „Entsperren“.
</p> </p>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button onclick={() => (banTarget = null)} class="btn btn-secondary flex-1">
onclick={() => (banTarget = null)}
class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:active:bg-gray-800"
>
Abbrechen Abbrechen
</button> </button>
<button <button onclick={confirmBan} disabled={banSubmitting} class="btn btn-danger flex-1">
onclick={confirmBan}
disabled={banSubmitting}
class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 active:bg-red-700 disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-400 dark:active:bg-red-400"
>
{banSubmitting ? 'Wird gesperrt…' : 'Sperren'} {banSubmitting ? 'Wird gesperrt…' : 'Sperren'}
</button> </button>
</div> </div>
@@ -551,15 +539,13 @@
{/if} {/if}
<!-- ── Statistiken ─────────────────────────────────────────────── --> <!-- ── Statistiken ─────────────────────────────────────────────── -->
<div <div class="card overflow-hidden">
class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
>
<button <button
onclick={() => (statsOpen = !statsOpen)} onclick={() => (statsOpen = !statsOpen)}
aria-expanded={statsOpen} aria-expanded={statsOpen}
class="flex w-full items-center justify-between px-5 py-4" class="flex w-full items-center justify-between px-5 py-4"
> >
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Statistiken</h2> <h2 class="section-title">Statistiken</h2>
<svg <svg
class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {statsOpen class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {statsOpen
? 'rotate-180' ? 'rotate-180'
@@ -580,50 +566,50 @@
<div <div
class="grid grid-cols-2 gap-3 border-t border-gray-100 p-4 dark:border-gray-700 sm:grid-cols-4" class="grid grid-cols-2 gap-3 border-t border-gray-100 p-4 dark:border-gray-700 sm:grid-cols-4"
> >
<div class="rounded-xl bg-gray-50 p-4 text-center dark:bg-gray-900/60"> <div class="surface-muted p-4 text-center">
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{users.length}</p> <p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{users.length}</p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Teilnehmer</p> <p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Teilnehmer</p>
</div> </div>
<div class="rounded-xl bg-gray-50 p-4 text-center dark:bg-gray-900/60"> <div class="surface-muted p-4 text-center">
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100"> <p class="text-2xl font-bold text-gray-900 dark:text-gray-100">
{users.reduce((s, u) => s + u.upload_count, 0)} {users.reduce((s, u) => s + u.upload_count, 0)}
</p> </p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Uploads</p> <p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Uploads</p>
</div> </div>
<div class="rounded-xl bg-gray-50 p-4 text-center dark:bg-gray-900/60"> <div class="surface-muted flex flex-col items-center p-4 text-center">
<p <span
class="text-2xl font-bold {event.uploads_locked class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-semibold {event.uploads_locked
? 'text-red-600 dark:text-red-400' ? 'bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300'
: 'text-green-600 dark:text-green-400'}" : 'bg-green-100 text-green-700 dark:bg-green-950/50 dark:text-green-300'}"
> >
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
{event.uploads_locked ? 'Gesperrt' : 'Offen'} {event.uploads_locked ? 'Gesperrt' : 'Offen'}
</p> </span>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Uploads</p> <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">Uploads</p>
</div> </div>
<div class="rounded-xl bg-gray-50 p-4 text-center dark:bg-gray-900/60"> <div class="surface-muted flex flex-col items-center p-4 text-center">
<p <span
class="text-2xl font-bold {event.export_released class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-semibold {event.export_released
? 'text-blue-600 dark:text-blue-400' ? 'bg-purple-100 text-purple-800 dark:bg-purple-950/50 dark:text-purple-200'
: 'text-gray-400 dark:text-gray-500'}" : 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300'}"
> >
{event.export_released ? 'Ja' : 'Nein'} <span class="h-1.5 w-1.5 rounded-full bg-current"></span>
</p> {event.export_released ? 'Freigegeben' : 'Noch nicht'}
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Freigegeben</p> </span>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">Galerie</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- ── Event-Einstellungen ─────────────────────────────────────── --> <!-- ── Event-Einstellungen ─────────────────────────────────────── -->
<div <div class="card overflow-hidden">
class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
>
<button <button
onclick={() => (settingsOpen = !settingsOpen)} onclick={() => (settingsOpen = !settingsOpen)}
aria-expanded={settingsOpen} aria-expanded={settingsOpen}
class="flex w-full items-center justify-between px-5 py-4" class="flex w-full items-center justify-between px-5 py-4"
> >
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Event-Einstellungen</h2> <h2 class="section-title">Event-Einstellungen</h2>
<svg <svg
class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {settingsOpen class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {settingsOpen
? 'rotate-180' ? 'rotate-180'
@@ -644,10 +630,10 @@
<div class="flex flex-wrap gap-3 border-t border-gray-100 p-5 dark:border-gray-700"> <div class="flex flex-wrap gap-3 border-t border-gray-100 p-5 dark:border-gray-700">
<button <button
onclick={toggleEventLock} onclick={toggleEventLock}
class="rounded-lg px-4 py-2 text-sm font-medium transition class="rounded-lg border px-4 py-2 text-sm font-medium transition
{event.uploads_locked {event.uploads_locked
? 'bg-green-600 text-white hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-400' ? 'border-green-600 bg-green-600 text-white hover:bg-green-700 dark:border-green-500 dark:bg-green-500 dark:hover:bg-green-400'
: 'bg-amber-500 text-white hover:bg-amber-600 dark:bg-amber-500 dark:hover:bg-amber-400'}" : 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700'}"
> >
{event.uploads_locked ? 'Uploads wieder öffnen' : 'Uploads sperren'} {event.uploads_locked ? 'Uploads wieder öffnen' : 'Uploads sperren'}
</button> </button>
@@ -662,10 +648,7 @@
run: releaseGallery run: releaseGallery
})} })}
disabled={event.export_released} disabled={event.export_released}
class="rounded-lg px-4 py-2 text-sm font-medium transition class="btn btn-primary btn-sm"
{event.export_released
? 'cursor-default bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500'
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400'}"
> >
{event.export_released ? 'Galerie bereits freigegeben' : 'Galerie freigeben'} {event.export_released ? 'Galerie bereits freigegeben' : 'Galerie freigeben'}
</button> </button>
@@ -751,15 +734,13 @@
</div> </div>
<!-- ── Nutzerverwaltung ───────────────────────────────────────── --> <!-- ── Nutzerverwaltung ───────────────────────────────────────── -->
<div <div class="card overflow-hidden">
class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
>
<button <button
onclick={() => (usersOpen = !usersOpen)} onclick={() => (usersOpen = !usersOpen)}
aria-expanded={usersOpen} aria-expanded={usersOpen}
class="flex w-full items-center justify-between px-5 py-4" class="flex w-full items-center justify-between px-5 py-4"
> >
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Nutzerverwaltung</h2> <h2 class="section-title">Nutzerverwaltung</h2>
<svg <svg
class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {usersOpen class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {usersOpen
? 'rotate-180' ? 'rotate-180'
@@ -818,21 +799,12 @@
>{user.display_name}</span >{user.display_name}</span
> >
{#if user.role === 'host'} {#if user.role === 'host'}
<span <span class="badge badge-primary">Host</span>
class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
>Host</span
>
{:else if user.role === 'admin'} {:else if user.role === 'admin'}
<span <span class="badge badge-gold">Admin</span>
class="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-200"
>Admin</span
>
{/if} {/if}
{#if user.is_banned} {#if user.is_banned}
<span <span class="badge badge-danger">Gesperrt</span>
class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/40 dark:text-red-200"
>Gesperrt</span
>
{/if} {/if}
</div> </div>
<p class="text-xs text-gray-400 dark:text-gray-500"> <p class="text-xs text-gray-400 dark:text-gray-500">

View File

@@ -139,142 +139,184 @@
} }
</script> </script>
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950"> <div
<div class="w-full max-w-sm"> class="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-gradient-to-b from-primary-50 via-gray-50 to-gray-50 px-4 py-10 dark:from-gray-900 dark:via-gray-950 dark:to-gray-950"
{#if nameTaken} >
<!-- Name-taken state: sign in with PIN or choose a different name --> <!-- Soft celebratory glow behind the card — a champagne-gold and a silver wash
for the classic warm/cool wedding tension, kept subtle. -->
<div
aria-hidden="true"
class="pointer-events-none absolute -top-20 h-72 w-72 rounded-full bg-primary-200/30 blur-3xl dark:bg-primary-900/25"
></div>
<div
aria-hidden="true"
class="pointer-events-none absolute -bottom-24 right-0 h-72 w-72 rounded-full bg-gray-200/50 blur-3xl dark:bg-gray-800/40"
></div>
<div class="relative w-full max-w-sm">
<!-- Brand -->
<div class="mb-7 flex flex-col items-center text-center">
<div <div
class="mb-5 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800/60 dark:bg-amber-950/30" class="mb-3 flex h-16 w-16 items-center justify-center rounded-2xl bg-blue-600 text-white shadow-lg shadow-primary-600/30"
> >
<p class="font-semibold text-amber-900 dark:text-amber-200"> <svg class="h-8 w-8" viewBox="0 0 24 24" fill="none" aria-hidden="true">
{takenName}" ist bereits vergeben. <path
</p> d="M4 8.5A2.5 2.5 0 0 1 6.5 6h1.2c.5 0 .95-.28 1.17-.72l.42-.85A1.5 1.5 0 0 1 10.9 3.6h2.2c.57 0 1.09.32 1.34.83l.42.85c.22.44.67.72 1.17.72h1.2A2.5 2.5 0 0 1 20 8.5v7A2.5 2.5 0 0 1 17.5 18h-11A2.5 2.5 0 0 1 4 15.5v-7Z"
<p class="mt-1 text-sm text-amber-800 dark:text-amber-300/90"> stroke="currentColor"
Wähle einen anderen Namen, z.&nbsp;B. einen Spitznamen oder füge deinen Nachnamen hinzu („{takenName} stroke-width="1.6"
M." oder „{takenName} aus Berlin"). />
</p> <circle cx="12" cy="12" r="3.2" stroke="currentColor" stroke-width="1.6" />
</svg>
</div> </div>
<span
<p class="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300"> class="font-display text-2xl font-semibold tracking-tight text-gray-900 dark:text-gray-100"
Falls du das bist, melde dich mit deinem PIN an: >EventSnap</span
</p>
<form
onsubmit={(e) => {
e.preventDefault();
handleInlineRecover();
}}
> >
<input </div>
type="text"
value={recoveryPin}
oninput={onRecoveryPinInput}
placeholder="4-stelliger PIN"
maxlength={4}
inputmode="numeric"
pattern="[0-9]*"
data-testid="recovery-pin-input"
class="mb-3 w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-center text-2xl font-mono tracking-widest text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
/>
{#if recoveryError} <!-- Invitation card -->
<p class="mb-3 text-sm text-red-600 dark:text-red-400" data-testid="recovery-error"> <div class="card p-6 shadow-xl">
{recoveryError} {#if nameTaken}
<!-- Name-taken state: sign in with PIN or choose a different name -->
<div
class="mb-5 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800/60 dark:bg-amber-950/30"
>
<p class="font-semibold text-amber-900 dark:text-amber-200">
{takenName}" ist bereits vergeben.
</p> </p>
{/if} <p class="mt-1 text-sm text-amber-800 dark:text-amber-300/90">
Wähle einen anderen Namen, z.&nbsp;B. einen Spitznamen oder füge deinen Nachnamen hinzu
(„{takenName}
M." oder „{takenName} aus Berlin").
</p>
</div>
<p class="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300">
Falls du das bist, melde dich mit deinem PIN an:
</p>
<form
onsubmit={(e) => {
e.preventDefault();
handleInlineRecover();
}}
>
<input
type="text"
value={recoveryPin}
oninput={onRecoveryPinInput}
placeholder="4-stelliger PIN"
maxlength={4}
inputmode="numeric"
pattern="[0-9]*"
data-testid="recovery-pin-input"
class="input mb-3 text-center font-mono text-2xl tracking-widest"
/>
{#if recoveryError}
<p class="mb-3 text-sm text-red-600 dark:text-red-400" data-testid="recovery-error">
{recoveryError}
</p>
{/if}
<button
type="submit"
disabled={recoveryLoading || recoveryPin.length < 4}
data-testid="recovery-submit"
class="btn btn-primary btn-block mb-3"
>
{recoveryLoading ? 'Wird angemeldet...' : 'Anmelden'}
</button>
</form>
<button <button
type="submit" onclick={tryDifferentName}
disabled={recoveryLoading || recoveryPin.length < 4} data-testid="try-different-name"
data-testid="recovery-submit" class="btn btn-secondary btn-block"
class="mb-3 w-full rounded-lg bg-blue-600 px-4 py-3 font-medium text-white transition hover:bg-blue-700 disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400"
> >
{recoveryLoading ? 'Wird angemeldet...' : 'Anmelden'} Anderen Namen wählen
</button> </button>
</form>
<button <!-- Forgot the PIN entirely — ask a host to reset it in-app. -->
onclick={tryDifferentName} {#if pinRequestSent}
data-testid="try-different-name" <p
class="w-full rounded-lg border border-gray-300 px-4 py-3 font-medium text-gray-700 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800" class="mt-3 rounded-lg bg-green-50 px-4 py-3 text-center text-sm text-green-700 dark:bg-green-950/30 dark:text-green-300"
> >
Anderen Namen wählen Anfrage gesendet. Bitte einen Host, deine PIN zurückzusetzen — danach kannst du dich mit
</button> der neuen PIN anmelden.
</p>
<!-- Forgot the PIN entirely — ask a host to reset it in-app. --> {:else}
{#if pinRequestSent} <button
<p onclick={requestPinReset}
class="mt-3 rounded-lg bg-green-50 px-4 py-3 text-center text-sm text-green-700 dark:bg-green-950/30 dark:text-green-300" disabled={pinRequestLoading}
> data-testid="request-pin-reset"
Anfrage gesendet. Bitte einen Host, deine PIN zurückzusetzen — danach kannst du dich mit class="mt-3 w-full text-center text-sm text-blue-600 underline decoration-dotted underline-offset-2 hover:text-blue-700 disabled:opacity-50 dark:text-blue-400"
der neuen PIN anmelden. >
</p> {pinRequestLoading ? 'Wird gesendet…' : 'PIN vergessen? Host um Zurücksetzen bitten'}
</button>
{/if}
{:else} {:else}
<button <!-- Normal join form -->
onclick={requestPinReset} <p class="mb-1 text-center text-sm font-medium text-gray-500 dark:text-gray-400">
disabled={pinRequestLoading} Willkommen bei
data-testid="request-pin-reset" </p>
class="mt-3 w-full text-center text-sm text-blue-600 underline decoration-dotted underline-offset-2 hover:text-blue-700 disabled:opacity-50 dark:text-blue-400" {#if eventName}
<h1
class="mb-3 text-center text-3xl font-semibold text-gray-900 dark:text-gray-100"
data-testid="join-event-name"
>
{eventName}
</h1>
{:else}
<h1 class="mb-3 text-center text-3xl font-semibold text-gray-900 dark:text-gray-100">
dem Event
</h1>
{/if}
<p class="mb-6 text-center text-gray-600 dark:text-gray-400">
Gib deinen Namen ein, um Fotos zu teilen und die Galerie zu sehen.
</p>
<form
onsubmit={(e) => {
e.preventDefault();
handleJoin();
}}
> >
{pinRequestLoading ? 'Wird gesendet…' : 'PIN vergessen? Host um Zurücksetzen bitten'} <input
</button> type="text"
{/if} bind:value={displayName}
{:else} placeholder="Dein Name"
<!-- Normal join form --> maxlength={50}
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900 dark:text-gray-100"> data-testid="join-name-input"
Willkommen! class="input mb-3 text-lg"
</h1> />
{#if eventName}
<p {#if error}
class="mb-1 text-center text-lg font-semibold text-blue-600 dark:text-blue-400" <p class="mb-3 text-sm text-red-600 dark:text-red-400" data-testid="join-error">
data-testid="join-event-name" {error}
> </p>
{eventName} {/if}
<button
type="submit"
disabled={loading || !displayName.trim()}
data-testid="join-submit"
class="btn btn-primary btn-lg btn-block"
>
{loading ? 'Wird geladen...' : 'Beitreten'}
</button>
</form>
<p class="mt-4 text-center text-sm">
<a
href="/recover"
data-testid="link-to-recover"
class="text-blue-600 hover:underline dark:text-blue-400"
>Ich habe bereits einen Account</a
>
</p> </p>
{/if} {/if}
<p class="mb-6 text-center text-gray-600 dark:text-gray-400"> </div>
Gib deinen Namen ein, um dem Event beizutreten.
</p>
<form
onsubmit={(e) => {
e.preventDefault();
handleJoin();
}}
>
<input
type="text"
bind:value={displayName}
placeholder="Dein Name"
maxlength={50}
data-testid="join-name-input"
class="mb-3 w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-lg text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500"
/>
{#if error}
<p class="mb-3 text-sm text-red-600 dark:text-red-400" data-testid="join-error">
{error}
</p>
{/if}
<button
type="submit"
disabled={loading || !displayName.trim()}
data-testid="join-submit"
class="w-full rounded-lg bg-blue-600 px-4 py-3 text-lg font-medium text-white transition hover:bg-blue-700 disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400"
>
{loading ? 'Wird geladen...' : 'Beitreten'}
</button>
</form>
<p class="mt-4 text-center text-sm">
<a
href="/recover"
data-testid="link-to-recover"
class="text-blue-600 hover:underline dark:text-blue-400">Ich habe bereits einen Account</a
>
</p>
{/if}
</div> </div>
</div> </div>
@@ -284,7 +326,7 @@
data-testid="pin-modal" data-testid="pin-modal"
> >
<div <div
class="w-full max-w-sm rounded-xl bg-white p-6 shadow-lg dark:bg-gray-900" class="card w-full max-w-sm p-6 shadow-xl"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="pin-modal-title" aria-labelledby="pin-modal-title"
@@ -298,18 +340,12 @@
wiederherzustellen. wiederherzustellen.
</p> </p>
<div <div class="surface-muted mb-4 flex items-center justify-center gap-3 p-4">
class="mb-4 flex items-center justify-center gap-3 rounded-lg bg-gray-100 p-4 dark:bg-gray-800"
>
<span <span
class="text-4xl font-mono font-bold tracking-widest text-gray-900 dark:text-gray-100" class="font-mono text-4xl font-bold tracking-widest text-gray-900 dark:text-gray-100"
data-testid="pin-display">{pin}</span data-testid="pin-display">{pin}</span
> >
<button <button onclick={copyPin} data-testid="pin-copy" class="btn btn-secondary btn-sm min-h-11">
onclick={copyPin}
data-testid="pin-copy"
class="min-h-11 min-w-11 rounded-md bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300 active:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 dark:active:bg-gray-600"
>
{copied ? 'Kopiert!' : 'Kopieren'} {copied ? 'Kopiert!' : 'Kopieren'}
</button> </button>
</div> </div>
@@ -317,14 +353,14 @@
<button <button
onclick={goToFeed} onclick={goToFeed}
data-testid="continue-to-feed" data-testid="continue-to-feed"
class="mb-2 w-full rounded-lg bg-blue-600 px-4 py-3 font-medium text-white transition hover:bg-blue-700 active:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-400" class="btn btn-primary btn-block mb-2"
> >
Weiter zur Galerie Weiter zur Galerie
</button> </button>
<button <button
type="button" type="button"
onclick={closePinModal} onclick={closePinModal}
class="w-full rounded-lg py-2 text-sm text-gray-500 hover:text-gray-700 active:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 dark:active:text-gray-200" class="btn btn-ghost btn-block text-sm text-gray-500 dark:text-gray-400"
> >
Schließen Schließen
</button> </button>

View File

@@ -132,7 +132,7 @@
placeholder="Dein Name" placeholder="Dein Name"
maxlength={50} maxlength={50}
data-testid="recover-name-input" data-testid="recover-name-input"
class="mb-3 w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-lg text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500" class="input mb-3 text-lg"
/> />
<input <input
type="text" type="text"
@@ -143,7 +143,7 @@
inputmode="numeric" inputmode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
data-testid="recover-pin-input" data-testid="recover-pin-input"
class="mb-3 w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-center text-2xl font-mono tracking-widest text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500" class="input mb-3 text-center text-2xl font-mono tracking-widest"
/> />
{#if error} {#if error}
@@ -156,7 +156,7 @@
type="submit" type="submit"
disabled={loading || !displayName.trim() || pin.length < 4} disabled={loading || !displayName.trim() || pin.length < 4}
data-testid="recover-submit" data-testid="recover-submit"
class="w-full rounded-lg bg-blue-600 px-4 py-3 text-lg font-medium text-white transition hover:bg-blue-700 disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400" class="btn btn-primary btn-lg btn-block"
> >
{loading ? 'Wird geladen...' : 'Wiederherstellen'} {loading ? 'Wird geladen...' : 'Wiederherstellen'}
</button> </button>

View File

@@ -151,8 +151,7 @@
onclick={handleSubmit} onclick={handleSubmit}
disabled={stagedFiles.length === 0 || submitting} disabled={stagedFiles.length === 0 || submitting}
data-testid="upload-submit-header" data-testid="upload-submit-header"
class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm font-semibold text-white transition class="btn btn-primary btn-sm"
hover:bg-blue-700 disabled:opacity-40 dark:bg-blue-500 dark:hover:bg-blue-400"
> >
{submitting ? 'Wird hochgeladen…' : 'Hochladen'} {submitting ? 'Wird hochgeladen…' : 'Hochladen'}
</button> </button>
@@ -199,31 +198,30 @@
{:else} {:else}
<!-- No files: prompt to go back and pick some --> <!-- 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"> <div class="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
<svg <div
class="h-16 w-16 text-gray-200 dark:text-gray-700" class="flex h-20 w-20 items-center justify-center rounded-2xl bg-primary-50 dark:bg-primary-900/20"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1"
> >
<path <svg
stroke-linecap="round" class="h-9 w-9 text-primary-500 dark:text-primary-500"
stroke-linejoin="round" fill="none"
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" viewBox="0 0 24 24"
/> stroke="currentColor"
</svg> stroke-width="1.5"
>
<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>
<div> <div>
<p class="font-medium text-gray-500 dark:text-gray-400">Keine Dateien ausgewählt</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"> <p class="mt-1 text-sm text-gray-400 dark:text-gray-500">
Geh zurück und tippe auf den Plus-Button. Geh zurück und tippe auf den Plus-Button.
</p> </p>
</div> </div>
<button <button onclick={cancel} class="btn btn-secondary btn-sm"> Zurück </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 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
Zurück
</button>
</div> </div>
{/if} {/if}
@@ -236,9 +234,7 @@
data-testid="upload-caption" data-testid="upload-caption"
placeholder="Beschreibung hinzufügen… (#hashtags möglich)" placeholder="Beschreibung hinzufügen… (#hashtags möglich)"
rows="4" 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 class="input resize-none text-sm"
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> ></textarea>
<div class="mt-1 text-xs text-gray-500 text-right dark:text-gray-400"> <div class="mt-1 text-xs text-gray-500 text-right dark:text-gray-400">
{caption.length} / {MAX_CAPTION_LENGTH} {caption.length} / {MAX_CAPTION_LENGTH}
@@ -316,8 +312,7 @@
onclick={handleSubmit} onclick={handleSubmit}
disabled={stagedFiles.length === 0 || submitting} disabled={stagedFiles.length === 0 || submitting}
data-testid="upload-submit" data-testid="upload-submit"
class="flex w-full items-center justify-center gap-2 rounded-xl bg-blue-600 py-3.5 text-sm font-semibold class="btn btn-primary btn-block"
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} {#if submitting}
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"> <svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">

View File

@@ -5,6 +5,21 @@
* *
* Tailwind v4 reads `@theme` blocks to populate utility classes; everything declared * Tailwind v4 reads `@theme` blocks to populate utility classes; everything declared
* here becomes a `bg-primary`, `text-accent`, `rounded-card`, etc. * here becomes a `bg-primary`, `text-accent`, `rounded-card`, etc.
*
* ── DESIGN LANGUAGE: "Warm & celebratory" ──────────────────────────────────
* The whole product exists to relive an event together, so the palette is a
* wedding-invitation one: warm ivory paper, warm-charcoal ink, a dusty-rose
* brand and champagne-gold accent. Headings are set in Fraunces (an elegant
* display serif); body copy in Inter. Both are self-hosted (see @font-face
* below) because the CSP is `font-src: 'self'` and the keepsake runs offline.
*
* The bulk of the app was authored in raw Tailwind classes (`bg-gray-900`,
* `text-blue-600`, …). Rather than touch 900+ class usages, we REMAP the
* underlying Tailwind ramps here so the warmth cascades everywhere:
* gray → warm "sand" neutrals
* blue → dusty-rose brand
* purple → champagne gold (hashtag chips / accents)
* Semantic red / green / amber keep their meaning and stay close to default.
*/ */
@import 'tailwindcss'; @import 'tailwindcss';
@@ -15,32 +30,108 @@
* `:where(...)` keeps the specificity low so existing utilities still override. */ * `:where(...)` keeps the specificity low so existing utilities still override. */
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:where(.dark, .dark *));
/* ── Self-hosted fonts (variable woff2, latin subset covers German umlauts) ── */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/Inter.woff2') format('woff2');
}
@font-face {
font-family: 'Fraunces';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/Fraunces.woff2') format('woff2');
}
@theme { @theme {
/* Brand palette — the blue used for primary buttons, FAB, active tabs. */ /* ── Type ─────────────────────────────────────────────────────────────── */
--color-primary-50: #eff6ff; --font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--color-primary-100: #dbeafe; --font-display: 'Fraunces', ui-serif, Georgia, 'Times New Roman', serif;
--color-primary-500: #3b82f6;
--color-primary-600: #2563eb;
--color-primary-700: #1d4ed8;
/* Accent for hashtag chips and highlights. */
--color-accent-500: #a855f7;
--color-accent-600: #9333ea;
/* Surface scale matches the existing gray-* usage. Listed here so the viewer
* picks up the same shade in case Tailwind defaults ever drift. */
--color-surface-0: #ffffff;
--color-surface-50: #f9fafb;
--color-surface-100: #f3f4f6;
--color-surface-200: #e5e7eb;
/* Typography. Keepsake should feel like the live app — same defaults. */
--font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, 'SF Mono', 'Courier New', monospace; --font-mono: ui-monospace, SFMono-Regular, 'SF Mono', 'Courier New', monospace;
/* Radii — keep cards and bottom sheets consistent. */ /* ── Brand: champagne / antique gold — the "signal" colour (remaps `blue-*`)
--radius-card: 0.75rem; /* rounded-card */ * Wedding research: treat gold as the signal (brand, primary CTA, active
--radius-sheet: 1.25rem; /* rounded-sheet */ * state, key highlights); let silver/greys carry the system. `600` is a deep
* antique gold so white button text clears WCAG AA. */
--color-blue-50: #faf6ea;
--color-blue-100: #f3ead0;
--color-blue-200: #e8d6a3;
--color-blue-300: #d8bd73;
--color-blue-400: #c6a24a;
--color-blue-500: #ab8433;
--color-blue-600: #8a6a2b; /* primary buttons / FAB / active tab — white text passes AA */
--color-blue-700: #6f5523;
--color-blue-800: #59441e;
--color-blue-900: #493819;
--color-blue-950: #29200d;
/* Semantic aliases so new work can use `bg-primary` intentionally. Full ramp
* (mirrors the gold `blue-*` values) so `@apply` in the component layer can
* reference any shade — a partial ramp hard-fails `@apply bg-primary-900`. */
--color-primary-50: #faf6ea;
--color-primary-100: #f3ead0;
--color-primary-200: #e8d6a3;
--color-primary-300: #d8bd73;
--color-primary-400: #c6a24a;
--color-primary-500: #ab8433;
--color-primary-600: #8a6a2b;
--color-primary-700: #6f5523;
--color-primary-800: #59441e;
--color-primary-900: #493819;
--color-primary-950: #29200d;
/* ── Accent: same champagne gold (remaps `purple-*` / `violet-*`) so hashtag
* chips + badges share the one signal colour rather than a competing hue. */
--color-purple-50: #faf6ea;
--color-purple-100: #f3ead0;
--color-purple-200: #e8d6a3;
--color-purple-300: #d8bd73;
--color-purple-400: #c6a24a;
--color-purple-500: #ab8433;
--color-purple-600: #8a6a2b;
--color-purple-700: #6f5523;
--color-purple-800: #59441e;
--color-purple-900: #493819;
--color-violet-500: #ab8433;
--color-violet-600: #8a6a2b;
--color-accent-500: #ab8433;
--color-accent-600: #8a6a2b;
/* ── Neutrals: pearl → silver → graphite (remaps `gray-*`). Whisper-warm
* pearl at the light end (research: "warm pearl, not stark white"), turning
* neutral-cool through the mids/darks so structure reads as silver, not sand. */
--color-gray-50: #faf9f7; /* soft pearl — light page background */
--color-gray-100: #f2f1ef; /* light silver — muted surfaces */
--color-gray-200: #e5e4e1; /* silver borders / dividers */
--color-gray-300: #d1cfcc;
--color-gray-400: #a6a4a1; /* silver — placeholder / disabled icon */
--color-gray-500: #767471; /* secondary text (AA on pearl) */
--color-gray-600: #545350;
--color-gray-700: #3b3a38; /* graphite — dark-mode borders */
--color-gray-800: #262524; /* dark-mode cards */
--color-gray-900: #1a1918; /* ink (light) / dark surface */
--color-gray-950: #100f0f; /* darkest — dark-mode page background */
/* Surface aliases (kept from the original tokens for the export viewer). */
--color-surface-0: #ffffff;
--color-surface-50: #faf9f7;
--color-surface-100: #f2f1ef;
--color-surface-200: #e5e4e1;
/* ── Radii — softer, keepsake-y corners ───────────────────────────────── */
--radius-card: 1rem; /* rounded-card */
--radius-sheet: 1.5rem; /* rounded-sheet */
/* ── Elevation — soft, cool-neutral shadows (remaps Tailwind shadow ramp) ─ */
--shadow-2xs: 0 1px 2px 0 rgba(28, 28, 35, 0.05);
--shadow-xs: 0 1px 3px 0 rgba(28, 28, 35, 0.06);
--shadow-sm: 0 2px 6px -1px rgba(28, 28, 35, 0.07), 0 1px 2px -1px rgba(28, 28, 35, 0.05);
--shadow-md: 0 6px 16px -4px rgba(28, 28, 35, 0.09), 0 2px 6px -2px rgba(28, 28, 35, 0.06);
--shadow-lg: 0 12px 28px -6px rgba(28, 28, 35, 0.1), 0 4px 10px -4px rgba(28, 28, 35, 0.07);
--shadow-xl: 0 24px 48px -12px rgba(28, 28, 35, 0.16), 0 8px 16px -8px rgba(28, 28, 35, 0.09);
} }
/* Baseline body background + text colour so pages that haven't been re-themed yet /* Baseline body background + text colour so pages that haven't been re-themed yet
@@ -48,13 +139,26 @@
* their own backgrounds via `bg-*` utilities, but this catches any gaps. */ * their own backgrounds via `bg-*` utilities, but this catches any gaps. */
@layer base { @layer base {
html { html {
background-color: #f9fafb; /* matches bg-gray-50 */ background-color: #faf9f7; /* soft pearl (bg-gray-50) */
color: #111827; /* matches text-gray-900 */ color: #1a1918; /* ink (text-gray-900) */
color-scheme: light; color-scheme: light;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
} }
html.dark { html.dark {
background-color: #030712; /* matches bg-gray-950 */ background-color: #100f0f; /* graphite near-black (bg-gray-950) */
color: #f3f4f6; /* matches text-gray-100 */ color: #f2f1ef; /* light silver (text-gray-100) */
color-scheme: dark; color-scheme: dark;
} }
/* Headings wear the display serif by default. Individual headings can still
* opt out with `font-sans`; the brand wordmark opts in via `.font-display`. */
h1,
h2,
h3 {
font-family: var(--font-display);
font-weight: 600;
letter-spacing: -0.015em;
}
} }

Binary file not shown.

Binary file not shown.