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

765 lines
28 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. 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 { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Sparkles, RefreshCw, Smile, Camera, Timer as TimerIcon, Heart, ChevronRight, CheckCircle2 } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { cn } from '@/lib/utils';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useEventBranding } from '../context/EventBrandingContext';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import {
getEmotionIcon,
getEmotionTheme,
type EmotionIdentity,
type EmotionTheme,
} from '../lib/emotionTheme';
import { getDeviceId } from '../lib/device';
interface Task {
id: number;
title: string;
description: string;
instructions: string;
duration: number; // minutes
emotion?: {
slug: string;
name: string;
};
is_completed: boolean;
}
type EmotionOption = {
slug: string;
name: string;
};
type EventPhoto = {
id: number;
thumbnail_path?: string | null;
file_path?: string | null;
likes_count?: number | null;
task_id?: number | null;
};
const SWIPE_THRESHOLD_PX = 40;
const SIMILAR_PHOTO_LIMIT = 6;
export default function TaskPickerPage() {
const { token } = useParams<{ token: string }>();
const eventKey = token ?? '';
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { branding } = useEventBranding();
const { t, locale } = useTranslation();
const radius = branding.buttons?.radius ?? 12;
const buttonStyle = branding.buttons?.style ?? 'filled';
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const { isCompleted } = useGuestTaskProgress(eventKey);
const [tasks, setTasks] = React.useState<Task[]>([]);
const [currentTask, setCurrentTask] = React.useState<Task | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [selectedEmotion, setSelectedEmotion] = React.useState<string>('all');
const [isFetching, setIsFetching] = React.useState(false);
const [photoPool, setPhotoPool] = React.useState<EventPhoto[]>([]);
const [photoPoolLoading, setPhotoPoolLoading] = React.useState(false);
const [photoPoolError, setPhotoPoolError] = React.useState<string | null>(null);
const [hasSwiped, setHasSwiped] = React.useState(false);
const [emotionPickerOpen, setEmotionPickerOpen] = React.useState(false);
const [recentEmotionSlug, setRecentEmotionSlug] = React.useState<string | null>(null);
const heroCardRef = React.useRef<HTMLDivElement | null>(null);
const cameraButtonStyle = React.useMemo(() => ({
background: `radial-gradient(circle at 20% 20%, ${branding.secondaryColor}, ${branding.primaryColor})`,
boxShadow: `0 18px 30px ${branding.primaryColor}44`,
color: '#ffffff',
}), [branding.primaryColor, branding.secondaryColor]);
const recentTaskIdsRef = React.useRef<number[]>([]);
const tasksCacheRef = React.useRef<Map<string, { data: Task[]; etag?: string | null }>>(new Map());
const initialEmotionRef = React.useRef(false);
const fetchTasks = React.useCallback(async () => {
if (!eventKey) return;
const cacheKey = `${eventKey}:${locale}`;
const cached = tasksCacheRef.current.get(cacheKey);
setIsFetching(true);
setLoading(!cached);
setError(null);
if (cached) {
setTasks(cached.data);
}
try {
const headers: HeadersInit = {
Accept: 'application/json',
'X-Locale': locale,
'X-Device-Id': getDeviceId(),
};
if (cached?.etag) {
headers['If-None-Match'] = cached.etag;
}
const response = await fetch(
`/api/v1/events/${encodeURIComponent(eventKey)}/tasks?locale=${encodeURIComponent(locale)}`,
{ headers }
);
if (response.status === 304 && cached) {
return;
}
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
const payload = await response.json();
const taskList = Array.isArray(payload)
? payload
: Array.isArray(payload?.data)
? payload.data
: Array.isArray(payload?.tasks)
? payload.tasks
: [];
const entry = { data: taskList, etag: response.headers.get('ETag') };
tasksCacheRef.current.set(cacheKey, entry);
setTasks(taskList);
} catch (err) {
console.error('Failed to load tasks', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
if (!cached) {
setTasks([]);
}
} finally {
setIsFetching(false);
setLoading(false);
}
}, [eventKey, locale]);
React.useEffect(() => {
fetchTasks();
}, [fetchTasks]);
React.useEffect(() => {
if (initialEmotionRef.current) return;
const queryEmotion = searchParams.get('emotion');
if (queryEmotion) {
setSelectedEmotion(queryEmotion);
}
initialEmotionRef.current = true;
}, [searchParams]);
const emotionOptions = React.useMemo<EmotionOption[]>(() => {
const map = new Map<string, string>();
tasks.forEach((task) => {
if (task.emotion?.slug) {
map.set(task.emotion.slug, task.emotion.name);
}
});
return Array.from(map.entries()).map(([slugValue, name]) => ({ slug: slugValue, name }));
}, [tasks]);
const emotionCounts = React.useMemo(() => {
const map = new Map<string, number>();
tasks.forEach((task) => {
const slugValue = task.emotion?.slug;
if (!slugValue) return;
map.set(slugValue, (map.get(slugValue) ?? 0) + 1);
});
return map;
}, [tasks]);
const filteredTasks = React.useMemo(() => {
if (selectedEmotion === 'all') return tasks;
return tasks.filter((task) => task.emotion?.slug === selectedEmotion);
}, [tasks, selectedEmotion]);
const alternativeTasks = React.useMemo(() => {
return filteredTasks.filter((task) => task.id !== currentTask?.id).slice(0, 6);
}, [filteredTasks, currentTask]);
const selectRandomTask = React.useCallback(
(list: Task[]) => {
if (!list.length) {
setCurrentTask(null);
return;
}
const avoidIds = recentTaskIdsRef.current;
const available = list.filter((task) => !isCompleted(task.id));
const base = available.length ? available : list;
let candidates = base.filter((task) => !avoidIds.includes(task.id));
if (!candidates.length) {
candidates = base;
}
const chosen = candidates[Math.floor(Math.random() * candidates.length)];
setCurrentTask(chosen);
recentTaskIdsRef.current = [...avoidIds.filter((id) => id !== chosen.id), chosen.id].slice(-3);
},
[isCompleted]
);
const handleSelectEmotion = React.useCallback(
(slugValue: string) => {
setSelectedEmotion(slugValue);
const next = new URLSearchParams(searchParams.toString());
if (slugValue === 'all') {
next.delete('emotion');
} else {
next.set('emotion', slugValue);
setRecentEmotionSlug(slugValue);
}
setSearchParams(next, { replace: true });
},
[searchParams, setSearchParams]
);
const handleNewTask = React.useCallback(() => {
selectRandomTask(filteredTasks);
}, [filteredTasks, selectRandomTask]);
const handleStartUpload = () => {
if (!currentTask || !eventKey) return;
navigate(`/e/${encodeURIComponent(eventKey)}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
};
const handleViewSimilar = React.useCallback(() => {
if (!currentTask || !eventKey) return;
navigate(`/e/${encodeURIComponent(eventKey)}/gallery?task=${currentTask.id}`);
}, [currentTask, eventKey, navigate]);
const handleSelectTask = React.useCallback((task: Task) => {
setCurrentTask(task);
}, []);
const handleRetryFetch = () => {
fetchTasks();
};
const handlePhotoPreview = React.useCallback(
(photoId: number) => {
if (!eventKey) return;
navigate(`/e/${encodeURIComponent(eventKey)}/gallery?photoId=${photoId}&task=${currentTask?.id ?? ''}`);
},
[eventKey, navigate, currentTask?.id]
);
React.useEffect(() => {
if (!filteredTasks.length) {
setCurrentTask(null);
return;
}
if (!currentTask || !filteredTasks.some((task) => task.id === currentTask.id)) {
selectRandomTask(filteredTasks);
}
}, [filteredTasks, currentTask, selectRandomTask]);
React.useEffect(() => {
if (currentTask?.emotion?.slug) {
setRecentEmotionSlug(currentTask.emotion.slug);
}
}, [currentTask?.emotion?.slug]);
React.useEffect(() => {
if (!eventKey || photoPool.length) return;
const controller = new AbortController();
setPhotoPoolLoading(true);
setPhotoPoolError(null);
fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/photos?locale=${encodeURIComponent(locale)}`, {
signal: controller.signal,
headers: {
Accept: 'application/json',
'X-Locale': locale,
},
})
.then((res) => {
if (!res.ok) {
throw new Error(t('tasks.page.inspirationError'));
}
return res.json();
})
.then((payload) => {
const data = Array.isArray(payload?.data) ? (payload.data as EventPhoto[]) : [];
setPhotoPool(data);
})
.catch((err) => {
if (controller.signal.aborted) return;
console.error('Failed to load photos', err);
setPhotoPoolError(t('tasks.page.inspirationError'));
})
.finally(() => {
if (!controller.signal.aborted) {
setPhotoPoolLoading(false);
}
});
return () => controller.abort();
}, [eventKey, photoPool.length, t, locale]);
const similarPhotos = React.useMemo(() => {
if (!currentTask) return [];
const matches = photoPool.filter((photo) => photo.task_id === currentTask.id);
return matches.slice(0, SIMILAR_PHOTO_LIMIT);
}, [photoPool, currentTask]);
React.useEffect(() => {
const card = heroCardRef.current;
if (!card) return;
let startX: number | null = null;
let startY: number | null = null;
const onTouchStart = (event: TouchEvent) => {
const touch = event.touches[0];
startX = touch.clientX;
startY = touch.clientY;
};
const onTouchEnd = (event: TouchEvent) => {
if (startX === null || startY === null) return;
const touch = event.changedTouches[0];
const deltaX = touch.clientX - startX;
const deltaY = touch.clientY - startY;
if (Math.abs(deltaX) > SWIPE_THRESHOLD_PX && Math.abs(deltaY) < 60) {
if (deltaX < 0) {
handleNewTask();
} else {
handleViewSimilar();
}
setHasSwiped(true);
}
startX = null;
startY = null;
};
card.addEventListener('touchstart', onTouchStart, { passive: true });
card.addEventListener('touchend', onTouchEnd);
return () => {
card.removeEventListener('touchstart', onTouchStart);
card.removeEventListener('touchend', onTouchEnd);
};
}, [handleNewTask, handleViewSimilar]);
const emptyState = !loading && (!filteredTasks.length || !currentTask);
const heroTheme = React.useMemo(() => getEmotionTheme(currentTask?.emotion ?? null), [currentTask?.emotion]);
const heroEmotionIcon = getEmotionIcon(currentTask?.emotion ?? null);
const recentEmotionOption = React.useMemo(
() => emotionOptions.find((option) => option.slug === recentEmotionSlug) ?? null,
[emotionOptions, recentEmotionSlug]
);
const toggleValue = selectedEmotion === 'all' ? 'none' : 'recent';
const handleToggleChange = React.useCallback(
(value: string) => {
if (!value) return;
if (value === 'picker') {
setEmotionPickerOpen(true);
return;
}
if (value === 'none') {
handleSelectEmotion('all');
return;
}
if (value === 'recent') {
if (recentEmotionSlug) {
handleSelectEmotion(recentEmotionSlug);
} else {
setEmotionPickerOpen(true);
}
}
},
[handleSelectEmotion, recentEmotionSlug]
);
return (
<>
<div className="space-y-6">
<header className="space-y-4">
<div className="space-y-1">
<p className="text-[11px] uppercase tracking-[0.4em] text-muted-foreground">{t('tasks.page.eyebrow')}</p>
<h1 className="text-2xl font-semibold text-foreground">{t('tasks.page.title')}</h1>
<p className="text-sm text-muted-foreground">{t('tasks.page.subtitle')}</p>
</div>
{emotionOptions.length > 0 && (
<div className="overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none]">
<ToggleGroup
type="single"
aria-label="Stimmung filtern"
value={toggleValue}
onValueChange={handleToggleChange}
className="inline-flex gap-1 rounded-full bg-muted/60 p-1"
variant="outline"
size="sm"
>
<ToggleGroupItem
value="none"
className="rounded-full !rounded-full px-4 py-2 text-sm font-medium first:!rounded-full last:!rounded-full"
>
<span className="mr-2">🎲</span>
{t('tasks.page.filters.none')}
</ToggleGroupItem>
<ToggleGroupItem
value="recent"
disabled={!recentEmotionOption}
className="rounded-full !rounded-full px-4 py-2 text-sm font-medium first:!rounded-full last:!rounded-full"
>
<span className="mr-2">{getEmotionIcon(recentEmotionOption)}</span>
{recentEmotionOption?.name ?? t('tasks.page.filters.recentFallback')}
</ToggleGroupItem>
<ToggleGroupItem
value="picker"
className="rounded-full !rounded-full px-4 py-2 text-sm font-medium first:!rounded-full last:!rounded-full"
>
<span className="mr-2">🗂</span>
{t('tasks.page.filters.showAll')}
</ToggleGroupItem>
</ToggleGroup>
</div>
)}
</header>
{loading && (
<div className="space-y-4">
<SkeletonBlock />
<SkeletonBlock />
</div>
)}
{error && !loading && (
<Alert variant="destructive">
<AlertDescription className="flex items-center justify-between gap-3">
<span>{error}</span>
<Button variant="outline" size="sm" onClick={handleRetryFetch} disabled={isFetching}>
Erneut versuchen
</Button>
</AlertDescription>
</Alert>
)}
{emptyState && (
<EmptyState
hasTasks={Boolean(tasks.length)}
onRetry={handleRetryFetch}
emotionOptions={emotionOptions}
onEmotionSelect={handleSelectEmotion}
t={t}
/>
)}
{!emptyState && currentTask && (
<div className="space-y-8">
<section
ref={heroCardRef}
className={cn(
'relative overflow-hidden rounded-3xl p-5 text-white shadow-lg transition-[background] duration-700',
'bg-gradient-to-br',
heroTheme.gradientClass
)}
style={{ background: heroTheme.gradientBackground }}
>
<div className="relative space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3 text-xs font-semibold uppercase tracking-[0.25em] text-white/70">
<span className="flex items-center gap-2 tracking-[0.3em]">
<Sparkles className="h-4 w-4" />
{heroEmotionIcon} {currentTask.emotion?.name ?? 'Neue Mission'}
</span>
<span className="flex items-center gap-1 text-white tracking-normal">
<TimerIcon className="h-4 w-4" />
{currentTask.duration} Min
</span>
</div>
<div className="space-y-3">
<h2 className="text-3xl font-semibold leading-tight drop-shadow-sm">{currentTask.title}</h2>
<p className="text-sm leading-relaxed text-white/80">{currentTask.description}</p>
</div>
{!hasSwiped && (
<p className="text-[11px] uppercase tracking-[0.4em] text-white/70">
{t('tasks.page.swipeHint')}
</p>
)}
{currentTask.instructions && (
<div className="rounded-2xl bg-white/15 p-3 text-sm font-medium text-white/90">
{currentTask.instructions}
</div>
)}
<div className="flex flex-wrap gap-2 text-sm text-white/80">
{isCompleted(currentTask.id) && (
<span className="rounded-full border border-white/30 px-3 py-1">
<CheckCircle2 className="mr-1 inline h-4 w-4" />
{t('tasks.page.completedLabel')}
</span>
)}
</div>
<div className="grid grid-cols-3 gap-3">
<Button
onClick={handleStartUpload}
className="col-span-2 flex h-14 items-center justify-center gap-2 rounded-2xl text-base font-semibold text-white shadow-lg shadow-black/10 transition hover:scale-[1.01]"
style={cameraButtonStyle}
>
<Camera className="h-6 w-6" />
{t('tasks.page.ctaStart')}
</Button>
<HeroActionButton
icon={RefreshCw}
label={t('tasks.page.shuffleCta')}
onClick={handleNewTask}
className="h-12 justify-center px-3 py-2"
/>
</div>
{(photoPoolLoading || photoPoolError || similarPhotos.length > 0) && (
<div className="space-y-2 rounded-2xl border border-white/25 bg-white/10 p-3">
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-[0.3em] text-white/70">
<span>{t('tasks.page.inspirationTitle')}</span>
{photoPoolLoading && <span className="text-[10px] text-white/70">{t('tasks.page.inspirationLoading')}</span>}
</div>
{photoPoolError && similarPhotos.length === 0 ? (
<p className="text-xs text-white/80">{photoPoolError}</p>
) : similarPhotos.length > 0 ? (
<div className="flex gap-3 overflow-x-auto pb-2 [-ms-overflow-style:none] [scrollbar-width:none]">
{similarPhotos.map((photo) => (
<SimilarPhotoChip key={photo.id} photo={photo} onOpen={handlePhotoPreview} />
))}
<button
type="button"
onClick={handleViewSimilar}
className="flex h-16 min-w-[64px] flex-col items-center justify-center rounded-2xl border border-dashed border-white/40 px-3 text-center text-[11px] font-semibold uppercase tracking-[0.3em] text-white/80"
>
{t('tasks.page.inspirationMore')}
</button>
</div>
) : (
<button
type="button"
onClick={handleStartUpload}
className="flex items-center justify-between rounded-2xl border border-white/30 bg-white/10 px-4 py-3 text-sm text-white/80 transition hover:bg-white/20"
>
<div className="text-left">
<p className="font-semibold">{t('tasks.page.inspirationEmptyTitle')}</p>
<p className="text-xs text-white/70">{t('tasks.page.inspirationEmptyDescription')}</p>
</div>
<Camera className="h-4 w-4" />
</button>
)}
</div>
)}
</div>
</section>
{alternativeTasks.length > 0 && (
<section className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm uppercase tracking-[0.2em] text-muted-foreground">{t('tasks.page.suggestionsEyebrow')}</p>
<h2 className="text-lg font-semibold text-foreground">{t('tasks.page.suggestionsTitle')}</h2>
</div>
<Button variant="outline" size="sm" onClick={handleNewTask} className="shrink-0">
{t('tasks.page.shuffleButton')}
</Button>
</div>
<div className="flex gap-3 overflow-x-auto pb-2 [-ms-overflow-style:none] [scrollbar-width:none]">
{alternativeTasks.map((task) => (
<TaskSuggestionCard key={task.id} task={task} onSelect={handleSelectTask} />
))}
</div>
</section>
)}
</div>
)}
{!loading && !tasks.length && !error && (
<Alert>
<AlertDescription>{t('tasks.page.noTasksAlert')}</AlertDescription>
</Alert>
)}
</div>
<Dialog open={emotionPickerOpen} onOpenChange={setEmotionPickerOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto" hideClose>
<DialogHeader>
<DialogTitle>{t('tasks.page.filters.dialogTitle')}</DialogTitle>
</DialogHeader>
{emotionOptions.length ? (
<div className="grid gap-3 sm:grid-cols-2">
{emotionOptions.map((emotion) => {
const count = emotionCounts.get(emotion.slug) ?? 0;
return (
<button
key={emotion.slug}
type="button"
onClick={() => {
handleSelectEmotion(emotion.slug);
setEmotionPickerOpen(false);
}}
className="flex items-center gap-3 rounded-2xl border border-muted/50 px-4 py-3 text-left transition hover:border-pink-300"
>
<span className="text-2xl" aria-hidden>
{getEmotionIcon(emotion)}
</span>
<div>
<p className="font-semibold text-foreground">{emotion.name}</p>
<p className="text-xs text-muted-foreground">
{count === 1
? t('tasks.page.filters.countOne', { count })
: t('tasks.page.filters.countMany', { count })}
</p>
</div>
</button>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground">{t('tasks.page.filters.empty')}</p>
)}
</DialogContent>
</Dialog>
</>
);
}
function SkeletonBlock() {
return <div className="h-24 animate-pulse rounded-xl bg-muted/60" />;
}
function EmptyState({
hasTasks,
onRetry,
emotionOptions,
onEmotionSelect,
t,
}: {
hasTasks: boolean;
onRetry: () => void;
emotionOptions: EmotionOption[];
onEmotionSelect: (slug: string) => void;
t: TranslateFn;
}) {
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-muted-foreground/30 bg-muted/20 p-8 text-center">
<Smile className="h-12 w-12 text-pink-500" aria-hidden />
<div className="space-y-2">
<h2 className="text-xl font-semibold">{t('tasks.page.emptyTitle')}</h2>
<p className="text-sm text-muted-foreground">
{hasTasks ? t('tasks.page.emptyDescriptionWithTasks') : t('tasks.page.emptyDescriptionNoTasks')}
</p>
</div>
{hasTasks && emotionOptions.length > 0 && (
<div className="grid w-full max-w-md grid-cols-2 gap-2 sm:grid-cols-3">
{emotionOptions.map((emotion) => (
<button
key={emotion.slug}
type="button"
onClick={() => onEmotionSelect(emotion.slug)}
className="rounded-full border border-border px-4 py-1 text-sm text-muted-foreground transition hover:border-pink-400 hover:text-foreground"
>
{emotion.name}
</button>
))}
</div>
)}
<Button onClick={onRetry} variant="outline" className="mt-2">
{t('tasks.page.reloadButton')}
</Button>
</div>
);
}
function HeroActionButton({
icon: Icon,
label,
detail,
onClick,
className,
}: {
icon: LucideIcon;
label: string;
detail?: string;
onClick: () => void;
className?: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn('flex flex-col rounded-2xl border border-white/30 bg-white/10 px-4 py-3 text-left text-sm font-medium text-white transition hover:bg-white/20', className)}
>
<span className="flex items-center gap-2 text-base font-semibold">
<Icon className="h-4 w-4" />
{label}
</span>
{detail && <span className="mt-1 text-xs text-white/80">{detail}</span>}
</button>
);
}
function SimilarPhotoChip({ photo, onOpen }: { photo: EventPhoto; onOpen: (photoId: number) => void }) {
const cover = photo.thumbnail_path || photo.file_path || '';
return (
<button
type="button"
onClick={() => onOpen(photo.id)}
className="relative h-16 w-16 overflow-hidden rounded-2xl border border-white/30 bg-white/10"
>
{cover ? (
<img
src={cover}
alt="Eventfoto"
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-[10px] font-semibold uppercase tracking-[0.3em] text-white/70">
Foto
</div>
)}
<div className="absolute bottom-1 left-1 flex items-center gap-1 rounded-full bg-black/60 px-1.5 py-0.5 text-[10px] font-semibold text-white">
<Heart className="h-3 w-3" />
<span>{photo.likes_count ?? 0}</span>
</div>
</button>
);
}
function TaskSuggestionCard({ task, onSelect }: { task: Task; onSelect: (task: Task) => void }) {
const theme = getEmotionTheme(task.emotion ?? null);
const emotionIcon = getEmotionIcon(task.emotion ?? null);
return (
<button
type="button"
onClick={() => onSelect(task)}
className={cn(
'group flex min-w-[220px] flex-col justify-between rounded-2xl border p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-md',
'bg-gradient-to-br text-gray-900 dark:text-white',
theme.suggestionGradient,
theme.suggestionBorder
)}
>
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.3em] text-gray-500 group-hover:text-gray-700 dark:text-gray-300">
{emotionIcon} {task.emotion?.name ?? 'Aufgabe'}
</p>
<h3 className="text-base font-semibold leading-tight line-clamp-2">{task.title}</h3>
<p className="text-xs text-gray-600 line-clamp-2 dark:text-gray-200">{task.description}</p>
</div>
<div className="mt-3 flex items-center justify-between text-xs font-semibold">
<span>{task.duration} Min</span>
<span className="flex items-center gap-1 text-pink-600 dark:text-pink-200">
Starten
<ChevronRight className="h-3.5 w-3.5" />
</span>
</div>
</button>
);
}