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:
@@ -2,111 +2,193 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { getToken } from '$lib/auth';
|
||||
import { addToQueue, loadQueue } from '$lib/upload-queue';
|
||||
import UploadQueue from '$lib/components/UploadQueue.svelte';
|
||||
import CameraCapture from '$lib/components/CameraCapture.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { showBottomNav } from '$lib/ui-store';
|
||||
import { pendingFiles, pendingCaption, clearPending } from '$lib/pending-upload-store';
|
||||
import { get } from 'svelte/store';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type { PendingFile } from '$lib/pending-upload-store';
|
||||
|
||||
interface StagedFile extends PendingFile {
|
||||
// previewUrl and file inherited from PendingFile
|
||||
}
|
||||
|
||||
let stagedFiles = $state<StagedFile[]>([]);
|
||||
let caption = $state('');
|
||||
let hashtags = $state('');
|
||||
let fileInput: HTMLInputElement;
|
||||
let showCamera = $state(false);
|
||||
let submitting = $state(false);
|
||||
let captionEl: HTMLTextAreaElement;
|
||||
|
||||
// Quick-tag chips derived from caption as the user types
|
||||
let captionTags = $derived.by(() => {
|
||||
const matches = [...caption.matchAll(/#(\w+)/g)];
|
||||
return [...new Set(matches.map((m) => m[1].toLowerCase()))];
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
showBottomNav.set(false);
|
||||
if (!getToken()) {
|
||||
goto('/join');
|
||||
return;
|
||||
}
|
||||
loadQueue();
|
||||
|
||||
// Pull staged files from the pending store (written by UploadSheet)
|
||||
const pf = get(pendingFiles);
|
||||
const pc = get(pendingCaption);
|
||||
stagedFiles = pf;
|
||||
caption = pc;
|
||||
|
||||
// Auto-focus caption textarea after a short delay (let layout settle)
|
||||
setTimeout(() => captionEl?.focus(), 80);
|
||||
});
|
||||
|
||||
async function handleFiles() {
|
||||
const files = fileInput?.files;
|
||||
if (!files || files.length === 0) return;
|
||||
onDestroy(() => {
|
||||
showBottomNav.set(true);
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
await addToQueue(file, caption, hashtags);
|
||||
}
|
||||
|
||||
// Reset form
|
||||
caption = '';
|
||||
hashtags = '';
|
||||
if (fileInput) fileInput.value = '';
|
||||
function removeFile(idx: number) {
|
||||
const removed = stagedFiles[idx];
|
||||
URL.revokeObjectURL(removed.previewUrl);
|
||||
stagedFiles = stagedFiles.filter((_, i) => i !== idx);
|
||||
}
|
||||
|
||||
async function handleCapture(blob: Blob, type: 'photo' | 'video') {
|
||||
const ext = type === 'photo' ? 'jpg' : blob.type.includes('mp4') ? 'mp4' : 'webm';
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const fileName = `${type}_${timestamp}.${ext}`;
|
||||
const file = new File([blob], fileName, { type: blob.type });
|
||||
await addToQueue(file, caption, hashtags);
|
||||
function cancel() {
|
||||
clearPending();
|
||||
goto('/feed');
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (stagedFiles.length === 0 || submitting) return;
|
||||
submitting = true;
|
||||
for (const sf of stagedFiles) {
|
||||
await addToQueue(sf.file, caption, '');
|
||||
}
|
||||
clearPending();
|
||||
goto('/feed');
|
||||
}
|
||||
|
||||
function isVideo(file: File): boolean {
|
||||
return file.type.startsWith('video/');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showCamera}
|
||||
<CameraCapture
|
||||
oncapture={handleCapture}
|
||||
onclose={() => (showCamera = false)}
|
||||
/>
|
||||
{/if}
|
||||
<!-- Full-screen composer — bottom nav is suppressed -->
|
||||
<div class="flex min-h-screen flex-col bg-white">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-3">
|
||||
<button
|
||||
onclick={cancel}
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full text-gray-500 transition hover:bg-gray-100"
|
||||
aria-label="Abbrechen"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 class="text-base font-semibold text-gray-900">Neuer Beitrag</h1>
|
||||
<!-- Submit button in header for desktop convenience -->
|
||||
<button
|
||||
onclick={handleSubmit}
|
||||
disabled={stagedFiles.length === 0 || submitting}
|
||||
class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm font-semibold text-white transition
|
||||
hover:bg-blue-700 disabled:opacity-40"
|
||||
>
|
||||
{submitting ? 'Wird hochgeladen…' : 'Hochladen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-screen bg-gray-50 p-4">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-gray-900">Hochladen</h1>
|
||||
<a href="/feed" class="text-sm text-blue-600 hover:underline">Zur Galerie</a>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- File picker -->
|
||||
<label
|
||||
class="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-6 transition hover:border-blue-400 hover:bg-blue-50"
|
||||
>
|
||||
<svg class="mb-2 h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 16V4m0 0l-4 4m4-4l4 4M4 20h16" />
|
||||
</svg>
|
||||
<span class="text-center text-sm font-medium text-gray-600">Galerie</span>
|
||||
<span class="mt-1 text-center text-xs text-gray-400">Mehrere Dateien</span>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
onchange={handleFiles}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!-- Camera button -->
|
||||
<div class="flex flex-1 flex-col overflow-y-auto">
|
||||
<!-- Thumbnail strip -->
|
||||
{#if stagedFiles.length > 0}
|
||||
<div class="flex gap-2 overflow-x-auto px-4 py-3 scrollbar-none">
|
||||
{#each stagedFiles as sf, i}
|
||||
<div class="relative h-20 w-20 shrink-0 overflow-hidden rounded-xl bg-gray-100">
|
||||
{#if isVideo(sf.file)}
|
||||
<div class="flex h-full w-full items-center justify-center bg-gray-800">
|
||||
<svg class="h-7 w-7 text-white/70" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<img src={sf.previewUrl} alt="" class="h-full w-full object-cover" />
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => removeFile(i)}
|
||||
class="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-white"
|
||||
aria-label="Entfernen"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="border-b border-gray-100"></div>
|
||||
{:else}
|
||||
<!-- No files: prompt to go back and pick some -->
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
|
||||
<svg class="h-16 w-16 text-gray-200" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M9 9.75h.008v.008H9V9.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-medium text-gray-500">Keine Dateien ausgewählt</p>
|
||||
<p class="mt-1 text-sm text-gray-400">Geh zurück und tippe auf den Plus-Button.</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => (showCamera = true)}
|
||||
class="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-6 transition hover:border-blue-400 hover:bg-blue-50"
|
||||
onclick={cancel}
|
||||
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<svg class="mb-2 h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-600">Kamera</span>
|
||||
<span class="mt-1 text-xs text-gray-400">Foto & Video</span>
|
||||
Zurück
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={caption}
|
||||
placeholder="Beschreibung (optional, #hashtags möglich)"
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={hashtags}
|
||||
placeholder="Hashtags (kommagetrennt, z.B. hochzeit, party)"
|
||||
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>
|
||||
<!-- Caption textarea -->
|
||||
<div class="px-4 pt-4">
|
||||
<textarea
|
||||
bind:this={captionEl}
|
||||
bind:value={caption}
|
||||
placeholder="Beschreibung hinzufügen… (#hashtags möglich)"
|
||||
rows="4"
|
||||
class="w-full resize-none rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900
|
||||
placeholder-gray-400 focus:border-blue-400 focus:bg-white focus:outline-none focus:ring-1 focus:ring-blue-200"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<UploadQueue />
|
||||
<!-- Quick-tag chips (derived from typed caption) -->
|
||||
{#if captionTags.length > 0}
|
||||
<div class="flex flex-wrap gap-1.5 px-4 pt-2">
|
||||
{#each captionTags as tag}
|
||||
<span class="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-600">
|
||||
#{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="h-8"></div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky submit button at bottom (mobile-primary) -->
|
||||
<div class="border-t border-gray-100 px-4 py-3">
|
||||
<button
|
||||
onclick={handleSubmit}
|
||||
disabled={stagedFiles.length === 0 || submitting}
|
||||
class="flex w-full items-center justify-center gap-2 rounded-xl bg-blue-600 py-3.5 text-sm font-semibold
|
||||
text-white transition hover:bg-blue-700 active:scale-[0.98] disabled:opacity-40"
|
||||
>
|
||||
{#if submitting}
|
||||
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Wird hochgeladen…
|
||||
{:else}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
{stagedFiles.length > 0 ? `${stagedFiles.length} Datei${stagedFiles.length > 1 ? 'en' : ''} hochladen` : 'Hochladen'}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user