injectManifest, a new typed SW source, runtime caching, and a non‑blocking update toast with an action button. The
guest shell now links a dedicated manifest and theme color, and background upload sync is managed in a single
PwaManager component.
Key changes (where/why)
- vite.config.ts: added VitePWA injectManifest config, guest manifest, and output to /public so the SW can control /
scope.
- resources/js/guest/guest-sw.ts: new Workbox SW (precache + runtime caching for guest navigation, GET /api/v1/*,
images, fonts) and preserves push/sync/notification logic.
- resources/js/guest/components/PwaManager.tsx: registers SW, shows update/offline toasts, and processes the upload
queue on sync/online.
- resources/js/guest/components/ToastHost.tsx: action-capable toasts so update prompts can include a CTA.
- resources/js/guest/i18n/messages.ts: added common.updateAvailable, common.updateAction, common.offlineReady.
- resources/views/guest.blade.php: manifest + theme color + apple touch icon.
- .gitignore: ignore generated public/guest-sw.js and public/guest.webmanifest; public/guest-sw.js removed since it’s
now build output.
793 lines
30 KiB
TypeScript
793 lines
30 KiB
TypeScript
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 { motion } from 'framer-motion';
|
||
import {
|
||
getEmotionIcon,
|
||
getEmotionTheme,
|
||
type EmotionIdentity,
|
||
type EmotionTheme,
|
||
} from '../lib/emotionTheme';
|
||
import { getDeviceId } from '../lib/device';
|
||
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
|
||
import PullToRefresh from '../components/PullToRefresh';
|
||
|
||
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 handleRefresh = React.useCallback(async () => {
|
||
tasksCacheRef.current.clear();
|
||
await fetchTasks();
|
||
setPhotoPool([]);
|
||
setPhotoPoolError(null);
|
||
}, [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 motionEnabled = !prefersReducedMotion();
|
||
const containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
|
||
const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
|
||
const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);
|
||
|
||
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 (
|
||
<>
|
||
<PullToRefresh
|
||
onRefresh={handleRefresh}
|
||
pullLabel={t('common.pullToRefresh')}
|
||
releaseLabel={t('common.releaseToRefresh')}
|
||
refreshingLabel={t('common.refreshing')}
|
||
>
|
||
<motion.div className="space-y-6" {...containerMotion}>
|
||
<motion.header className="space-y-4" {...fadeUpMotion}>
|
||
<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 && (
|
||
<motion.div className="overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none]" {...fadeUpMotion}>
|
||
<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>
|
||
</motion.div>
|
||
)}
|
||
</motion.header>
|
||
|
||
{loading && (
|
||
<motion.div className="space-y-4" {...fadeUpMotion}>
|
||
<SkeletonBlock />
|
||
<SkeletonBlock />
|
||
</motion.div>
|
||
)}
|
||
|
||
{error && !loading && (
|
||
<motion.div {...fadeUpMotion}>
|
||
<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>
|
||
</motion.div>
|
||
)}
|
||
|
||
{emptyState && (
|
||
<motion.div {...fadeUpMotion}>
|
||
<EmptyState
|
||
hasTasks={Boolean(tasks.length)}
|
||
onRetry={handleRetryFetch}
|
||
emotionOptions={emotionOptions}
|
||
onEmotionSelect={handleSelectEmotion}
|
||
t={t}
|
||
/>
|
||
</motion.div>
|
||
)}
|
||
|
||
{!emptyState && currentTask && (
|
||
<motion.div className="space-y-8" {...fadeUpMotion}>
|
||
<motion.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 }}
|
||
{...fadeScaleMotion}
|
||
>
|
||
<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>
|
||
</motion.section>
|
||
|
||
{alternativeTasks.length > 0 && (
|
||
<motion.section className="space-y-3" {...fadeUpMotion}>
|
||
<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>
|
||
</motion.section>
|
||
)}
|
||
</motion.div>
|
||
)}
|
||
|
||
{!loading && !tasks.length && !error && (
|
||
<motion.div {...fadeUpMotion}>
|
||
<Alert>
|
||
<AlertDescription>{t('tasks.page.noTasksAlert')}</AlertDescription>
|
||
</Alert>
|
||
</motion.div>
|
||
)}
|
||
</motion.div>
|
||
</PullToRefresh>
|
||
<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>
|
||
);
|
||
}
|