Initialize repo and add session changes (2025-09-08)
This commit is contained in:
11
resources/js/guest/pages/AchievementsPage.tsx
Normal file
11
resources/js/guest/pages/AchievementsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
87
resources/js/guest/pages/GalleryPage.tsx
Normal file
87
resources/js/guest/pages/GalleryPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
resources/js/guest/pages/HomePage.tsx
Normal file
33
resources/js/guest/pages/HomePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
resources/js/guest/pages/LandingPage.tsx
Normal file
51
resources/js/guest/pages/LandingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
resources/js/guest/pages/LegalPage.tsx
Normal file
13
resources/js/guest/pages/LegalPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
11
resources/js/guest/pages/NotFoundPage.tsx
Normal file
11
resources/js/guest/pages/NotFoundPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
63
resources/js/guest/pages/PhotoLightbox.tsx
Normal file
63
resources/js/guest/pages/PhotoLightbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
resources/js/guest/pages/ProfileSetupPage.tsx
Normal file
13
resources/js/guest/pages/ProfileSetupPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
16
resources/js/guest/pages/SettingsPage.tsx
Normal file
16
resources/js/guest/pages/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
11
resources/js/guest/pages/SlideshowPage.tsx
Normal file
11
resources/js/guest/pages/SlideshowPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
11
resources/js/guest/pages/TaskDetailPage.tsx
Normal file
11
resources/js/guest/pages/TaskDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
11
resources/js/guest/pages/TaskPickerPage.tsx
Normal file
11
resources/js/guest/pages/TaskPickerPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
99
resources/js/guest/pages/UploadPage.tsx
Normal file
99
resources/js/guest/pages/UploadPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
resources/js/guest/pages/UploadQueuePage.tsx
Normal file
11
resources/js/guest/pages/UploadQueuePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
11
resources/js/guest/pages/_util.tsx
Normal file
11
resources/js/guest/pages/_util.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user