ü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-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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user