refactor(guest): retire legacy guest app and move shared modules
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-06 08:42:53 +01:00
parent b14435df8b
commit 0a08f2704f
191 changed files with 243 additions and 12631 deletions

View File

@@ -0,0 +1,93 @@
import React from 'react';
export const TASK_BADGE_TARGET = 5;
function storageKey(eventKey: string) {
return `guestTasks_${eventKey}`;
}
function parseStored(value: string | null) {
if (!value) {
return [] as number[];
}
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) {
return parsed.filter((item) => Number.isInteger(item)) as number[];
}
return [];
} catch (error) {
console.warn('Failed to parse task progress from storage', error);
return [];
}
}
export function useGuestTaskProgress(eventKey: string | undefined) {
const [completed, setCompleted] = React.useState<number[]>([]);
const [hydrated, setHydrated] = React.useState(false);
React.useEffect(() => {
if (!eventKey) {
setCompleted([]);
setHydrated(true);
return;
}
try {
const stored = window.localStorage.getItem(storageKey(eventKey));
setCompleted(parseStored(stored));
} catch (error) {
console.warn('Failed to read task progress', error);
setCompleted([]);
} finally {
setHydrated(true);
}
}, [eventKey]);
const markCompleted = React.useCallback(
(taskId: number) => {
if (!eventKey || !Number.isInteger(taskId)) {
return;
}
setCompleted((prev) => {
if (prev.includes(taskId)) {
return prev;
}
const next = [...prev, taskId];
try {
window.localStorage.setItem(storageKey(eventKey), JSON.stringify(next));
} catch (error) {
console.warn('Failed to persist task progress', error);
}
return next;
});
},
[eventKey]
);
const clearProgress = React.useCallback(() => {
if (!eventKey) return;
setCompleted([]);
try {
window.localStorage.removeItem(storageKey(eventKey));
} catch (error) {
console.warn('Failed to clear task progress', error);
}
}, [eventKey]);
const isCompleted = React.useCallback(
(taskId: number | null | undefined) => {
if (!Number.isInteger(taskId)) return false;
return completed.includes(taskId as number);
},
[completed]
);
return {
hydrated,
completed,
completedCount: completed.length,
markCompleted,
clearProgress,
isCompleted,
};
}

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { getHapticsPreference, setHapticsPreference, supportsHaptics } from '../lib/haptics';
export function useHapticsPreference() {
const [enabled, setEnabledState] = React.useState(() => getHapticsPreference());
const [supported, setSupported] = React.useState(() => supportsHaptics());
React.useEffect(() => {
setEnabledState(getHapticsPreference());
setSupported(supportsHaptics());
}, []);
const setEnabled = React.useCallback((value: boolean) => {
setHapticsPreference(value);
setEnabledState(value);
}, []);
return { enabled, setEnabled, supported };
}

View File

