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

@@ -34,16 +34,68 @@
created_at: string;
}
const CONFIG_LABELS: Record<string, string> = {
max_image_size_mb: 'Max. Bildgröße (MB)',
max_video_size_mb: 'Max. Videogröße (MB)',
upload_rate_per_hour: 'Upload-Limit pro Stunde',
feed_rate_per_min: 'Feed-Anfragen pro Minute',
export_rate_per_day: 'Export-Downloads pro Tag',
quota_tolerance: 'Speicherkontingent-Toleranz (01)',
estimated_guest_count: 'Geschätzte Gästezahl',
compression_concurrency: 'Kompressions-Worker'
};
type ConfigKind = 'number' | 'bool' | 'text';
interface ConfigField {
key: string;
label: string;
kind: ConfigKind;
hint?: string;
}
interface ConfigGroup {
title: string;
fields: ConfigField[];
}
// Grouped sections — adding a new key is one entry in the right group, no other
// code changes required. The form renders each field based on `kind`.
const CONFIG_GROUPS: ConfigGroup[] = [
{
title: 'Limits & Größen',
fields: [
{ key: 'max_image_size_mb', label: 'Max. Bildgröße (MB)', kind: 'number' },
{ key: 'max_video_size_mb', label: 'Max. Videogröße (MB)', kind: 'number' },
{ key: 'compression_concurrency', label: 'Kompressions-Worker', kind: 'number' }
]
},
{
title: 'Rate-Limits',
fields: [
{ key: 'rate_limits_enabled', label: 'Rate-Limits aktiv', kind: 'bool', hint: 'Hauptschalter — wenn aus, sind alle Rate-Limits deaktiviert.' },
{ key: 'upload_rate_enabled', label: 'Upload-Limit aktiv', kind: 'bool' },
{ key: 'feed_rate_enabled', label: 'Feed-Limit aktiv', kind: 'bool' },
{ key: 'export_rate_enabled', label: 'Export-Limit aktiv', kind: 'bool' },
{ key: 'join_rate_enabled', label: 'Join-Limit aktiv', kind: 'bool' },
{ key: 'upload_rate_per_hour', label: 'Upload-Limit pro Stunde', kind: 'number' },
{ key: 'feed_rate_per_min', label: 'Feed-Anfragen pro Minute', kind: 'number' },
{ key: 'export_rate_per_day', label: 'Export-Downloads pro Tag', kind: 'number' }
]
},
{
title: 'Quoten',
fields: [
{ key: 'quota_enabled', label: 'Quoten aktiv', kind: 'bool', hint: 'Hauptschalter — wenn aus, wird nichts geprüft.' },
{ key: 'storage_quota_enabled', label: 'Speicher-Quote aktiv', kind: 'bool' },
{ key: 'upload_count_quota_enabled', label: 'Upload-Anzahl-Quote aktiv', kind: 'bool', hint: 'Reserviert für künftige Anzahl-Limits.' },
{ key: 'quota_tolerance', label: 'Toleranz (01)', kind: 'number' },
{ key: 'estimated_guest_count', label: 'Geschätzte Gästezahl', kind: 'number' }
]
},
{
title: 'Datenschutzhinweis',
fields: [
{ key: 'privacy_note', label: 'Datenschutzhinweis (freier Text)', kind: 'text', hint: 'Wird wörtlich im Konto-Bereich angezeigt. Kein HTML — Leerzeichen und Zeilenumbrüche werden übernommen.' }
]
}
];
function isTrue(v: string | undefined): boolean {
if (!v) return false;
return ['true', '1', 'yes', 'on'].includes(v.trim().toLowerCase());
}
function toggleBool(key: string) {
configDraft = { ...configDraft, [key]: isTrue(configDraft[key]) ? 'false' : 'true' };
}
type AdminTab = 'stats' | 'config' | 'export' | 'users';
const TAB_LABELS: Record<AdminTab, string> = { stats: 'Stats', config: 'Config', export: 'Export', users: 'Nutzer' };
@@ -74,6 +126,12 @@
let banHideUploads = $state(false);
let banSubmitting = $state(false);
// PIN reset state — `pinModal` holds the freshly-issued plaintext PIN. We forget it
// the moment the modal closes.
let pinResetTarget = $state<UserSummary | null>(null);
let pinResetSubmitting = $state(false);
let pinModal = $state<{ name: string; pin: string } | null>(null);
const myRole = getRole();
onMount(async () => {
@@ -200,6 +258,39 @@
}
}
function askResetPin(user: UserSummary) {
pinResetTarget = user;
}
async function confirmResetPin() {
if (!pinResetTarget) return;
pinResetSubmitting = true;
try {
const res = await api.post<{ pin: string }>(`/host/users/${pinResetTarget.id}/pin-reset`);
pinModal = { name: pinResetTarget.display_name, pin: res.pin };
pinResetTarget = null;
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler beim Zurücksetzen.');
} finally {
pinResetSubmitting = false;
}
}
function copyPinModal() {
if (!pinModal) return;
navigator.clipboard.writeText(pinModal.pin);
showToast('PIN kopiert.');
}
/** True iff the current caller may reset this target's PIN. Mirrors the backend
* rules in `handlers::host::reset_user_pin`. */
function canResetPinFor(target: UserSummary): boolean {
if (target.role === 'admin') return false;
if (myRole === 'admin') return true; // any non-admin
if (myRole === 'host') return target.role === 'guest';
return false;
}
function formatBytes(bytes: number): string {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
@@ -217,10 +308,10 @@
function statusBadgeClass(status: string): string {
switch (status) {
case 'done': return 'bg-green-100 text-green-700';
case 'running': return 'bg-blue-100 text-blue-700';
case 'failed': return 'bg-red-100 text-red-700';
default: return 'bg-gray-100 text-gray-600';
case 'done': return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-200';
case 'running': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200';
case 'failed': return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200';
default: return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
}
}
@@ -235,21 +326,63 @@
}
</script>
<!-- PIN reset confirmation -->
{#if pinResetTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
<h2 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">PIN zurücksetzen</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Eine neue PIN für <strong>{pinResetTarget.display_name}</strong> wird erzeugt. Die alte PIN funktioniert dann nicht mehr.
</p>
<div class="flex gap-2">
<button onclick={() => (pinResetTarget = null)} class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">Abbrechen</button>
<button onclick={confirmResetPin} disabled={pinResetSubmitting} class="flex-1 rounded-lg bg-amber-500 py-2 text-sm font-medium text-white hover:bg-amber-600 disabled:opacity-50 dark:bg-amber-500 dark:hover:bg-amber-400">
{pinResetSubmitting ? 'Wird erzeugt…' : 'Neue PIN erzeugen'}
</button>
</div>
</div>
</div>
{/if}
<!-- One-time PIN display modal -->
{#if pinModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
<h2 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Neue PIN für {pinModal.name}</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Zeige diese PIN dem Benutzer. Sie wird nur einmal angezeigt — beim Schließen wird sie verworfen.
</p>
<div class="mb-4 flex items-center justify-between rounded-lg bg-amber-50 px-4 py-3 dark:bg-amber-950/30">
<span class="font-mono text-3xl font-bold tracking-widest text-gray-900 dark:text-gray-100">{pinModal.pin}</span>
<button onclick={copyPinModal} class="rounded-md bg-amber-100 px-3 py-1.5 text-sm font-medium text-amber-800 hover:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60">
Kopieren
</button>
</div>
<button
onclick={() => (pinModal = null)}
class="w-full rounded-lg bg-blue-600 py-2 text-sm font-semibold text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400"
>
Schließen
</button>
</div>
</div>
{/if}
<!-- Ban modal -->
{#if banTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl">
<h2 class="mb-1 text-lg font-bold text-gray-900">Benutzer sperren</h2>
<p class="mb-4 text-sm text-gray-600">
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
<h2 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Benutzer sperren</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Was soll mit den Uploads von <strong>{banTarget.display_name}</strong> passieren?
</p>
<label class="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3">
<input type="checkbox" bind:checked={banHideUploads} class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500" />
<span class="text-sm text-gray-700">Uploads aus der Galerie ausblenden</span>
<label class="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<input type="checkbox" bind:checked={banHideUploads} class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600" />
<span class="text-sm text-gray-700 dark:text-gray-300">Uploads aus der Galerie ausblenden</span>
</label>
<div class="flex gap-2">
<button onclick={() => (banTarget = null)} class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50">Abbrechen</button>
<button onclick={confirmBan} disabled={banSubmitting} class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50">
<button onclick={() => (banTarget = null)} class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">Abbrechen</button>
<button onclick={confirmBan} disabled={banSubmitting} class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-400">
{banSubmitting ? 'Wird gesperrt…' : 'Sperren'}
</button>
</div>
@@ -259,36 +392,36 @@
<!-- Toast -->
{#if toast}
<div class="fixed bottom-24 left-1/2 z-50 -translate-x-1/2 rounded-full bg-gray-900 px-5 py-2.5 text-sm text-white shadow-lg">
<div class="fixed bottom-24 left-1/2 z-50 -translate-x-1/2 rounded-full bg-gray-900 px-5 py-2.5 text-sm text-white shadow-lg dark:bg-gray-100 dark:text-gray-900">
{toast}
</div>
{/if}
<div class="min-h-screen bg-gray-50 pb-24">
<div class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950">
<!-- Header -->
<div class="border-b border-gray-200 bg-white">
<div class="border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
<div class="mx-auto flex max-w-3xl items-center gap-3 px-4 py-4">
<button
onclick={() => goto('/account')}
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-gray-500 transition hover:bg-gray-100"
class="flex h-9 w-9 shrink-0 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="Zurück"
>
<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="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
</button>
<h1 class="text-xl font-bold text-gray-900">Admin-Dashboard</h1>
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Admin-Dashboard</h1>
</div>
</div>
<!-- Inner tab bar -->
<div class="sticky top-0 z-20 overflow-x-auto border-b border-gray-200 bg-white">
<div class="sticky top-0 z-20 overflow-x-auto border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
<div class="mx-auto flex max-w-3xl min-w-max">
{#each Object.entries(TAB_LABELS) as [tab, label]}
<button
onclick={() => (activeTab = tab as AdminTab)}
class="px-5 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors
{activeTab === tab ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}"
{activeTab === tab ? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}"
>
{label}
</button>
@@ -298,7 +431,7 @@
<div class="mx-auto max-w-3xl p-4">
{#if loading}
<div class="py-16 text-center text-gray-400">Laden…</div>
<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
{:else if error}
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-700">{error}</div>
{:else}
@@ -308,63 +441,103 @@
<div class="space-y-3">
{#if stats}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center">
<p class="text-3xl font-bold text-gray-900">{stats.user_count}</p>
<p class="mt-0.5 text-xs text-gray-500">Gäste</p>
<div 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">{stats.user_count}</p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Gäste</p>
</div>
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center">
<p class="text-3xl font-bold text-gray-900">{stats.upload_count}</p>
<p class="mt-0.5 text-xs text-gray-500">Uploads</p>
<div 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">{stats.upload_count}</p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Uploads</p>
</div>
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center">
<p class="text-3xl font-bold text-gray-900">{stats.comment_count}</p>
<p class="mt-0.5 text-xs text-gray-500">Kommentare</p>
<div 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">{stats.comment_count}</p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Kommentare</p>
</div>
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center">
<p class="text-3xl font-bold text-gray-900">{diskPct(stats)} %</p>
<p class="mt-0.5 text-xs text-gray-500">Speicher</p>
<div 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">{diskPct(stats)} %</p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Speicher</p>
</div>
</div>
<!-- Disk bar -->
<div class="rounded-xl border border-gray-200 bg-white p-5">
<div class="mb-1 flex items-center justify-between text-xs text-gray-500">
<div class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>Speicherauslastung</span>
<span>{formatBytes(stats.disk_used_bytes)} / {formatBytes(stats.disk_total_bytes)}</span>
</div>
<div class="h-2.5 overflow-hidden rounded-full bg-gray-200">
<div class="h-2.5 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full rounded-full transition-all {diskPct(stats) >= 90 ? 'bg-red-500' : diskPct(stats) >= 75 ? 'bg-amber-500' : 'bg-blue-500'}"
style="width: {diskPct(stats)}%"
></div>
</div>
<p class="mt-1.5 text-xs text-gray-400">{formatBytes(stats.disk_free_bytes)} frei</p>
<p class="mt-1.5 text-xs text-gray-400 dark:text-gray-500">{formatBytes(stats.disk_free_bytes)} frei</p>
</div>
{/if}
</div>
<!-- ── Config tab ───────────────────────────────────────────────── -->
{:else if activeTab === 'config'}
<div class="relative">
<div class="space-y-3 rounded-xl border border-gray-200 bg-white p-5 pb-20">
{#each Object.entries(CONFIG_LABELS) as [key, label]}
<div>
<label for={key} class="mb-1 block text-sm font-medium text-gray-700">{label}</label>
<input
id={key}
type="number"
step="any"
bind:value={configDraft[key]}
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-200"
/>
<div class="relative space-y-3 pb-20">
{#each CONFIG_GROUPS as group (group.title)}
<div 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">
<h3 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{group.title}</h3>
</div>
{/each}
</div>
<div class="space-y-4 px-5 py-4">
{#each group.fields as field (field.key)}
<div>
{#if field.kind === 'bool'}
<label class="flex cursor-pointer items-start gap-3" for={field.key}>
<input
id={field.key}
type="checkbox"
class="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
checked={isTrue(configDraft[field.key])}
onchange={() => toggleBool(field.key)}
/>
<div class="min-w-0">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{field.label}</span>
{#if field.hint}
<p class="text-xs text-gray-500 dark:text-gray-400">{field.hint}</p>
{/if}
</div>
</label>
{:else if field.kind === 'text'}
<label for={field.key} class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{field.label}</label>
<textarea
id={field.key}
rows="6"
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"
></textarea>
{#if field.hint}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{field.hint}</p>
{/if}
{:else}
<label for={field.key} class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{field.label}</label>
<input
id={field.key}
type="number"
step="any"
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"
/>
{#if field.hint}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{field.hint}</p>
{/if}
{/if}
</div>
{/each}
</div>
</div>
{/each}
<!-- Sticky save button -->
<div class="sticky bottom-0 border-t border-gray-100 bg-white px-5 py-3">
<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">
<button
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"
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'}
</button>
@@ -375,52 +548,52 @@
{:else if activeTab === 'export'}
<div class="space-y-3">
<!-- Gallery release -->
<div class="rounded-xl border border-gray-200 bg-white p-5">
<h3 class="mb-3 font-semibold text-gray-900">Galerie</h3>
<div 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>
<button
onclick={releaseGallery}
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700"
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"
>
Galerie freigeben
</button>
</div>
<!-- Export jobs -->
<div class="rounded-xl border border-gray-200 bg-white p-5">
<div 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">
<h3 class="font-semibold text-gray-900">Export-Jobs</h3>
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Export-Jobs</h3>
<button
onclick={refreshExportJobs}
disabled={exportJobsRefreshing}
class="text-xs text-blue-600 hover:underline disabled:opacity-50"
class="text-xs text-blue-600 hover:underline disabled:opacity-50 dark:text-blue-400"
>
{exportJobsRefreshing ? 'Lädt…' : 'Aktualisieren'}
</button>
</div>
{#if exportJobs.length === 0}
<p class="text-sm text-gray-400">Noch keine Export-Jobs.</p>
<p class="text-sm text-gray-400 dark:text-gray-500">Noch keine Export-Jobs.</p>
{:else}
<div class="space-y-3">
{#each exportJobs as job}
<div class="rounded-lg border border-gray-100 p-3">
<div class="rounded-lg border border-gray-100 p-3 dark:border-gray-700">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-900">{jobLabel(job.type)}</span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{jobLabel(job.type)}</span>
<span class="rounded-full px-2 py-0.5 text-xs font-medium {statusBadgeClass(job.status)}">
{statusLabel(job.status)}
</span>
</div>
{#if job.status === 'running'}
<div class="mt-2">
<div class="mb-1 flex justify-between text-xs text-gray-500">
<div class="mb-1 flex justify-between text-xs text-gray-500 dark:text-gray-400">
<span>Fortschritt</span><span>{job.progress_pct} %</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-gray-200">
<div class="h-1.5 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div class="h-full rounded-full bg-blue-500 transition-all" style="width: {job.progress_pct}%"></div>
</div>
</div>
{/if}
{#if job.error_message}
<p class="mt-1 text-xs text-red-600">{job.error_message}</p>
<p class="mt-1 text-xs text-red-600 dark:text-red-400">{job.error_message}</p>
{/if}
</div>
{/each}
@@ -431,66 +604,71 @@
<!-- ── Nutzer tab ───────────────────────────────────────────────── -->
{:else if activeTab === 'users'}
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<!-- Search -->
<div class="p-4">
<div class="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<div class="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-700 dark:bg-gray-900">
<svg class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<input
type="search"
placeholder="Nutzer suchen…"
bind:value={userSearch}
class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none"
class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
</div>
{#if filteredUsers.length === 0}
<p class="px-5 py-8 text-center text-sm text-gray-400">Keine Treffer.</p>
<p class="px-5 py-8 text-center text-sm text-gray-400 dark:text-gray-500">Keine Treffer.</p>
{:else}
<div class="divide-y divide-gray-100">
<div class="divide-y divide-gray-100 dark:divide-gray-700">
{#each filteredUsers as user}
<div class="flex items-center gap-3 px-5 py-3">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-medium text-gray-900">{user.display_name}</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{user.display_name}</span>
{#if user.role === 'host'}
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">Host</span>
<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'}
<span class="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700">Admin</span>
<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 user.is_banned}
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">Gesperrt</span>
<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}
</div>
<p class="text-xs text-gray-400">
<p class="text-xs text-gray-400 dark:text-gray-500">
{user.upload_count} Upload{user.upload_count !== 1 ? 's' : ''} · {formatBytes(user.total_upload_bytes)}
</p>
</div>
<div class="flex shrink-0 gap-1.5">
{#if user.role !== 'admin'}
{#if user.is_banned}
<button onclick={() => unban(user)} class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200">
Entsperren
</button>
{:else}
{#if user.role === 'guest'}
<button onclick={() => promoteToHost(user)} class="rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100">
Host
</button>
{/if}
{#if user.role === 'host' && myRole === 'admin'}
<button onclick={() => demoteToGuest(user)} class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200">
Degradieren
</button>
{/if}
<button onclick={() => openBanModal(user)} class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100">
Sperren
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
{#if user.role !== 'admin'}
{#if user.is_banned}
<button onclick={() => unban(user)} class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600">
Entsperren
</button>
{:else}
{#if user.role === 'guest'}
<button onclick={() => promoteToHost(user)} class="rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60">
Host
</button>
{/if}
{#if user.role === 'host'}
<button onclick={() => demoteToGuest(user)} class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600">
Degradieren
</button>
{/if}
{#if canResetPinFor(user)}
<button onclick={() => askResetPin(user)} class="rounded-lg bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-100 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60">
PIN zurücksetzen
</button>
{/if}
<button onclick={() => openBanModal(user)} class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100 dark:bg-red-950/40 dark:text-red-300 dark:hover:bg-red-950/60">
Sperren
</button>
{/if}
</div>
{/if}
</div>
</div>
{/each}
</div>

View File

@@ -34,10 +34,10 @@
}
</script>
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
<div class="w-full max-w-sm">
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900">Admin-Login</h1>
<p class="mb-6 text-center text-gray-500 text-sm">Nur für Veranstalter</p>
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900 dark:text-gray-100">Admin-Login</h1>
<p class="mb-6 text-center text-gray-500 text-sm dark:text-gray-400">Nur für Veranstalter</p>
<form onsubmit={(e) => { e.preventDefault(); handleLogin(); }}>
<input
@@ -45,24 +45,24 @@
bind:value={password}
placeholder="Passwort"
autocomplete="current-password"
class="mb-3 w-full rounded-lg border border-gray-300 px-4 py-3 text-lg focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
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">{error}</p>
<p class="mb-3 text-sm text-red-600 dark:text-red-400">{error}</p>
{/if}
<button
type="submit"
disabled={loading || !password}
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"
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 angemeldet…' : 'Anmelden'}
</button>
</form>
<p class="mt-4 text-center text-sm text-gray-500">
<a href="/join" class="text-blue-600 hover:underline">Zurück zum Event</a>
<p class="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
<a href="/join" class="text-blue-600 hover:underline dark:text-blue-400">Zurück zum Event</a>
</p>
</div>
</div>