Initialize repo and add session changes (2025-09-08)
This commit is contained in:
38
resources/js/guest/components/BottomNav.tsx
Normal file
38
resources/js/guest/components/BottomNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
resources/js/guest/components/FiltersBar.tsx
Normal file
17
resources/js/guest/components/FiltersBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
89
resources/js/guest/components/Header.tsx
Normal file
89
resources/js/guest/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
resources/js/guest/components/ToastHost.tsx
Normal file
38
resources/js/guest/components/ToastHost.tsx
Normal 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;
|
||||
}
|
||||
19
resources/js/guest/lib/device.ts
Normal file
19
resources/js/guest/lib/device.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
|
||||
91
resources/js/guest/lib/image.ts
Normal file
91
resources/js/guest/lib/image.ts
Normal 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`;
|
||||
}
|
||||
|
||||
32
resources/js/guest/main.tsx
Normal file
32
resources/js/guest/main.tsx
Normal 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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
42
resources/js/guest/polling/usePollGalleryDelta.ts
Normal file
42
resources/js/guest/polling/usePollGalleryDelta.ts
Normal 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 };
|
||||
}
|
||||
39
resources/js/guest/polling/usePollStats.ts
Normal file
39
resources/js/guest/polling/usePollStats.ts
Normal 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 };
|
||||
}
|
||||
|
||||
40
resources/js/guest/queue/hooks.ts
Normal file
40
resources/js/guest/queue/hooks.ts
Normal 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;
|
||||
}
|
||||
|
||||
34
resources/js/guest/queue/idb.ts
Normal file
34
resources/js/guest/queue/idb.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
|
||||
11
resources/js/guest/queue/notify.ts
Normal file
11
resources/js/guest/queue/notify.ts
Normal 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 {}
|
||||
});
|
||||
}
|
||||
|
||||
113
resources/js/guest/queue/queue.ts
Normal file
113
resources/js/guest/queue/queue.ts
Normal 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);
|
||||
}));
|
||||
}
|
||||
33
resources/js/guest/queue/xhr.ts
Normal file
33
resources/js/guest/queue/xhr.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
66
resources/js/guest/router.tsx
Normal file
66
resources/js/guest/router.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
resources/js/guest/services/photosApi.ts
Normal file
15
resources/js/guest/services/photosApi.ts
Normal 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user