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:
Codex Agent
2025-12-27 10:59:44 +01:00
parent efc173cf5d
commit 3e3a2c49d6
30 changed files with 3862 additions and 812 deletions

View File

@@ -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>