import { useCallback, useEffect, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import Notice from '../components/Notice'; import RunProgress from '../components/RunProgress'; import { AssignModal, ClassifyModal } from '../components/ThreadModals'; import { ApiError, api } from '../lib/api'; import { BOARD_LABEL, CLASSIFICATION_LABEL, formatDate } from '../lib/format'; import { useRefreshOnFocus } from '../lib/useRefreshOnFocus'; import { useSession } from '../lib/session'; import type { ForumScanSummary, ForumThread, ScanState, ThreadOrder, UnassignedThreads, UntrackedAuthor, } from '../lib/types'; /// Die Board-Pfade des Abgleichs in lesbarer Form. const BOARD_PFAD: Record = { 'board/66-custom-characters': 'die Antragsübersicht', 'board/355-aktive-ccs': 'die aktiven CCs', 'board/113-cc-archiv': 'das Archiv', }; const SEITE = 25; export default function Forum() { const { can } = useSession(); const navigate = useNavigate(); const darfEntscheiden = can('EDITOR'); const [threads, setThreads] = useState([]); const [notices, setNotices] = useState([]); const [error, setError] = useState(null); const [flash, setFlash] = useState(null); const [busy, setBusy] = useState(false); const [dialog, setDialog] = useState(null); const [assigning, setAssigning] = useState(null); const [scanZustand, setScan] = useState(null); // Arbeitsliste const [sucheOffen, setSucheOffen] = useState(''); const [sortOffen, setSortOffen] = useState('newest'); // Ohne zugeordneten Spieler const [ohneSpieler, setOhneSpieler] = useState(null); const [sucheOhne, setSucheOhne] = useState(''); const [sortOhne, setSortOhne] = useState('newest'); const [seite, setSeite] = useState(0); const [zeigeOhneSpieler, setZeigeOhneSpieler] = useState(false); // Ersteller ohne Spieler const [autoren, setAutoren] = useState([]); const [zeigeAutoren, setZeigeAutoren] = useState(false); /// Hochzählen heißt: die abhängigen Listen neu holen. const [stand, setStand] = useState(0); const ladeOffene = useCallback(async (query: string, order: ThreadOrder) => { const params = new URLSearchParams({ open: 'true', order }); if (query.trim()) params.set('q', query.trim()); try { const data = await api<{ items: ForumThread[] }>(`/api/forum/threads?${params}`); setThreads(data.items); } catch (err) { setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen'); } }, []); const ladeOhneSpieler = useCallback( async (query: string, order: ThreadOrder, skip: number) => { const params = new URLSearchParams({ order, take: String(SEITE), skip: String(skip), }); if (query.trim()) params.set('q', query.trim()); try { setOhneSpieler(await api(`/api/forum/threads/unassigned?${params}`)); } catch (err) { setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen'); } }, [], ); const ladeAutoren = useCallback(async () => { try { const data = await api<{ items: UntrackedAuthor[] }>('/api/forum/authors/untracked'); setAutoren(data.items); } catch { // Die Liste ist Beiwerk; ein Fehler hier darf den Rest nicht stören. } }, []); const load = useCallback(async () => { try { setNotices((await api<{ items: ForumThread[] }>('/api/forum/notices')).items); setError(null); } catch (err) { setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen'); } await ladeAutoren(); setStand((n) => n + 1); }, [ladeAutoren]); useEffect(() => { void load(); }, [load]); useRefreshOnFocus(load); // Beim Tippen kurz abwarten, statt jede Taste an den Server zu schicken. useEffect(() => { const timer = window.setTimeout( () => void ladeOffene(sucheOffen, sortOffen), sucheOffen ? 250 : 0, ); return () => window.clearTimeout(timer); }, [sucheOffen, sortOffen, stand, ladeOffene]); useEffect(() => { const timer = window.setTimeout( () => void ladeOhneSpieler(sucheOhne, sortOhne, seite * SEITE), sucheOhne ? 250 : 0, ); return () => window.clearTimeout(timer); }, [sucheOhne, sortOhne, seite, stand, ladeOhneSpieler]); // Eine neue Suche fängt wieder auf Seite eins an. useEffect(() => { setSeite(0); }, [sucheOhne, sortOhne]); const bericht = (result: ForumScanSummary): string => { const parts = [`${result.scannedPages} Seiten gelesen`]; if (result.newThreads > 0) parts.push(`${result.newThreads} neue Anträge`); if (result.movedThreads > 0) parts.push(`${result.movedThreads} verschoben`); if (result.unassignedThreads > 0) { parts.push(`${result.unassignedThreads} ohne zugeordneten Spieler`); } if (result.skippedLabels > 0) parts.push(`${result.skippedLabels} Ankündigungen`); if (result.skippedStubs > 0) parts.push(`${result.skippedStubs} Verweise`); if (result.rateLimited > 0) parts.push(`${result.rateLimited} Mal ausgebremst`); if (result.failedBoards.length > 0) { parts.push( result.failedBoards .map((b) => `${b.board} nur bis Seite ${b.pagesRead} (${b.reason})`) .join('; '), ); } return parts.join(', ') + '.'; }; // Der Lauf läuft im Hintergrund weiter, auch wenn die Seite neu geladen // wird. Deshalb wird der Stand abgefragt, statt auf eine Antwort zu warten. useEffect(() => { if (!busy) return; const abfragen = async () => { try { const state = await api('/api/forum/scan/status'); setScan(state); if (state.running) return; setBusy(false); if (state.error) setError(state.error); else if (state.summary) { setFlash(bericht(state.summary)); window.setTimeout(() => setFlash(null), 12000); } await load(); } catch (err) { setBusy(false); setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen'); } }; const timer = window.setInterval(() => void abfragen(), 1500); return () => window.clearInterval(timer); }, [busy, load]); // Ein Lauf aus einer anderen Sitzung soll hier ebenfalls sichtbar sein. useEffect(() => { void (async () => { try { const state = await api('/api/forum/scan/status'); setScan(state); if (state.running) setBusy(true); } catch { // Ohne Stand bleibt der Knopf einfach bedienbar. } })(); }, []); const scan = async () => { setError(null); try { setScan(await api('/api/forum/scan', { method: 'POST', body: {} })); setBusy(true); } catch (err) { setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen'); } }; const dismiss = async (thread: ForumThread) => { try { await api(`/api/forum/threads/${thread.threadId}/dismiss`, { method: 'POST', body: {} }); await load(); } catch (err) { setError(err instanceof ApiError ? err.message : 'Konnte nicht ausgeblendet werden'); } }; const seiten = ohneSpieler ? Math.ceil(ohneSpieler.total / SEITE) : 0; return (

Forums-Anträge

Jeder Antrag wird von Hand entschieden - Titel und Board sind kein verlässliches Signal und lösen nie eine Buchung aus. Anträge von Erstellern, die hier unter keinem Forum-Namen geführt werden, stehen weiter unten.

{darfEntscheiden && ( )}
{error && setError(null)}>{error}} {flash && setFlash(null)}>{flash}} {busy && ( )} {notices.length > 0 && (

Verschoben nach der Entscheidung ({notices.length})

Diese Anträge wurden bereits entschieden, ihr Thread liegt inzwischen aber in einem anderen Board. Das ist nur ein Hinweis - geprüft und geklickt wird von Hand.
    {notices.map((thread) => (
  • {thread.title} {BOARD_LABEL[thread.currentBoard]}

    {thread.classification && CLASSIFICATION_LABEL[thread.classification]} {thread.linkedCharacter && ` · CC: ${thread.linkedCharacter.name}`} {thread.lastBoardChangeAt && ` · verschoben ${formatDate(thread.lastBoardChangeAt)}`}

    Thread öffnen {darfEntscheiden && ( )}
  • ))}
)}

