Files
fotospiel-app/resources/js/guest/components/RouteTransition.tsx
Codex Agent 3e3a2c49d6 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.
2025-12-27 10:59:44 +01:00

101 lines
2.8 KiB
TypeScript

import React from 'react';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { Outlet, useLocation, useNavigationType } from 'react-router-dom';
const TAB_SECTIONS = new Set(['home', 'tasks', 'achievements', 'gallery']);
export function getTabKey(pathname: string): string | null {
const match = pathname.match(/^\/e\/[^/]+(?:\/([^/]+))?$/);
if (!match) {
return null;
}
const section = match[1];
if (!section) {
return 'home';
}
return TAB_SECTIONS.has(section) ? section : null;
}
export function getTransitionKind(prevPath: string, nextPath: string): 'tab' | 'stack' {
const prevTab = getTabKey(prevPath);
const nextTab = getTabKey(nextPath);
if (prevTab && nextTab && prevTab !== nextTab) {
return 'tab';
}
return 'stack';
}
export function isTransitionDisabled(pathname: string): boolean {
if (pathname.startsWith('/share/')) {
return true;
}
return /^\/e\/[^/]+\/upload(?:\/|$)/.test(pathname);
}
export default function RouteTransition({ children }: { children?: React.ReactNode }) {
const location = useLocation();
const navigationType = useNavigationType();
const prefersReducedMotion = useReducedMotion();
const prevPathRef = React.useRef(location.pathname);
const prevPath = prevPathRef.current;
const direction = navigationType === 'POP' ? 'back' : 'forward';
const kind = getTransitionKind(prevPath, location.pathname);
const disableTransitions = prefersReducedMotion
|| isTransitionDisabled(prevPath)
|| isTransitionDisabled(location.pathname);
React.useEffect(() => {
prevPathRef.current = location.pathname;
}, [location.pathname]);
const content = children ?? <Outlet />;
if (disableTransitions) {
return <>{content}</>;
}
const stackVariants = {
enter: ({ direction }: { direction: 'forward' | 'back' }) => ({
x: direction === 'back' ? -28 : 28,
opacity: 0,
}),
center: { x: 0, opacity: 1 },
exit: ({ direction }: { direction: 'forward' | 'back' }) => ({
x: direction === 'back' ? 28 : -28,
opacity: 0,
}),
};
const tabVariants = {
enter: { opacity: 0, scale: 0.985 },
center: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.985 },
};
const transition = kind === 'tab'
? { duration: 0.18, ease: [0.22, 0.61, 0.36, 1] }
: { duration: 0.24, ease: [0.25, 0.8, 0.25, 1] };
return (
<AnimatePresence initial={false}>
<motion.div
key={location.pathname}
custom={{ direction }}
variants={kind === 'tab' ? tabVariants : stackVariants}
initial="enter"
animate="center"
exit="exit"
transition={transition}
style={{ willChange: 'transform, opacity' }}
>
{content}
</motion.div>
</AnimatePresence>
);
}