Implemented guest-only PWA using vite-plugin-pwa (the actual published package; @vite-pwa/plugin isn’t on npm) with
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.
This commit is contained in:
@@ -10,6 +10,7 @@ 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,
|
||||
@@ -17,6 +18,8 @@ import {
|
||||
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;
|
||||
@@ -243,6 +246,13 @@ export default function TaskPickerPage() {
|
||||
fetchTasks();
|
||||
};
|
||||
|
||||
const handleRefresh = React.useCallback(async () => {
|
||||
tasksCacheRef.current.clear();
|
||||
await fetchTasks();
|
||||
setPhotoPool([]);
|
||||
setPhotoPoolError(null);
|
||||
}, [fetchTasks]);
|
||||
|
||||
const handlePhotoPreview = React.useCallback(
|
||||
(photoId: number) => {
|
||||
if (!eventKey) return;
|
||||
@@ -354,6 +364,10 @@ export default function TaskPickerPage() {
|
||||
[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) => {
|
||||
@@ -379,211 +393,225 @@ export default function TaskPickerPage() {
|
||||
|
||||
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>
|
||||
<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>
|
||||
)}
|
||||
</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}
|
||||
{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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{(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>}
|
||||
{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>
|
||||
{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 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>
|
||||
</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 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>
|
||||
<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>
|
||||
{(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>
|
||||
|
||||
Reference in New Issue
Block a user