Files
fotospiel-app/resources/js/guest/polling/usePollGalleryDelta.ts
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

168 lines
5.3 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import type { LocaleCode } from '../i18n/messages';
type Photo = {
id: number;
file_path?: string;
thumbnail_path?: string;
created_at?: string;
session_id?: string | null;
uploader_name?: string | null;
};
type RawPhoto = Record<string, unknown>;
export function usePollGalleryDelta(token: string, locale: LocaleCode) {
const [photos, setPhotos] = useState<Photo[]>([]);
const [loading, setLoading] = useState(true);
const [newCount, setNewCount] = useState(0);
const latestAt = useRef<string | null>(null);
const etagRef = useRef<string | null>(null);
const timer = useRef<number | null>(null);
const [visible, setVisible] = useState(
typeof document !== 'undefined' ? document.visibilityState === 'visible' : true
);
const fetchDelta = useCallback(async () => {
if (!token) {
setLoading(false);
return;
}
try {
const params = new URLSearchParams();
if (latestAt.current) {
params.set('since', latestAt.current);
}
params.set('locale', locale);
const headers: HeadersInit = {
'Cache-Control': 'no-store',
'X-Locale': locale,
'Accept': 'application/json',
};
if (etagRef.current) {
headers['If-None-Match'] = etagRef.current;
}
const res = await fetch(`/api/v1/events/${encodeURIComponent(token)}/photos?${params.toString()}`, {
headers,
});
if (res.status === 304) return; // No new content
if (!res.ok) {
console.warn(`Gallery API error: ${res.status} ${res.statusText}`);
return; // Don't update state on error
}
const json = await res.json();
etagRef.current = res.headers.get('ETag');
// Handle different response formats
const rawPhotos = Array.isArray(json.data) ? json.data :
Array.isArray(json) ? json :
json.photos || [];
const newPhotos: Photo[] = rawPhotos.map((photo: RawPhoto) => ({
...(photo as Photo),
session_id: typeof photo.session_id === 'string' ? photo.session_id : (photo.guest_name as string | null) ?? null,
uploader_name:
typeof photo.uploader_name === 'string'
? (photo.uploader_name as string)
: typeof photo.guest_name === 'string'
? (photo.guest_name as string)
: null,
}));
if (newPhotos.length > 0) {
const added = newPhotos.length;
const hasBaseline = latestAt.current !== null;
setPhotos((prev) => {
if (hasBaseline) {
// Delta mode: merge new photos with existing list by id
const merged = [...newPhotos, ...prev];
const byId = new Map<number, Photo>();
merged.forEach((photo) => byId.set(photo.id, photo));
return Array.from(byId.values());
}
return newPhotos;
});
if (hasBaseline && added > 0) {
setNewCount((c) => c + added);
}
// Update latest timestamp
if (json.latest_photo_at) {
latestAt.current = json.latest_photo_at;
} else if (newPhotos.length > 0) {
// Fallback: use newest photo timestamp
const newest = newPhotos.reduce((latest: number, photo: RawPhoto) => {
const photoTime = new Date((photo.created_at as string | undefined) || (photo.created_at_timestamp as number | undefined) || 0).getTime();
return photoTime > latest ? photoTime : latest;
}, 0);
latestAt.current = new Date(newest).toISOString();
}
} else if (latestAt.current) {
// Delta mode but no new photos: keep existing photos
console.log('No new photos, keeping existing gallery state');
// Don't update photos state
} else {
// Initial load with no photos
setPhotos([]);
}
setLoading(false);
} catch (error) {
console.error('Gallery polling error:', error);
setLoading(false);
// Don't update state on error - keep previous photos
}
}, [locale, token]);
useEffect(() => {
const onVis = () => setVisible(document.visibilityState === 'visible');
document.addEventListener('visibilitychange', onVis);
return () => document.removeEventListener('visibilitychange', onVis);
}, []);
useEffect(() => {
if (!token) {
setPhotos([]);
setLoading(false);
return;
}
setLoading(true);
latestAt.current = null;
etagRef.current = null;
setPhotos([]);
void fetchDelta();
if (timer.current) window.clearInterval(timer.current);
// Poll less aggressively when hidden
const interval = visible ? 30_000 : 90_000;
timer.current = window.setInterval(() => { void fetchDelta(); }, interval);
return () => {
if (timer.current) window.clearInterval(timer.current);
};
}, [token, visible, locale, fetchDelta]);
const refreshNow = useCallback(async () => {
if (!token) {
return;
}
setLoading(true);
latestAt.current = null;
etagRef.current = null;
setNewCount(0);
setPhotos([]);
await fetchDelta();
}, [fetchDelta, token]);
function acknowledgeNew() { setNewCount(0); }
return { loading, photos, newCount, acknowledgeNew, refreshNow };
}