feat: extend event toolkit and polish guest pwa

This commit is contained in:
Codex Agent
2025-10-28 18:28:22 +01:00
parent f29067f570
commit a7bbf230fd
45 changed files with 3809 additions and 351 deletions

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import Header from '../components/Header';
import BottomNav from '../components/BottomNav';
@@ -7,7 +7,6 @@ import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { uploadPhoto } from '../services/photosApi';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { useAppearance } from '../../hooks/use-appearance';
import { cn } from '@/lib/utils';
import {
AlertTriangle,
@@ -46,6 +45,39 @@ type CameraPreferences = {
flashPreferred: boolean;
};
type TaskPayload = Partial<Task> & { id: number };
function isTaskPayload(value: unknown): value is TaskPayload {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as { id?: unknown };
return typeof candidate.id === 'number';
}
function getErrorName(error: unknown): string | undefined {
if (typeof error === 'object' && error !== null && 'name' in error) {
const name = (error as { name?: unknown }).name;
return typeof name === 'string' ? name : undefined;
}
return undefined;
}
function getErrorMessage(error: unknown): string | undefined {
if (error instanceof Error && typeof error.message === 'string') {
return error.message;
}
if (typeof error === 'object' && error !== null && 'message' in error) {
const message = (error as { message?: unknown }).message;
return typeof message === 'string' ? message : undefined;
}
return undefined;
}
const DEFAULT_PREFS: CameraPreferences = {
facingMode: 'environment',
countdownSeconds: 3,
@@ -60,8 +92,6 @@ export default function UploadPage() {
const eventKey = token ?? '';
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { appearance } = useAppearance();
const isDarkMode = appearance === 'dark';
const { markCompleted } = useGuestTaskProgress(token);
const { t } = useTranslation();
@@ -75,7 +105,6 @@ export default function UploadPage() {
const [task, setTask] = useState<Task | null>(null);
const [loadingTask, setLoadingTask] = useState(true);
const [taskError, setTaskError] = useState<string | null>(null);
const [permissionState, setPermissionState] = useState<PermissionState>('idle');
const [permissionMessage, setPermissionMessage] = useState<string | null>(null);
@@ -138,8 +167,7 @@ export default function UploadPage() {
// Load task metadata
useEffect(() => {
if (!token || !taskId) {
setTaskError(t('upload.loadError.title'));
if (!token || taskId === null) {
setLoadingTask(false);
return;
}
@@ -147,18 +175,19 @@ export default function UploadPage() {
let active = true;
async function loadTask() {
const fallbackTitle = t('upload.taskInfo.fallbackTitle').replace('{id}', `${taskId!}`);
const currentTaskId = taskId;
const fallbackTitle = t('upload.taskInfo.fallbackTitle').replace('{id}', `${currentTaskId}`);
const fallbackDescription = t('upload.taskInfo.fallbackDescription');
const fallbackInstructions = t('upload.taskInfo.fallbackInstructions');
try {
setLoadingTask(true);
setTaskError(null);
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`);
if (!res.ok) throw new Error('Tasks konnten nicht geladen werden');
const tasks = await res.json();
const found = Array.isArray(tasks) ? tasks.find((entry: any) => entry.id === taskId!) : null;
const payload = (await res.json()) as unknown;
const entries = Array.isArray(payload) ? payload.filter(isTaskPayload) : [];
const found = entries.find((entry) => entry.id === currentTaskId) ?? null;
if (!active) return;
@@ -174,7 +203,7 @@ export default function UploadPage() {
});
} else {
setTask({
id: taskId!,
id: currentTaskId,
title: fallbackTitle,
description: fallbackDescription,
instructions: fallbackInstructions,
@@ -188,9 +217,8 @@ export default function UploadPage() {
} catch (error) {
console.error('Failed to fetch task', error);
if (active) {
setTaskError(t('upload.loadError.title'));
setTask({
id: taskId!,
id: currentTaskId,
title: fallbackTitle,
description: fallbackDescription,
instructions: fallbackInstructions,
@@ -210,7 +238,7 @@ export default function UploadPage() {
return () => {
active = false;
};
}, [eventKey, taskId, emotionSlug, t]);
}, [eventKey, taskId, emotionSlug, t, token]);
// Check upload limits
useEffect(() => {
@@ -294,14 +322,15 @@ export default function UploadPage() {
streamRef.current = stream;
attachStreamToVideo(stream);
setPermissionState('granted');
} catch (error: any) {
} catch (error: unknown) {
console.error('Camera access error', error);
stopStream();
if (error?.name === 'NotAllowedError') {
const errorName = getErrorName(error);
if (errorName === 'NotAllowedError') {
setPermissionState('denied');
setPermissionMessage(t('upload.cameraDenied.explanation'));
} else if (error?.name === 'NotFoundError') {
} else if (errorName === 'NotFoundError') {
setPermissionState('error');
setPermissionMessage(t('upload.cameraUnsupported.message'));
} else {
@@ -489,9 +518,9 @@ export default function UploadPage() {
markCompleted(task.id);
stopStream();
navigateAfterUpload(photoId);
} catch (error: any) {
} catch (error: unknown) {
console.error('Upload failed', error);
setUploadError(error?.message || t('upload.status.failed'));
setUploadError(getErrorMessage(error) || t('upload.status.failed'));
setMode('review');
} finally {
if (uploadProgressTimerRef.current) {
@@ -533,7 +562,6 @@ export default function UploadPage() {
const isCameraActive = permissionState === 'granted' && mode !== 'uploading';
const showTaskOverlay = task && mode !== 'uploading';
const isUploadDisabled = !canUpload || !task;
useEffect(() => () => {
resetCountdownTimer();
@@ -542,49 +570,41 @@ export default function UploadPage() {
}
}, [resetCountdownTimer]);
const renderPage = (content: ReactNode, mainClassName = 'px-4 py-6') => (
<div className="pb-16">
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
<main className={mainClassName}>{content}</main>
<BottomNav />
</div>
);
if (!supportsCamera && !task) {
return (
<div className="pb-16">
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
<main className="px-4 py-6">
<Alert>
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
</Alert>
</main>
<BottomNav />
</div>
return renderPage(
<Alert>
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
</Alert>
);
}
if (loadingTask) {
return (
<div className="pb-16">
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
<main className="px-4 py-6 flex flex-col items-center justify-center text-center">
<Loader2 className="h-10 w-10 animate-spin text-pink-500 mb-4" />
<p className="text-sm text-muted-foreground">{t('upload.preparing')}</p>
</main>
<BottomNav />
return renderPage(
<div className="flex flex-col items-center justify-center gap-4 text-center">
<Loader2 className="h-10 w-10 animate-spin text-pink-500" />
<p className="text-sm text-muted-foreground">{t('upload.preparing')}</p>
</div>
);
}
if (!canUpload) {
return (
<div className="pb-16">
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
<main className="px-4 py-6">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
{t('upload.limitReached')
.replace('{used}', `${eventPackage?.used_photos || 0}`)
.replace('{max}', `${eventPackage?.package.max_photos || 0}`)}
</AlertDescription>
</Alert>
</main>
<BottomNav />
</div>
return renderPage(
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
{t('upload.limitReached')
.replace('{used}', `${eventPackage?.used_photos || 0}`)
.replace('{max}', `${eventPackage?.package.max_photos || 0}`)}
</AlertDescription>
</Alert>
);
}
@@ -636,18 +656,16 @@ export default function UploadPage() {
);
};
return (
<div className="pb-16">
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
<main className="relative flex flex-col gap-4 pb-4">
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
{renderPrimer()}
</div>
<div className="pt-32" />
{permissionState !== 'granted' && renderPermissionNotice()}
return renderPage(
<>
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
{renderPrimer()}
</div>
<div className="pt-32" />
{permissionState !== 'granted' && renderPermissionNotice()}
<section className="relative mx-4 overflow-hidden rounded-2xl border border-white/10 bg-black text-white shadow-xl">
<div className="relative aspect-[3/4] sm:aspect-video">
<section className="relative mx-4 overflow-hidden rounded-2xl border border-white/10 bg-black text-white shadow-xl">
<div className="relative aspect-[3/4] sm:aspect-video">
<video
ref={videoRef}
className={cn(
@@ -863,9 +881,10 @@ export default function UploadPage() {
/>
<div ref={liveRegionRef} className="sr-only" aria-live="assertive" />
</main>
<BottomNav />
<canvas ref={canvasRef} className="hidden" />
</div>
<canvas ref={canvasRef} className="hidden" />
</>
,
'relative flex flex-col gap-4 pb-4'
);
}