referenzen auf "credits" entfernt. Kamera-Seite schicker gemacht

This commit is contained in:
Codex Agent
2025-11-13 10:44:16 +01:00
parent a4feb431fb
commit d9a63a6209
14 changed files with 373 additions and 219 deletions

View File

@@ -1,7 +1,5 @@
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';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
@@ -19,6 +17,7 @@ import { cn } from '@/lib/utils';
import {
AlertTriangle,
Camera,
ChevronDown,
Grid3X3,
ImagePlus,
Info,
@@ -109,7 +108,7 @@ export default function UploadPage() {
const eventKey = token ?? '';
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { markCompleted, completedCount } = useGuestTaskProgress(token);
const { markCompleted } = useGuestTaskProgress(token);
const { t, locale } = useTranslation();
const stats = useEventStats();
@@ -137,7 +136,8 @@ const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null);
const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null);
const [taskDetailsExpanded, setTaskDetailsExpanded] = useState(false);
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
const [canUpload, setCanUpload] = useState(true);
@@ -616,10 +616,13 @@ const [canUpload, setCanUpload] = useState(true);
reader.readAsDataURL(file);
}, [canUpload, t]);
const handleOpenInspiration = useCallback(() => {
if (!eventKey) return;
navigate(`/e/${encodeURIComponent(eventKey)}/gallery`);
}, [eventKey, navigate]);
const emotionLabel = useMemo(() => {
if (task?.emotion?.name) return task.emotion.name;
if (emotionSlug) {
return emotionSlug.replace('-', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
}
return t('upload.hud.moodFallback');
}, [emotionSlug, t, task?.emotion?.name]);
const difficultyBadgeClass = useMemo(() => {
if (!task) return 'text-white';
@@ -641,6 +644,27 @@ const [canUpload, setCanUpload] = useState(true);
[stats.latestPhotoAt, t],
);
const socialChips = useMemo(
() => [
{
id: 'online',
label: t('upload.hud.cards.online'),
value: stats.onlineGuests > 0 ? `${stats.onlineGuests}` : '0',
},
{
id: 'emotion',
label: t('upload.taskInfo.emotion').replace('{value}', emotionLabel),
value: t('upload.hud.moodLabel').replace('{mood}', emotionLabel),
},
{
id: 'last-upload',
label: t('upload.hud.cards.lastUpload'),
value: relativeLastUpload,
},
],
[emotionLabel, relativeLastUpload, stats.onlineGuests, t],
);
useEffect(() => () => {
resetCountdownTimer();
if (uploadProgressTimerRef.current) {
@@ -648,7 +672,64 @@ const [canUpload, setCanUpload] = useState(true);
}
}, [resetCountdownTimer]);
const heroEmotion = task?.emotion?.name ?? t('upload.hud.moodFallback');
useEffect(() => {
setTaskDetailsExpanded(false);
}, [task?.id]);
const handlePrimaryAction = useCallback(() => {
if (!isCameraActive) {
startCamera();
return;
}
beginCapture();
}, [beginCapture, isCameraActive, startCamera]);
const taskFloatingCard = showTaskOverlay && task ? (
<button
type="button"
onClick={() => setTaskDetailsExpanded((prev) => !prev)}
className="absolute left-6 right-6 top-0 z-30 -translate-y-1/2 rounded-3xl border border-white/40 bg-black/70 p-4 text-left text-white shadow-2xl backdrop-blur transition hover:bg-black/80 focus:outline-none focus:ring-2 focus:ring-white/60"
>
<div className="flex items-center gap-3">
<Badge variant="secondary" className="flex items-center gap-2 rounded-full text-[11px]">
<Sparkles className="h-3.5 w-3.5" />
{t('upload.taskInfo.badge').replace('{id}', `${task.id}`)}
</Badge>
<span className={cn('text-xs font-semibold uppercase tracking-wide', difficultyBadgeClass)}>
{t(`upload.taskInfo.difficulty.${task.difficulty ?? 'medium'}`)}
</span>
<span className="ml-auto flex items-center text-xs uppercase tracking-wide text-white/70">
{emotionLabel}
<ChevronDown
className={cn('ml-1 h-3.5 w-3.5 transition', taskDetailsExpanded ? 'rotate-180' : 'rotate-0')}
/>
</span>
</div>
<p className="mt-3 text-sm font-semibold leading-snug">{task.title}</p>
{taskDetailsExpanded ? (
<div className="mt-2 space-y-2 text-xs text-white/80">
<p>{task.description}</p>
<div className="flex flex-wrap items-center gap-2 text-[11px]">
{task.instructions && (
<span>
{t('upload.taskInfo.instructionsPrefix')}: {task.instructions}
</span>
)}
{preferences.countdownEnabled && (
<span className="rounded-full border border-white/20 px-2 py-0.5">
{t('upload.countdownLabel').replace('{seconds}', `${preferences.countdownSeconds}`)}
</span>
)}
{emotionLabel && (
<span className="rounded-full border border-white/20 px-2 py-0.5">
{t('upload.taskInfo.emotion').replace('{value}', emotionLabel)}
</span>
)}
</div>
</div>
) : null}
</button>
) : null;
const limitStatusSection = limitCards.length > 0 ? (
<section className="space-y-4 rounded-[28px] border border-white/20 bg-white/80 p-5 shadow-lg backdrop-blur dark:border-white/10 dark:bg-slate-900/60">
@@ -735,28 +816,11 @@ const [canUpload, setCanUpload] = useState(true);
const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[120px]') => (
<>
<div className="pb-16">
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
<main className="px-4">
<div className={wrapperClassName}>{content}</div>
</main>
<BottomNav />
</div>
<div className={wrapperClassName}>{content}</div>
{errorDialogNode}
</>
);
if (!supportsCamera && !task) {
return renderWithDialog(
<>
{limitStatusSection}
<Alert>
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
</Alert>
</>
);
}
if (loadingTask) {
return renderWithDialog(
<div className="flex flex-col items-center justify-center gap-4 text-center">
@@ -804,35 +868,59 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
const renderPermissionNotice = () => {
if (permissionState === 'granted') return null;
if (permissionState === 'unsupported') {
return (
<Alert className="rounded-[24px] border border-amber-200 bg-amber-50/70 text-amber-900 dark:border-amber-400/40 dark:bg-amber-500/10 dark:text-amber-50">
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
</Alert>
);
}
if (permissionState === 'denied' || permissionState === 'error') {
return (
<Alert variant="destructive" className="rounded-[24px] border border-rose-200 bg-rose-50/80 text-rose-900 dark:border-rose-500/40 dark:bg-rose-500/15 dark:text-rose-50">
<AlertDescription className="space-y-3">
<div>{permissionMessage}</div>
<Button size="sm" variant="outline" onClick={startCamera}>
{t('upload.buttons.tryAgain')}
</Button>
</AlertDescription>
</Alert>
);
}
const titles: Record<PermissionState, string> = {
idle: t('upload.cameraDenied.title'),
prompt: t('upload.cameraDenied.title'),
granted: '',
denied: t('upload.cameraDenied.title'),
error: t('upload.cameraError.title'),
unsupported: t('upload.cameraUnsupported.title'),
};
const fallbackMessages: Record<PermissionState, string> = {
idle: t('upload.cameraDenied.prompt'),
prompt: t('upload.cameraDenied.prompt'),
granted: '',
denied: t('upload.cameraDenied.explanation'),
error: t('upload.cameraError.explanation'),
unsupported: t('upload.cameraUnsupported.message'),
};
const title = titles[permissionState];
const description = permissionMessage ?? fallbackMessages[permissionState];
const canRetryCamera = permissionState !== 'unsupported';
return (
<Alert className="rounded-[24px] border border-slate-200 bg-white/80 text-slate-800 dark:border-white/10 dark:bg-white/5 dark:text-white">
<AlertDescription>{t('upload.cameraDenied.prompt')}</AlertDescription>
</Alert>
<div className="rounded-[32px] border border-white/15 bg-white/85 p-5 text-slate-900 shadow-lg dark:border-white/10 dark:bg-white/5 dark:text-white">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-900/10 dark:bg-white/10">
<Camera className="h-6 w-6" />
</div>
<div>
<p className="text-sm font-semibold">{title}</p>
<p className="text-xs text-slate-600 dark:text-white/70">{description}</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-3">
{canRetryCamera && (
<Button onClick={startCamera} size="sm">
{t('upload.buttons.startCamera')}
</Button>
)}
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
{t('upload.galleryButton')}
</Button>
</div>
</div>
);
};
return renderWithDialog(
<>
<section className="relative overflow-hidden rounded-[32px] border border-white/10 bg-black text-white shadow-2xl">
<div className="relative pt-8">
{taskFloatingCard}
<section className="relative overflow-hidden rounded-[32px] border border-white/10 bg-black text-white shadow-2xl">
<div className="relative aspect-[3/4] sm:aspect-video">
<video
ref={videoRef}
@@ -857,57 +945,13 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
)}
{!isCameraActive && (
<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">
{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}>
{t('upload.buttons.startCamera')}
</Button>
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()}>
{t('upload.galleryButton')}
</Button>
</div>
</div>
)}
{showTaskOverlay && task && (
<div className="absolute left-3 right-3 top-3 z-30 flex flex-col gap-2 rounded-xl border border-white/15 bg-black/40 p-3 backdrop-blur-sm">
<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" />
{t('upload.taskInfo.badge').replace('{id}', `${task.id}`)}
</Badge>
<span className={cn('text-xs font-medium uppercase tracking-wide', difficultyBadgeClass)}>
{t(`upload.taskInfo.difficulty.${task.difficulty ?? 'medium'}`)}
</span>
</div>
<div>
<h1 className="text-lg font-semibold leading-tight">{task.title}</h1>
<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>
{t('upload.taskInfo.instructionsPrefix')}: {task.instructions}
</span>
)}
{emotionSlug && (
<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">
{t('upload.countdownLabel').replace('{seconds}', `${preferences.countdownSeconds}`)}
</span>
)}
</div>
<div className="absolute left-4 top-4 z-20 flex items-center gap-2 rounded-full bg-black/70 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white">
<Camera className="h-4 w-4 text-pink-400" />
<span>
{permissionState === 'unsupported'
? t('upload.cameraUnsupported.title')
: t('upload.cameraDenied.title')}
</span>
</div>
)}
@@ -940,7 +984,7 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
)}
</div>
<div className="relative z-30 flex flex-col gap-3 bg-gradient-to-t from-black via-black/80 to-transparent p-4">
<div className="relative z-30 flex flex-col gap-4 bg-gradient-to-t from-black via-black/80 to-transparent p-4">
{uploadWarning && (
<Alert className="border-yellow-400/20 bg-yellow-500/10 text-white">
<AlertDescription className="text-xs">
@@ -957,72 +1001,67 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
</Alert>
)}
<div className="flex flex-wrap justify-between gap-3">
<div className="flex flex-wrap gap-2">
<Button
size="icon"
variant={preferences.gridEnabled ? 'default' : 'secondary'}
className="h-10 w-10 rounded-full bg-white/15 text-white"
onClick={handleToggleGrid}
>
<Grid3X3 className="h-5 w-5" />
<span className="sr-only">{t('upload.controls.toggleGrid')}</span>
</Button>
<Button
size="icon"
variant={preferences.countdownEnabled ? 'default' : 'secondary'}
className="h-10 w-10 rounded-full bg-white/15 text-white"
onClick={handleToggleCountdown}
>
<span className="text-sm font-semibold">{preferences.countdownSeconds}s</span>
<span className="sr-only">{t('upload.controls.toggleCountdown')}</span>
</Button>
{preferences.facingMode === 'user' && (
<Button
size="icon"
variant={preferences.mirrorFrontPreview ? 'default' : 'secondary'}
className="h-10 w-10 rounded-full bg-white/15 text-white"
onClick={handleToggleMirror}
>
<span className="text-sm font-semibold">?</span>
<span className="sr-only">{t('upload.controls.toggleMirror')}</span>
</Button>
<div className="flex flex-wrap items-center justify-center gap-2 text-xs font-medium uppercase tracking-wide text-white/80">
<Button
size="sm"
variant={preferences.gridEnabled ? 'default' : 'secondary'}
className={cn(
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
preferences.gridEnabled && 'bg-white text-black'
)}
onClick={handleToggleGrid}
>
<Grid3X3 className="mr-1 h-3.5 w-3.5" />
{t('upload.controls.toggleGrid')}
</Button>
<Button
size="sm"
variant={preferences.countdownEnabled ? 'default' : 'secondary'}
className={cn(
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
preferences.countdownEnabled && 'bg-white text-black'
)}
onClick={handleToggleCountdown}
>
{t('upload.countdownLabel').replace('{seconds}', `${preferences.countdownSeconds}`)}
</Button>
{preferences.facingMode === 'user' && (
<Button
size="icon"
variant={preferences.flashPreferred ? 'default' : 'secondary'}
className="h-10 w-10 rounded-full bg-white/15 text-white"
onClick={handleToggleFlashPreference}
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">{t('upload.controls.toggleFlash')}</span>
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
className="rounded-full border-white/30 bg-white/10 text-white"
onClick={handleSwitchCamera}
variant={preferences.mirrorFrontPreview ? 'default' : 'secondary'}
className={cn(
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
preferences.mirrorFrontPreview && 'bg-white text-black'
)}
onClick={handleToggleMirror}
>
<RotateCcw className="mr-1 h-4 w-4" />
{t('upload.switchCamera')}
{t('upload.controls.toggleMirror')}
</Button>
<Button
variant="secondary"
size="sm"
className="rounded-full border-white/30 bg-white/10 text-white"
onClick={() => fileInputRef.current?.click()}
>
<ImagePlus className="mr-1 h-4 w-4" />
{t('upload.galleryButton')}
</Button>
</div>
)}
<Button
size="sm"
variant={preferences.flashPreferred ? 'default' : 'secondary'}
className={cn(
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
preferences.flashPreferred && 'bg-white text-black'
)}
onClick={handleToggleFlashPreference}
disabled={preferences.facingMode !== 'environment'}
>
{preferences.flashPreferred ? <Zap className="mr-1 h-3.5 w-3.5 text-yellow-300" /> : <ZapOff className="mr-1 h-3.5 w-3.5" />}
{t('upload.controls.toggleFlash')}
</Button>
</div>
<div className="flex items-center justify-center">
<div className="flex items-center justify-center gap-6">
<Button
variant="ghost"
className="flex h-14 w-14 items-center justify-center rounded-full border border-white/30 bg-white/10 text-white"
onClick={() => fileInputRef.current?.click()}
>
<ImagePlus className="h-6 w-6" />
<span className="sr-only">{t('upload.galleryButton')}</span>
</Button>
{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}>
@@ -1035,17 +1074,42 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
) : (
<Button
size="lg"
className="h-16 w-16 rounded-full border-4 border-white/40 bg-white/90 text-black shadow-xl"
onClick={beginCapture}
disabled={!isCameraActive || mode === 'countdown'}
className="flex h-20 w-20 items-center justify-center rounded-full border-4 border-white/40 bg-white text-black shadow-2xl"
onClick={handlePrimaryAction}
disabled={mode === 'countdown' || mode === 'uploading'}
>
<Camera className="h-7 w-7" />
<span className="sr-only">{t('upload.captureButton')}</span>
<Camera className="h-8 w-8" />
<span className="sr-only">
{isCameraActive ? t('upload.captureButton') : t('upload.buttons.startCamera')}
</span>
</Button>
)}
<Button
variant="ghost"
className="flex h-14 w-14 items-center justify-center rounded-full border border-white/30 bg-white/10 text-white"
onClick={handleSwitchCamera}
>
<RotateCcw className="h-6 w-6" />
<span className="sr-only">{t('upload.switchCamera')}</span>
</Button>
</div>
</div>
</section>
</section>
</div>
{socialChips.length > 0 && (
<div className="mt-4 flex gap-3 overflow-x-auto pb-2">
{socialChips.map((chip) => (
<div
key={chip.id}
className="shrink-0 rounded-full border border-white/15 bg-white/80 px-4 py-2 text-xs font-semibold text-slate-800 shadow dark:border-white/10 dark:bg-white/10 dark:text-white"
>
<span className="block text-[10px] uppercase tracking-wide opacity-70">{chip.label}</span>
<span className="text-sm">{chip.value}</span>
</div>
))}
</div>
)}
{permissionState !== 'granted' && renderPermissionNotice()}
{limitStatusSection}