weitere verbesserungen der Guest PWA (vor allem TaskPicker)
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user