reworked the guest pwa, modernized start and gallery page. added share link functionality.

This commit is contained in:
Codex Agent
2025-11-10 22:25:25 +01:00
parent 1e8810ca51
commit 1cec116933
22 changed files with 1208 additions and 476 deletions

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 } from 'lucide-react';
import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset, X, Camera, ArrowUpRight } from 'lucide-react';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import { useEventBranding } from '../context/EventBrandingContext';
import type { EventBranding } from '../types/event-branding';
@@ -67,25 +67,52 @@ export default function HomePage() {
const accentColor = branding.primaryColor;
const secondaryAccent = branding.secondaryColor;
const primaryActions = React.useMemo(
const statItems = React.useMemo(
() => [
{
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 />,
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 quickActions = React.useMemo(
() => [
{
to: 'upload',
label: t('home.actions.items.upload.label'),
description: t('home.actions.items.upload.description'),
icon: <UploadCloud className="h-5 w-5" aria-hidden />,
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 />,
icon: <Images className="h-5 w-5" aria-hidden />,
},
],
[t],
@@ -105,7 +132,7 @@ export default function HomePage() {
}
return (
<div className="space-y-6 pb-24">
<div className="space-y-6 pb-32">
{heroVisible && (
<HeroCard
name={displayName}
@@ -114,73 +141,36 @@ export default function HomePage() {
t={t}
branding={branding}
onDismiss={dismissHero}
ctaLabel={t('home.actions.items.tasks.label')}
ctaHref={`/e/${encodeURIComponent(token)}/tasks`}
/>
)}
<Card style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
<CardContent className="grid grid-cols-1 gap-4 py-4 sm:grid-cols-4">
<StatTile
icon={<Users className="h-4 w-4" aria-hidden />}
label={t('home.stats.online')}
value={`${stats.onlineGuests}`}
accentColor={accentColor}
/>
<StatTile
icon={<Sparkles className="h-4 w-4" aria-hidden />}
label={t('home.stats.tasksSolved')}
value={`${stats.tasksSolved}`}
accentColor={accentColor}
/>
<StatTile
icon={<TimerReset className="h-4 w-4" aria-hidden />}
label={t('home.stats.lastUpload')}
value={latestUploadText}
accentColor={accentColor}
/>
<StatTile
icon={<CheckCircle2 className="h-4 w-4" aria-hidden />}
label={t('home.stats.completedTasks')}
value={`${completedCount}`}
accentColor={accentColor}
/>
</CardContent>
</Card>
<StatsRibbon items={statItems} accentColor={accentColor} fontFamily={branding.fontFamily} />
<section className="space-y-3">
<div className="flex items-center justify-between" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t('home.actions.title')}
</h2>
<span className="text-xs text-muted-foreground">{t('home.actions.subtitle')}</span>
<section className="space-y-4" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
<div className="flex items-center justify-between">
<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>
</div>
<ArrowUpRight className="h-5 w-5 text-muted-foreground" aria-hidden />
</div>
<div className="grid gap-3 sm:grid-cols-2">
{primaryActions.map((action) => (
<Link to={action.to} key={action.to} className="block touch-manipulation">
<Card className="transition-all hover:shadow-lg">
<CardContent className="flex items-center gap-3 py-4">
<div
className="flex h-10 w-10 items-center justify-center rounded-lg shadow-sm"
style={{
backgroundColor: `${secondaryAccent}1a`,
color: secondaryAccent,
}}
>
{action.icon}
</div>
<div className="flex flex-col">
<span className="text-base font-semibold" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>{action.label}</span>
<span className="text-sm text-muted-foreground">{action.description}</span>
</div>
</CardContent>
</Card>
</Link>
<div className="grid gap-3 sm:grid-cols-3">
{quickActions.map((action) => (
<QuickActionCard
key={action.to}
action={action}
accentColor={accentColor}
secondaryAccent={secondaryAccent}
/>
))}
</div>
<Button
variant="outline"
asChild
className="w-full touch-manipulation"
style={{ borderColor: `${accentColor}33` }}
className="w-full touch-manipulation border-dashed"
style={{ borderColor: `${accentColor}44` }}
>
<Link to="queue">{t('home.actions.queueButton')}</Link>
</Button>
@@ -217,6 +207,8 @@ function HeroCard({
t,
branding,
onDismiss,
ctaLabel,
ctaHref,
}: {
name: string;
eventName: string;
@@ -224,6 +216,8 @@ function HeroCard({
t: TranslateFn;
branding: EventBranding;
onDismiss: () => void;
ctaLabel?: string;
ctaHref?: string;
}) {
const heroTitle = t('home.hero.title').replace('{name}', name);
const heroDescription = t('home.hero.description').replace('{eventName}', eventName);
@@ -249,33 +243,114 @@ function HeroCard({
<X className="h-4 w-4" aria-hidden />
<span className="sr-only">{t('common.actions.close')}</span>
</Button>
<CardHeader className="space-y-2 pr-10">
<CardHeader className="space-y-4 pr-10">
<CardDescription className="text-sm text-white/80">{t('home.hero.subtitle')}</CardDescription>
<CardTitle className="text-2xl font-bold leading-snug">{heroTitle}</CardTitle>
<p className="text-sm text-white/85">{heroDescription}</p>
<p className="text-sm font-medium text-white/90">{progressMessage}</p>
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm font-medium text-white/90">{progressMessage}</p>
{ctaHref && ctaLabel && (
<Button
variant="secondary"
size="sm"
asChild
className="rounded-full bg-white/15 px-4 py-1 text-xs font-semibold uppercase tracking-wide text-white hover:bg-white/25"
>
<Link to={ctaHref}>{ctaLabel}</Link>
</Button>
)}
</div>
</CardHeader>
</Card>
);
}
function StatTile({ icon, label, value, accentColor }: { icon: React.ReactNode; label: string; value: string; accentColor: string }) {
function StatsRibbon({
items,
accentColor,
fontFamily,
}: {
items: { icon: React.ReactNode; label: string; value: string }[];
accentColor: string;
fontFamily?: string | null;
}) {
return (
<div className="flex items-center gap-3 rounded-lg border bg-muted/30 px-3 py-2">
<div className="overflow-hidden rounded-3xl border border-muted/40 bg-white/70 shadow-sm backdrop-blur">
<div
className="flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-sm"
style={{ color: accentColor }}
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}
>
{icon}
</div>
<div className="flex flex-col">
<span className="text-xs uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="text-lg font-semibold text-foreground">{value}</span>
{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"
>
<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>
);
}
function QuickActionCard({
action,
accentColor,
secondaryAccent,
}: {
action: { to: string; label: string; description: string; icon: React.ReactNode; highlight?: boolean };
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}
</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>
<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>
);
}
function formatLatestUpload(isoDate: string | null, t: TranslateFn) {
if (!isoDate) {
return t('home.latestUpload.none');