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

@@ -269,33 +269,66 @@ function Highlights({ topPhoto, trendingEmotion }: { topPhoto: TopPhotoHighlight
);
}
function SummaryCards({ data }: { data: AchievementsPayload }) {
function FlowSummary({ data, token }: { data: AchievementsPayload; token: string }) {
const personal = data.personal;
const tasksDone = personal?.tasks ?? data.summary.tasksSolved;
const photos = personal?.photos ?? data.summary.totalPhotos;
const likes = personal?.likes ?? data.summary.likesTotal;
const guests = data.summary.uniqueGuests;
const earnedBadges = personal?.badges.filter((badge) => badge.earned).length ?? 0;
const nextBadge = personal?.badges.find((badge) => !badge.earned);
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardContent className="flex flex-col gap-1 py-4">
<span className="text-xs uppercase text-muted-foreground">Fotos gesamt</span>
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.totalPhotos)}</span>
</CardContent>
</Card>
<Card>
<CardContent className="flex flex-col gap-1 py-4">
<span className="text-xs uppercase text-muted-foreground">Aktive Gäste</span>
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.uniqueGuests)}</span>
</CardContent>
</Card>
<Card>
<CardContent className="flex flex-col gap-1 py-4">
<span className="text-xs uppercase text-muted-foreground">Erfüllte Aufgaben</span>
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.tasksSolved)}</span>
</CardContent>
</Card>
<Card>
<CardContent className="flex flex-col gap-1 py-4">
<span className="text-xs uppercase text-muted-foreground">Likes insgesamt</span>
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.likesTotal)}</span>
</CardContent>
</Card>
<section className="rounded-[32px] border border-slate-100 bg-white/95 p-6 shadow-sm dark:border-white/10 dark:bg-slate-900/70">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.35em] text-slate-500 dark:text-white/60">Dein Flow</p>
<h2 className="mt-1 text-2xl font-semibold text-slate-900 dark:text-white">
{tasksDone === 0 ? 'Starte deine erste Mission.' : 'Weiter so, dein Event lebt!'}
</h2>
<p className="text-sm text-slate-500 dark:text-white/70">
{nextBadge ? `Noch ${Math.max(0, nextBadge.target - nextBadge.progress)} Schritte bis „${nextBadge.title}“.` : 'Alle aktuellen Badges freigeschaltet.'}
</p>
</div>
<div className="flex gap-2">
<Button asChild>
<Link to={`/e/${encodeURIComponent(token)}/tasks`} className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
Aufgabe ziehen
</Link>
</Button>
<Button variant="outline" asChild>
<Link to={`/e/${encodeURIComponent(token)}/gallery`} className="flex items-center gap-2">
<Camera className="h-4 w-4" />
Galerie öffnen
</Link>
</Button>
</div>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<FlowStat label="Deine Aufgaben" value={formatNumber(tasksDone)} />
<FlowStat label="Fotos gesamt" value={formatNumber(photos)} />
<FlowStat label="Likes gesammelt" value={formatNumber(likes)} />
<FlowStat label="Badges" value={`${earnedBadges}/${personal?.badges.length ?? 0}`} />
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<FlowStat label="Aktive Gäste" value={formatNumber(guests)} />
<FlowStat label="Letzte Mission" value={personal ? personal.guestName || 'Gast' : 'Event'} muted />
</div>
</section>
);
}
function FlowStat({ label, value, muted = false }: { label: string; value: string; muted?: boolean }) {
return (
<div
className={cn(
'rounded-2xl border border-slate-100 bg-white/80 px-4 py-3 text-left shadow-sm dark:border-white/10 dark:bg-white/5',
muted && 'opacity-80'
)}
>
<p className="text-[0.65rem] uppercase tracking-[0.35em] text-slate-400 dark:text-white/60">{label}</p>
<p className="mt-2 text-xl font-semibold text-slate-900 dark:text-white">{value}</p>
</div>
);
}
@@ -393,7 +426,7 @@ export default function AchievementsPage() {
{!loading && !error && data && (
<>
<SummaryCards data={data} />
<FlowSummary data={data} token={token} />
<div className="flex flex-wrap items-center gap-2">
<Button

View File

@@ -9,7 +9,7 @@ import { useGuestIdentity } from '../context/GuestIdentityContext';
import { useEventStats } from '../context/EventStatsContext';
import { useEventData } from '../hooks/useEventData';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset, X, Camera, ArrowUpRight } from 'lucide-react';
import { Sparkles, UploadCloud, X, Camera, RefreshCw } from 'lucide-react';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import { useEventBranding } from '../context/EventBrandingContext';
import type { EventBranding } from '../types/event-branding';
@@ -63,69 +63,63 @@ export default function HomePage() {
const displayName = hydrated && name ? name : t('home.fallbackGuestName');
const eventNameDisplay = event?.name ?? t('home.hero.defaultEventName');
const latestUploadText = formatLatestUpload(stats.latestPhotoAt, t);
const accentColor = branding.primaryColor;
const secondaryAccent = branding.secondaryColor;
const statItems = React.useMemo(
() => [
{
icon: <Users className="h-4 w-4" aria-hidden />,
label: t('home.stats.online'),
value: `${stats.onlineGuests}`,
},
{
icon: <Sparkles className="h-4 w-4" aria-hidden />,
label: t('home.stats.tasksSolved'),
value: `${stats.tasksSolved}`,
},
{
icon: <TimerReset className="h-4 w-4" aria-hidden />,
label: t('home.stats.lastUpload'),
value: latestUploadText,
},
{
icon: <CheckCircle2 className="h-4 w-4" aria-hidden />,
label: t('home.stats.completedTasks'),
value: `${completedCount}`,
},
],
[completedCount, latestUploadText, stats.onlineGuests, stats.tasksSolved, t],
);
const [missionPreview, setMissionPreview] = React.useState<MissionPreview | null>(null);
const [missionLoading, setMissionLoading] = React.useState(false);
const missionPoolRef = React.useRef<MissionPreview[]>([]);
const quickActions = React.useMemo(
() => [
{
to: 'upload',
label: t('home.actions.items.upload.label'),
description: t('home.actions.items.upload.description'),
icon: <Camera className="h-5 w-5" aria-hidden />,
highlight: true,
},
{
to: 'tasks',
label: t('home.actions.items.tasks.label'),
description: t('home.actions.items.tasks.description'),
icon: <Sparkles className="h-5 w-5" aria-hidden />,
},
{
to: 'gallery',
label: t('home.actions.items.gallery.label'),
description: t('home.actions.items.gallery.description'),
icon: <Images className="h-5 w-5" aria-hidden />,
},
],
[t],
);
const shuffleMissionPreview = React.useCallback(() => {
const pool = missionPoolRef.current;
if (!pool.length) {
setMissionPreview(null);
return;
}
const choice = pool[Math.floor(Math.random() * pool.length)];
setMissionPreview(choice);
}, []);
const checklistItems = React.useMemo(
() => [
t('home.checklist.steps.first'),
t('home.checklist.steps.second'),
t('home.checklist.steps.third'),
],
[t],
);
React.useEffect(() => {
if (!token) return;
let cancelled = false;
async function loadMissions() {
setMissionLoading(true);
try {
const response = await fetch(`/api/v1/events/${encodeURIComponent(token)}/tasks`);
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
const payload = await response.json();
if (cancelled) return;
if (Array.isArray(payload) && payload.length) {
missionPoolRef.current = payload.map((task: any) => ({
id: Number(task.id),
title: task.title ?? 'Mission',
description: task.description ?? '',
duration: typeof task.duration === 'number' ? task.duration : 3,
emotion: task.emotion ?? null,
}));
shuffleMissionPreview();
} else {
missionPoolRef.current = [];
setMissionPreview(null);
}
} catch (err) {
if (!cancelled) {
console.warn('Mission preview failed', err);
missionPoolRef.current = [];
setMissionPreview(null);
}
} finally {
if (!cancelled) {
setMissionLoading(false);
}
}
}
loadMissions();
return () => {
cancelled = true;
};
}, [shuffleMissionPreview, token]);
if (!token) {
return null;
@@ -146,55 +140,28 @@ export default function HomePage() {
/>
)}
<StatsRibbon items={statItems} accentColor={accentColor} fontFamily={branding.fontFamily} />
<section className="space-y-4" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-foreground">{t('home.actions.title')}</h2>
<p className="text-xs text-muted-foreground">{t('home.actions.subtitle')}</p>
<h2 className="text-base font-semibold text-foreground">Starte dein Fotospiel</h2>
<p className="text-xs text-muted-foreground">Wähle, wie du den nächsten Moment einfängst.</p>
</div>
<ArrowUpRight className="h-5 w-5 text-muted-foreground" aria-hidden />
</div>
<div className="grid gap-3 sm:grid-cols-3">
{quickActions.map((action) => (
<QuickActionCard
key={action.to}
action={action}
accentColor={accentColor}
secondaryAccent={secondaryAccent}
/>
))}
<div className="space-y-3">
<MissionActionCard
token={token}
mission={missionPreview}
loading={missionLoading}
onShuffle={shuffleMissionPreview}
/>
<EmotionActionCard />
<UploadActionCard token={token} accentColor={accentColor} secondaryAccent={secondaryAccent} />
</div>
<Button
variant="outline"
asChild
className="w-full touch-manipulation border-dashed"
style={{ borderColor: `${accentColor}44` }}
>
<Link to="queue">{t('home.actions.queueButton')}</Link>
</Button>
</section>
<Card>
<CardHeader style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
<CardTitle>{t('home.checklist.title')}</CardTitle>
<CardDescription>{t('home.checklist.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{checklistItems.map((item) => (
<div key={item} className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-5 w-5 text-green-500" aria-hidden />
<span className="text-sm leading-relaxed text-muted-foreground">{item}</span>
</div>
))}
</CardContent>
</Card>
<Separator />
<EmotionPicker />
<GalleryPreview token={token} />
</div>
);
@@ -265,112 +232,119 @@ function HeroCard({
);
}
function StatsRibbon({
items,
accentColor,
fontFamily,
type MissionPreview = {
id: number;
title: string;
description?: string;
duration?: number;
emotion?: { name?: string; slug?: string } | null;
};
function MissionActionCard({
token,
mission,
loading,
onShuffle,
}: {
items: { icon: React.ReactNode; label: string; value: string }[];
accentColor: string;
fontFamily?: string | null;
token: string;
mission: MissionPreview | null;
loading: boolean;
onShuffle: () => void;
}) {
return (
<div className="overflow-hidden rounded-3xl border border-muted/40 bg-white/70 shadow-sm backdrop-blur">
<div
className="flex gap-3 overflow-x-auto px-4 py-3 [scrollbar-width:none] sm:grid sm:grid-cols-4 sm:overflow-visible"
style={fontFamily ? { fontFamily } : undefined}
>
{items.map((item) => (
<div
key={item.label}
className="flex min-w-[150px] flex-1 items-center gap-3 rounded-2xl border border-transparent bg-white/60 px-3 py-2 shadow-sm transition hover:-translate-y-0.5 hover:border-white"
<Card className="border-0 shadow-sm">
<CardHeader className="flex flex-row items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-pink-100 text-pink-600">
<Sparkles className="h-5 w-5" aria-hidden />
</div>
<div>
<CardTitle className="text-base font-semibold text-foreground">Mission starten</CardTitle>
<CardDescription className="text-xs text-muted-foreground">
Wir haben bereits eine Aufgabe für dich vorbereitet. Tippe, um direkt loszulegen.
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-3">
{mission ? (
<>
<p className="text-lg font-semibold text-foreground">{mission.title}</p>
{mission.description && (
<p className="text-sm text-muted-foreground line-clamp-2">{mission.description}</p>
)}
<div className="flex gap-3 text-xs text-muted-foreground">
<span>{mission.duration ?? 3} Min</span>
{mission.emotion?.name && <span>{mission.emotion.name}</span>}
</div>
</>
) : (
<p className="text-sm text-muted-foreground">
Ziehe deine erste Mission im Aufgaben-Tab oder lade deine Stimmung hoch.
</p>
)}
<div className="flex flex-col gap-2 sm:flex-row">
<Button asChild className="flex-1">
<Link to={`/e/${encodeURIComponent(token)}/tasks`}>Mission starten</Link>
</Button>
<Button
type="button"
variant="ghost"
className="flex-1"
onClick={onShuffle}
disabled={loading}
>
<div
className="flex h-10 w-10 items-center justify-center rounded-full bg-white shadow"
style={{ color: accentColor }}
>
{item.icon}
</div>
<div className="flex flex-col">
<span className="text-[11px] uppercase tracking-wide text-muted-foreground">{item.label}</span>
<span className="text-lg font-semibold text-foreground">{item.value}</span>
</div>
</div>
))}
</div>
</div>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} aria-hidden />
Andere Mission
</Button>
</div>
</CardContent>
</Card>
);
}
function QuickActionCard({
action,
function EmotionActionCard() {
return (
<Card className="border border-muted/40 shadow-sm">
<CardHeader>
<CardTitle>Foto nach Gefühlslage</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
Wähle deine Stimmung, wir schlagen dir passende Missionen vor.
</CardDescription>
</CardHeader>
<CardContent>
<EmotionPicker variant="embedded" showSkip={false} />
</CardContent>
</Card>
);
}
function UploadActionCard({
token,
accentColor,
secondaryAccent,
}: {
action: { to: string; label: string; description: string; icon: React.ReactNode; highlight?: boolean };
token: string;
accentColor: string;
secondaryAccent: string;
}) {
const highlightStyle = action.highlight
? {
background: `linear-gradient(120deg, ${accentColor}, ${secondaryAccent})`,
color: '#fff',
}
: undefined;
return (
<Link to={action.to} className="group block">
<Card
className="relative overflow-hidden border-0 shadow-sm transition-all group-hover:shadow-lg"
style={highlightStyle}
>
<CardContent className="flex items-start gap-4 py-4">
<div
className={`flex h-11 w-11 items-center justify-center rounded-2xl shadow-sm ${
action.highlight ? 'bg-white/15 text-white' : 'bg-pink-50'
}`}
style={!action.highlight ? { color: secondaryAccent } : undefined}
>
{action.icon}
<Card
className="overflow-hidden border-0 text-white shadow-sm"
style={{ background: `linear-gradient(120deg, ${accentColor}, ${secondaryAccent})` }}
>
<CardContent className="flex flex-col gap-3 py-5">
<div className="flex items-center gap-3">
<div className="rounded-2xl bg-white/15 p-3">
<UploadCloud className="h-5 w-5" aria-hidden />
</div>
<div className="flex flex-1 flex-col">
<span className={`text-base font-semibold ${action.highlight ? 'text-white' : 'text-foreground'}`}>
{action.label}
</span>
<span className={`text-sm ${action.highlight ? 'text-white/80' : 'text-muted-foreground'}`}>
{action.description}
</span>
<div>
<p className="text-lg font-semibold">Direkt hochladen</p>
<p className="text-sm text-white/80">Kamera öffnen oder ein Foto aus deiner Galerie wählen.</p>
</div>
<ArrowUpRight
className={`h-4 w-4 transition ${action.highlight ? 'text-white/70 group-hover:translate-x-0.5 group-hover:-translate-y-0.5' : 'text-muted-foreground group-hover:text-foreground'}`}
aria-hidden
/>
</CardContent>
</Card>
</Link>
</div>
<Button asChild className="bg-white/90 text-slate-900 hover:bg-white">
<Link to={`/e/${encodeURIComponent(token)}/upload`}>Foto hochladen</Link>
</Button>
</CardContent>
</Card>
);
}
function formatLatestUpload(isoDate: string | null, t: TranslateFn) {
if (!isoDate) {
return t('home.latestUpload.none');
}
const date = new Date(isoDate);
if (Number.isNaN(date.getTime())) {
return t('home.latestUpload.invalid');
}
const diffMs = Date.now() - date.getTime();
const diffMinutes = Math.round(diffMs / 60000);
if (diffMinutes < 1) {
return t('home.latestUpload.justNow');
}
if (diffMinutes < 60) {
return t('home.latestUpload.minutes').replace('{count}', `${diffMinutes}`);
}
const diffHours = Math.round(diffMinutes / 60);
if (diffHours < 24) {
return t('home.latestUpload.hours').replace('{count}', `${diffHours}`);
}
const diffDays = Math.round(diffHours / 24);
return t('home.latestUpload.days').replace('{count}', `${diffDays}`);
}

File diff suppressed because it is too large Load Diff

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}`);
}