Files
fotospiel-app/resources/js/guest/pages/HomePage.tsx

885 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { Link, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from '@/components/ui/separator';
import EmotionPicker from '../components/EmotionPicker';
import GalleryPreview from '../components/GalleryPreview';
import { useGuestIdentity } from '../context/GuestIdentityContext';
import { useEventData } from '../hooks/useEventData';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { ChevronDown, Sparkles, UploadCloud, X, RefreshCw, Timer } from 'lucide-react';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import { useEventBranding } from '../context/EventBrandingContext';
import type { EventBranding } from '../types/event-branding';
import { Swiper, SwiperSlide } from 'swiper/react';
import { EffectCards } from 'swiper/modules';
import 'swiper/css';
import 'swiper/css/effect-cards';
import { getEmotionIcon, getEmotionTheme, type EmotionIdentity } from '../lib/emotionTheme';
import { getDeviceId } from '../lib/device';
import { useDirectUpload } from '../hooks/useDirectUpload';
import { useNavigate } from 'react-router-dom';
import { isTaskModeEnabled } from '../lib/engagement';
export default function HomePage() {
const { token } = useParams<{ token: string }>();
const { name, hydrated } = useGuestIdentity();
const { event } = useEventData();
const { completedCount } = useGuestTaskProgress(token ?? '');
const { t, locale } = useTranslation();
const { branding } = useEventBranding();
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const radius = branding.buttons?.radius ?? 12;
const heroStorageKey = token ? `guestHeroDismissed_${token}` : 'guestHeroDismissed';
const [heroVisible, setHeroVisible] = React.useState(false);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
try {
const stored = window.sessionStorage.getItem(heroStorageKey);
// standardmäßig versteckt, nur sichtbar falls explizit gesetzt (kann später wieder aktiviert werden)
setHeroVisible(stored === 'show');
} catch {
setHeroVisible(false);
}
}, [heroStorageKey]);
const dismissHero = React.useCallback(() => {
setHeroVisible(false);
if (typeof window === 'undefined') {
return;
}
try {
window.sessionStorage.setItem(heroStorageKey, '1');
} catch {
// ignore storage exceptions (e.g. private mode)
}
}, [heroStorageKey]);
const displayName = hydrated && name ? name : t('home.fallbackGuestName');
const eventNameDisplay = event?.name ?? t('home.hero.defaultEventName');
const accentColor = branding.primaryColor;
const secondaryAccent = branding.secondaryColor;
const uploadsRequireApproval =
(event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate';
const tasksEnabled = isTaskModeEnabled(event);
const [missionDeck, setMissionDeck] = React.useState<MissionPreview[]>([]);
const [missionPool, setMissionPool] = React.useState<MissionPreview[]>([]);
const [missionLoading, setMissionLoading] = React.useState(false);
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
const [hasMore, setHasMore] = React.useState(true);
const [page, setPage] = React.useState(1);
const [swipeCount, setSwipeCount] = React.useState(0);
const seenIdsRef = React.useRef<Set<number>>(new Set());
const poolIndexRef = React.useRef(0);
const sliderStateKey = token ? `missionSliderIndex:${token}` : null;
const swiperRef = React.useRef<any>(null);
const advanceDeck = React.useCallback(() => {
if (swiperRef.current) {
swiperRef.current.slideNext();
}
}, []);
const normalizeTasks = React.useCallback(
(tasks: Record<string, unknown>[]): MissionPreview[] =>
tasks.map((task) => ({
id: Number(task.id),
title: typeof task.title === 'string' ? task.title : 'Mission',
description: typeof task.description === 'string' ? task.description : '',
duration: typeof task.duration === 'number' ? task.duration : 3,
emotion: (task.emotion as EmotionIdentity) ?? null,
})),
[]
);
const mergeIntoPool = React.useCallback(
(incoming: MissionPreview[]) => {
const slugTitle = (title: string) =>
title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
setMissionPool((prev) => {
const byId = new Map<number, MissionPreview>();
const byTitle = new Map<string, MissionPreview>();
const addCandidate = (candidate: MissionPreview) => {
if (byId.has(candidate.id)) return;
const titleKey = slugTitle(candidate.title);
if (byTitle.has(titleKey)) return;
byId.set(candidate.id, candidate);
byTitle.set(titleKey, candidate);
};
prev.forEach(addCandidate);
incoming.forEach(addCandidate);
return Array.from(byId.values());
});
},
[]
);
const fetchTasksPage = React.useCallback(
async (pageToFetch: number, isInitial: boolean = false) => {
if (!token) return;
if (isInitial) {
setMissionLoading(true);
}
setIsLoadingMore(true);
try {
const perPage = 20;
const response = await fetch(
`/api/v1/events/${encodeURIComponent(token)}/tasks?page=${pageToFetch}&per_page=${perPage}&locale=${encodeURIComponent(locale)}`,
{
headers: {
Accept: 'application/json',
'X-Locale': locale,
'X-Device-Id': getDeviceId(),
},
}
);
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
const payload = await response.json();
let items: Record<string, unknown>[] = [];
let hasMoreFlag = false;
let nextPage = pageToFetch + 1;
if (Array.isArray(payload)) {
items = payload;
hasMoreFlag = false;
} else if (payload && Array.isArray(payload.tasks)) {
items = payload.tasks;
hasMoreFlag = false;
} else if (payload && Array.isArray(payload.data)) {
items = payload.data;
if (payload.meta?.current_page && payload.meta?.last_page) {
hasMoreFlag = payload.meta.current_page < payload.meta.last_page;
nextPage = (payload.meta.current_page as number) + 1;
} else if (payload.next_page_url !== undefined) {
hasMoreFlag = Boolean(payload.next_page_url);
} else {
hasMoreFlag = items.length > 0;
}
} else {
hasMoreFlag = false;
}
const normalized = normalizeTasks(items);
const deduped = normalized.filter((task) => {
if (seenIdsRef.current.has(task.id)) return false;
seenIdsRef.current.add(task.id);
return true;
});
if (deduped.length) {
mergeIntoPool(deduped);
}
setHasMore(hasMoreFlag);
if (hasMoreFlag) {
setPage(nextPage);
}
} catch (err) {
console.warn('Mission fetch failed', err);
setHasMore(false);
} finally {
setIsLoadingMore(false);
if (isInitial) {
setMissionLoading(false);
}
}
},
[locale, normalizeTasks, token]
);
React.useEffect(() => {
if (!token) return;
seenIdsRef.current = new Set();
setMissionDeck([]);
setMissionPool([]);
setPage(1);
setHasMore(true);
// restore persisted slider position for this event
let restoredIndex = 0;
if (sliderStateKey && typeof window !== 'undefined') {
try {
const stored = window.sessionStorage.getItem(sliderStateKey);
if (stored) {
const parsed = Number(stored);
if (!Number.isNaN(parsed) && parsed >= 0) {
restoredIndex = parsed;
}
}
} catch {
restoredIndex = 0;
}
}
poolIndexRef.current = restoredIndex;
if (!tasksEnabled) return;
fetchTasksPage(1, true);
}, [fetchTasksPage, locale, sliderStateKey, tasksEnabled, token]);
React.useEffect(() => {
if (missionPool.length === 0) return;
if (poolIndexRef.current >= missionPool.length) {
poolIndexRef.current = poolIndexRef.current % missionPool.length;
}
setMissionDeck(missionPool);
}, [missionPool]);
React.useEffect(() => {
if (!swiperRef.current) return;
if (!missionDeck.length) return;
const target = poolIndexRef.current % missionDeck.length;
const inst = swiperRef.current;
if (typeof inst.slideToLoop === 'function') {
inst.slideToLoop(target, 0);
} else if (typeof inst.slideTo === 'function') {
inst.slideTo(target, 0);
}
}, [missionDeck.length]);
React.useEffect(() => {
if (missionLoading) return;
if (!hasMore || isLoadingMore) return;
// Prefetch when we are within 6 items of the end of the current pool
const remaining = missionPool.length - poolIndexRef.current;
if (remaining <= 6) {
fetchTasksPage(page);
}
}, [fetchTasksPage, hasMore, isLoadingMore, missionLoading, missionPool.length, page]);
if (!token) {
return null;
}
const introArray: string[] = [];
for (let i = 0; i < 12; i += 1) {
const candidate = t(`home.introRotating.${i}`, '');
if (candidate) {
introArray.push(candidate);
}
}
const introMessageRef = React.useRef<string | null>(null);
if (!introMessageRef.current) {
introMessageRef.current =
introArray.length > 0 ? introArray[Math.floor(Math.random() * introArray.length)] : '';
}
const introMessage = introMessageRef.current;
if (!tasksEnabled) {
return (
<div className="space-y-3 pb-24" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
<section className="space-y-1 px-4" style={headingFont ? { fontFamily: headingFont } : undefined}>
<p className="text-sm font-semibold text-foreground">
{t('home.welcomeLine').replace('{name}', displayName)}
</p>
{introMessage && <p className="text-xs text-muted-foreground">{introMessage}</p>}
</section>
<section className="space-y-2 px-4">
<UploadActionCard
token={token}
accentColor={accentColor}
secondaryAccent={secondaryAccent}
radius={radius}
bodyFont={bodyFont}
requiresApproval={uploadsRequireApproval}
/>
</section>
<Separator />
<GalleryPreview token={token} />
</div>
);
}
return (
<div className="space-y-0.5 pb-24" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
<section className="space-y-1 px-4" style={headingFont ? { fontFamily: headingFont } : undefined}>
<p className="text-sm font-semibold text-foreground">
{t('home.welcomeLine').replace('{name}', displayName)}
</p>
{introMessage && (
<p className="text-xs text-muted-foreground">
{introMessage}
</p>
)}
</section>
{heroVisible && (
<HeroCard
name={displayName}
eventName={eventNameDisplay}
tasksCompleted={completedCount}
t={t}
branding={branding}
onDismiss={dismissHero}
ctaLabel={t('home.actions.items.tasks.label')}
ctaHref={`/e/${encodeURIComponent(token)}/tasks`}
/>
)}
<section className="space-y-0.5" style={headingFont ? { fontFamily: headingFont } : undefined}>
<div className="space-y-0.5">
<MissionActionCard
token={token}
mission={missionDeck[0] ?? null}
loading={missionLoading}
onAdvance={advanceDeck}
stack={missionDeck.slice(1)}
initialIndex={poolIndexRef.current}
onIndexChange={(idx, total) => {
poolIndexRef.current = idx % Math.max(1, total);
if (sliderStateKey && typeof window !== 'undefined') {
try {
window.sessionStorage.setItem(sliderStateKey, String(poolIndexRef.current));
} catch {
// ignore storage errors
}
}
}}
swiperRef={swiperRef}
/>
<UploadActionCard
token={token}
accentColor={accentColor}
secondaryAccent={secondaryAccent}
radius={radius}
bodyFont={bodyFont}
requiresApproval={uploadsRequireApproval}
/>
<EmotionActionCard />
</div>
</section>
<Separator />
<GalleryPreview token={token} />
</div>
);
}
function HeroCard({
name,
eventName,
tasksCompleted,
t,
branding,
onDismiss,
ctaLabel,
ctaHref,
}: {
name: string;
eventName: string;
tasksCompleted: number;
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);
const progressMessage = tasksCompleted > 0
? t('home.hero.progress.some').replace('{count}', `${tasksCompleted}`)
: t('home.hero.progress.none');
const style = React.useMemo(() => ({
background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`,
color: '#ffffff',
fontFamily: branding.fontFamily ?? undefined,
}), [branding.fontFamily, branding.primaryColor, branding.secondaryColor]);
return (
<Card className="relative overflow-hidden border-0 text-white shadow-md" style={style}>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-3 top-3 h-8 w-8 rounded-full bg-white/20 text-white hover:bg-white/30"
onClick={onDismiss}
>
<X className="h-4 w-4" aria-hidden />
<span className="sr-only">{t('common.actions.close')}</span>
</Button>
<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>
<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>
);
}
type MissionPreview = {
id: number;
title: string;
description?: string;
duration?: number;
emotion?: EmotionIdentity | null;
};
function MissionActionCard({
token,
mission,
loading,
onAdvance,
stack,
initialIndex,
onIndexChange,
swiperRef,
}: {
token: string;
mission: MissionPreview | null;
loading: boolean;
onAdvance: () => void;
stack: MissionPreview[];
initialIndex: number;
onIndexChange: (index: number, total: number) => void;
swiperRef: React.MutableRefObject<any>;
}) {
const { branding } = useEventBranding();
const radius = branding.buttons?.radius ?? 12;
const primary = branding.buttons?.primary ?? branding.primaryColor;
const secondary = branding.buttons?.secondary ?? branding.secondaryColor;
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const cards = mission ? [mission, ...stack] : stack;
const shellRadius = `${radius + 10}px`;
const normalizeText = (value: string | undefined | null) =>
(value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
const [expandedTaskId, setExpandedTaskId] = React.useState<number | null>(null);
const lastSlideIndexRef = React.useRef<number>(initialIndex);
const titleRefs = React.useRef(new Map<number, HTMLParagraphElement | null>());
const [expandableTitles, setExpandableTitles] = React.useState<Record<number, boolean>>({});
const measureTitleOverflow = React.useCallback(() => {
setExpandableTitles((prev) => {
let hasChange = false;
const next = { ...prev };
titleRefs.current.forEach((element, id) => {
if (!element || element.dataset.collapsed !== 'true') {
return;
}
const isOverflowing = element.scrollHeight > element.clientHeight + 1;
if (next[id] !== isOverflowing) {
next[id] = isOverflowing;
hasChange = true;
}
});
return hasChange ? next : prev;
});
}, []);
React.useLayoutEffect(() => {
if (typeof window === 'undefined') {
return;
}
const raf = window.requestAnimationFrame(measureTitleOverflow);
return () => window.cancelAnimationFrame(raf);
}, [measureTitleOverflow, cards, headingFont]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleResize = () => {
window.requestAnimationFrame(measureTitleOverflow);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [measureTitleOverflow]);
const renderCardContent = (card: MissionPreview | null) => {
const theme = getEmotionTheme(card?.emotion ?? null);
const emotionIcon = getEmotionIcon(card?.emotion ?? null);
const durationMinutes = card?.duration ?? 3;
const titleFont = headingFont ? { fontFamily: headingFont } : undefined;
const gradientBackground = card ? theme.gradientBackground : `linear-gradient(135deg, ${primary}, ${secondary})`;
const isExpanded = card ? expandedTaskId === card.id : false;
const isExpandable = Boolean(card && expandableTitles[card.id]);
const titleClamp = isExpanded ? '' : 'line-clamp-2 sm:line-clamp-3';
const titleClasses = `text-xl font-semibold leading-snug text-slate-900 sm:text-2xl break-words py-1 min-h-[3.75rem] sm:min-h-[4.5rem] ${titleClamp}`;
const titleId = card ? `task-title-${card.id}` : undefined;
const toggleExpanded = () => {
if (!card) return;
setExpandedTaskId((prev) => (prev === card.id ? null : card.id));
};
return (
<div
className="relative isolate overflow-hidden"
style={{
borderRadius: shellRadius,
background: gradientBackground,
boxShadow: `0 18px 50px ${primary}35, 0 6px 18px ${primary}22`,
fontFamily: bodyFont,
}}
>
<div
className="absolute inset-0 opacity-25"
style={{
backgroundImage:
'linear-gradient(90deg, rgba(255,255,255,0.18) 1px, transparent 1px), linear-gradient(0deg, rgba(255,255,255,0.18) 1px, transparent 1px)',
backgroundSize: '26px 26px',
}}
/>
<div className="absolute -left-12 -top-10 h-32 w-32 rounded-full bg-white/40 blur-3xl" />
<div className="absolute -right-10 bottom-0 h-28 w-28 rounded-full bg-white/25 blur-3xl" />
<div className="relative z-10 m-3 rounded-2xl border border-white/35 bg-white/80 px-4 py-4 shadow-lg backdrop-blur-xl">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="relative">
<div className="absolute inset-0 rounded-full bg-white/60 blur-xl" />
<Avatar className="relative h-12 w-12 border border-white/50 bg-white/80 shadow-md">
<AvatarFallback className="text-xl">{emotionIcon}</AvatarFallback>
</Avatar>
</div>
<div className="space-y-1">
<Badge
className="border-0 text-[11px] font-semibold uppercase tracking-wide text-white shadow"
style={{ backgroundImage: gradientBackground }}
>
{card?.emotion?.name ?? 'Fotoaufgabe'}
</Badge>
<div className="flex items-center gap-2 text-xs font-medium text-slate-600">
<Sparkles className="h-4 w-4 text-amber-500" aria-hidden />
<span>Foto-Challenge</span>
</div>
</div>
</div>
<div className="hidden items-center gap-1 rounded-full border border-white/70 bg-white/80 px-3 py-1 text-xs font-semibold text-slate-700 shadow-sm sm:flex">
<Timer className="mr-1 h-3.5 w-3.5 text-slate-500" aria-hidden />
<span>ca. {durationMinutes} min</span>
</div>
</div>
<div className="mt-4 text-center">
{card ? (
isExpandable ? (
<button
type="button"
onClick={toggleExpanded}
className="mx-auto flex w-full flex-col items-center gap-1 text-center"
aria-expanded={isExpanded}
aria-controls={titleId}
>
<p
id={titleId}
ref={(node) => {
if (card) {
titleRefs.current.set(card.id, node);
}
}}
data-collapsed={!isExpanded}
className={titleClasses}
style={{ ...titleFont, textShadow: '0 6px 18px rgba(15,23,42,0.28)' }}
>
{card.title}
</p>
<ChevronDown
className={`h-4 w-4 text-slate-600 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
aria-hidden
/>
<span className="sr-only">Titel ein- oder ausklappen</span>
</button>
) : (
<p
id={titleId}
ref={(node) => {
if (card) {
titleRefs.current.set(card.id, node);
}
}}
data-collapsed={!isExpanded}
className={titleClasses}
style={{ ...titleFont, textShadow: '0 6px 18px rgba(15,23,42,0.28)' }}
>
{card.title}
</p>
)
) : loading ? (
<div className="space-y-2">
<Skeleton className="mx-auto h-6 w-3/4" />
<Skeleton className="mx-auto h-4 w-full" />
<Skeleton className="mx-auto h-4 w-5/6" />
</div>
) : (
<p className="text-sm text-slate-600">Ziehe deine erste Mission oder wähle eine Stimmung.</p>
)}
</div>
{card?.description && normalizeText(card.title) !== normalizeText(card.description) ? (
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-1 text-left">
<p className="text-sm font-semibold text-slate-800" style={titleFont}>
{card.title}
</p>
<p className="text-sm leading-relaxed text-slate-600">{card.description}</p>
</div>
</div>
</div>
) : null}
<div className="mt-4 grid grid-cols-[2fr_1fr] gap-2">
<Button
asChild
className="w-full text-white shadow-lg"
style={{
borderRadius: `${radius}px`,
background: `linear-gradient(120deg, ${primary}, ${secondary})`,
boxShadow: `0 12px 28px ${primary}25`,
}}
>
<Link
to={
card
? `/e/${encodeURIComponent(token)}/upload?task=${card.id}`
: `/e/${encodeURIComponent(token)}/tasks`
}
>
Aufgabe starten
</Link>
</Button>
<Button
type="button"
variant="secondary"
className="w-full border border-slate-200 bg-white/80 text-slate-800 shadow-sm backdrop-blur"
onClick={onAdvance}
disabled={loading}
style={{
borderRadius: `${radius}px`,
}}
>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} aria-hidden />
<span className="hidden sm:inline">Andere</span>
</Button>
</div>
</div>
</div>
);
};
const slides = cards.length ? cards : [mission ?? null];
const initialSlide = Math.min(initialIndex, Math.max(0, slides.length - 1));
return (
<Card className="border-0 bg-transparent shadow-none" style={{ fontFamily: bodyFont }}>
<CardContent className="px-0 py-0">
<div className="relative min-h-[280px] px-2">
<Swiper
effect="cards"
modules={[EffectCards]}
cardsEffect={{
perSlideRotate: 1,
perSlideOffset: 1,
slideShadows: false,
}}
slidesPerView={1}
grabCursor
allowTouchMove
loop={slides.length > 1}
initialSlide={initialSlide}
onSwiper={(instance) => {
swiperRef.current = instance;
if (initialSlide > 0) {
instance.slideToLoop ? instance.slideToLoop(initialSlide, 0) : instance.slideTo(initialSlide, 0);
}
}}
onSlideChange={(instance) => {
const realIndex = typeof instance.realIndex === 'number' ? instance.realIndex : instance.activeIndex ?? 0;
if (realIndex !== lastSlideIndexRef.current) {
setExpandedTaskId(null);
}
lastSlideIndexRef.current = realIndex;
onIndexChange(realIndex, slides.length);
}}
className="!pb-2"
style={{ paddingLeft: '0.25rem', paddingRight: '0.25rem' }}
>
{slides.map((card, index) => {
const key = `card-${card?.id ?? 'x'}-${index}`;
return (
<SwiperSlide key={key} className="!h-auto">
<div className="mx-auto w-full max-w-[calc(100vw-2rem)]">
{renderCardContent(card)}
</div>
</SwiperSlide>
);
})}
</Swiper>
</div>
</CardContent>
</Card>
);
}
function EmotionActionCard() {
return (
<Card className="border border-muted/40 shadow-sm">
<CardHeader className="py-[4px]">
<CardTitle>Wähle eine Stimmung und erhalte eine passende Aufgabe</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
Tippe deinen Mood, wir picken die nächste Mission für dich.
</CardDescription>
</CardHeader>
<CardContent className="py-[4px]">
<EmotionPicker variant="embedded" showSkip={false} />
</CardContent>
</Card>
);
}
function UploadActionCard({
token,
accentColor,
secondaryAccent,
radius,
bodyFont,
requiresApproval,
}: {
token: string;
accentColor: string;
secondaryAccent: string;
radius: number;
bodyFont?: string;
requiresApproval: boolean;
}) {
const inputRef = React.useRef<HTMLInputElement | null>(null);
const [busy, setBusy] = React.useState(false);
const [message, setMessage] = React.useState<string | null>(null);
const navigate = useNavigate();
const { upload, uploading, error, warning, progress, reset } = useDirectUpload({
eventToken: token,
taskId: undefined,
emotionSlug: undefined,
onCompleted: () => {
setMessage(null);
navigate(`/e/${encodeURIComponent(token)}/gallery`);
},
});
const onPick = React.useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setBusy(true);
setMessage(null);
try {
await upload(file);
} finally {
setBusy(false);
}
if (inputRef.current) {
inputRef.current.value = '';
}
},
[upload]
);
React.useEffect(() => {
if (error) {
setMessage(error);
} else if (warning) {
setMessage(warning);
} else {
setMessage(null);
}
}, [error, warning]);
return (
<Card
className="overflow-hidden border border-muted/30 shadow-sm"
style={{
background: 'var(--guest-surface)',
borderRadius: `${radius}px`,
fontFamily: bodyFont,
}}
>
<CardContent className="flex flex-col gap-2 py-[4px]">
<Button
type="button"
className="text-white justify-center"
style={{
borderRadius: `${radius}px`,
background: `linear-gradient(120deg, ${accentColor}, ${secondaryAccent})`,
boxShadow: `0 12px 28px ${accentColor}25`,
}}
disabled={busy || uploading}
onClick={() => {
reset();
setMessage(null);
inputRef.current?.click();
}}
>
<span className="flex items-center gap-2">
<UploadCloud className={`h-5 w-5 ${busy || uploading ? 'animate-pulse' : ''}`} aria-hidden />
<span>{busy || uploading ? 'Lädt …' : 'Direkt hochladen'}</span>
</span>
</Button>
<input
ref={inputRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={onPick}
/>
<p className="text-xs text-muted-foreground">
Kamera öffnen oder ein Foto aus deiner Galerie wählen. Offline möglich wir laden später hoch.
</p>
{requiresApproval ? (
<p className="text-xs font-medium text-amber-700 dark:text-amber-300">
Deine Fotos werden kurz geprüft und erscheinen danach in der Galerie.
</p>
) : null}
{message && (
<p className="text-xs font-medium text-amber-600 dark:text-amber-400">
{message} {progress > 0 && progress < 100 ? `(${Math.round(progress)}%)` : ''}
</p>
)}
</CardContent>
</Card>
);
}