überarbeitung der kamera-seite: deutlich aufgeräumter und mehr platz für das Video/Bild
This commit is contained in:
11
package-lock.json
generated
11
package-lock.json
generated
@@ -32,6 +32,7 @@
|
|||||||
"@types/react": "^19.0.3",
|
"@types/react": "^19.0.3",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
"canvas-confetti": "^1.9.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -5544,6 +5545,16 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/chai": {
|
||||||
"version": "5.3.3",
|
"version": "5.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"@types/react": "^19.0.3",
|
"@types/react": "^19.0.3",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
"canvas-confetti": "^1.9.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
|||||||
@@ -20,15 +20,16 @@ import {
|
|||||||
Camera,
|
Camera,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Grid3X3,
|
Grid3X3,
|
||||||
|
Menu,
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
Info,
|
Info,
|
||||||
Loader2,
|
Loader2,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
Timer,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
FlipHorizontal,
|
||||||
Zap,
|
Zap,
|
||||||
ZapOff,
|
ZapOff,
|
||||||
Maximize2,
|
|
||||||
Minimize2,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getEventPackage, type EventPackage } from '../services/eventApi';
|
import { getEventPackage, type EventPackage } from '../services/eventApi';
|
||||||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||||
@@ -147,7 +148,8 @@ export default function UploadPage() {
|
|||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
const [uploadWarning, setUploadWarning] = 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 [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null);
|
||||||
const [taskDetailsExpanded, setTaskDetailsExpanded] = useState(false);
|
const [taskDetailsExpanded, setTaskDetailsExpanded] = useState(false);
|
||||||
@@ -193,6 +195,11 @@ const [canUpload, setCanUpload] = useState(true);
|
|||||||
return Number.isFinite(parsed) ? parsed : null;
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
}, [taskIdParam]);
|
}, [taskIdParam]);
|
||||||
|
|
||||||
|
const tasksUrl = useMemo(() => {
|
||||||
|
if (!eventKey) return '/tasks';
|
||||||
|
return `/e/${encodeURIComponent(eventKey)}/tasks`;
|
||||||
|
}, [eventKey]);
|
||||||
|
|
||||||
// Load preferences from storage
|
// Load preferences from storage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@@ -473,6 +480,25 @@ const [canUpload, setCanUpload] = useState(true);
|
|||||||
setPreferences((prev) => ({ ...prev, flashPreferred: !prev.flashPreferred }));
|
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(() => {
|
const resetCountdownTimer = useCallback(() => {
|
||||||
if (countdownTimerRef.current) {
|
if (countdownTimerRef.current) {
|
||||||
window.clearInterval(countdownTimerRef.current);
|
window.clearInterval(countdownTimerRef.current);
|
||||||
@@ -638,6 +664,11 @@ const [canUpload, setCanUpload] = useState(true);
|
|||||||
if (task?.id) {
|
if (task?.id) {
|
||||||
markCompleted(task.id);
|
markCompleted(task.id);
|
||||||
}
|
}
|
||||||
|
setShowCelebration(true);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.setTimeout(() => setShowCelebration(false), 1800);
|
||||||
|
}
|
||||||
|
void triggerConfetti();
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem('my-photo-ids');
|
const raw = localStorage.getItem('my-photo-ids');
|
||||||
const arr: number[] = raw ? JSON.parse(raw) : [];
|
const arr: number[] = raw ? JSON.parse(raw) : [];
|
||||||
@@ -647,6 +678,9 @@ const [canUpload, setCanUpload] = useState(true);
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to persist my-photo-ids', error);
|
console.warn('Failed to persist my-photo-ids', error);
|
||||||
}
|
}
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
window.setTimeout(resolve, 420);
|
||||||
|
});
|
||||||
stopStream();
|
stopStream();
|
||||||
navigateAfterUpload(photoId);
|
navigateAfterUpload(photoId);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -678,7 +712,7 @@ const [canUpload, setCanUpload] = useState(true);
|
|||||||
} finally {
|
} finally {
|
||||||
setStatusMessage('');
|
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>) => {
|
const handleGalleryPick = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!canUpload) return;
|
if (!canUpload) return;
|
||||||
@@ -795,7 +829,7 @@ const [canUpload, setCanUpload] = useState(true);
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTaskDetailsExpanded((prev) => !prev)}
|
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">
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant="secondary" className="flex items-center gap-2 rounded-full text-[11px]">
|
<Badge variant="secondary" className="flex items-center gap-2 rounded-full text-[11px]">
|
||||||
@@ -838,59 +872,6 @@ const [canUpload, setCanUpload] = useState(true);
|
|||||||
</button>
|
</button>
|
||||||
) : null;
|
) : 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> = {
|
const dialogToneIconClass: Record<Exclude<UploadErrorDialog['tone'], undefined>, string> = {
|
||||||
danger: 'text-rose-500',
|
danger: 'text-rose-500',
|
||||||
warning: 'text-amber-500',
|
warning: 'text-amber-500',
|
||||||
@@ -939,17 +920,14 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
|||||||
|
|
||||||
if (!canUpload) {
|
if (!canUpload) {
|
||||||
return renderWithDialog(
|
return renderWithDialog(
|
||||||
<>
|
<Alert variant="destructive">
|
||||||
{limitStatusSection}
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<Alert variant="destructive">
|
<AlertDescription>
|
||||||
<AlertTriangle className="h-4 w-4" />
|
{t('upload.limitReached')
|
||||||
<AlertDescription>
|
.replace('{used}', `${eventPackage?.used_photos || 0}`)
|
||||||
{t('upload.limitReached')
|
.replace('{max}', `${eventPackage?.package?.max_photos || 0}`)}
|
||||||
.replace('{used}', `${eventPackage?.used_photos || 0}`)
|
</AlertDescription>
|
||||||
.replace('{max}', `${eventPackage?.package?.max_photos || 0}`)}
|
</Alert>
|
||||||
</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(
|
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}
|
{taskFloatingCard}
|
||||||
<section
|
<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 }}
|
style={{ borderRadius: radius }}
|
||||||
>
|
>
|
||||||
<div className="relative aspect-[3/4] sm:aspect-video">
|
<div className="relative aspect-[3/4] sm:aspect-video">
|
||||||
@@ -1090,6 +1076,98 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
|||||||
</div>
|
</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' && (
|
{mode === 'uploading' && (
|
||||||
<div className="absolute inset-0 z-40 flex flex-col items-center justify-center gap-4 bg-black/80 text-white">
|
<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" />
|
<Loader2 className="h-10 w-10 animate-spin" />
|
||||||
@@ -1126,106 +1204,88 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-2 text-xs font-medium uppercase tracking-wide text-white/80">
|
{showCelebration && (
|
||||||
<Button
|
<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">
|
||||||
size="sm"
|
<div className="flex items-center gap-2">
|
||||||
variant={preferences.gridEnabled ? 'default' : 'secondary'}
|
<Sparkles className="h-4 w-4 text-amber-200" />
|
||||||
className={cn(
|
<p className="font-semibold">
|
||||||
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
|
{t('upload.taskInfo.completed', 'Aufgabe gelöst!')}
|
||||||
preferences.gridEnabled && 'bg-white text-black'
|
</p>
|
||||||
)}
|
</div>
|
||||||
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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={preferences.mirrorFrontPreview ? 'default' : 'secondary'}
|
variant="secondary"
|
||||||
className={cn(
|
className="rounded-full border border-white/30 bg-white/80 text-slate-900 hover:bg-white"
|
||||||
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
|
onClick={() => navigate(tasksUrl)}
|
||||||
preferences.mirrorFrontPreview && 'bg-white text-black'
|
|
||||||
)}
|
|
||||||
onClick={handleToggleMirror}
|
|
||||||
>
|
>
|
||||||
{t('upload.controls.toggleMirror')}
|
{t('upload.taskInfo.nextPrompt', 'Gleich noch eine Aufgabe?')}
|
||||||
</Button>
|
</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>
|
|
||||||
<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 className="flex items-center justify-center gap-6">
|
<div className="flex items-center justify-center gap-8">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
<ImagePlus className="h-6 w-6" />
|
<ImagePlus className="h-6 w-6" />
|
||||||
<span className="sr-only">{t('upload.galleryButton')}</span>
|
<span className="sr-only">{t('upload.galleryButton')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{mode === 'review' && reviewPhoto ? (
|
{mode === 'review' && reviewPhoto ? (
|
||||||
<div className="flex w-full max-w-md flex-col gap-3 sm:flex-row">
|
<div className="flex w-full max-w-md flex-col gap-3 sm:flex-row">
|
||||||
<Button variant="secondary" className="flex-1" onClick={handleRetake}>
|
<Button variant="secondary" className="flex-1" onClick={handleRetake}>
|
||||||
{t('upload.review.retake')}
|
{t('upload.review.retake')}
|
||||||
</Button>
|
</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')}
|
{t('upload.review.keep')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<div className="relative h-24 w-24">
|
||||||
size="lg"
|
{!isCountdownActive && mode !== 'uploading' && (
|
||||||
className="flex h-20 w-20 items-center justify-center rounded-full border-4 border-white/40 bg-white text-black shadow-2xl"
|
<span className="pointer-events-none absolute inset-0 rounded-full border border-white/30 opacity-60 animate-ping" />
|
||||||
onClick={handlePrimaryAction}
|
)}
|
||||||
disabled={mode === 'countdown' || mode === 'uploading'}
|
{isCountdownActive && (
|
||||||
>
|
<div
|
||||||
<Camera className="h-8 w-8" />
|
className="absolute inset-0 rounded-full"
|
||||||
<span className="sr-only">
|
style={{
|
||||||
{isCameraActive ? t('upload.captureButton') : t('upload.buttons.startCamera')}
|
background: `conic-gradient(#fff ${countdownDegrees}deg, rgba(255,255,255,0.15) ${countdownDegrees}deg)`,
|
||||||
</span>
|
}}
|
||||||
</Button>
|
/>
|
||||||
|
)}
|
||||||
|
<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
|
<Button
|
||||||
variant="ghost"
|
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}
|
onClick={handleSwitchCamera}
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-6 w-6" />
|
<RotateCcw className="h-6 w-6" />
|
||||||
@@ -1251,7 +1311,6 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{permissionState !== 'granted' && renderPermissionNotice()}
|
{permissionState !== 'granted' && renderPermissionNotice()}
|
||||||
{limitStatusSection}
|
|
||||||
{renderPrimer()}
|
{renderPrimer()}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
|||||||
Reference in New Issue
Block a user