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