refactor(guest): retire legacy guest app and move shared modules
This commit is contained in:
@@ -1,37 +0,0 @@
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useHapticsPreference } from '../useHapticsPreference';
|
||||
import { HAPTICS_STORAGE_KEY } from '../../lib/haptics';
|
||||
|
||||
function TestHarness() {
|
||||
const { enabled, setEnabled } = useHapticsPreference();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="toggle"
|
||||
onClick={() => setEnabled(!enabled)}
|
||||
>
|
||||
{enabled ? 'on' : 'off'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useHapticsPreference', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.removeItem(HAPTICS_STORAGE_KEY);
|
||||
Object.defineProperty(navigator, 'vibrate', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles and persists preference', () => {
|
||||
render(<TestHarness />);
|
||||
const button = screen.getByTestId('toggle');
|
||||
expect(button).toHaveTextContent('on');
|
||||
fireEvent.click(button);
|
||||
expect(button).toHaveTextContent('off');
|
||||
expect(window.localStorage.getItem(HAPTICS_STORAGE_KEY)).toBe('0');
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildFramePhotos,
|
||||
resolveIntervalMs,
|
||||
resolveItemsPerFrame,
|
||||
resolvePlaybackQueue,
|
||||
} from '../useLiveShowPlayback';
|
||||
import type { LiveShowPhoto, LiveShowSettings } from '../../services/liveShowApi';
|
||||
|
||||
const baseSettings: LiveShowSettings = {
|
||||
retention_window_hours: 12,
|
||||
moderation_mode: 'manual',
|
||||
playback_mode: 'newest_first',
|
||||
pace_mode: 'auto',
|
||||
fixed_interval_seconds: 8,
|
||||
layout_mode: 'single',
|
||||
effect_preset: 'film_cut',
|
||||
effect_intensity: 70,
|
||||
background_mode: 'blur_last',
|
||||
};
|
||||
|
||||
const photos: LiveShowPhoto[] = [
|
||||
{
|
||||
id: 1,
|
||||
full_url: '/one.jpg',
|
||||
thumb_url: '/one-thumb.jpg',
|
||||
approved_at: '2025-01-01T10:00:00Z',
|
||||
is_featured: false,
|
||||
live_priority: 0,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
full_url: '/two.jpg',
|
||||
thumb_url: '/two-thumb.jpg',
|
||||
approved_at: '2025-01-01T12:00:00Z',
|
||||
is_featured: true,
|
||||
live_priority: 2,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
full_url: '/three.jpg',
|
||||
thumb_url: '/three-thumb.jpg',
|
||||
approved_at: '2025-01-01T11:00:00Z',
|
||||
is_featured: false,
|
||||
live_priority: 0,
|
||||
},
|
||||
];
|
||||
|
||||
describe('useLiveShowPlayback helpers', () => {
|
||||
it('resolves items per frame per layout', () => {
|
||||
expect(resolveItemsPerFrame('single')).toBe(1);
|
||||
expect(resolveItemsPerFrame('split')).toBe(2);
|
||||
expect(resolveItemsPerFrame('grid_burst')).toBe(4);
|
||||
});
|
||||
|
||||
it('builds a curated queue when configured', () => {
|
||||
const queue = resolvePlaybackQueue(photos, {
|
||||
...baseSettings,
|
||||
playback_mode: 'curated',
|
||||
});
|
||||
|
||||
expect(queue[0].id).toBe(2);
|
||||
expect(queue.every((photo) => photo.id === 2 || photo.live_priority > 0 || photo.is_featured)).toBe(true);
|
||||
});
|
||||
|
||||
it('builds frame photos without duplicates when list is smaller', () => {
|
||||
const frame = buildFramePhotos([photos[0]], 0, 4);
|
||||
expect(frame).toHaveLength(1);
|
||||
expect(frame[0].id).toBe(1);
|
||||
});
|
||||
|
||||
it('uses fixed interval when configured', () => {
|
||||
const interval = resolveIntervalMs(
|
||||
{
|
||||
...baseSettings,
|
||||
pace_mode: 'fixed',
|
||||
fixed_interval_seconds: 12,
|
||||
},
|
||||
photos.length
|
||||
);
|
||||
|
||||
expect(interval).toBe(12_000);
|
||||
});
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { compressPhoto, formatBytes } from '../lib/image';
|
||||
import { uploadPhoto, type UploadError } from '../services/photosApi';
|
||||
import { useGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
||||
import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog';
|
||||
import { notify } from '../queue/notify';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { isGuestDemoModeEnabled } from '../demo/demoMode';
|
||||
import { useEventData } from './useEventData';
|
||||
import { triggerHaptic } from '../lib/haptics';
|
||||
|
||||
type DirectUploadResult = {
|
||||
success: boolean;
|
||||
photoId?: number;
|
||||
warning?: string | null;
|
||||
error?: string | null;
|
||||
dialog?: UploadErrorDialog | null;
|
||||
};
|
||||
|
||||
type UseDirectUploadOptions = {
|
||||
eventToken: string;
|
||||
taskId?: number | null;
|
||||
emotionSlug?: string;
|
||||
onCompleted?: (photoId: number) => void;
|
||||
};
|
||||
|
||||
export function useDirectUpload({ eventToken, taskId, emotionSlug, onCompleted }: UseDirectUploadOptions) {
|
||||
const { name } = useGuestIdentity();
|
||||
const { markCompleted } = useGuestTaskProgress(eventToken);
|
||||
const { event } = useEventData();
|
||||
const { t } = useTranslation();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null);
|
||||
const [canUpload, setCanUpload] = useState(true);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setProgress(0);
|
||||
setWarning(null);
|
||||
setError(null);
|
||||
setErrorDialog(null);
|
||||
}, []);
|
||||
|
||||
const preparePhoto = useCallback(async (file: File) => {
|
||||
reset();
|
||||
let prepared = file;
|
||||
try {
|
||||
prepared = await compressPhoto(file, {
|
||||
maxEdge: 2400,
|
||||
targetBytes: 4_000_000,
|
||||
qualityStart: 0.82,
|
||||
});
|
||||
if (prepared.size < file.size - 50_000) {
|
||||
const saved = formatBytes(file.size - prepared.size);
|
||||
setWarning(`Wir haben dein Foto verkleinert, damit der Upload schneller klappt. Eingespart: ${saved}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Direct upload: optimization failed, using original', err);
|
||||
setWarning('Optimierung nicht möglich – wir laden das Original hoch.');
|
||||
}
|
||||
|
||||
if (prepared.size > 12_000_000) {
|
||||
setError('Das Foto war zu groß. Bitte erneut versuchen – wir verkleinern es automatisch.');
|
||||
return { ok: false as const };
|
||||
}
|
||||
|
||||
return { ok: true as const, prepared };
|
||||
}, [reset]);
|
||||
|
||||
const upload = useCallback(
|
||||
async (file: File): Promise<DirectUploadResult> => {
|
||||
if (!canUpload || uploading) return { success: false, warning, error };
|
||||
if (isGuestDemoModeEnabled() || event?.demo_read_only) {
|
||||
const demoMessage = t('upload.demoReadOnly', 'Uploads sind in der Demo deaktiviert.');
|
||||
setError(demoMessage);
|
||||
setWarning(null);
|
||||
notify(demoMessage, 'error');
|
||||
return { success: false, warning, error: demoMessage };
|
||||
}
|
||||
const preparedResult = await preparePhoto(file);
|
||||
if (!preparedResult.ok) {
|
||||
return { success: false, warning, error };
|
||||
}
|
||||
|
||||
const prepared = preparedResult.prepared;
|
||||
setUploading(true);
|
||||
setProgress(2);
|
||||
setError(null);
|
||||
setErrorDialog(null);
|
||||
|
||||
try {
|
||||
const photoId = await uploadPhoto(eventToken, prepared, taskId ?? undefined, emotionSlug || undefined, {
|
||||
maxRetries: 2,
|
||||
guestName: name || undefined,
|
||||
onProgress: (percent) => {
|
||||
setProgress(Math.max(10, Math.min(98, percent)));
|
||||
},
|
||||
onRetry: (attempt) => {
|
||||
setWarning(`Verbindung holperig – neuer Versuch (${attempt}).`);
|
||||
},
|
||||
});
|
||||
|
||||
setProgress(100);
|
||||
if (taskId) {
|
||||
markCompleted(taskId);
|
||||
}
|
||||
triggerHaptic('success');
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem('my-photo-ids');
|
||||
const arr: number[] = raw ? JSON.parse(raw) : [];
|
||||
if (photoId && !arr.includes(photoId)) {
|
||||
localStorage.setItem('my-photo-ids', JSON.stringify([photoId, ...arr]));
|
||||
}
|
||||
} catch (persistErr) {
|
||||
console.warn('Direct upload: persist my-photo-ids failed', persistErr);
|
||||
}
|
||||
|
||||
onCompleted?.(photoId);
|
||||
return { success: true, photoId, warning };
|
||||
} catch (err) {
|
||||
console.error('Direct upload failed', err);
|
||||
triggerHaptic('error');
|
||||
const uploadErr = err as UploadError;
|
||||
const meta = uploadErr.meta as Record<string, unknown> | undefined;
|
||||
const dialog = resolveUploadErrorDialog(uploadErr.code, meta, (v: string) => v);
|
||||
setErrorDialog(dialog);
|
||||
setError(dialog?.description ?? uploadErr.message ?? 'Upload fehlgeschlagen.');
|
||||
setWarning(null);
|
||||
|
||||
if (uploadErr.code === 'demo_read_only') {
|
||||
notify(t('upload.demoReadOnly', 'Uploads sind in der Demo deaktiviert.'), 'error');
|
||||
}
|
||||
|
||||
if (
|
||||
uploadErr.code === 'photo_limit_exceeded'
|
||||
|| uploadErr.code === 'upload_device_limit'
|
||||
|| uploadErr.code === 'event_package_missing'
|
||||
|| uploadErr.code === 'event_not_found'
|
||||
|| uploadErr.code === 'gallery_expired'
|
||||
) {
|
||||
setCanUpload(false);
|
||||
}
|
||||
|
||||
if (uploadErr.status === 422 || uploadErr.code === 'validation_error') {
|
||||
setWarning('Das Foto war zu groß. Bitte erneut versuchen – wir verkleinern es automatisch.');
|
||||
}
|
||||
|
||||
return { success: false, warning, error: dialog?.description ?? uploadErr.message, dialog };
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setProgress((p) => (p === 100 ? p : 0));
|
||||
}
|
||||
},
|
||||
[canUpload, emotionSlug, eventToken, markCompleted, name, preparePhoto, taskId, uploading, warning, onCompleted]
|
||||
);
|
||||
|
||||
return {
|
||||
upload,
|
||||
uploading,
|
||||
progress,
|
||||
warning,
|
||||
error,
|
||||
errorDialog,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
fetchEvent,
|
||||
EventData,
|
||||
FetchEventError,
|
||||
FetchEventErrorCode,
|
||||
} from '../services/eventApi';
|
||||
|
||||
type EventDataStatus = 'loading' | 'ready' | 'error';
|
||||
|
||||
interface UseEventDataResult {
|
||||
event: EventData | null;
|
||||
status: EventDataStatus;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
errorCode: FetchEventErrorCode | null;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
const NO_TOKEN_ERROR_MESSAGE = 'Es wurde kein Einladungscode übergeben.';
|
||||
const eventCache = new Map<string, EventData>();
|
||||
|
||||
export function useEventData(): UseEventDataResult {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const cachedEvent = token ? eventCache.get(token) ?? null : null;
|
||||
const [event, setEvent] = useState<EventData | null>(cachedEvent);
|
||||
const [status, setStatus] = useState<EventDataStatus>(token ? (cachedEvent ? 'ready' : 'loading') : 'error');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(token ? null : NO_TOKEN_ERROR_MESSAGE);
|
||||
const [errorCode, setErrorCode] = useState<FetchEventErrorCode | null>(token ? null : 'invalid_token');
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setEvent(null);
|
||||
setStatus('error');
|
||||
setErrorCode('invalid_token');
|
||||
setErrorMessage(NO_TOKEN_ERROR_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadEvent = async () => {
|
||||
const cached = eventCache.get(token) ?? null;
|
||||
if (!cached) {
|
||||
setStatus('loading');
|
||||
}
|
||||
setErrorCode(null);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const eventData = await fetchEvent(token);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
eventCache.set(token, eventData);
|
||||
setEvent(eventData);
|
||||
setStatus('ready');
|
||||
} catch (err) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cached) {
|
||||
setEvent(cached);
|
||||
setStatus('ready');
|
||||
return;
|
||||
}
|
||||
setEvent(null);
|
||||
setStatus('error');
|
||||
|
||||
if (err instanceof FetchEventError) {
|
||||
setErrorCode(err.code);
|
||||
setErrorMessage(err.message);
|
||||
} else if (err instanceof Error) {
|
||||
setErrorCode('unknown');
|
||||
setErrorMessage(err.message || 'Event konnte nicht geladen werden.');
|
||||
} else {
|
||||
setErrorCode('unknown');
|
||||
setErrorMessage('Event konnte nicht geladen werden.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadEvent();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
return {
|
||||
event,
|
||||
status,
|
||||
loading: status === 'loading',
|
||||
error: errorMessage,
|
||||
errorCode,
|
||||
token: token ?? null,
|
||||
};
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
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]
|
||||
);
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import React from 'react';
|
||||
import { getPushConfig } from '../lib/runtime-config';
|
||||
import { registerPushSubscription, unregisterPushSubscription } from '../services/pushApi';
|
||||
|
||||
type PushSubscriptionState = {
|
||||
supported: boolean;
|
||||
permission: NotificationPermission;
|
||||
subscribed: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
enable: () => Promise<void>;
|
||||
disable: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function usePushSubscription(eventToken?: string): PushSubscriptionState {
|
||||
const pushConfig = React.useMemo(() => getPushConfig(), []);
|
||||
const supported = React.useMemo(() => {
|
||||
return typeof window !== 'undefined'
|
||||
&& typeof navigator !== 'undefined'
|
||||
&& typeof Notification !== 'undefined'
|
||||
&& 'serviceWorker' in navigator
|
||||
&& 'PushManager' in window
|
||||
&& pushConfig.enabled;
|
||||
}, [pushConfig.enabled]);
|
||||
|
||||
const [permission, setPermission] = React.useState<NotificationPermission>(() => {
|
||||
if (typeof Notification === 'undefined') {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
return Notification.permission;
|
||||
});
|
||||
const [subscription, setSubscription] = React.useState<PushSubscription | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
if (!supported || !eventToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const current = await registration.pushManager.getSubscription();
|
||||
setSubscription(current);
|
||||
} catch (err) {
|
||||
console.warn('Unable to refresh push subscription', err);
|
||||
setSubscription(null);
|
||||
}
|
||||
}, [eventToken, supported]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!supported) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refresh();
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data?.type === 'push-subscription-change') {
|
||||
void refresh();
|
||||
}
|
||||
};
|
||||
|
||||
navigator.serviceWorker?.addEventListener('message', handleMessage);
|
||||
|
||||
return () => {
|
||||
navigator.serviceWorker?.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, [refresh, supported]);
|
||||
|
||||
const enable = React.useCallback(async () => {
|
||||
if (!supported || !eventToken) {
|
||||
setError('Push-Benachrichtigungen werden auf diesem Gerät nicht unterstützt.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const permissionResult = await Notification.requestPermission();
|
||||
setPermission(permissionResult);
|
||||
|
||||
if (permissionResult !== 'granted') {
|
||||
throw new Error('Bitte erlaube Benachrichtigungen, um Push zu aktivieren.');
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const existing = await registration.pushManager.getSubscription();
|
||||
|
||||
if (existing) {
|
||||
await registerPushSubscription(eventToken, existing);
|
||||
setSubscription(existing);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pushConfig.vapidPublicKey) {
|
||||
throw new Error('Push-Konfiguration ist nicht vollständig.');
|
||||
}
|
||||
|
||||
const newSubscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(pushConfig.vapidPublicKey).buffer as ArrayBuffer,
|
||||
});
|
||||
|
||||
await registerPushSubscription(eventToken, newSubscription);
|
||||
setSubscription(newSubscription);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Push konnte nicht aktiviert werden.';
|
||||
setError(message);
|
||||
console.error(err);
|
||||
await refresh();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [eventToken, pushConfig.vapidPublicKey, refresh, supported]);
|
||||
|
||||
const disable = React.useCallback(async () => {
|
||||
if (!supported || !eventToken || !subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await unregisterPushSubscription(eventToken, subscription.endpoint);
|
||||
await subscription.unsubscribe();
|
||||
setSubscription(null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Push konnte nicht deaktiviert werden.';
|
||||
setError(message);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [eventToken, subscription, supported]);
|
||||
|
||||
return {
|
||||
supported,
|
||||
permission,
|
||||
subscribed: Boolean(subscription),
|
||||
loading,
|
||||
error,
|
||||
enable,
|
||||
disable,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = typeof window !== 'undefined'
|
||||
? window.atob(base64)
|
||||
: Buffer.from(base64, 'base64').toString('binary');
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; i += 1) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
|
||||
return outputArray;
|
||||
}
|
||||
Reference in New Issue
Block a user