Files
fotospiel-app/resources/js/guest/components/PullToRefresh.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

150 lines
4.4 KiB
TypeScript

import React from 'react';
import { ArrowDown, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
const MAX_PULL = 96;
const TRIGGER_PULL = 72;
const DAMPING = 0.55;
type PullToRefreshProps = {
onRefresh: () => Promise<void> | void;
disabled?: boolean;
className?: string;
pullLabel?: string;
releaseLabel?: string;
refreshingLabel?: string;
children: React.ReactNode;
};
export default function PullToRefresh({
onRefresh,
disabled = false,
className,
pullLabel = 'Pull to refresh',
releaseLabel = 'Release to refresh',
refreshingLabel = 'Refreshing…',
children,
}: PullToRefreshProps) {
const containerRef = React.useRef<HTMLDivElement | null>(null);
const startYRef = React.useRef<number | null>(null);
const pullDistanceRef = React.useRef(0);
const [pullDistance, setPullDistance] = React.useState(0);
const [dragging, setDragging] = React.useState(false);
const [refreshing, setRefreshing] = React.useState(false);
const updatePull = React.useCallback((value: number) => {
pullDistanceRef.current = value;
setPullDistance(value);
}, []);
React.useEffect(() => {
const container = containerRef.current;
if (!container || disabled) {
return;
}
const handleStart = (event: TouchEvent) => {
if (refreshing || window.scrollY > 0) {
return;
}
startYRef.current = event.touches[0]?.clientY ?? null;
setDragging(true);
};
const handleMove = (event: TouchEvent) => {
if (refreshing || startYRef.current === null) {
return;
}
if (window.scrollY > 0) {
startYRef.current = null;
updatePull(0);
setDragging(false);
return;
}
const currentY = event.touches[0]?.clientY ?? 0;
const delta = currentY - startYRef.current;
if (delta <= 0) {
updatePull(0);
return;
}
event.preventDefault();
const next = Math.min(MAX_PULL, delta * DAMPING);
updatePull(next);
};
const handleEnd = async () => {
if (startYRef.current === null) {
return;
}
startYRef.current = null;
setDragging(false);
if (pullDistanceRef.current >= TRIGGER_PULL) {
setRefreshing(true);
updatePull(TRIGGER_PULL);
try {
await onRefresh();
} finally {
setRefreshing(false);
updatePull(0);
}
return;
}
updatePull(0);
};
container.addEventListener('touchstart', handleStart, { passive: true });
container.addEventListener('touchmove', handleMove, { passive: false });
container.addEventListener('touchend', handleEnd);
container.addEventListener('touchcancel', handleEnd);
return () => {
container.removeEventListener('touchstart', handleStart);
container.removeEventListener('touchmove', handleMove);
container.removeEventListener('touchend', handleEnd);
container.removeEventListener('touchcancel', handleEnd);
};
}, [disabled, onRefresh, refreshing, updatePull]);
const progress = Math.min(pullDistance / TRIGGER_PULL, 1);
const ready = pullDistance >= TRIGGER_PULL;
const indicatorLabel = refreshing
? refreshingLabel
: ready
? releaseLabel
: pullLabel;
return (
<div ref={containerRef} className={cn('relative', className)}>
<div
className="pointer-events-none absolute left-0 right-0 top-2 flex h-10 items-center justify-center"
style={{
opacity: progress,
transform: `translateY(${Math.min(pullDistance, TRIGGER_PULL) - 48}px)`,
transition: dragging ? 'none' : 'transform 200ms ease-out, opacity 200ms ease-out',
}}
>
<div className="flex items-center gap-2 rounded-full border border-white/30 bg-white/90 px-3 py-1 text-xs font-semibold text-slate-700 shadow-sm dark:border-white/10 dark:bg-slate-900/80 dark:text-slate-100">
{refreshing ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
) : (
<ArrowDown
className={cn('h-4 w-4 transition-transform duration-200', ready && 'rotate-180')}
aria-hidden
/>
)}
<span>{indicatorLabel}</span>
</div>
</div>
<div
className={cn('will-change-transform', !dragging && 'transition-transform duration-200 ease-out')}
style={{ transform: `translateY(${pullDistance}px)` }}
>
{children}
</div>
</div>
);
}