Wartet auf Entscheidung ({threads.length})

setSucheOffen(event.target.value)} />
{threads.length === 0 ? (

{sucheOffen ? 'Kein Treffer.' : `Keine offenen Anträge. ${darfEntscheiden ? 'Über „Forum abgleichen" nach neuen suchen.' : ''}`}

) : (
    {threads.map((thread) => (
  • {thread.title} {BOARD_LABEL[thread.currentBoard]} {thread.labels && · {thread.labels}}

    von {thread.authorName ?? 'unbekannt'} ·{' '} {thread.player ? ( {thread.player.displayName} ) : ( kein Spieler zugeordnet )}{' '} · gestellt {thread.postedAt ? formatDate(thread.postedAt) : 'unbekannt'}

    {darfEntscheiden && !thread.player && ( )} {darfEntscheiden && ( )}
  • ))}
)}

Ersteller ohne Spieler ({autoren.length})

Wer im Forum Anträge gestellt hat, hier aber unter keinem Forum-Namen geführt wird. Ein Klick legt den Spieler an - der Forum-Name steht schon drin, und seine Anträge wandern anschließend von selbst in die Arbeitsliste.

{autoren.length > 0 && ( )}
{autoren.length === 0 ? (

Jeder Ersteller ist einem Spieler zugeordnet.

) : ( zeigeAutoren && (
    {autoren.map((autor) => (
  • {autor.authorName} {autor.threadCount} {autor.threadCount === 1 ? 'Antrag' : 'Anträge'}
    {darfEntscheiden && ( )}
  • ))}
) )}

Ohne zugeordneten Spieler ({ohneSpieler?.total ?? 0})

Einzelne Anträge, deren Ersteller hier nicht geführt wird. Sie bleiben aus der Arbeitsliste heraus, gehen aber nicht verloren.

{zeigeOhneSpieler && ( <>
setSucheOhne(event.target.value)} />
{ohneSpieler === null || ohneSpieler.items.length === 0 ? (

{sucheOhne ? 'Nichts gefunden.' : 'Alle erfassten Anträge sind zugeordnet.'}

) : ( <>
    {ohneSpieler.items.map((thread) => (
  • {thread.title} {BOARD_LABEL[thread.currentBoard]}

    {thread.postedAt ? formatDate(thread.postedAt) : 'ohne Datum'} · von{' '} {thread.authorName ?? 'unbekannt'} {thread.labels && ` · ${thread.labels}`}

    {darfEntscheiden && (
    )}
  • ))}
{seiten > 1 && (
Seite {seite + 1} von {seiten} · {ohneSpieler.total} Anträge
)} )} )}
{dialog && ( setDialog(null)} onDone={(message) => { setDialog(null); setFlash(message); void load(); window.setTimeout(() => setFlash(null), 6000); }} onError={setError} /> )} {assigning && ( setAssigning(null)} onDone={() => { setAssigning(null); void load(); }} onError={setError} /> )}
); }