feat: mobile-first UI redesign (v0.15.0)

- Persistent bottom tab bar (Feed · FAB · Account) on all authenticated pages
- Upload FAB triggers bottom sheet (Galerie / Kamera) → navigates to composer
- Upload page redesigned as full-screen composer with thumbnail strip, textarea,
  quick-tag chips, sticky submit button; bottom nav suppressed while composing
- Slim upload progress bar above bottom nav driven by queue state
- Feed: list/grid view toggle; list = chronological full-width FeedListCard;
  grid = 3-col with search bar, autocomplete from loaded posts, filter chips
- Account page: role-gated dashboard links (Host / Admin); Konto section with
  leave-confirm bottom sheet; no more per-page header nav icons
- Host dashboard: back arrow, collapsible sections, 2-col stats, user search
- Admin dashboard: back arrow, inner tab bar (Stats/Config/Export/Nutzer),
  stacked config inputs with sticky save, new Nutzer tab
- BottomNav hidden on unauthenticated pages via isAuthenticated store
- FeedGrid: threeCol prop; OnboardingGuide upload step updated for FAB
- Concept docs added to docs/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-04-05 18:40:57 +02:00
parent 4757be71a3
commit 4a5506f32d
16 changed files with 2166 additions and 454 deletions

View File

@@ -27,14 +27,28 @@
let loading = $state(true);
let error = $state<string | null>(null);
// Collapsible section state
let statsOpen = $state(true);
let settingsOpen = $state(true);
let usersOpen = $state(true);
// User search
let userSearch = $state('');
let filteredUsers = $derived(
userSearch.trim()
? users.filter((u) => u.display_name.toLowerCase().includes(userSearch.toLowerCase()))
: users
);
// Ban modal state
let banTarget = $state<UserSummary | null>(null);
let banHideUploads = $state(false);
let banSubmitting = $state(false);
// Toast state
let toast = $state<string | null>(null);
const myRole = getRole();
onMount(async () => {
const token = getToken();
const role = getRole();
@@ -146,8 +160,6 @@
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const myRole = getRole();
</script>
<!-- Ban modal -->
@@ -187,140 +199,208 @@
<!-- Toast -->
{#if toast}
<div class="fixed bottom-6 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">
{toast}
</div>
{/if}
<div class="min-h-screen bg-gray-50">
<div class="min-h-screen bg-gray-50 pb-24">
<!-- Header -->
<div class="border-b border-gray-200 bg-white">
<div class="mx-auto flex max-w-3xl items-center justify-between px-4 py-4">
<div>
<h1 class="text-xl font-bold text-gray-900">Host Dashboard</h1>
<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"
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>
<div class="min-w-0">
<h1 class="text-xl font-bold text-gray-900">Host-Dashboard</h1>
{#if event}
<p class="text-sm text-gray-500">{event.name}</p>
<p class="truncate text-sm text-gray-500">{event.name}</p>
{/if}
</div>
<div class="flex items-center gap-3">
{#if myRole === 'admin'}
<a href="/admin" class="text-gray-400 hover:text-gray-600" aria-label="Admin Dashboard">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
</svg>
</a>
{/if}
<a href="/feed" class="text-sm text-blue-600 hover:underline">Zur Galerie</a>
</div>
</div>
</div>
<div class="mx-auto max-w-3xl space-y-4 p-4">
<div class="mx-auto max-w-3xl space-y-3 p-4">
{#if loading}
<div class="py-16 text-center text-gray-400">Laden…</div>
{:else if error}
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-700">{error}</div>
{:else if event}
<!-- Event controls -->
<div class="rounded-xl border border-gray-200 bg-white p-5">
<h2 class="mb-4 font-semibold text-gray-900">Veranstaltung</h2>
<div class="flex flex-wrap gap-3">
<button
onclick={toggleEventLock}
class="rounded-lg px-4 py-2 text-sm font-medium {event.uploads_locked
? 'bg-green-600 text-white hover:bg-green-700'
: 'bg-amber-500 text-white hover:bg-amber-600'}"
<!-- ── Statistiken ─────────────────────────────────────────────── -->
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
<button
onclick={() => (statsOpen = !statsOpen)}
class="flex w-full items-center justify-between px-5 py-4"
>
<h2 class="font-semibold text-gray-900">Statistiken</h2>
<svg
class="h-5 w-5 text-gray-400 transition-transform duration-200 {statsOpen ? 'rotate-180' : ''}"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
>
{event.uploads_locked ? 'Uploads wieder öffnen' : 'Uploads sperren'}
</button>
<button
onclick={releaseGallery}
disabled={event.export_released}
class="rounded-lg px-4 py-2 text-sm font-medium {event.export_released
? 'cursor-default bg-gray-100 text-gray-400'
: 'bg-blue-600 text-white hover:bg-blue-700'}"
>
{event.export_released ? 'Galerie bereits freigegeben' : 'Galerie freigeben'}
</button>
</div>
<div class="mt-3 flex gap-4 text-xs text-gray-500">
<span class="flex items-center gap-1">
<span class="h-2 w-2 rounded-full {event.uploads_locked ? 'bg-red-500' : 'bg-green-500'}"></span>
Uploads {event.uploads_locked ? 'gesperrt' : 'offen'}
</span>
<span class="flex items-center gap-1">
<span class="h-2 w-2 rounded-full {event.export_released ? 'bg-blue-500' : 'bg-gray-300'}"></span>
Export {event.export_released ? 'freigegeben' : 'gesperrt'}
</span>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
<div class="overflow-hidden transition-[max-height] duration-200 {statsOpen ? 'max-h-[500px]' : 'max-h-0'}">
<div class="grid grid-cols-2 gap-3 border-t border-gray-100 p-4 sm:grid-cols-4">
<div class="rounded-xl bg-gray-50 p-4 text-center">
<p class="text-2xl font-bold text-gray-900">{users.length}</p>
<p class="mt-0.5 text-xs text-gray-500">Gäste</p>
</div>
<div class="rounded-xl bg-gray-50 p-4 text-center">
<p class="text-2xl font-bold text-gray-900">{users.reduce((s, u) => s + u.upload_count, 0)}</p>
<p class="mt-0.5 text-xs text-gray-500">Uploads</p>
</div>
<div class="rounded-xl bg-gray-50 p-4 text-center">
<p class="text-2xl font-bold {event.uploads_locked ? 'text-red-600' : 'text-green-600'}">
{event.uploads_locked ? 'Gesperrt' : 'Offen'}
</p>
<p class="mt-0.5 text-xs text-gray-500">Uploads</p>
</div>
<div class="rounded-xl bg-gray-50 p-4 text-center">
<p class="text-2xl font-bold {event.export_released ? 'text-blue-600' : 'text-gray-400'}">
{event.export_released ? 'Ja' : 'Nein'}
</p>
<p class="mt-0.5 text-xs text-gray-500">Freigegeben</p>
</div>
</div>
</div>
</div>
<!-- User management -->
<div class="rounded-xl border border-gray-200 bg-white">
<div class="border-b border-gray-100 px-5 py-4">
<h2 class="font-semibold text-gray-900">Gäste ({users.length})</h2>
</div>
{#if users.length === 0}
<p class="px-5 py-8 text-center text-sm text-gray-400">Noch keine Gäste.</p>
{:else}
<div class="divide-y divide-gray-100">
{#each users as user}
<div class="flex items-center gap-3 px-5 py-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate font-medium text-gray-900">{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>
{: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>
{/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>
{/if}
</div>
<p class="text-xs text-gray-400">
{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' && (myRole === 'host' || myRole === 'admin')}
<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
</button>
{/if}
{/if}
</div>
</div>
{/each}
<!-- ── Event-Einstellungen ─────────────────────────────────────── -->
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
<button
onclick={() => (settingsOpen = !settingsOpen)}
class="flex w-full items-center justify-between px-5 py-4"
>
<h2 class="font-semibold text-gray-900">Event-Einstellungen</h2>
<svg
class="h-5 w-5 text-gray-400 transition-transform duration-200 {settingsOpen ? 'rotate-180' : ''}"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
<div class="overflow-hidden transition-[max-height] duration-200 {settingsOpen ? 'max-h-[500px]' : 'max-h-0'}">
<div class="flex flex-wrap gap-3 border-t border-gray-100 p-5">
<button
onclick={toggleEventLock}
class="rounded-lg px-4 py-2 text-sm font-medium transition
{event.uploads_locked ? 'bg-green-600 text-white hover:bg-green-700' : 'bg-amber-500 text-white hover:bg-amber-600'}"
>
{event.uploads_locked ? 'Uploads wieder öffnen' : 'Uploads sperren'}
</button>
<button
onclick={releaseGallery}
disabled={event.export_released}
class="rounded-lg px-4 py-2 text-sm font-medium transition
{event.export_released ? 'cursor-default bg-gray-100 text-gray-400' : 'bg-blue-600 text-white hover:bg-blue-700'}"
>
{event.export_released ? 'Galerie bereits freigegeben' : 'Galerie freigeben'}
</button>
</div>
{/if}
</div>
</div>
<!-- ── Nutzerverwaltung ───────────────────────────────────────── -->
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
<button
onclick={() => (usersOpen = !usersOpen)}
class="flex w-full items-center justify-between px-5 py-4"
>
<h2 class="font-semibold text-gray-900">Nutzerverwaltung</h2>
<svg
class="h-5 w-5 text-gray-400 transition-transform duration-200 {usersOpen ? 'rotate-180' : ''}"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
<div class="overflow-hidden transition-[max-height] duration-300 {usersOpen ? 'max-h-[9999px]' : 'max-h-0'}">
<div class="border-t border-gray-100">
<!-- Search -->
<div class="px-4 py-3">
<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">
<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"
/>
</div>
</div>
{#if filteredUsers.length === 0}
<p class="px-5 py-8 text-center text-sm text-gray-400">Keine Treffer.</p>
{:else}
<div class="divide-y divide-gray-100">
{#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>
{#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>
{: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>
{/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>
{/if}
</div>
<p class="text-xs text-gray-400">
{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' && (myRole === 'host' || myRole === 'admin')}
<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
</button>
{/if}
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
</div>