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,47 @@
import React from 'react';
import { enqueue, list, processQueue, clearDone, remove, type QueueItem } from './queue';
export function useUploadQueue() {
const [items, setItems] = React.useState<QueueItem[]>([]);
const [loading, setLoading] = React.useState(true);
const refresh = React.useCallback(async () => {
setLoading(true);
const all = await list();
setItems(all);
setLoading(false);
}, []);
const add = React.useCallback(async (it: Parameters<typeof enqueue>[0]) => {
await enqueue(it);
await refresh();
await processQueue();
}, [refresh]);
const retryAll = React.useCallback(async () => {
await processQueue();
await refresh();
}, [refresh]);
const clearFinished = React.useCallback(async () => {
await clearDone();
await refresh();
}, [refresh]);
const removeItem = React.useCallback(
async (id: number) => {
await remove(id);
await refresh();
},
[refresh]
);
React.useEffect(() => {
refresh();
const online = () => processQueue().then(refresh);
window.addEventListener('online', online);
return () => window.removeEventListener('online', online);
}, [refresh]);
return { items, loading, refresh, add, retryAll, clearFinished, remove: removeItem } as const;
}

View File

@@ -0,0 +1,34 @@
// @ts-nocheck
export type TxMode = 'readonly' | 'readwrite';
export function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open('guest-upload-queue', 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains('items')) {
const store = db.createObjectStore('items', { keyPath: 'id', autoIncrement: true });
store.createIndex('status', 'status', { unique: false });
store.createIndex('nextAttemptAt', 'nextAttemptAt', { unique: false });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function withStore<T>(mode: TxMode, fn: (store: IDBObjectStore) => void | Promise<T>): Promise<T> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction('items', mode);
const store = tx.objectStore('items');
let result: unknown;
const wrap = async () => {
try { result = await fn(store); } catch (e) { reject(e); }
};
wrap();
tx.oncomplete = () => resolve(result);
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}

View File

@@ -0,0 +1,16 @@
export function notify(text: string, type: 'success'|'error') {
// Lazy import to avoid cycle
import('../components/ToastHost')
.then(() => {
try {
// This only works inside React tree; for SW-triggered, we fallback
const evt = new CustomEvent('guest-toast', { detail: { text, type } });
window.dispatchEvent(evt);
} catch (error) {
console.warn('Dispatching toast event failed', error);
}
})
.catch((error) => {
console.warn('Toast module failed to load', error);
});
}

View File

@@ -0,0 +1,134 @@
// @ts-nocheck
import { withStore } from './idb';
import { getDeviceId } from '../lib/device';
import { createUpload } from './xhr';
import { notify } from './notify';
type SyncManager = { register(tag: string): Promise<void>; };
export const buildQueueUploadUrl = (eventToken: string) =>
`/api/v1/events/${encodeURIComponent(eventToken)}/upload`;
export type QueueItem = {
id?: number;
eventToken: string;
fileName: string;
blob: Blob;
emotion_id?: number | null;
task_id?: number | null;
live_show_opt_in?: boolean | null;
status: 'pending' | 'uploading' | 'done' | 'error';
retries: number;
nextAttemptAt?: number | null;
createdAt: number;
photoId?: number;
};
let processing = false;
export async function enqueue(item: Omit<QueueItem, 'id' | 'status' | 'retries' | 'createdAt'>) {
const now = Date.now();
await withStore('readwrite', (store) => {
store.add({ ...item, status: 'pending', retries: 0, createdAt: now });
});
// Register background sync if available
if ('serviceWorker' in navigator && 'SyncManager' in window) {
try {
const reg = await navigator.serviceWorker.ready;
(reg as ServiceWorkerRegistration & { sync?: SyncManager }).sync?.register('upload-queue');
} catch (error) {
console.warn('Background sync registration failed', error);
}
}
}
export async function list(): Promise<QueueItem[]> {
return withStore('readonly', (store) => new Promise((resolve) => {
const req = store.getAll();
req.onsuccess = () => resolve(req.result as QueueItem[]);
}));
}
export async function clearDone() {
const items = await list();
await withStore('readwrite', (store) => {
for (const it of items) {
if (it.status === 'done') store.delete(it.id!);
}
});
}
export async function remove(id: number) {
await withStore('readwrite', (store) => {
store.delete(id);
});
}
export async function processQueue() {
if (processing) return; processing = true;
try {
const now = Date.now();
const items = await list();
for (const it of items) {
if (it.status === 'done') continue;
if (it.nextAttemptAt && it.nextAttemptAt > now) continue;
await markStatus(it.id!, 'uploading');
const ok = await attemptUpload(it);
if (ok) {
await markStatus(it.id!, 'done');
} else {
const retries = (it.retries ?? 0) + 1;
const backoffSec = Math.min(60, Math.pow(2, Math.min(retries, 5))); // 2,4,8,16,32,60
await update(it.id!, { status: 'error', retries, nextAttemptAt: Date.now() + backoffSec * 1000 });
}
}
} finally {
processing = false;
}
}
async function attemptUpload(it: QueueItem): Promise<boolean> {
if (!navigator.onLine) return false;
try {
const json = await createUpload(
buildQueueUploadUrl(it.eventToken),
it,
getDeviceId(),
(pct) => {
try {
window.dispatchEvent(new CustomEvent('queue-progress', { detail: { id: it.id, progress: pct } }));
} catch (error) {
console.warn('Queue progress dispatch failed', error);
}
}
);
// mark my-photo-ids for "Meine"
try {
const raw = localStorage.getItem('my-photo-ids');
const arr: number[] = raw ? JSON.parse(raw) : [];
if (json.id && !arr.includes(json.id)) localStorage.setItem('my-photo-ids', JSON.stringify([json.id, ...arr]));
} catch (error) {
console.warn('Failed to persist my-photo-ids', error);
}
notify('Upload erfolgreich', 'success');
return true;
} catch {
notify('Upload fehlgeschlagen', 'error');
return false;
}
}
async function markStatus(id: number, status: QueueItem['status']) {
await update(id, { status });
}
async function update(id: number, patch: Partial<QueueItem>) {
await withStore('readwrite', (store) => new Promise<void>((resolve, reject) => {
const getReq = store.get(id);
getReq.onsuccess = () => {
const val = getReq.result as QueueItem;
store.put({ ...val, ...patch, id });
resolve();
};
getReq.onerror = () => reject(getReq.error);
}));
}

View File

@@ -0,0 +1,45 @@
import type { QueueItem } from './queue';
import { buildCsrfHeaders } from '../lib/csrf';
export async function createUpload(
url: string,
it: QueueItem,
deviceId: string,
onProgress?: (percent: number) => void
): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
const headers = buildCsrfHeaders(deviceId);
Object.entries(headers).forEach(([key, value]) => {
xhr.setRequestHeader(key, value);
});
const form = new FormData();
form.append('photo', it.blob, it.fileName);
if (it.emotion_id) form.append('emotion_id', String(it.emotion_id));
if (it.task_id) form.append('task_id', String(it.task_id));
if (typeof it.live_show_opt_in === 'boolean') {
form.append('live_show_opt_in', it.live_show_opt_in ? '1' : '0');
}
xhr.upload.onprogress = (ev) => {
if (onProgress && ev.lengthComputable) {
const pct = Math.min(100, Math.round((ev.loaded / ev.total) * 100));
onProgress(pct);
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText));
} catch (error) {
console.warn('Upload response parse failed', error);
resolve({});
}
} else {
reject(new Error('upload failed'));
}
};
xhr.onerror = () => reject(new Error('network error'));
xhr.send(form);
});
}