weitere verbesserungen der Guest PWA (vor allem TaskPicker)

This commit is contained in:
Codex Agent
2025-11-12 13:19:28 +01:00
parent 1cec116933
commit d91108c883
20 changed files with 2306 additions and 653 deletions

View File

@@ -29,9 +29,10 @@ import {
ZapOff,
} from 'lucide-react';
import { getEventPackage, type EventPackage } from '../services/eventApi';
import { useTranslation } from '../i18n/useTranslation';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import { buildLimitSummaries, type LimitSummaryCard } from '../lib/limitSummaries';
import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog';
import { useEventStats } from '../context/EventStatsContext';
interface Task {
id: number;
@@ -102,13 +103,15 @@ const LIMIT_CARD_STYLES: Record<LimitSummaryCard['tone'], { card: string; badge:
},
};
export default function UploadPage() {
const { token } = useParams<{ token: string }>();
const eventKey = token ?? '';
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { markCompleted } = useGuestTaskProgress(token);
const { markCompleted, completedCount } = useGuestTaskProgress(token);
const { t } = useTranslation();
const stats = useEventStats();
const taskIdParam = searchParams.get('task');
const emotionSlug = searchParams.get('emotion') || '';
@@ -605,6 +608,11 @@ const [canUpload, setCanUpload] = useState(true);
reader.readAsDataURL(file);
}, [canUpload, t]);
const handleOpenInspiration = useCallback(() => {
if (!eventKey) return;
navigate(`/e/${encodeURIComponent(eventKey)}/gallery`);
}, [eventKey, navigate]);
const difficultyBadgeClass = useMemo(() => {
if (!task) return 'text-white';
switch (task.difficulty) {
@@ -620,6 +628,10 @@ const [canUpload, setCanUpload] = useState(true);
const isCameraActive = permissionState === 'granted' && mode !== 'uploading';
const showTaskOverlay = task && mode !== 'uploading';
const relativeLastUpload = useMemo(
() => formatRelativeTimeLabel(stats.latestPhotoAt, t),
[stats.latestPhotoAt, t],
);
useEffect(() => () => {
resetCountdownTimer();
@@ -628,24 +640,31 @@ const [canUpload, setCanUpload] = useState(true);
}
}, [resetCountdownTimer]);
const heroEmotion = task?.emotion?.name ?? t('upload.hud.moodFallback');
const limitStatusSection = limitCards.length > 0 ? (
<section className="mx-4 mb-6 space-y-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-900 dark:text-white">
{t('upload.status.title')}
</h2>
<p className="text-xs text-slate-600 dark:text-white/70">
{t('upload.status.subtitle')}
</p>
<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-3 sm:grid-cols-2">
<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-xl border p-4 shadow-sm backdrop-blur transition-colors',
'rounded-2xl border p-4 shadow-sm transition-colors',
styles.card
)}
>
@@ -676,14 +695,6 @@ const [canUpload, setCanUpload] = useState(true);
</section>
) : null;
const renderPage = (content: ReactNode, mainClassName = 'px-4 py-6') => (
<div className="pb-16">
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
<main className={mainClassName}>{content}</main>
<BottomNav />
</div>
);
const dialogToneIconClass: Record<Exclude<UploadErrorDialog['tone'], undefined>, string> = {
danger: 'text-rose-500',
warning: 'text-amber-500',
@@ -714,12 +725,18 @@ const [canUpload, setCanUpload] = useState(true);
</Dialog>
);
const renderWithDialog = (content: ReactNode, mainClassName = 'px-4 py-6') => (
<>
{renderPage(content, mainClassName)}
{errorDialogNode}
</>
);
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>
{errorDialogNode}
</>
);
if (!supportsCamera && !task) {
return renderWithDialog(
@@ -759,7 +776,7 @@ const [canUpload, setCanUpload] = useState(true);
const renderPrimer = () => (
showPrimer && (
<div className="mx-4 mt-3 rounded-xl border border-pink-200 bg-white/90 p-4 text-sm text-pink-900 shadow">
<div className="rounded-[28px] border border-pink-200/60 bg-white/90 p-4 text-sm text-pink-900 shadow-lg dark:border-pink-500/40 dark:bg-pink-500/10 dark:text-pink-50">
<div className="flex items-start gap-3">
<Info className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="text-left">
@@ -781,14 +798,14 @@ const [canUpload, setCanUpload] = useState(true);
if (permissionState === 'granted') return null;
if (permissionState === 'unsupported') {
return (
<Alert className="mx-4">
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
<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="mx-4">
<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}>
@@ -799,22 +816,15 @@ const [canUpload, setCanUpload] = useState(true);
);
}
return (
<Alert className="mx-4">
<AlertDescription>{t('upload.cameraDenied.prompt')}</AlertDescription>
</Alert>
<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>
);
};
return renderWithDialog(
<>
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
{renderPrimer()}
</div>
<div className="pt-32" />
{permissionState !== 'granted' && renderPermissionNotice()}
{limitStatusSection}
<section className="relative mx-4 overflow-hidden rounded-2xl border border-white/10 bg-black text-white shadow-xl">
<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}
@@ -1027,7 +1037,11 @@ const [canUpload, setCanUpload] = useState(true);
)}
</div>
</div>
</section>
</section>
{permissionState !== 'granted' && renderPermissionNotice()}
{limitStatusSection}
{renderPrimer()}
<input
ref={fileInputRef}
@@ -1042,6 +1056,29 @@ const [canUpload, setCanUpload] = useState(true);
<canvas ref={canvasRef} className="hidden" />
</>
,
'relative flex flex-col gap-4 pb-4'
'space-y-6 pb-[140px]'
);
}
function formatRelativeTimeLabel(value: string | null | undefined, t: TranslateFn): string {
if (!value) {
return t('upload.hud.relative.now');
}
const timestamp = new Date(value).getTime();
if (Number.isNaN(timestamp)) {
return t('upload.hud.relative.now');
}
const diffMinutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000));
if (diffMinutes < 1) {
return t('upload.hud.relative.now');
}
if (diffMinutes < 60) {
return t('upload.hud.relative.minutes').replace('{count}', `${diffMinutes}`);
}
const hours = Math.round(diffMinutes / 60);
if (hours < 24) {
return t('upload.hud.relative.hours').replace('{count}', `${hours}`);
}
const days = Math.round(hours / 24);
return t('upload.hud.relative.days').replace('{count}', `${days}`);
}