@@ -0,0 +1,229 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { LiveShowLayoutMode, LiveShowPhoto, LiveShowSettings } from '../services/liveShowApi';
const MIN_FIXED_SECONDS = 3;
const MAX_FIXED_SECONDS = 20;
function resolveApprovedAt(photo: LiveShowPhoto): number {
if (!photo.approved_at) {
return 0;
}
const parsed = Date.parse(photo.approved_at);
return Number.isNaN(parsed) ? 0 : parsed;
}
function resolvePriority(photo: LiveShowPhoto): number {
return Number.isFinite(photo.live_priority) ? photo.live_priority : 0;
}
export function resolveItemsPerFrame(layout: LiveShowLayoutMode): number {
switch (layout) {
case 'split':
return 2;
case 'grid_burst':
return 4;
case 'single':
default:
return 1;
}
}
export function resolveIntervalMs(settings: LiveShowSettings, totalCount: number): number {
if (settings.pace_mode === 'fixed') {
const safeSeconds = Math.min(MAX_FIXED_SECONDS, Math.max(MIN_FIXED_SECONDS, settings.fixed_interval_seconds));
return safeSeconds * 1000;
}
if (totalCount >= 60) return 4500;
if (totalCount >= 30) return 5500;
if (totalCount >= 15) return 6500;
if (totalCount >= 6) return 7500;
return 9000;
}
export function resolvePlaybackQueue(photos: LiveShowPhoto[], settings: LiveShowSettings): LiveShowPhoto[] {
if (photos.length === 0) {
return [];
}
const newestFirst = [...photos].sort((a, b) => {
const timeDiff = resolveApprovedAt(b) - resolveApprovedAt(a);
if (timeDiff !== 0) return timeDiff;
return b.id - a.id;
});
if (settings.playback_mode === 'newest_first') {
return newestFirst;
}
if (settings.playback_mode === 'curated') {
const curated = photos.filter((photo) => photo.is_featured || resolvePriority(photo) > 0);
const base = curated.length > 0 ? curated : photos;
return [...base].sort((a, b) => {
const priorityDiff = resolvePriority(b) - resolvePriority(a);
if (priorityDiff !== 0) return priorityDiff;
const timeDiff = resolveApprovedAt(b) - resolveApprovedAt(a);
if (timeDiff !== 0) return timeDiff;
return b.id - a.id;
});
}
const oldestFirst = [...photos].sort((a, b) => {
const timeDiff = resolveApprovedAt(a) - resolveApprovedAt(b);
if (timeDiff !== 0) return timeDiff;
return a.id - b.id;
});
const balanced: LiveShowPhoto[] = [];
const seen = new Set<number>();
let newestIndex = 0;
let oldestIndex = 0;
let newestStreak = 0;
while (balanced.length < photos.length) {
let added = false;
if (newestIndex < newestFirst.length && newestStreak < 2) {
const candidate = newestFirst[newestIndex++];
if (!seen.has(candidate.id)) {
balanced.push(candidate);
seen.add(candidate.id);
newestStreak += 1;
added = true;
}
}
if (!added) {
while (oldestIndex < oldestFirst.length && seen.has(oldestFirst[oldestIndex].id)) {
oldestIndex += 1;
}
if (oldestIndex < oldestFirst.length) {
const candidate = oldestFirst[oldestIndex++];
balanced.push(candidate);
seen.add(candidate.id);
newestStreak = 0;
added = true;
}
}
if (!added) {
while (newestIndex < newestFirst.length && seen.has(newestFirst[newestIndex].id)) {
newestIndex += 1;
}
if (newestIndex < newestFirst.length) {
const candidate = newestFirst[newestIndex++];
balanced.push(candidate);
seen.add(candidate.id);
newestStreak += 1;
added = true;
}
}
if (!added) {
break;
}
}
return balanced;
}
export function buildFramePhotos(
queue: LiveShowPhoto[],
startIndex: number,
itemsPerFrame: number
): LiveShowPhoto[] {
if (queue.length === 0) {
return [];
}
const safeCount = Math.min(itemsPerFrame, queue.length);
const result: LiveShowPhoto[] = [];
for (let offset = 0; offset < safeCount; offset += 1) {
const idx = (startIndex + offset) % queue.length;
result.push(queue[idx]);
}
return result;
}
export type LiveShowPlaybackState = {
frame: LiveShowPhoto[];
layout: LiveShowLayoutMode;
intervalMs: number;
frameKey: string;
nextFrame: LiveShowPhoto[];
};
export function useLiveShowPlayback(
photos: LiveShowPhoto[],
settings: LiveShowSettings,
options: { paused?: boolean } = {}
): LiveShowPlaybackState {
const queue = useMemo(() => resolvePlaybackQueue(photos, settings), [photos, settings]);
const layout = settings.layout_mode;
const itemsPerFrame = resolveItemsPerFrame(layout);
const [index, setIndex] = useState(0);
const currentIdRef = useRef<number | null>(null);
const paused = Boolean(options.paused);
useEffect(() => {
if (queue.length === 0) {
setIndex(0);
currentIdRef.current = null;
return;
}
if (currentIdRef.current !== null) {
const existingIndex = queue.findIndex((photo) => photo.id === currentIdRef.current);
if (existingIndex >= 0) {
setIndex(existingIndex);
return;
}
}
setIndex((prev) => prev % queue.length);
}, [queue]);
const frame = useMemo(() => {
const framePhotos = buildFramePhotos(queue, index, itemsPerFrame);
currentIdRef.current = framePhotos[0]?.id ?? null;
return framePhotos;
}, [queue, index, itemsPerFrame]);
const frameKey = useMemo(() => {
if (frame.length === 0) {
return `empty-${layout}`;
}
return frame.map((photo) => photo.id).join('-');
}, [frame, layout]);
const nextFrame = useMemo(() => {
if (queue.length === 0) {
return [];
}
return buildFramePhotos(queue, index + itemsPerFrame, itemsPerFrame);
}, [index, itemsPerFrame, queue]);
const intervalMs = resolveIntervalMs(settings, queue.length);
useEffect(() => {
if (queue.length === 0 || paused) {
return undefined;
}
const timer = window.setInterval(() => {
setIndex((prev) => (prev + itemsPerFrame) % queue.length);
}, intervalMs);
return () => window.clearInterval(timer);
}, [intervalMs, itemsPerFrame, queue.length]);
return {
frame,
layout,
intervalMs,
frameKey,
nextFrame,
};
}

