Guest PWA vollständig lokalisiert

This commit is contained in:
Codex Agent
2025-10-17 15:00:07 +02:00
parent bd38decc23
commit 25e8f0511b
26 changed files with 1464 additions and 588 deletions

View File

@@ -10,6 +10,7 @@ import { useEventStats } from '../context/EventStatsContext';
import { useEventData } from '../hooks/useEventData';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset } from 'lucide-react';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
export default function HomePage() {
const { token } = useParams<{ token: string }>();
@@ -17,63 +18,76 @@ export default function HomePage() {
const stats = useEventStats();
const { event } = useEventData();
const { completedCount } = useGuestTaskProgress(token);
const { t } = useTranslation();
if (!token) return null;
const displayName = hydrated && name ? name : 'Gast';
const latestUploadText = formatLatestUpload(stats.latestPhotoAt);
const displayName = hydrated && name ? name : t('home.fallbackGuestName');
const eventNameDisplay = event?.name ?? t('home.hero.defaultEventName');
const latestUploadText = formatLatestUpload(stats.latestPhotoAt, t);
const primaryActions: Array<{ to: string; label: string; description: string; icon: React.ReactNode }> = [
{
to: 'tasks',
label: 'Aufgabe ziehen',
description: 'Hol dir deine naechste Challenge',
icon: <Sparkles className="h-5 w-5" />,
},
{
to: 'upload',
label: 'Direkt hochladen',
description: 'Teile deine neuesten Fotos',
icon: <UploadCloud className="h-5 w-5" />,
},
{
to: 'gallery',
label: 'Galerie ansehen',
description: 'Lass dich von anderen inspirieren',
icon: <Images className="h-5 w-5" />,
},
];
const primaryActions = React.useMemo(
() => [
{
to: 'tasks',
label: t('home.actions.items.tasks.label'),
description: t('home.actions.items.tasks.description'),
icon: <Sparkles className="h-5 w-5" />,
},
{
to: 'upload',
label: t('home.actions.items.upload.label'),
description: t('home.actions.items.upload.description'),
icon: <UploadCloud className="h-5 w-5" />,
},
{
to: 'gallery',
label: t('home.actions.items.gallery.label'),
description: t('home.actions.items.gallery.description'),
icon: <Images className="h-5 w-5" />,
},
],
[t],
);
const checklistItems = [
'Aufgabe auswaehlen oder starten',
'Emotion festhalten und Foto schiessen',
'Bild hochladen und Credits sammeln',
];
const checklistItems = React.useMemo(
() => [
t('home.checklist.steps.first'),
t('home.checklist.steps.second'),
t('home.checklist.steps.third'),
],
[t],
);
return (
<div className="space-y-6 pb-24">
<HeroCard name={displayName} eventName={event?.name ?? 'Dein Event'} tasksCompleted={completedCount} />
<HeroCard
name={displayName}
eventName={eventNameDisplay}
tasksCompleted={completedCount}
t={t}
/>
<Card>
<CardContent className="grid grid-cols-1 gap-4 py-4 sm:grid-cols-4">
<StatTile
icon={<Users className="h-4 w-4" />}
label="Gleichzeitig online"
label={t('home.stats.online')}
value={`${stats.onlineGuests}`}
/>
<StatTile
icon={<Sparkles className="h-4 w-4" />}
label="Aufgaben gelöst"
label={t('home.stats.tasksSolved')}
value={`${stats.tasksSolved}`}
/>
<StatTile
icon={<TimerReset className="h-4 w-4" />}
label="Letzter Upload"
label={t('home.stats.lastUpload')}
value={latestUploadText}
/>
<StatTile
icon={<CheckCircle2 className="h-4 w-4" />}
label="Deine erledigten Aufgaben"
label={t('home.stats.completedTasks')}
value={`${completedCount}`}
/>
</CardContent>
@@ -81,8 +95,10 @@ export default function HomePage() {
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Deine Aktionen</h2>
<span className="text-xs text-muted-foreground">Waehle aus, womit du starten willst</span>
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t('home.actions.title')}
</h2>
<span className="text-xs text-muted-foreground">{t('home.actions.subtitle')}</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{primaryActions.map((action) => (
@@ -102,14 +118,14 @@ export default function HomePage() {
))}
</div>
<Button variant="outline" asChild className="w-full">
<Link to="queue">Uploads in Warteschlange ansehen</Link>
<Link to="queue">{t('home.actions.queueButton')}</Link>
</Button>
</section>
<Card>
<CardHeader>
<CardTitle>Dein Fortschritt</CardTitle>
<CardDescription>Halte dich an diese drei kurzen Schritte fuer die besten Ergebnisse.</CardDescription>
<CardTitle>{t('home.checklist.title')}</CardTitle>
<CardDescription>{t('home.checklist.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{checklistItems.map((item) => (
@@ -130,17 +146,29 @@ export default function HomePage() {
);
}
function HeroCard({ name, eventName, tasksCompleted }: { name: string; eventName: string; tasksCompleted: number }) {
function HeroCard({
name,
eventName,
tasksCompleted,
t,
}: {
name: string;
eventName: string;
tasksCompleted: number;
t: TranslateFn;
}) {
const heroTitle = t('home.hero.title').replace('{name}', name);
const heroDescription = t('home.hero.description').replace('{eventName}', eventName);
const progressMessage = tasksCompleted > 0
? `Schon ${tasksCompleted} Aufgaben erledigt - weiter so!`
: 'Starte mit deiner ersten Aufgabe - wir zählen auf dich!';
? t('home.hero.progress.some').replace('{count}', `${tasksCompleted}`)
: t('home.hero.progress.none');
return (
<Card className="overflow-hidden border-0 bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-md">
<CardHeader className="space-y-1">
<CardDescription className="text-sm text-white/80">Willkommen zur Party</CardDescription>
<CardTitle className="text-2xl font-bold">Hey {name}!</CardTitle>
<p className="text-sm text-white/80">Du bist bereit für "{eventName}". Fang die Highlights des Events ein und teile sie mit allen Gästen.</p>
<CardDescription className="text-sm text-white/80">{t('home.hero.subtitle')}</CardDescription>
<CardTitle className="text-2xl font-bold">{heroTitle}</CardTitle>
<p className="text-sm text-white/80">{heroDescription}</p>
<p className="text-sm font-medium text-white/90">{progressMessage}</p>
</CardHeader>
</Card>
@@ -161,26 +189,26 @@ function StatTile({ icon, label, value }: { icon: React.ReactNode; label: string
);
}
function formatLatestUpload(isoDate: string | null) {
function formatLatestUpload(isoDate: string | null, t: TranslateFn) {
if (!isoDate) {
return 'Noch kein Upload';
return t('home.latestUpload.none');
}
const date = new Date(isoDate);
if (Number.isNaN(date.getTime())) {
return 'Noch kein Upload';
return t('home.latestUpload.invalid');
}
const diffMs = Date.now() - date.getTime();
const diffMinutes = Math.round(diffMs / 60000);
if (diffMinutes < 1) {
return 'Gerade eben';
return t('home.latestUpload.justNow');
}
if (diffMinutes < 60) {
return `vor ${diffMinutes} Min`;
return t('home.latestUpload.minutes').replace('{count}', `${diffMinutes}`);
}
const diffHours = Math.round(diffMinutes / 60);
if (diffHours < 24) {
return `vor ${diffHours} Std`;
return t('home.latestUpload.hours').replace('{count}', `${diffHours}`);
}
const diffDays = Math.round(diffHours / 24);
return `vor ${diffDays} Tagen`;
return t('home.latestUpload.days').replace('{count}', `${diffDays}`);
}

View File

@@ -7,14 +7,19 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Html5Qrcode } from 'html5-qrcode';
import { readGuestName } from '../context/GuestIdentityContext';
import { useTranslation } from '../i18n/useTranslation';
type LandingErrorKey = 'eventClosed' | 'network' | 'camera';
export default function LandingPage() {
const nav = useNavigate();
const { t } = useTranslation();
const [eventCode, setEventCode] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [errorKey, setErrorKey] = useState<LandingErrorKey | null>(null);
const [isScanning, setIsScanning] = useState(false);
const [scanner, setScanner] = useState<Html5Qrcode | null>(null);
const errorMessage = errorKey ? t(`landing.errors.${errorKey}`) : null;
function extractEventKey(raw: string): string {
const trimmed = raw.trim();
@@ -48,11 +53,11 @@ export default function LandingPage() {
const normalized = extractEventKey(provided);
if (!normalized) return;
setLoading(true);
setError(null);
setErrorKey(null);
try {
const res = await fetch(`/api/v1/events/${encodeURIComponent(normalized)}`);
if (!res.ok) {
setError('Event nicht gefunden oder geschlossen.');
setErrorKey('eventClosed');
return;
}
const data = await res.json();
@@ -65,7 +70,7 @@ export default function LandingPage() {
}
} catch (e) {
console.error('Join request failed', e);
setError('Netzwerkfehler. Bitte spaeter erneut versuchen.');
setErrorKey('network');
} finally {
setLoading(false);
}
@@ -80,7 +85,7 @@ export default function LandingPage() {
setIsScanning(true);
} catch (err) {
console.error('Scanner start failed', err);
setError('Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.');
setErrorKey('camera');
}
return;
}
@@ -92,7 +97,7 @@ export default function LandingPage() {
setIsScanning(true);
} catch (err) {
console.error('Scanner initialisation failed', err);
setError('Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.');
setErrorKey('camera');
}
}
@@ -123,22 +128,22 @@ export default function LandingPage() {
}, [scanner]);
return (
<Page title="Willkommen bei der Fotobox!">
{error && (
<Page title={t('landing.pageTitle')}>
{errorMessage && (
<Alert className="mb-3" variant="destructive">
<AlertDescription>{error}</AlertDescription>
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
<div className="space-y-6 pb-20">
<div className="text-center space-y-2">
<h1 className="text-2xl font-bold text-gray-900">Willkommen bei der Fotobox!</h1>
<p className="text-lg text-gray-600">Dein Schluessel zu unvergesslichen Momenten.</p>
<h1 className="text-2xl font-bold text-gray-900">{t('landing.headline')}</h1>
<p className="text-lg text-gray-600">{t('landing.subheadline')}</p>
</div>
<Card className="mx-auto w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-xl font-semibold">Event beitreten</CardTitle>
<CardDescription>Scanne den QR-Code oder gib den Code manuell ein.</CardDescription>
<CardTitle className="text-xl font-semibold">{t('landing.join.title')}</CardTitle>
<CardDescription>{t('landing.join.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 p-6">
<div className="flex flex-col items-center space-y-3">
@@ -155,20 +160,20 @@ export default function LandingPage() {
onClick={isScanning ? stopScanner : startScanner}
disabled={loading}
>
{isScanning ? 'Scanner stoppen' : 'QR-Code scannen'}
{isScanning ? t('landing.scan.stop') : t('landing.scan.start')}
</Button>
</div>
</div>
<div className="border-t border-gray-200 py-2 text-center text-sm text-gray-500">
Oder manuell eingeben
{t('landing.scan.manualDivider')}
</div>
<div className="space-y-2">
<Input
value={eventCode}
onChange={(event) => setEventCode(event.target.value)}
placeholder="Event-Code eingeben"
placeholder={t('landing.input.placeholder')}
disabled={loading}
/>
<Button
@@ -176,7 +181,7 @@ export default function LandingPage() {
disabled={loading || !eventCode.trim()}
onClick={() => join()}
>
{loading ? 'Pruefe...' : 'Event beitreten'}
{loading ? t('landing.join.buttonLoading') : t('landing.join.button')}
</Button>
</div>
</CardContent>

View File

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

View File

@@ -4,6 +4,7 @@ import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Heart, ChevronLeft, ChevronRight, X } from 'lucide-react';
import { likePhoto } from '../services/photosApi';
import { useTranslation } from '../i18n/useTranslation';
type Photo = {
id: number;
@@ -31,6 +32,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
const navigate = useNavigate();
const photoId = params.photoId;
const eventSlug = params.token || slug;
const { t } = useTranslation();
const [standalonePhoto, setStandalonePhoto] = useState<Photo | null>(null);
const [loading, setLoading] = useState(true);
@@ -65,10 +67,10 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
setStandalonePhoto(location.state.photo);
}
} else {
setError('Foto nicht gefunden');
setError(t('lightbox.errors.notFound'));
}
} catch (err) {
setError('Fehler beim Laden des Fotos');
setError(t('lightbox.errors.loadFailed'));
} finally {
setLoading(false);
}
@@ -78,7 +80,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
} else if (!isStandalone) {
setLoading(false);
}
}, [isStandalone, photoId, eventSlug, standalonePhoto, location.state]);
}, [isStandalone, photoId, eventSlug, standalonePhoto, location.state, t]);
// Update likes when photo changes
React.useEffect(() => {
@@ -149,31 +151,31 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
if (foundTask) {
setTask({
id: foundTask.id,
title: foundTask.title || `Aufgabe ${taskId}`
title: foundTask.title || t('lightbox.fallbackTitle').replace('{id}', `${taskId}`)
});
} else {
setTask({
id: taskId,
title: `Unbekannte Aufgabe ${taskId}`
title: t('lightbox.unknownTitle').replace('{id}', `${taskId}`)
});
}
} else {
setTask({
id: taskId,
title: `Unbekannte Aufgabe ${taskId}`
title: t('lightbox.unknownTitle').replace('{id}', `${taskId}`)
});
}
} catch (error) {
console.error('Failed to load task:', error);
setTask({
id: taskId,
title: `Unbekannte Aufgabe ${taskId}`
title: t('lightbox.unknownTitle').replace('{id}', `${taskId}`)
});
} finally {
setTaskLoading(false);
}
})();
}, [photo?.task_id, eventSlug]);
}, [photo?.task_id, eventSlug, t]);
async function onLike() {
if (liked || !photo) return;
@@ -251,9 +253,9 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
{task && (
<div className="absolute bottom-4 left-4 right-4 z-20 bg-black/60 backdrop-blur-sm rounded-xl p-3 border border-white/20 max-w-md">
<div className="text-sm">
<div className="font-semibold mb-1 text-white">Task: {task.title}</div>
<div className="font-semibold mb-1 text-white">{t('lightbox.taskLabel')}: {task.title}</div>
{taskLoading && (
<div className="text-xs opacity-70 text-gray-300">Lade Aufgabe...</div>
<div className="text-xs opacity-70 text-gray-300">{t('lightbox.loadingTask')}</div>
)}
</div>
</div>
@@ -269,7 +271,14 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
>
<img
src={photo?.file_path || photo?.thumbnail_path}
alt={`Foto ${photo?.id || ''}${photo?.task_title ? ` - ${photo.task_title}` : ''}`}
alt={t('lightbox.photoAlt')
.replace('{id}', `${photo?.id ?? ''}`)
.replace(
'{suffix}',
photo?.task_title
? t('lightbox.photoAltTaskSuffix').replace('{taskTitle}', photo.task_title)
: ''
)}
className="max-h-[80vh] max-w-full object-contain transition-transform duration-200"
onError={(e) => {
console.error('Image load error:', e);
@@ -283,7 +292,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
<div className="absolute top-4 left-4 right-4 z-20 bg-black/60 backdrop-blur-sm rounded-xl p-3 border border-white/20 max-w-md">
<div className="text-sm text-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mx-auto mb-1"></div>
<div className="text-xs opacity-70">Lade Aufgabe...</div>
<div className="text-xs opacity-70">{t('lightbox.loadingTask')}</div>
</div>
</div>
)}

View File

@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import Header from '../components/Header';
import { useTranslation } from '../i18n/useTranslation';
export default function ProfileSetupPage() {
const { token } = useParams<{ token: string }>();
@@ -15,6 +15,7 @@ export default function ProfileSetupPage() {
const { name: storedName, setName: persistName, hydrated } = useGuestIdentity();
const [name, setName] = useState(storedName);
const [submitting, setSubmitting] = useState(false);
const { t } = useTranslation();
useEffect(() => {
if (!token) {
@@ -51,7 +52,7 @@ export default function ProfileSetupPage() {
if (loading) {
return (
<div className="flex justify-center items-center h-32">
<div className="text-lg">Lade Event...</div>
<div className="text-lg">{t('profileSetup.loading')}</div>
</div>
);
}
@@ -59,31 +60,30 @@ export default function ProfileSetupPage() {
if (error || !event) {
return (
<div className="text-center p-4">
<p className="text-red-600 mb-4">{error || 'Event nicht gefunden.'}</p>
<Button onClick={() => nav('/')}>Zurück zur Startseite</Button>
<p className="text-red-600 mb-4">{error || t('profileSetup.error.default')}</p>
<Button onClick={() => nav('/')}>{t('profileSetup.error.backToStart')}</Button>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-b from-pink-50 to-purple-50 flex flex-col">
<Header slug={token!} />
<div className="flex-1 flex flex-col justify-center items-center px-4 py-8">
<Card className="w-full max-w-md">
<CardHeader className="text-center space-y-2">
<CardTitle className="text-2xl font-bold text-gray-900">{event.name}</CardTitle>
<CardDescription className="text-lg text-gray-600">
Fange den schoensten Moment ein!
{t('profileSetup.card.description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 p-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-medium">Dein Name (z.B. Anna)</Label>
<Label htmlFor="name" className="text-sm font-medium">{t('profileSetup.form.label')}</Label>
<Input
id="name"
value={name}
onChange={(e) => handleChange(e.target.value)}
placeholder="Dein Name"
placeholder={t('profileSetup.form.placeholder')}
className="text-lg"
disabled={submitting || !hydrated}
autoComplete="name"
@@ -94,7 +94,7 @@ export default function ProfileSetupPage() {
onClick={submitName}
disabled={submitting || !name.trim() || !hydrated}
>
{submitting ? 'Speichere...' : "Let's go!"}
{submitting ? t('profileSetup.form.submitting') : t('profileSetup.form.submit')}
</Button>
</CardContent>
</Card>

View File

@@ -1,16 +1,12 @@
import React from 'react';
import { Page } from './_util';
import { useTranslation } from '../i18n/useTranslation';
export default function SettingsPage() {
const { t } = useTranslation();
return (
<Page title="Einstellungen">
<ul>
<li>Sprache</li>
<li>Theme</li>
<li>Cache leeren</li>
<li>Rechtliches</li>
</ul>
<Page title={t('settings.title')}>
<p style={{ fontSize: 14 }}>{t('settings.subtitle')}</p>
</Page>
);
}

View File

@@ -22,6 +22,7 @@ import {
ZapOff,
} from 'lucide-react';
import { getEventPackage, type EventPackage } from '../services/eventApi';
import { useTranslation } from '../i18n/useTranslation';
interface Task {
id: number;
@@ -62,6 +63,7 @@ export default function UploadPage() {
const { appearance } = useAppearance();
const isDarkMode = appearance === 'dark';
const { markCompleted } = useGuestTaskProgress(slug);
const { t } = useTranslation();
const taskIdParam = searchParams.get('task');
const emotionSlug = searchParams.get('emotion') || '';
@@ -137,7 +139,7 @@ export default function UploadPage() {
// Load task metadata
useEffect(() => {
if (!slug || !taskId) {
setTaskError('Keine Aufgabeninformationen gefunden.');
setTaskError(t('upload.loadError.title'));
setLoadingTask(false);
return;
}
@@ -145,6 +147,10 @@ export default function UploadPage() {
let active = true;
async function loadTask() {
const fallbackTitle = t('upload.taskInfo.fallbackTitle').replace('{id}', `${taskId!}`);
const fallbackDescription = t('upload.taskInfo.fallbackDescription');
const fallbackInstructions = t('upload.taskInfo.fallbackInstructions');
try {
setLoadingTask(true);
setTaskError(null);
@@ -159,9 +165,9 @@ export default function UploadPage() {
if (found) {
setTask({
id: found.id,
title: found.title || `Aufgabe ${taskId!}`,
description: found.description || 'Halte den Moment fest und teile ihn mit allen Gästen.',
instructions: found.instructions,
title: found.title || fallbackTitle,
description: found.description || fallbackDescription,
instructions: found.instructions ?? fallbackInstructions,
duration: found.duration || 2,
emotion: found.emotion,
difficulty: found.difficulty ?? 'medium',
@@ -169,9 +175,9 @@ export default function UploadPage() {
} else {
setTask({
id: taskId!,
title: `Aufgabe ${taskId!}`,
description: 'Halte den Moment fest und teile ihn mit allen Gästen.',
instructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.',
title: fallbackTitle,
description: fallbackDescription,
instructions: fallbackInstructions,
duration: 2,
emotion: emotionSlug
? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, (l) => l.toUpperCase()) }
@@ -182,12 +188,12 @@ export default function UploadPage() {
} catch (error) {
console.error('Failed to fetch task', error);
if (active) {
setTaskError('Aufgabe konnte nicht geladen werden. Du kannst trotzdem ein Foto machen.');
setTaskError(t('upload.loadError.title'));
setTask({
id: taskId!,
title: `Aufgabe ${taskId!}`,
description: 'Halte den Moment fest und teile ihn mit allen Gästen.',
instructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.',
title: fallbackTitle,
description: fallbackDescription,
instructions: fallbackInstructions,
duration: 2,
emotion: emotionSlug
? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, (l) => l.toUpperCase()) }
@@ -204,7 +210,7 @@ export default function UploadPage() {
return () => {
active = false;
};
}, [eventKey, taskId, emotionSlug]);
}, [eventKey, taskId, emotionSlug, t]);
// Check upload limits
useEffect(() => {
@@ -216,19 +222,27 @@ export default function UploadPage() {
setEventPackage(pkg);
if (pkg && pkg.used_photos >= pkg.package.max_photos) {
setCanUpload(false);
setUploadError('Upload-Limit erreicht. Kontaktieren Sie den Organisator für ein Upgrade.');
const maxLabel = pkg.package.max_photos == null
? t('upload.limitUnlimited')
: `${pkg.package.max_photos}`;
setUploadError(
t('upload.limitReached')
.replace('{used}', `${pkg.used_photos}`)
.replace('{max}', maxLabel)
);
} else {
setCanUpload(true);
setUploadError(null);
}
} catch (err) {
console.error('Failed to check package limits', err);
setCanUpload(false);
setUploadError('Fehler beim Prüfen des Limits. Upload deaktiviert.');
setUploadError(t('upload.limitCheckError'));
}
};
checkLimits();
}, [eventKey, task]);
}, [eventKey, task, t]);
const stopStream = useCallback(() => {
if (streamRef.current) {
@@ -265,7 +279,7 @@ export default function UploadPage() {
const startCamera = useCallback(async () => {
if (!supportsCamera) {
setPermissionState('unsupported');
setPermissionMessage('Dieses Gerät oder der Browser unterstützt keine Kamera-Zugriffe.');
setPermissionMessage(t('upload.cameraUnsupported.message'));
return;
}
@@ -286,18 +300,16 @@ export default function UploadPage() {
if (error?.name === 'NotAllowedError') {
setPermissionState('denied');
setPermissionMessage(
'Kamera-Zugriff wurde blockiert. Prüfe die Berechtigungen deines Browsers und versuche es erneut.'
);
setPermissionMessage(t('upload.cameraDenied.explanation'));
} else if (error?.name === 'NotFoundError') {
setPermissionState('error');
setPermissionMessage('Keine Kamera gefunden. Du kannst stattdessen ein Foto aus deiner Galerie wählen.');
setPermissionMessage(t('upload.cameraUnsupported.message'));
} else {
setPermissionState('error');
setPermissionMessage(`Kamera konnte nicht gestartet werden: ${error?.message || 'Unbekannter Fehler'}`);
setPermissionMessage(t('upload.cameraError.explanation'));
}
}
}, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, task]);
}, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, task, t]);
useEffect(() => {
if (!task || loadingTask) return;
@@ -311,15 +323,15 @@ export default function UploadPage() {
useEffect(() => {
if (!liveRegionRef.current) return;
if (mode === 'countdown') {
liveRegionRef.current.textContent = `Foto wird in ${countdownValue} Sekunden aufgenommen.`;
liveRegionRef.current.textContent = t('upload.countdown.ready').replace('{count}', `${countdownValue}`);
} else if (mode === 'review') {
liveRegionRef.current.textContent = 'Foto aufgenommen. <20>berpr<70>fe die Vorschau.';
liveRegionRef.current.textContent = t('upload.review.readyAnnouncement');
} else if (mode === 'uploading') {
liveRegionRef.current.textContent = 'Foto wird hochgeladen.';
liveRegionRef.current.textContent = t('upload.status.uploading');
} else {
liveRegionRef.current.textContent = '';
}
}, [mode, countdownValue]);
}, [mode, countdownValue, t]);
const dismissPrimer = useCallback(() => {
setShowPrimer(false);
@@ -360,7 +372,7 @@ export default function UploadPage() {
const performCapture = useCallback(() => {
if (!videoRef.current || !canvasRef.current) {
setUploadError('Kamera nicht bereit. Bitte versuche es erneut.');
setUploadError(t('upload.captureError'));
setMode('preview');
return;
}
@@ -371,7 +383,7 @@ export default function UploadPage() {
const height = video.videoHeight;
if (!width || !height) {
setUploadError('Kamera liefert kein Bild. Bitte starte die Kamera neu.');
setUploadError(t('upload.feedError'));
setMode('preview');
startCamera();
return;
@@ -381,7 +393,7 @@ export default function UploadPage() {
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
setUploadError('Canvas konnte nicht initialisiert werden.');
setUploadError(t('upload.canvasError'));
setMode('preview');
return;
}
@@ -399,7 +411,7 @@ export default function UploadPage() {
canvas.toBlob(
(blob) => {
if (!blob) {
setUploadError('Foto konnte nicht erstellt werden.');
setUploadError(t('upload.captureError'));
setMode('preview');
return;
}
@@ -413,7 +425,7 @@ export default function UploadPage() {
'image/jpeg',
0.92
);
}, [preferences.facingMode, preferences.mirrorFrontPreview, startCamera]);
}, [preferences.facingMode, preferences.mirrorFrontPreview, startCamera, t]);
const beginCapture = useCallback(() => {
setUploadError(null);
@@ -461,7 +473,7 @@ export default function UploadPage() {
setMode('uploading');
setUploadProgress(5);
setUploadError(null);
setStatusMessage('Foto wird vorbereitet...');
setStatusMessage(t('upload.status.preparing'));
if (uploadProgressTimerRef.current) {
window.clearInterval(uploadProgressTimerRef.current);
@@ -473,13 +485,13 @@ export default function UploadPage() {
try {
const photoId = await uploadPhoto(eventKey, reviewPhoto.file, task.id, emotionSlug || undefined);
setUploadProgress(100);
setStatusMessage('Upload abgeschlossen.');
setStatusMessage(t('upload.status.completed'));
markCompleted(task.id);
stopStream();
navigateAfterUpload(photoId);
} catch (error: any) {
console.error('Upload failed', error);
setUploadError(error?.message || 'Upload fehlgeschlagen. Bitte versuche es erneut.');
setUploadError(error?.message || t('upload.status.failed'));
setMode('review');
} finally {
if (uploadProgressTimerRef.current) {
@@ -488,7 +500,7 @@ export default function UploadPage() {
}
setStatusMessage('');
}
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload]);
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t]);
const handleGalleryPick = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
if (!canUpload) return;
@@ -501,10 +513,10 @@ export default function UploadPage() {
setMode('review');
};
reader.onerror = () => {
setUploadError('Auswahl fehlgeschlagen. Bitte versuche es erneut.');
setUploadError(t('upload.galleryPickError'));
};
reader.readAsDataURL(file);
}, [canUpload]);
}, [canUpload, t]);
const difficultyBadgeClass = useMemo(() => {
if (!task) return 'text-white';
@@ -533,12 +545,10 @@ export default function UploadPage() {
if (!supportsCamera && !task) {
return (
<div className="pb-16">
<Header slug={eventKey} title="Kamera" />
<Header slug={eventKey} title={t('upload.cameraTitle')} />
<main className="px-4 py-6">
<Alert>
<AlertDescription>
Dieses Gerät unterstützt keine Kamera-Zugriffe. Du kannst stattdessen Fotos aus deiner Galerie hochladen.
</AlertDescription>
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
</Alert>
</main>
<BottomNav />
@@ -549,10 +559,10 @@ export default function UploadPage() {
if (loadingTask) {
return (
<div className="pb-16">
<Header slug={eventKey} title="Kamera" />
<Header slug={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">Aufgabe und Kamera werden vorbereitet ...</p>
<p className="text-sm text-muted-foreground">{t('upload.preparing')}</p>
</main>
<BottomNav />
</div>
@@ -562,13 +572,14 @@ export default function UploadPage() {
if (!canUpload) {
return (
<div className="pb-16">
<Header slug={eventKey} title="Kamera" />
<Header slug={eventKey} title={t('upload.cameraTitle')} />
<main className="px-4 py-6">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Upload-Limit erreicht ({eventPackage?.used_photos || 0} / {eventPackage?.package.max_photos || 0} Fotos).
Kontaktieren Sie den Organisator für ein Package-Upgrade.
{t('upload.limitReached')
.replace('{used}', `${eventPackage?.used_photos || 0}`)
.replace('{max}', `${eventPackage?.package.max_photos || 0}`)}
</AlertDescription>
</Alert>
</main>
@@ -583,13 +594,14 @@ export default function UploadPage() {
<div className="flex items-start gap-3">
<Info className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="text-left">
<p className="font-semibold">Bereit für dein Shooting?</p>
<p className="font-semibold">{t('upload.primer.title')}</p>
<p className="mt-1">
Suche dir gutes Licht, halte die Stimmung der Aufgabe fest und nutze die Kontrollleiste für Countdown, Grid und Kamerawechsel.
{t('upload.primer.body.part1')}{' '}
{t('upload.primer.body.part2')}
</p>
</div>
<Button variant="ghost" size="sm" onClick={dismissPrimer}>
Alles klar
{t('upload.primer.dismiss')}
</Button>
</div>
</div>
@@ -601,9 +613,7 @@ export default function UploadPage() {
if (permissionState === 'unsupported') {
return (
<Alert className="mx-4">
<AlertDescription>
Dieses Gerät unterstützt keine Kamera. Nutze den Button `Foto aus Galerie wählen`, um dennoch teilzunehmen.
</AlertDescription>
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
</Alert>
);
}
@@ -613,24 +623,22 @@ export default function UploadPage() {
<AlertDescription className="space-y-3">
<div>{permissionMessage}</div>
<Button size="sm" variant="outline" onClick={startCamera}>
Erneut versuchen
{t('upload.buttons.tryAgain')}
</Button>
</AlertDescription>
</Alert>
);
}
return (
<Alert className="mx-4">
<AlertDescription>
Wir benötigen Zugriff auf deine Kamera. Bestätige die Browser-Abfrage oder nutze alternativ ein Foto aus deiner Galerie.
</AlertDescription>
</Alert>
<Alert className="mx-4">
<AlertDescription>{t('upload.cameraDenied.prompt')}</AlertDescription>
</Alert>
);
};
return (
<div className="pb-16">
<Header slug={eventKey} title="Kamera" />
<Header slug={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()}
@@ -666,14 +674,17 @@ export default function UploadPage() {
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/70 text-center text-sm">
<Camera className="mb-3 h-8 w-8 text-pink-400" />
<p className="max-w-xs text-white/90">
Kamera ist nicht aktiv. {permissionMessage || 'Tippe auf `Kamera starten`, um loszulegen.'}
{t('upload.cameraInactive').replace(
'{hint}',
(permissionMessage ?? t('upload.cameraInactiveHint').replace('{label}', t('upload.buttons.startCamera')))
)}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" onClick={startCamera}>
Kamera starten
{t('upload.buttons.startCamera')}
</Button>
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()}>
Foto aus Galerie wählen
{t('upload.galleryButton')}
</Button>
</div>
</div>
@@ -684,14 +695,10 @@ export default function UploadPage() {
<div className="flex items-center justify-between gap-2">
<Badge variant="secondary" className="flex items-center gap-2 text-xs">
<Sparkles className="h-3.5 w-3.5" />
Aufgabe #{task.id}
{t('upload.taskInfo.badge').replace('{id}', `${task.id}`)}
</Badge>
<span className={cn('text-xs font-medium uppercase tracking-wide', difficultyBadgeClass)}>
{task.difficulty === 'easy'
? 'Leicht'
: task.difficulty === 'hard'
? 'Herausfordernd'
: 'Medium'}
{t(`upload.taskInfo.difficulty.${task.difficulty ?? 'medium'}`)}
</span>
</div>
<div>
@@ -699,13 +706,19 @@ export default function UploadPage() {
<p className="mt-1 text-xs leading-relaxed text-white/80">{task.description}</p>
</div>
<div className="flex flex-wrap items-center gap-2 text-[11px] text-white/70">
{task.instructions && <span>Hinweis: {task.instructions}</span>}
{task.instructions && (
<span>
{t('upload.taskInfo.instructionsPrefix')}: {task.instructions}
</span>
)}
{emotionSlug && (
<span className="rounded-full border border-white/20 px-2 py-0.5">Stimmung: {task.emotion?.name || emotionSlug}</span>
<span className="rounded-full border border-white/20 px-2 py-0.5">
{t('upload.taskInfo.emotion').replace('{value}', `${task.emotion?.name || emotionSlug}`)}
</span>
)}
{preferences.countdownEnabled && (
<span className="rounded-full border border-white/20 px-2 py-0.5">
Countdown: {preferences.countdownSeconds}s
{t('upload.countdownLabel').replace('{seconds}', `${preferences.countdownSeconds}`)}
</span>
)}
</div>
@@ -715,7 +728,7 @@ export default function UploadPage() {
{mode === 'countdown' && (
<div className="absolute inset-0 z-40 flex flex-col items-center justify-center bg-black/60 text-white">
<div className="text-6xl font-bold">{countdownValue}</div>
<p className="mt-2 text-sm text-white/70">Bereit machen ...</p>
<p className="mt-2 text-sm text-white/70">{t('upload.countdownReady')}</p>
</div>
)}
@@ -760,7 +773,7 @@ export default function UploadPage() {
onClick={handleToggleGrid}
>
<Grid3X3 className="h-5 w-5" />
<span className="sr-only">Raster umschalten</span>
<span className="sr-only">{t('upload.controls.toggleGrid')}</span>
</Button>
<Button
size="icon"
@@ -769,7 +782,7 @@ export default function UploadPage() {
onClick={handleToggleCountdown}
>
<span className="text-sm font-semibold">{preferences.countdownSeconds}s</span>
<span className="sr-only">Countdown umschalten</span>
<span className="sr-only">{t('upload.controls.toggleCountdown')}</span>
</Button>
{preferences.facingMode === 'user' && (
<Button
@@ -779,7 +792,7 @@ export default function UploadPage() {
onClick={handleToggleMirror}
>
<span className="text-sm font-semibold">?</span>
<span className="sr-only">Spiegelung für Frontkamera umschalten</span>
<span className="sr-only">{t('upload.controls.toggleMirror')}</span>
</Button>
)}
<Button
@@ -790,7 +803,7 @@ export default function UploadPage() {
disabled={preferences.facingMode !== 'environment'}
>
{preferences.flashPreferred ? <Zap className="h-5 w-5 text-yellow-300" /> : <ZapOff className="h-5 w-5" />}
<span className="sr-only">Blitzpräferenz umschalten</span>
<span className="sr-only">{t('upload.controls.toggleFlash')}</span>
</Button>
</div>
@@ -802,7 +815,7 @@ export default function UploadPage() {
onClick={handleSwitchCamera}
>
<RotateCcw className="mr-1 h-4 w-4" />
Kamera wechseln
{t('upload.switchCamera')}
</Button>
<Button
variant="secondary"
@@ -811,7 +824,7 @@ export default function UploadPage() {
onClick={() => fileInputRef.current?.click()}
>
<ImagePlus className="mr-1 h-4 w-4" />
Foto aus Galerie
{t('upload.galleryButton')}
</Button>
</div>
</div>
@@ -820,10 +833,10 @@ export default function UploadPage() {
{mode === 'review' && reviewPhoto ? (
<div className="flex w-full max-w-md flex-col gap-3 sm:flex-row">
<Button variant="secondary" className="flex-1" onClick={handleRetake}>
Noch einmal
{t('upload.review.retake')}
</Button>
<Button className="flex-1" onClick={handleUsePhoto}>
Foto verwenden
{t('upload.review.keep')}
</Button>
</div>
) : (
@@ -834,7 +847,7 @@ export default function UploadPage() {
disabled={!isCameraActive || mode === 'countdown'}
>
<Camera className="h-7 w-7" />
<span className="sr-only">Foto aufnehmen</span>
<span className="sr-only">{t('upload.captureButton')}</span>
</Button>
)}
</div>

View File

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