refactor(guest): retire legacy guest app and move shared modules
This commit is contained in:
@@ -1,165 +0,0 @@
|
||||
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;
|
||||
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);
|
||||
await fetchDelta();
|
||||
}, [fetchDelta, token]);
|
||||
|
||||
function acknowledgeNew() { setNewCount(0); }
|
||||
return { loading, photos, newCount, acknowledgeNew, refreshNow };
|
||||
}
|
||||
Reference in New Issue
Block a user