View File

@@ -0,0 +1,317 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
buildLiveShowStreamUrl,
DEFAULT_LIVE_SHOW_SETTINGS,
fetchLiveShowState,
fetchLiveShowUpdates,
LiveShowCursor,
LiveShowEvent,
LiveShowPhoto,
LiveShowSettings,
LiveShowState,
LiveShowError,
} from '../services/liveShowApi';
export type LiveShowStatus = 'loading' | 'ready' | 'error';
export type LiveShowConnection = 'idle' | 'sse' | 'polling';
const MAX_PHOTOS = 200;
const POLL_INTERVAL_MS = 12_000;
const POLL_HIDDEN_INTERVAL_MS = 30_000;
function mergePhotos(existing: LiveShowPhoto[], incoming: LiveShowPhoto[]): LiveShowPhoto[] {
if (incoming.length === 0) {
return existing;
}
const byId = new Map<number, LiveShowPhoto>();
existing.forEach((photo) => byId.set(photo.id, photo));
incoming.forEach((photo) => {
if (!byId.has(photo.id)) {
byId.set(photo.id, photo);
}
});
return Array.from(byId.values()).slice(-MAX_PHOTOS);
}
function resolveErrorMessage(error: unknown): string {
if (error instanceof LiveShowError) {
return error.message || 'Live Show konnte nicht geladen werden.';
}
if (error instanceof Error) {
return error.message || 'Live Show konnte nicht geladen werden.';
}
return 'Live Show konnte nicht geladen werden.';
}
function safeParseJson<T>(value: string): T | null {
try {
return JSON.parse(value) as T;
} catch (error) {
console.warn('Live show event payload parse failed:', error);
return null;
}
}
export type LiveShowStateResult = {
status: LiveShowStatus;
connection: LiveShowConnection;
error: string | null;
event: LiveShowEvent | null;
settings: LiveShowSettings;
settingsVersion: string;
photos: LiveShowPhoto[];
cursor: LiveShowCursor | null;
};
export function useLiveShowState(token: string | null, limit = 50): LiveShowStateResult {
const [status, setStatus] = useState<LiveShowStatus>('loading');
const [connection, setConnection] = useState<LiveShowConnection>('idle');
const [error, setError] = useState<string | null>(null);
const [event, setEvent] = useState<LiveShowEvent | null>(null);
const [settings, setSettings] = useState<LiveShowSettings>(DEFAULT_LIVE_SHOW_SETTINGS);
const [settingsVersion, setSettingsVersion] = useState('');
const [photos, setPhotos] = useState<LiveShowPhoto[]>([]);
const [cursor, setCursor] = useState<LiveShowCursor | null>(null);
const [visible, setVisible] = useState(
typeof document !== 'undefined' ? document.visibilityState === 'visible' : true
);
const cursorRef = useRef<LiveShowCursor | null>(null);
const settingsVersionRef = useRef<string>('');
const eventSourceRef = useRef<EventSource | null>(null);
const pollingTimerRef = useRef<number | null>(null);
const pollInFlight = useRef(false);
const updateCursor = useCallback((next: LiveShowCursor | null) => {
cursorRef.current = next;
setCursor(next);
}, []);
const applySettings = useCallback((nextSettings: LiveShowSettings, nextVersion: string) => {
setSettings(nextSettings);
setSettingsVersion(nextVersion);
settingsVersionRef.current = nextVersion;
}, []);
const applyPhotos = useCallback(
(incoming: LiveShowPhoto[], nextCursor?: LiveShowCursor | null) => {
if (incoming.length === 0) {
return;
}
setPhotos((existing) => mergePhotos(existing, incoming));
if (nextCursor) {
updateCursor(nextCursor);
}
},
[updateCursor]
);
const closeEventSource = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
}, []);
const clearPolling = useCallback(() => {
if (pollingTimerRef.current !== null) {
window.clearInterval(pollingTimerRef.current);
pollingTimerRef.current = null;
}
}, []);
const pollUpdates = useCallback(async () => {
if (!token || pollInFlight.current) {
return;
}
pollInFlight.current = true;
try {
const update = await fetchLiveShowUpdates(token, {
cursor: cursorRef.current,
settingsVersion: settingsVersionRef.current,
limit,
});
if (update.settings) {
applySettings(update.settings, update.settings_version);
} else if (update.settings_version && update.settings_version !== settingsVersionRef.current) {
settingsVersionRef.current = update.settings_version;
setSettingsVersion(update.settings_version);
}
if (update.photos.length > 0) {
applyPhotos(update.photos, update.cursor ?? cursorRef.current);
} else if (update.cursor) {
updateCursor(update.cursor);
}
} catch (err) {
console.warn('Live show polling error:', err);
} finally {
pollInFlight.current = false;
}
}, [applyPhotos, applySettings, limit, token, updateCursor]);
const startPolling = useCallback(() => {
clearPolling();
setConnection('polling');
void pollUpdates();
const interval = visible ? POLL_INTERVAL_MS : POLL_HIDDEN_INTERVAL_MS;
pollingTimerRef.current = window.setInterval(() => {
void pollUpdates();
}, interval);
}, [clearPolling, pollUpdates, visible]);
const startSse = useCallback(() => {
if (!token || typeof EventSource === 'undefined') {
return false;
}
closeEventSource();
const url = buildLiveShowStreamUrl(token, {
cursor: cursorRef.current,
settingsVersion: settingsVersionRef.current,
limit,
});
try {
const stream = new EventSource(url);
eventSourceRef.current = stream;
setConnection('sse');
stream.addEventListener('settings.updated', (event) => {
const payload = safeParseJson<{
settings?: LiveShowSettings;
settings_version?: string;
}>((event as MessageEvent<string>).data);
if (!payload) {
return;
}
if (payload.settings && payload.settings_version) {
applySettings(payload.settings, payload.settings_version);
}
});
stream.addEventListener('photo.approved', (event) => {
const payload = safeParseJson<{
photo?: LiveShowPhoto;
cursor?: LiveShowCursor | null;
}>((event as MessageEvent<string>).data);
if (!payload) {
return;
}
if (payload.photo) {
applyPhotos([payload.photo], payload.cursor ?? null);
} else if (payload.cursor) {
updateCursor(payload.cursor);
}
});
stream.addEventListener('error', () => {
closeEventSource();
startPolling();
});
return true;
} catch (err) {
console.warn('Live show SSE failed:', err);
closeEventSource();
return false;
}
}, [applyPhotos, applySettings, closeEventSource, limit, startPolling, token, updateCursor]);
const startStreaming = useCallback(() => {
clearPolling();
if (!startSse()) {
startPolling();
}
}, [clearPolling, startPolling, startSse]);
useEffect(() => {
const onVisibility = () => setVisible(document.visibilityState === 'visible');
document.addEventListener('visibilitychange', onVisibility);
return () => document.removeEventListener('visibilitychange', onVisibility);
}, []);
useEffect(() => {
if (connection !== 'polling') {
return;
}
startPolling();
}, [connection, startPolling, visible]);
useEffect(() => {
if (!token) {
setStatus('error');
setError('Live Show konnte nicht geladen werden.');
setEvent(null);
setPhotos([]);
updateCursor(null);
setSettings(DEFAULT_LIVE_SHOW_SETTINGS);
setSettingsVersion('');
setConnection('idle');
return;
}
let cancelled = false;
const load = async () => {
setStatus('loading');
setError(null);
setConnection('idle');
try {
const data: LiveShowState = await fetchLiveShowState(token, limit);
if (cancelled) {
return;
}
setEvent(data.event);
setPhotos(data.photos);
updateCursor(data.cursor);
applySettings(data.settings, data.settings_version);
setStatus('ready');
startStreaming();
} catch (err) {
if (cancelled) {
return;
}
setStatus('error');
setError(resolveErrorMessage(err));
setConnection('idle');
}
};
load();
return () => {
cancelled = true;
closeEventSource();
clearPolling();
setConnection('idle');
};
}, [applySettings, clearPolling, closeEventSource, limit, startStreaming, token, updateCursor]);
return useMemo(
() => ({
status,
connection,
error,
event,
settings,
settingsVersion,
photos,
cursor,
}),
[status, connection, error, event, settings, settingsVersion, photos, cursor]
);
}