Initialize repo and add session changes (2025-09-08)

This commit is contained in:
Auto Commit
2025-09-08 14:03:43 +02:00
commit 44ab0a534b
327 changed files with 40952 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { NavLink, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { GalleryHorizontal, Home, Trophy } from 'lucide-react';
function TabLink({ to, children }: { to: string; children: React.ReactNode }) {
return (
<NavLink to={to} className={({ isActive }) => (isActive ? 'text-foreground' : 'text-muted-foreground')}>
{children}
</NavLink>
);
}
export default function BottomNav() {
const { slug } = useParams();
const base = `/e/${encodeURIComponent(slug ?? 'demo')}`;
return (
<div className="fixed inset-x-0 bottom-0 z-20 border-t bg-white/90 px-3 py-2 backdrop-blur dark:bg-black/40">
<div className="mx-auto flex max-w-md items-center justify-between">
<TabLink to={`${base}`}>
<Button variant="ghost" size="sm" className="flex flex-col gap-1">
<Home className="h-5 w-5" /> <span className="text-xs">Start</span>
</Button>
</TabLink>
<TabLink to={`${base}/gallery`}>
<Button variant="ghost" size="sm" className="flex flex-col gap-1">
<GalleryHorizontal className="h-5 w-5" /> <span className="text-xs">Galerie</span>
</Button>
</TabLink>
<TabLink to={`${base}/achievements`}>
<Button variant="ghost" size="sm" className="flex flex-col gap-1">
<Trophy className="h-5 w-5" /> <span className="text-xs">Erfolge</span>
</Button>
</TabLink>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
export type GalleryFilter = 'latest' | 'popular' | 'mine';
export default function FiltersBar({ value, onChange }: { value: GalleryFilter; onChange: (v: GalleryFilter) => void }) {
return (
<div className="mb-3 flex items-center justify-between">
<ToggleGroup type="single" value={value} onValueChange={(v) => v && onChange(v as GalleryFilter)}>
<ToggleGroupItem value="latest">Neueste</ToggleGroupItem>
<ToggleGroupItem value="popular">Beliebt</ToggleGroupItem>
<ToggleGroupItem value="mine">Meine</ToggleGroupItem>
</ToggleGroup>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
import { Settings } from 'lucide-react';
export default function Header({ title = '' }: { title?: string }) {
return (
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
<div className="font-semibold">{title}</div>
<div className="flex items-center gap-2">
<AppearanceToggleDropdown />
<SettingsSheet />
</div>
</div>
);
}
function SettingsSheet() {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-md">
<Settings className="h-5 w-5" />
<span className="sr-only">Einstellungen öffnen</span>
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-80 sm:w-96">
<SheetHeader>
<SheetTitle>Einstellungen</SheetTitle>
</SheetHeader>
<div className="mt-4 space-y-4">
<div>
<div className="text-sm font-medium">Darstellung</div>
<div className="text-sm text-muted-foreground">Hell, Dunkel oder System</div>
<div className="mt-2">
<AppearanceToggleDropdown />
</div>
</div>
<div>
<div className="text-sm font-medium">Cache</div>
<ClearCacheButton />
</div>
<div>
<div className="text-sm font-medium">Rechtliches</div>
<ul className="mt-2 list-disc pl-5 text-sm">
<li><a href="/legal/imprint" className="underline">Impressum</a></li>
<li><a href="/legal/privacy" className="underline">Datenschutz</a></li>
<li><a href="/legal/terms" className="underline">AGB</a></li>
</ul>
</div>
</div>
</SheetContent>
</Sheet>
);
}
function ClearCacheButton() {
const [busy, setBusy] = React.useState(false);
const [done, setDone] = React.useState(false);
async function clearAll() {
setBusy(true); setDone(false);
try {
// Clear CacheStorage
if ('caches' in window) {
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
}
// Clear known IndexedDB dbs (best-effort)
if ('indexedDB' in window) {
try { await new Promise((res, rej) => { const r = indexedDB.deleteDatabase('upload-queue'); r.onsuccess=()=>res(null); r.onerror=()=>res(null); }); } catch {}
}
setDone(true);
} finally {
setBusy(false);
setTimeout(() => setDone(false), 2500);
}
}
return (
<div className="mt-2">
<Button variant="secondary" onClick={clearAll} disabled={busy}>
{busy ? 'Leere Cache…' : 'Cache leeren'}
</Button>
{done && <div className="mt-2 text-xs text-muted-foreground">Cache gelöscht.</div>}
</div>
);
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
type Toast = { id: number; text: string; type?: 'success'|'error' };
const Ctx = React.createContext<{ push: (t: Omit<Toast,'id'>) => void } | null>(null);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [list, setList] = React.useState<Toast[]>([]);
const push = React.useCallback((t: Omit<Toast,'id'>) => {
const id = Date.now() + Math.random();
setList((arr) => [...arr, { id, ...t }]);
setTimeout(() => setList((arr) => arr.filter((x) => x.id !== id)), 3000);
}, []);
React.useEffect(() => {
const onEvt = (e: any) => push(e.detail);
window.addEventListener('guest-toast', onEvt);
return () => window.removeEventListener('guest-toast', onEvt);
}, [push]);
return (
<Ctx.Provider value={{ push }}>
{children}
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
<div className="flex w-full max-w-sm flex-col gap-2">
{list.map((t) => (
<div key={t.id} className={`pointer-events-auto rounded-md border p-3 shadow-sm ${t.type==='error'?'border-red-300 bg-red-50 text-red-700':'border-green-300 bg-green-50 text-green-700'}`}>
{t.text}
</div>
))}
</div>
</div>
</Ctx.Provider>
);
}
export function useToast() {
const ctx = React.useContext(Ctx);
if (!ctx) throw new Error('ToastProvider missing');
return ctx;
}

View File

@@ -0,0 +1,19 @@
export function getDeviceId(): string {
const KEY = 'device-id';
let id = localStorage.getItem(KEY);
if (!id) {
id = genId();
localStorage.setItem(KEY, id);
}
return id;
}
function genId() {
// Simple UUID v4-ish generator
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (crypto.getRandomValues(new Uint8Array(1))[0] & 0xf) >> 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}

View File

@@ -0,0 +1,91 @@
export async function compressPhoto(
file: File,
opts: { targetBytes?: number; maxEdge?: number; qualityStart?: number } = {}
): Promise<File> {
const targetBytes = opts.targetBytes ?? 1_500_000; // 1.5 MB
const maxEdge = opts.maxEdge ?? 2560;
const qualityStart = opts.qualityStart ?? 0.85;
// If already small and jpeg, return as-is
if (file.size <= targetBytes && file.type === 'image/jpeg') return file;
const img = await loadImageBitmap(file);
const { width, height } = fitWithin(img.width, img.height, maxEdge);
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas unsupported');
ctx.drawImage(img as any, 0, 0, width, height);
// Iteratively lower quality to fit target size
let quality = qualityStart;
let blob: Blob | null = await toBlob(canvas, 'image/jpeg', quality);
if (!blob) throw new Error('Failed to encode image');
while (blob.size > targetBytes && quality > 0.5) {
quality -= 0.05;
const attempt = await toBlob(canvas, 'image/jpeg', quality);
if (attempt) blob = attempt;
else break;
}
// If still too large, downscale further by 0.9 until it fits or edge < 800
let currentWidth = width;
let currentHeight = height;
while (blob.size > targetBytes && Math.max(currentWidth, currentHeight) > 800) {
currentWidth = Math.round(currentWidth * 0.9);
currentHeight = Math.round(currentHeight * 0.9);
const c2 = createCanvas(currentWidth, currentHeight);
const c2ctx = c2.getContext('2d');
if (!c2ctx) break;
c2ctx.drawImage(canvas, 0, 0, currentWidth, currentHeight);
const attempt = await toBlob(c2, 'image/jpeg', quality);
if (attempt) blob = attempt;
}
const outName = ensureJpegExtension(file.name);
return new File([blob], outName, { type: 'image/jpeg', lastModified: Date.now() });
}
function fitWithin(w: number, h: number, maxEdge: number) {
const scale = Math.min(1, maxEdge / Math.max(w, h));
return { width: Math.round(w * scale), height: Math.round(h * scale) };
}
function createCanvas(w: number, h: number): HTMLCanvasElement {
const c = document.createElement('canvas');
c.width = w; c.height = h; return c;
}
function toBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob | null> {
return new Promise((resolve) => canvas.toBlob(resolve, type, quality));
}
async function loadImageBitmap(file: File): Promise<HTMLImageElement | ImageBitmap> {
const canBitmap = 'createImageBitmap' in window;
if (canBitmap) {
try { return await (createImageBitmap as any)(file); } catch {}
}
return await loadHtmlImage(file);
}
function loadHtmlImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
img.onerror = (e) => { URL.revokeObjectURL(url); reject(e); };
img.src = url;
});
}
function ensureJpegExtension(name: string) {
return name.replace(/\.(heic|heif|png|webp|jpg|jpeg)$/i, '') + '.jpg';
}
export function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { router } from './router';
import '../../css/app.css';
import { initializeTheme } from '@/hooks/use-appearance';
import { ToastProvider } from './components/ToastHost';
initializeTheme();
const rootEl = document.getElementById('root')!;
// Register a minimal service worker for background sync (best-effort)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/guest-sw.js').catch(() => {});
navigator.serviceWorker.addEventListener('message', (evt) => {
if (evt.data?.type === 'sync-queue') {
import('./queue/queue').then((m) => m.processQueue().catch(() => {}));
}
});
// Also attempt to process queue on load and when going online
import('./queue/queue').then((m) => m.processQueue().catch(() => {}));
window.addEventListener('online', () => {
import('./queue/queue').then((m) => m.processQueue().catch(() => {}));
});
}
createRoot(rootEl).render(
<React.StrictMode>
<ToastProvider>
<RouterProvider router={router} />
</ToastProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,11 @@
import React from 'react';
import { Page } from './_util';
export default function AchievementsPage() {
return (
<Page title="Erfolge">
<p>Badges and progress placeholder.</p>
</Page>
);
}

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { Page } from './_util';
import { useParams } from 'react-router-dom';
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
import { Card, CardContent } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import FiltersBar, { type GalleryFilter } from '../components/FiltersBar';
import { Link } from 'react-router-dom';
import { Heart } from 'lucide-react';
import { likePhoto } from '../services/photosApi';
export default function GalleryPage() {
const { slug } = useParams();
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(slug!);
const [filter, setFilter] = React.useState<GalleryFilter>('latest');
const myPhotoIds = React.useMemo(() => {
try {
const raw = localStorage.getItem('my-photo-ids');
return new Set<number>(raw ? JSON.parse(raw) : []);
} catch { return new Set<number>(); }
}, []);
const list = React.useMemo(() => {
let arr = photos.slice();
if (filter === 'popular') {
arr.sort((a: any, b: any) => (b.likes_count ?? 0) - (a.likes_count ?? 0));
} else if (filter === 'mine') {
arr = arr.filter((p: any) => myPhotoIds.has(p.id));
} else {
arr.sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
}
return arr;
}, [photos, filter, myPhotoIds]);
const [liked, setLiked] = React.useState<Set<number>>(new Set());
const [counts, setCounts] = React.useState<Record<number, number>>({});
async function onLike(id: number) {
if (liked.has(id)) return;
setLiked(new Set(liked).add(id));
try {
const c = await likePhoto(id);
setCounts((m) => ({ ...m, [id]: c }));
// keep a simple record of liked items
try {
const raw = localStorage.getItem('liked-photo-ids');
const arr: number[] = raw ? JSON.parse(raw) : [];
if (!arr.includes(id)) localStorage.setItem('liked-photo-ids', JSON.stringify([...arr, id]));
} catch {}
} catch {
const s = new Set(liked); s.delete(id); setLiked(s);
}
}
return (
<Page title="Galerie">
<FiltersBar value={filter} onChange={setFilter} />
{newCount > 0 && (
<Alert className="mb-3">
<AlertDescription>
{newCount} neue Fotos verfügbar.{' '}
<Button variant="link" className="px-1" onClick={acknowledgeNew}>Aktualisieren</Button>
</AlertDescription>
</Alert>
)}
{loading && <p>Lade</p>}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{list.map((p) => (
<Card key={p.id} className="relative overflow-hidden">
<CardContent className="p-0">
<Link to={`../photo/${p.id}`} state={{ photo: p }}>
<img src={p.thumbnail_path || p.file_path} alt="Foto" className="aspect-square w-full object-cover" />
</Link>
</CardContent>
<div className="absolute bottom-1 right-1 flex items-center gap-1 rounded-full bg-black/50 px-2 py-1 text-white">
<button onClick={() => onLike(p.id)} className={`inline-flex items-center ${liked.has(p.id) ? 'text-red-400' : ''}`} aria-label="Like">
<Heart className="h-4 w-4" />
</button>
<span className="text-xs">{counts[p.id] ?? p.likes_count ?? 0}</span>
</div>
</Card>
))}
</div>
</Page>
);
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { Page } from './_util';
import { useParams, Link } from 'react-router-dom';
import { usePollStats } from '../polling/usePollStats';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
export default function HomePage() {
const { slug } = useParams();
const stats = usePollStats(slug!);
return (
<Page title={`Event: ${slug}`}>
<Card>
<CardContent className="p-3 text-sm">
{stats.loading ? 'Lade…' : (
<span>
<span className="font-medium">{stats.onlineGuests}</span> Gäste online · {' '}
<span className="font-medium">{stats.tasksSolved}</span> Aufgaben gelöst
</span>
)}
</CardContent>
</Card>
<div className="h-3" />
<div className="flex flex-wrap gap-2">
<Link to="tasks"><Button variant="secondary">Aufgabe ziehen</Button></Link>
<Link to="tasks"><Button variant="secondary">Wie fühlst du dich?</Button></Link>
<Link to="upload"><Button>Einfach ein Foto machen</Button></Link>
</div>
<div className="h-4" />
<Link to="gallery" className="underline">Zur Galerie</Link>
</Page>
);
}

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { Page } from './_util';
import { useNavigate } from 'react-router-dom';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
export default function LandingPage() {
const nav = useNavigate();
const [slug, setSlug] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
async function join() {
const s = slug.trim();
if (!s) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/v1/events/${encodeURIComponent(s)}`);
if (!res.ok) {
setError('Event nicht gefunden oder geschlossen.');
return;
}
nav(`/e/${encodeURIComponent(s)}`);
} catch (e) {
setError('Netzwerkfehler. Bitte später erneut versuchen.');
} finally {
setLoading(false);
}
}
return (
<Page title="Willkommen bei Fotochallenge 🎉">
{error && (
<Alert className="mb-3" variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Input
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="QR/PIN oder Event-Slug eingeben"
/>
<div className="h-3" />
<Button disabled={loading || !slug.trim()} onClick={join}>
{loading ? 'Prüfe…' : 'Event beitreten'}
</Button>
</Page>
);
}

View File

@@ -0,0 +1,13 @@
import React from 'react';
import { Page } from './_util';
import { useParams } from 'react-router-dom';
export default function LegalPage() {
const { page } = useParams();
return (
<Page title={`Rechtliches: ${page}`}>
<p>Impressum / Datenschutz / AGB</p>
</Page>
);
}

View File

@@ -0,0 +1,11 @@
import React from 'react';
import { Page } from './_util';
export default function NotFoundPage() {
return (
<Page title="Nicht gefunden">
<p>Die Seite konnte nicht gefunden werden.</p>
</Page>
);
}

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Heart } from 'lucide-react';
import { likePhoto } from '../services/photosApi';
type Photo = { id: number; file_path?: string; thumbnail_path?: string; likes_count?: number; created_at?: string };
export default function PhotoLightbox() {
const nav = useNavigate();
const { state } = useLocation();
const { photoId } = useParams();
const [photo, setPhoto] = React.useState<Photo | null>((state as any)?.photo ?? null);
const [likes, setLikes] = React.useState<number | null>(null);
const [liked, setLiked] = React.useState(false);
React.useEffect(() => {
if (photo) return;
(async () => {
const res = await fetch(`/api/v1/photos/${photoId}`);
if (res.ok) setPhoto(await res.json());
})();
}, [photo, photoId]);
React.useEffect(() => {
if (photo && likes === null) setLikes(photo.likes_count ?? 0);
}, [photo, likes]);
async function onLike() {
if (liked || !photo) return;
setLiked(true);
const c = await likePhoto(photo.id);
setLikes(c);
}
function onOpenChange(open: boolean) {
if (!open) nav(-1);
}
return (
<Dialog open onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl border-0 bg-black p-0 text-white">
<div className="flex items-center justify-between p-2">
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={onLike} disabled={liked}>
<Heart className="mr-1 h-4 w-4" /> {likes ?? 0}
</Button>
</div>
<Button variant="secondary" size="sm" onClick={() => nav(-1)}>Schließen</Button>
</div>
<div className="flex items-center justify-center">
{photo ? (
<img src={photo.file_path || photo.thumbnail_path} alt="Foto" className="max-h-[80vh] w-auto" />
) : (
<div className="p-6">Lade</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,13 @@
import React from 'react';
import { Page } from './_util';
export default function ProfileSetupPage() {
return (
<Page title="Profil erstellen">
<input placeholder="Dein Name" style={{ width: '100%', padding: 10, border: '1px solid #ddd', borderRadius: 8 }} />
<div style={{ height: 12 }} />
<button style={{ padding: '10px 16px', borderRadius: 8, background: '#111827', color: 'white' }}>Starten</button>
</Page>
);
}

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { Page } from './_util';
export default function SettingsPage() {
return (
<Page title="Einstellungen">
<ul>
<li>Sprache</li>
<li>Theme</li>
<li>Cache leeren</li>
<li>Rechtliches</li>
</ul>
</Page>
);
}

View File

@@ -0,0 +1,11 @@
import React from 'react';
import { Page } from './_util';
export default function SlideshowPage() {
return (
<Page title="Slideshow">
<p>Auto-advancing gallery placeholder.</p>
</Page>
);
}

View File

@@ -0,0 +1,11 @@
import React from 'react';
import { Page } from './_util';
export default function TaskDetailPage() {
return (
<Page title="Aufgaben-Detail">
<p>Aufgabenbeschreibung, Dauer, Gruppengröße.</p>
</Page>
);
}

View File

@@ -0,0 +1,11 @@
import React from 'react';
import { Page } from './_util';
export default function TaskPickerPage() {
return (
<Page title="Stimmung wählen / Aufgabe ziehen">
<p>Stubs for emotion grid and random task.</p>
</Page>
);
}

View File

@@ -0,0 +1,99 @@
import React from 'react';
import { Page } from './_util';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useParams } from 'react-router-dom';
import { getDeviceId } from '../lib/device';
import { compressPhoto, formatBytes } from '../lib/image';
import { useUploadQueue } from '../queue/hooks';
import React from 'react';
type Item = { file: File; out?: File; progress: number; done?: boolean; error?: string; id?: number };
export default function UploadPage() {
const { slug } = useParams();
const [items, setItems] = React.useState<Item[]>([]);
const queue = useUploadQueue();
const [progressMap, setProgressMap] = React.useState<Record<number, number>>({});
React.useEffect(() => {
const onProg = (e: any) => {
const { id, progress } = e.detail || {};
if (typeof id === 'number') setProgressMap((m) => ({ ...m, [id]: progress }));
};
window.addEventListener('queue-progress', onProg);
return () => window.removeEventListener('queue-progress', onProg);
}, []);
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []).slice(0, 10);
const results: Item[] = [];
for (const f of files) {
try {
const out = await compressPhoto(f, { targetBytes: 1_500_000, maxEdge: 2560, qualityStart: 0.85 });
results.push({ file: f, out, progress: 0 });
} catch (err: any) {
results.push({ file: f, progress: 0, error: err?.message || 'Komprimierung fehlgeschlagen' });
}
}
setItems(results);
}
async function startUpload() {
// Enqueue items for offline-friendly processing
for (const it of items) {
await queue.add({ slug: slug!, fileName: it.out?.name ?? it.file.name, blob: it.out ?? it.file });
}
setItems([]);
}
return (
<Page title="Foto aufnehmen/hochladen">
<Input type="file" accept="image/*" multiple capture="environment" onChange={onPick} />
<div className="h-3" />
<Button onClick={startUpload} disabled={items.length === 0}>Hochladen</Button>
<div className="mt-4 space-y-2">
{items.map((it, i) => (
<div key={i} className="rounded border p-2 text-sm">
<div className="flex items-center justify-between">
<div className="truncate">{it.file.name}</div>
<div className="text-xs text-muted-foreground">
{formatBytes(it.out?.size ?? it.file.size)}
</div>
</div>
{it.done && <div className="text-xs text-muted-foreground">Fertig</div>}
{it.error && <div className="text-xs text-red-500">{it.error}</div>}
</div>
))}
</div>
<div className="mt-6">
<div className="mb-2 text-sm font-medium">Warteschlange</div>
{queue.loading ? (
<div className="text-sm text-muted-foreground">Lade</div>
) : queue.items.length === 0 ? (
<div className="text-sm text-muted-foreground">Keine offenen Uploads.</div>
) : (
<div className="space-y-2">
{queue.items.map((q) => (
<div key={q.id} className="rounded border p-2 text-sm">
<div className="flex items-center justify-between">
<div className="truncate">{q.fileName}</div>
<div className="text-xs text-muted-foreground">{q.status}{q.status==='uploading' && typeof q.id==='number' ? `${progressMap[q.id] ?? 0}%` : ''}</div>
</div>
{q.status === 'uploading' && typeof q.id==='number' && (
<div className="mt-2 h-2 w-full rounded bg-gray-200 dark:bg-gray-700">
<div className="h-2 rounded bg-emerald-500" style={{ width: `${progressMap[q.id] ?? 0}%` }} />
</div>
)}
</div>
))}
<div className="flex gap-2">
<Button variant="secondary" onClick={queue.retryAll}>Erneut versuchen</Button>
<Button variant="secondary" onClick={queue.clearFinished}>Erledigte entfernen</Button>
</div>
</div>
)}
</div>
</Page>
);
}

View File

@@ -0,0 +1,11 @@
import React from 'react';
import { Page } from './_util';
export default function UploadQueuePage() {
return (
<Page title="Uploads">
<p>Queue with progress/retry; background sync toggle.</p>
</Page>
);
}

View File

@@ -0,0 +1,11 @@
import React from 'react';
export function Page({ title, children }: { title: string; children?: React.ReactNode }) {
return (
<div style={{ maxWidth: 720, margin: '0 auto', padding: 16 }}>
<h1 style={{ fontSize: 20, fontWeight: 600, marginBottom: 12 }}>{title}</h1>
<div>{children}</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { useEffect, useRef, useState } from 'react';
type Photo = { id: number; file_path?: string; thumbnail_path?: string; created_at?: string };
export function usePollGalleryDelta(slug: string) {
const [photos, setPhotos] = useState<Photo[]>([]);
const [loading, setLoading] = useState(true);
const [newCount, setNewCount] = useState(0);
const latestAt = useRef<string | null>(null);
const timer = useRef<number | null>(null);
async function fetchDelta() {
const qs = latestAt.current ? `?since=${encodeURIComponent(latestAt.current)}` : '';
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/photos${qs}`, {
headers: { 'Cache-Control': 'no-store' },
});
if (res.status === 304) return;
const json = await res.json();
if (Array.isArray(json.data)) {
const added = json.data.length;
const merged = latestAt.current ? [...json.data, ...photos] : json.data;
if (added > 0 && latestAt.current) setNewCount((c) => c + added);
setPhotos(merged);
}
if (json.latest_photo_at) latestAt.current = json.latest_photo_at;
setLoading(false);
}
useEffect(() => {
setLoading(true);
latestAt.current = null;
setPhotos([]);
fetchDelta();
timer.current = window.setInterval(fetchDelta, 30_000);
return () => {
if (timer.current) window.clearInterval(timer.current);
};
}, [slug]);
function acknowledgeNew() { setNewCount(0); }
return { loading, photos, newCount, acknowledgeNew };
}

View File

@@ -0,0 +1,39 @@
import { useEffect, useRef, useState } from 'react';
type Stats = { onlineGuests: number; tasksSolved: number; latestPhotoAt?: string };
export function usePollStats(slug: string) {
const [data, setData] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const timer = useRef<number | null>(null);
const visible = typeof document !== 'undefined' ? document.visibilityState === 'visible' : true;
async function fetchOnce() {
try {
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/stats`, {
headers: { 'Cache-Control': 'no-store' },
});
if (res.status === 304) return;
const json = await res.json();
setData({ onlineGuests: json.online_guests ?? 0, tasksSolved: json.tasks_solved ?? 0, latestPhotoAt: json.latest_photo_at });
} finally {
setLoading(false);
}
}
useEffect(() => {
setLoading(true);
fetchOnce();
function schedule() {
if (!visible) return;
timer.current = window.setInterval(fetchOnce, 10_000);
}
schedule();
return () => {
if (timer.current) window.clearInterval(timer.current);
};
}, [slug, visible]);
return { loading, onlineGuests: data?.onlineGuests ?? 0, tasksSolved: data?.tasksSolved ?? 0 };
}

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { enqueue, list, processQueue, clearDone, type QueueItem } from './queue';
export function useUploadQueue() {
const [items, setItems] = React.useState<QueueItem[]>([]);
const [loading, setLoading] = React.useState(true);
const refresh = React.useCallback(async () => {
setLoading(true);
const all = await list();
setItems(all);
setLoading(false);
}, []);
const add = React.useCallback(async (it: Parameters<typeof enqueue>[0]) => {
await enqueue(it);
await refresh();
await processQueue();
}, [refresh]);
const retryAll = React.useCallback(async () => {
await processQueue();
await refresh();
}, [refresh]);
const clearFinished = React.useCallback(async () => {
await clearDone();
await refresh();
}, [refresh]);
React.useEffect(() => {
refresh();
const online = () => processQueue().then(refresh);
window.addEventListener('online', online);
return () => window.removeEventListener('online', online);
}, [refresh]);
return { items, loading, refresh, add, retryAll, clearFinished } as const;
}

View File

@@ -0,0 +1,34 @@
export type TxMode = 'readonly' | 'readwrite';
export function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open('guest-upload-queue', 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains('items')) {
const store = db.createObjectStore('items', { keyPath: 'id', autoIncrement: true });
store.createIndex('status', 'status', { unique: false });
store.createIndex('nextAttemptAt', 'nextAttemptAt', { unique: false });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function withStore<T>(mode: TxMode, fn: (store: IDBObjectStore) => void | Promise<T>): Promise<T> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction('items', mode);
const store = tx.objectStore('items');
let result: any;
const wrap = async () => {
try { result = await fn(store); } catch (e) { reject(e); }
};
wrap();
tx.oncomplete = () => resolve(result);
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}

View File

@@ -0,0 +1,11 @@
export function notify(text: string, type: 'success'|'error') {
// Lazy import to avoid cycle
import('../components/ToastHost').then(({ useToast }) => {
try {
// This only works inside React tree; for SW-triggered, we fallback
const evt = new CustomEvent('guest-toast', { detail: { text, type } });
window.dispatchEvent(evt);
} catch {}
});
}

View File

@@ -0,0 +1,113 @@
import { withStore } from './idb';
import { getDeviceId } from '../lib/device';
import { createUpload } from './xhr';
import { notify } from './notify';
export type QueueItem = {
id?: number;
slug: string;
fileName: string;
blob: Blob;
emotion_id?: number | null;
task_id?: number | null;
status: 'pending' | 'uploading' | 'done' | 'error';
retries: number;
nextAttemptAt?: number | null;
createdAt: number;
photoId?: number;
};
let processing = false;
export async function enqueue(item: Omit<QueueItem, 'id' | 'status' | 'retries' | 'createdAt'>) {
const now = Date.now();
await withStore('readwrite', (store) => {
store.add({ ...item, status: 'pending', retries: 0, createdAt: now });
});
// Register background sync if available
if ('serviceWorker' in navigator && 'SyncManager' in window) {
try { const reg = await navigator.serviceWorker.ready; await reg.sync.register('upload-queue'); } catch {}
}
}
export async function list(): Promise<QueueItem[]> {
return withStore('readonly', (store) => new Promise((resolve) => {
const req = store.getAll();
req.onsuccess = () => resolve(req.result as QueueItem[]);
}));
}
export async function clearDone() {
const items = await list();
await withStore('readwrite', (store) => {
for (const it of items) {
if (it.status === 'done') store.delete(it.id!);
}
});
}
export async function processQueue() {
if (processing) return; processing = true;
try {
const now = Date.now();
let items = await list();
for (const it of items) {
if (it.status === 'done') continue;
if (it.nextAttemptAt && it.nextAttemptAt > now) continue;
await markStatus(it.id!, 'uploading');
const ok = await attemptUpload(it);
if (ok) {
await markStatus(it.id!, 'done');
} else {
const retries = (it.retries ?? 0) + 1;
const backoffSec = Math.min(60, Math.pow(2, Math.min(retries, 5))); // 2,4,8,16,32,60
await update(it.id!, { status: 'error', retries, nextAttemptAt: Date.now() + backoffSec * 1000 });
}
}
} finally {
processing = false;
}
}
async function attemptUpload(it: QueueItem): Promise<boolean> {
if (!navigator.onLine) return false;
try {
const json = await createUpload(
`/api/v1/events/${encodeURIComponent(it.slug)}/photos`,
it,
getDeviceId(),
(pct) => {
try {
window.dispatchEvent(new CustomEvent('queue-progress', { detail: { id: it.id, progress: pct } }));
} catch {}
}
);
// mark my-photo-ids for "Meine"
try {
const raw = localStorage.getItem('my-photo-ids');
const arr: number[] = raw ? JSON.parse(raw) : [];
if (json.id && !arr.includes(json.id)) localStorage.setItem('my-photo-ids', JSON.stringify([json.id, ...arr]));
} catch {}
notify('Upload erfolgreich', 'success');
return true;
} catch {
notify('Upload fehlgeschlagen', 'error');
return false;
}
}
async function markStatus(id: number, status: QueueItem['status']) {
await update(id, { status });
}
async function update(id: number, patch: Partial<QueueItem>) {
await withStore('readwrite', (store) => new Promise<void>((resolve, reject) => {
const getReq = store.get(id);
getReq.onsuccess = () => {
const val = getReq.result as QueueItem;
store.put({ ...val, ...patch, id });
resolve();
};
getReq.onerror = () => reject(getReq.error);
}));
}

View File

@@ -0,0 +1,33 @@
import type { QueueItem } from './queue';
export async function createUpload(
url: string,
it: QueueItem,
deviceId: string,
onProgress?: (percent: number) => void
): Promise<any> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.setRequestHeader('X-Device-Id', deviceId);
const form = new FormData();
form.append('photo', it.blob, it.fileName);
if (it.emotion_id) form.append('emotion_id', String(it.emotion_id));
if (it.task_id) form.append('task_id', String(it.task_id));
xhr.upload.onprogress = (ev) => {
if (onProgress && ev.lengthComputable) {
const pct = Math.min(100, Math.round((ev.loaded / ev.total) * 100));
onProgress(pct);
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try { resolve(JSON.parse(xhr.responseText)); } catch { resolve({}); }
} else {
reject(new Error('upload failed'));
}
};
xhr.onerror = () => reject(new Error('network error'));
xhr.send(form);
});
}

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { createBrowserRouter, Outlet, useParams } from 'react-router-dom';
import Header from './components/Header';
import BottomNav from './components/BottomNav';
import LandingPage from './pages/LandingPage';
import ProfileSetupPage from './pages/ProfileSetupPage';
import HomePage from './pages/HomePage';
import TaskPickerPage from './pages/TaskPickerPage';
import TaskDetailPage from './pages/TaskDetailPage';
import UploadPage from './pages/UploadPage';
import UploadQueuePage from './pages/UploadQueuePage';
import GalleryPage from './pages/GalleryPage';
import PhotoLightbox from './pages/PhotoLightbox';
import AchievementsPage from './pages/AchievementsPage';
import SlideshowPage from './pages/SlideshowPage';
import SettingsPage from './pages/SettingsPage';
import LegalPage from './pages/LegalPage';
import NotFoundPage from './pages/NotFoundPage';
function HomeLayout() {
const { slug } = useParams();
return (
<div className="pb-16">
<Header title={slug ? `Event: ${slug}` : 'Fotospiel'} />
<div className="px-4 py-3">
<Outlet />
</div>
<BottomNav />
</div>
);
}
export const router = createBrowserRouter([
{ path: '/', element: <SimpleLayout title="Fotospiel"><LandingPage /></SimpleLayout> },
{ path: '/setup', element: <SimpleLayout title="Profil"><ProfileSetupPage /></SimpleLayout> },
{
path: '/e/:slug',
element: <HomeLayout />,
children: [
{ index: true, element: <HomePage /> },
{ path: 'tasks', element: <TaskPickerPage /> },
{ path: 'tasks/:taskId', element: <TaskDetailPage /> },
{ path: 'upload', element: <UploadPage /> },
{ path: 'queue', element: <UploadQueuePage /> },
{ path: 'gallery', element: <GalleryPage /> },
{ path: 'photo/:photoId', element: <PhotoLightbox /> },
{ path: 'achievements', element: <AchievementsPage /> },
{ path: 'slideshow', element: <SlideshowPage /> },
],
},
{ path: '/settings', element: <SimpleLayout title="Einstellungen"><SettingsPage /></SimpleLayout> },
{ path: '/legal/:page', element: <SimpleLayout title="Rechtliches"><LegalPage /></SimpleLayout> },
{ path: '*', element: <NotFoundPage /> },
]);
function SimpleLayout({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="pb-16">
<Header title={title} />
<div className="px-4 py-3">
{children}
</div>
<BottomNav />
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { getDeviceId } from '../lib/device';
export async function likePhoto(id: number): Promise<number> {
const res = await fetch(`/api/v1/photos/${id}/like`, {
method: 'POST',
headers: {
'X-Device-Id': getDeviceId(),
'Content-Type': 'application/json',
},
});
if (!res.ok) throw new Error('like failed');
const json = await res.json();
return json.likes_count ?? 0;
}