überarbeitung der kamera-seite: deutlich aufgeräumter und mehr platz für das Video/Bild

This commit is contained in:
Codex Agent
2025-12-05 18:44:11 +01:00
parent c1bd4c1eb3
commit f4f40f7a0c
3 changed files with 219 additions and 148 deletions

11
package-lock.json generated
View File

@@ -32,6 +32,7 @@
"@types/react": "^19.0.3",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.6.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -5544,6 +5545,16 @@
"node": ">=6"
}
},
"node_modules/canvas-confetti": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz",
"integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==",
"license": "ISC",
"funding": {
"type": "donate",
"url": "https://www.paypal.me/kirilvatev"
}
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",

View File

@@ -68,6 +68,7 @@
"@types/react": "^19.0.3",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.6.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",

View File

@@ -20,15 +20,16 @@ import {
Camera,
ChevronDown,
Grid3X3,
Menu,
ImagePlus,
Info,
Loader2,
RotateCcw,
Timer,
Sparkles,
FlipHorizontal,
Zap,
ZapOff,
Maximize2,
Minimize2,
} from 'lucide-react';
import { getEventPackage, type EventPackage } from '../services/eventApi';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
@@ -147,7 +148,8 @@ export default function UploadPage() {
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
const [immersiveMode, setImmersiveMode] = useState(false);
const [immersiveMode, setImmersiveMode] = useState(true);
const [showCelebration, setShowCelebration] = useState(false);
const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null);
const [taskDetailsExpanded, setTaskDetailsExpanded] = useState(false);
@@ -193,6 +195,11 @@ const [canUpload, setCanUpload] = useState(true);
return Number.isFinite(parsed) ? parsed : null;
}, [taskIdParam]);
const tasksUrl = useMemo(() => {
if (!eventKey) return '/tasks';
return `/e/${encodeURIComponent(eventKey)}/tasks`;
}, [eventKey]);
// Load preferences from storage
useEffect(() => {
if (typeof window === 'undefined') return;
@@ -473,6 +480,25 @@ const [canUpload, setCanUpload] = useState(true);
setPreferences((prev) => ({ ...prev, flashPreferred: !prev.flashPreferred }));
}, []);
const triggerConfetti = useCallback(async () => {
if (typeof window === 'undefined') return;
const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
if (prefersReducedMotion) return;
try {
const { default: confetti } = await import('canvas-confetti');
confetti({
particleCount: 70,
spread: 65,
origin: { x: 0.5, y: 0.35 },
ticks: 160,
scalar: 0.9,
});
} catch (error) {
console.warn('Confetti could not start', error);
}
}, []);
const resetCountdownTimer = useCallback(() => {
if (countdownTimerRef.current) {
window.clearInterval(countdownTimerRef.current);
@@ -638,6 +664,11 @@ const [canUpload, setCanUpload] = useState(true);
if (task?.id) {
markCompleted(task.id);
}
setShowCelebration(true);
if (typeof window !== 'undefined') {
window.setTimeout(() => setShowCelebration(false), 1800);
}
void triggerConfetti();
try {
const raw = localStorage.getItem('my-photo-ids');
const arr: number[] = raw ? JSON.parse(raw) : [];
@@ -647,6 +678,9 @@ const [canUpload, setCanUpload] = useState(true);
} catch (error) {
console.warn('Failed to persist my-photo-ids', error);
}
await new Promise((resolve) => {
window.setTimeout(resolve, 420);
});
stopStream();
navigateAfterUpload(photoId);
} catch (error: unknown) {
@@ -678,7 +712,7 @@ const [canUpload, setCanUpload] = useState(true);
} finally {
setStatusMessage('');
}
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t, identity.name]);
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t, identity.name, triggerConfetti]);
const handleGalleryPick = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!canUpload) return;
@@ -795,7 +829,7 @@ const [canUpload, setCanUpload] = useState(true);
<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"
className="absolute left-4 right-4 top-6 z-30 rounded-3xl border border-white/40 bg-black/60 p-4 text-left text-white shadow-2xl backdrop-blur transition hover:bg-black/70 focus:outline-none focus:ring-2 focus:ring-white/60 sm:left-6 sm:right-6 sm:top-8"
>
<div className="flex items-center gap-3">
<Badge variant="secondary" className="flex items-center gap-2 rounded-full text-[11px]">
@@ -838,59 +872,6 @@ const [canUpload, setCanUpload] = useState(true);
</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">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.35em] text-slate-500 dark:text-white/60">
{t('upload.limitSummary.title')}
</p>
<p className="text-base font-semibold text-slate-900 dark:text-white">
{t('upload.limitSummary.subtitle')}
</p>
</div>
<Badge variant="secondary" className="rounded-full bg-black/5 text-xs text-slate-700 dark:bg-white/10 dark:text-white">
{t('upload.limitSummary.badgeLabel')}
</Badge>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{limitCards.map((card) => {
const styles = LIMIT_CARD_STYLES[card.tone];
return (
<div
key={card.id}
className={cn(
'rounded-2xl border p-4 shadow-sm transition-colors',
styles.card
)}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide opacity-70">
{card.label}
</p>
<p className="text-lg font-semibold">{card.valueLabel}</p>
</div>
<Badge className={cn('text-[10px] font-semibold uppercase tracking-wide', styles.badge)}>
{card.badgeLabel}
</Badge>
</div>
{card.progress !== null && (
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-white/60 dark:bg-white/10">
<div
className={cn('h-full rounded-full transition-all', styles.bar)}
style={{ width: `${card.progress}%` }}
/>
</div>
)}
<p className="mt-3 text-sm opacity-80">{card.description}</p>
</div>
);
})}
</div>
</section>
) : null;
const dialogToneIconClass: Record<Exclude<UploadErrorDialog['tone'], undefined>, string> = {
danger: 'text-rose-500',
warning: 'text-amber-500',
@@ -939,17 +920,14 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
if (!canUpload) {
return renderWithDialog(
<>
{limitStatusSection}
<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>
</>
<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>
);
}
@@ -1035,12 +1013,20 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
);
};
const isCountdownActive = mode === 'countdown';
const countdownProgress = preferences.countdownEnabled && preferences.countdownSeconds > 0
? Math.max(0, Math.min(1, countdownValue / preferences.countdownSeconds))
: 0;
const countdownDegrees = Math.round(countdownProgress * 360);
const controlIconButtonBase =
'flex h-10 w-10 items-center justify-center rounded-full border border-white/25 bg-white/10 text-white shadow-sm backdrop-blur transition hover:border-white/40 hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60';
return renderWithDialog(
<>
<div className="relative pt-8" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
<div className="relative flex min-h-screen flex-col gap-6 pt-4" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
{taskFloatingCard}
<section
className="relative overflow-hidden border border-white/10 bg-black text-white shadow-2xl"
className="relative flex min-h-[70vh] flex-col overflow-hidden border border-white/10 bg-black text-white shadow-2xl"
style={{ borderRadius: radius }}
>
<div className="relative aspect-[3/4] sm:aspect-video">
@@ -1090,6 +1076,98 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-6 z-30 flex justify-center">
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/20 bg-black/40 px-3 py-2 backdrop-blur">
<Button
size="icon"
variant="ghost"
className={cn(
controlIconButtonBase,
preferences.gridEnabled && 'border-white bg-white text-black'
)}
onClick={handleToggleGrid}
title={t('upload.controls.toggleGrid')}
aria-pressed={preferences.gridEnabled}
>
<Grid3X3 className="h-4 w-4" />
<span className="sr-only">{t('upload.controls.toggleGrid')}</span>
</Button>
<Button
size="icon"
variant="ghost"
className={cn(
controlIconButtonBase,
preferences.countdownEnabled && 'border-white bg-white text-black'
)}
onClick={handleToggleCountdown}
title={t('upload.countdownLabel').replace('{seconds}', `${preferences.countdownSeconds}`)}
aria-pressed={preferences.countdownEnabled}
>
<Timer className="h-4 w-4" />
<span className="sr-only">
{t('upload.countdownLabel').replace('{seconds}', `${preferences.countdownSeconds}`)}
</span>
</Button>
{preferences.facingMode === 'user' && (
<Button
size="icon"
variant="ghost"
className={cn(
controlIconButtonBase,
preferences.mirrorFrontPreview && 'border-white bg-white text-black'
)}
onClick={handleToggleMirror}
title={t('upload.controls.toggleMirror')}
aria-pressed={preferences.mirrorFrontPreview}
>
<FlipHorizontal className="h-4 w-4" />
<span className="sr-only">{t('upload.controls.toggleMirror')}</span>
</Button>
)}
<Button
size="icon"
variant="ghost"
className={cn(
controlIconButtonBase,
preferences.flashPreferred && 'border-white bg-white text-black'
)}
onClick={handleToggleFlashPreference}
disabled={preferences.facingMode !== 'environment'}
title={t('upload.controls.toggleFlash')}
aria-pressed={preferences.flashPreferred}
>
{preferences.flashPreferred ? <Zap className="h-4 w-4 text-yellow-300" /> : <ZapOff className="h-4 w-4" />}
<span className="sr-only">{t('upload.controls.toggleFlash')}</span>
</Button>
<Button
size="icon"
variant="ghost"
className={cn(
controlIconButtonBase,
immersiveMode && 'border-white bg-white text-black'
)}
onClick={() => setImmersiveMode((prev) => !prev)}
title={
immersiveMode
? t('upload.controls.exitFullscreen', 'Menü einblenden')
: t('upload.controls.enterFullscreen', 'Vollbild')
}
aria-pressed={immersiveMode}
>
<Menu className="h-4 w-4" />
<span className="sr-only">
{immersiveMode
? t('upload.controls.exitFullscreen', 'Menü einblenden')
: t('upload.controls.enterFullscreen', 'Vollbild')}
</span>
</Button>
</div>
</div>
{mode === 'uploading' && (
<div className="absolute inset-0 z-40 flex flex-col items-center justify-center gap-4 bg-black/80 text-white">
<Loader2 className="h-10 w-10 animate-spin" />
@@ -1126,106 +1204,88 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
</Alert>
)}
<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' && (
{showCelebration && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-white/20 bg-white/10 px-4 py-3 text-sm text-white shadow-lg backdrop-blur">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-amber-200" />
<p className="font-semibold">
{t('upload.taskInfo.completed', 'Aufgabe gelöst!')}
</p>
</div>
<Button
size="sm"
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}
variant="secondary"
className="rounded-full border border-white/30 bg-white/80 text-slate-900 hover:bg-white"
onClick={() => navigate(tasksUrl)}
>
{t('upload.controls.toggleMirror')}
{t('upload.taskInfo.nextPrompt', 'Gleich noch eine Aufgabe?')}
</Button>
)}
<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>
<Button
size="sm"
variant={immersiveMode ? 'default' : 'secondary'}
className={cn(
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
immersiveMode && 'bg-white text-black'
)}
onClick={() => setImmersiveMode((prev) => !prev)}
>
{immersiveMode ? <Minimize2 className="mr-1 h-3.5 w-3.5" /> : <Maximize2 className="mr-1 h-3.5 w-3.5" />}
{immersiveMode
? t('upload.controls.exitFullscreen', 'Menü einblenden')
: t('upload.controls.enterFullscreen', 'Vollbild')}
</Button>
</div>
</div>
)}
<div className="flex items-center justify-center gap-6">
<div className="flex items-center justify-center gap-8">
<Button
variant="ghost"
className="flex h-14 w-14 items-center justify-center rounded-full border border-white/30 bg-white/10 text-white"
className="flex h-14 w-14 items-center justify-center rounded-full border border-white/25 bg-white/10 text-white shadow-sm backdrop-blur hover:border-white/40 hover:bg-white/15"
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}>
{t('upload.review.retake')}
</Button>
<Button className="flex-1" onClick={handleUsePhoto}>
<Button
className="flex-1 animate-pulse bg-pink-500 text-white shadow-lg hover:bg-pink-600 focus-visible:ring-pink-300"
onClick={handleUsePhoto}
>
{t('upload.review.keep')}
</Button>
</div>
) : (
<Button
size="lg"
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-8 w-8" />
<span className="sr-only">
{isCameraActive ? t('upload.captureButton') : t('upload.buttons.startCamera')}
</span>
</Button>
<div className="relative h-24 w-24">
{!isCountdownActive && mode !== 'uploading' && (
<span className="pointer-events-none absolute inset-0 rounded-full border border-white/30 opacity-60 animate-ping" />
)}
{isCountdownActive && (
<div
className="absolute inset-0 rounded-full"
style={{
background: `conic-gradient(#fff ${countdownDegrees}deg, rgba(255,255,255,0.15) ${countdownDegrees}deg)`,
}}
/>
)}
<div className="absolute inset-2 rounded-full bg-black/70 shadow-[0_10px_40px_rgba(0,0,0,0.45)] backdrop-blur" />
<Button
size="lg"
className="relative z-10 flex h-20 w-20 items-center justify-center rounded-full border-4 border-white/40 text-white shadow-2xl"
onClick={handlePrimaryAction}
disabled={mode === 'uploading' || isCountdownActive}
style={{
background: `radial-gradient(circle at 20% 20%, ${branding.secondaryColor}, ${branding.primaryColor})`,
boxShadow: `0 18px 36px ${branding.primaryColor}55`,
}}
>
{isCountdownActive ? (
<span className="text-3xl font-bold leading-none">{countdownValue}</span>
) : mode === 'uploading' ? (
<Loader2 className="h-9 w-9 animate-spin" />
) : (
<Camera className="h-8 w-8 sm:h-10 sm:w-10" style={{ height: '32px', width: '32px' }} />
)}
<span className="sr-only">
{isCameraActive ? t('upload.captureButton') : t('upload.buttons.startCamera')}
</span>
</Button>
</div>
)}
<Button
variant="ghost"
className="flex h-14 w-14 items-center justify-center rounded-full border border-white/30 bg-white/10 text-white"
className="flex h-14 w-14 items-center justify-center rounded-full border border-white/25 bg-white/10 text-white shadow-sm backdrop-blur hover:border-white/40 hover:bg-white/15"
onClick={handleSwitchCamera}
>
<RotateCcw className="h-6 w-6" />
@@ -1251,7 +1311,6 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
)}
{permissionState !== 'granted' && renderPermissionNotice()}
{limitStatusSection}
{renderPrimer()}
<input