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,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>
);
}