Bei einem CC mit mehreren Nutzern gibt es zwei Ebenen, und bisher liess sich nur die obere bedienen. Jetzt beide: Ein Spieler kann seinen Anteil redesignen und steigt damit aus - er bekommt einen eigenen CC, die Gruppe laeuft unberuehrt weiter. Fuer ihn ist es ein Tausch wie jeder andere: der Gruppenplatz geht weg, der eigene CC kommt an seine Stelle, es kommt keiner hinzu. Wohin er gewechselt ist, bleibt an der Mitgliedschaft festgehalten und steht bei den frueheren Nutzern. Das Redesign der ganzen Gruppe, bei dem alle mitwandern, gibt es unveraendert daneben. Ebenso koennen einzelne Nutzer ausfallen, ohne die uebrigen zu beruehren: bei einem Trio kann einer im Rollenspiel sterben, waehrend die anderen weiterspielen, und ebenso koennen alle nacheinander wegfallen. Grund und Begruendung haengen jetzt auch an der Mitgliedschaft. Die Rueckkehr folgt denselben Regeln wie beim eigenen CC - geputzt gratis, entzogen gegen Guthaben, gestorben nur durch einen Administrator mit Begruendung. Wer ausgefallen ist, sieht seinen CC weiter auf der Spielerseite; sonst gaebe es nichts, worueber er zurueckkehren koennte. Ein freiwilliger Austritt bleibt davon getrennt: dort gibt es nichts zurueckzuholen, ein Wiedereintritt kostet neu. Der GExtension-Abgleich hat denselben Ladebalken wie der Forumslauf bekommen, fuer alle Spieler wie fuer einzelne, samt Protokoll. Beide teilen sich jetzt dieselbe Anzeige. Dabei aufgefallen: Zeitplan und Handklick konnten denselben Abgleich doppelt starten und haetten dieselben Profile zweimal abgerufen. Die Sperre sitzt jetzt im Lauf selbst statt im Starter, damit sie fuer beide Wege gilt.
561 lines
20 KiB
TypeScript
561 lines
20 KiB
TypeScript
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 { 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<string, string> = {
|
|
'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<ForumThread[]>([]);
|
|
const [notices, setNotices] = useState<ForumThread[]>([]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [flash, setFlash] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [dialog, setDialog] = useState<ForumThread | null>(null);
|
|
const [assigning, setAssigning] = useState<ForumThread | null>(null);
|
|
const [scanZustand, setScan] = useState<ScanState | null>(null);
|
|
|
|
// Arbeitsliste
|
|
const [sucheOffen, setSucheOffen] = useState('');
|
|
const [sortOffen, setSortOffen] = useState<ThreadOrder>('newest');
|
|
|
|
// Ohne zugeordneten Spieler
|
|
const [ohneSpieler, setOhneSpieler] = useState<UnassignedThreads | null>(null);
|
|
const [sucheOhne, setSucheOhne] = useState('');
|
|
const [sortOhne, setSortOhne] = useState<ThreadOrder>('newest');
|
|
const [seite, setSeite] = useState(0);
|
|
const [zeigeOhneSpieler, setZeigeOhneSpieler] = useState(false);
|
|
|
|
// Ersteller ohne Spieler
|
|
const [autoren, setAutoren] = useState<UntrackedAuthor[]>([]);
|
|
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<UnassignedThreads>(`/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]);
|
|
|
|
// 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<ScanState>('/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<ScanState>('/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<ScanState>('/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 (
|
|
<div className="page">
|
|
<div className="page-head">
|
|
<div>
|
|
<h1>Forums-Anträge</h1>
|
|
<p className="muted">
|
|
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.
|
|
</p>
|
|
</div>
|
|
{darfEntscheiden && (
|
|
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void scan()}>
|
|
{busy ? 'Lese Forum …' : 'Forum abgleichen'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
|
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
|
|
|
|
{busy && (
|
|
<RunProgress
|
|
done={scanZustand?.pagesRead ?? 0}
|
|
total={scanZustand?.totalPages ?? 0}
|
|
current={
|
|
scanZustand?.currentBoard
|
|
? `liest ${BOARD_PFAD[scanZustand.currentBoard] ?? scanZustand.currentBoard}`
|
|
: null
|
|
}
|
|
log={scanZustand?.log ?? []}
|
|
hint="Zwischen den Abrufen wird bewusst gewartet, damit das Forum den Zugriff nicht bremst. Der Lauf geht im Hintergrund weiter, auch wenn diese Seite geschlossen wird."
|
|
/>
|
|
)}
|
|
|
|
{notices.length > 0 && (
|
|
<section className="block block-flag">
|
|
<h2>Verschoben nach der Entscheidung ({notices.length})</h2>
|
|
<Notice kind="warn">
|
|
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.
|
|
</Notice>
|
|
|
|
<ul className="cc-list">
|
|
{notices.map((thread) => (
|
|
<li key={thread.threadId} className="cc-item">
|
|
<div>
|
|
<a href={thread.url} target="_blank" rel="noreferrer" className="link-strong">
|
|
{thread.title}
|
|
</a>
|
|
<span className="badge">{BOARD_LABEL[thread.currentBoard]}</span>
|
|
<p className="muted small">
|
|
{thread.classification && CLASSIFICATION_LABEL[thread.classification]}
|
|
{thread.linkedCharacter && ` · CC: ${thread.linkedCharacter.name}`}
|
|
{thread.lastBoardChangeAt && ` · verschoben ${formatDate(thread.lastBoardChangeAt)}`}
|
|
</p>
|
|
</div>
|
|
<div className="cc-actions">
|
|
<a className="btn btn-small btn-ghost" href={thread.url} target="_blank" rel="noreferrer">
|
|
Thread öffnen
|
|
</a>
|
|
{darfEntscheiden && (
|
|
<button type="button" className="btn btn-small" onClick={() => void dismiss(thread)}>
|
|
Erledigt
|
|
</button>
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
|
|
<section className="block">
|
|
<h2>Wartet auf Entscheidung ({threads.length})</h2>
|
|
|
|
<div className="list-tools">
|
|
<input
|
|
type="search"
|
|
placeholder="Nach Titel oder Ersteller suchen …"
|
|
value={sucheOffen}
|
|
onChange={(event) => setSucheOffen(event.target.value)}
|
|
/>
|
|
<select
|
|
value={sortOffen}
|
|
onChange={(event) => setSortOffen(event.target.value as ThreadOrder)}
|
|
aria-label="Sortierung"
|
|
>
|
|
<option value="newest">Neueste zuerst</option>
|
|
<option value="oldest">Älteste zuerst</option>
|
|
</select>
|
|
</div>
|
|
|
|
{threads.length === 0 ? (
|
|
<p className="muted">
|
|
{sucheOffen
|
|
? 'Kein Treffer.'
|
|
: `Keine offenen Anträge. ${darfEntscheiden ? 'Über „Forum abgleichen" nach neuen suchen.' : ''}`}
|
|
</p>
|
|
) : (
|
|
<ul className="cc-list">
|
|
{threads.map((thread) => (
|
|
<li key={thread.threadId} className="cc-item">
|
|
<div>
|
|
<a
|
|
href={thread.url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="link-strong"
|
|
title="Im Forum öffnen"
|
|
>
|
|
{thread.title}
|
|
</a>
|
|
<span className="badge">{BOARD_LABEL[thread.currentBoard]}</span>
|
|
{thread.labels && <span className="muted small"> · {thread.labels}</span>}
|
|
<p className="muted small">
|
|
von {thread.authorName ?? 'unbekannt'} ·{' '}
|
|
{thread.player ? (
|
|
<Link to={`/players/${thread.player.id}`}>{thread.player.displayName}</Link>
|
|
) : (
|
|
<span className="neg">kein Spieler zugeordnet</span>
|
|
)}{' '}
|
|
· gestellt {thread.postedAt ? formatDate(thread.postedAt) : 'unbekannt'}
|
|
</p>
|
|
</div>
|
|
<div className="cc-actions">
|
|
{darfEntscheiden && !thread.player && (
|
|
<button type="button" className="btn btn-small" onClick={() => setAssigning(thread)}>
|
|
Spieler zuordnen
|
|
</button>
|
|
)}
|
|
{darfEntscheiden && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-small btn-primary"
|
|
onClick={() => setDialog(thread)}
|
|
>
|
|
Entscheiden
|
|
</button>
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
<section className="block">
|
|
<div className="page-head">
|
|
<div>
|
|
<h2>Ersteller ohne Spieler ({autoren.length})</h2>
|
|
<p className="muted small">
|
|
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.
|
|
</p>
|
|
</div>
|
|
{autoren.length > 0 && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-small btn-ghost"
|
|
onClick={() => setZeigeAutoren((v) => !v)}
|
|
>
|
|
{zeigeAutoren ? 'Einklappen' : 'Anzeigen'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{autoren.length === 0 ? (
|
|
<p className="muted">Jeder Ersteller ist einem Spieler zugeordnet.</p>
|
|
) : (
|
|
zeigeAutoren && (
|
|
<ul className="cc-list">
|
|
{autoren.map((autor) => (
|
|
<li key={autor.authorName} className="cc-item">
|
|
<div>
|
|
<strong>{autor.authorName}</strong>
|
|
<span className="badge">
|
|
{autor.threadCount} {autor.threadCount === 1 ? 'Antrag' : 'Anträge'}
|
|
</span>
|
|
</div>
|
|
<div className="cc-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-small btn-ghost"
|
|
onClick={() => {
|
|
setSucheOhne(autor.authorName);
|
|
setZeigeOhneSpieler(true);
|
|
}}
|
|
>
|
|
Anträge zeigen
|
|
</button>
|
|
{darfEntscheiden && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-small btn-primary"
|
|
onClick={() =>
|
|
navigate(`/?anlegen=${encodeURIComponent(autor.authorName)}`)
|
|
}
|
|
>
|
|
Spieler anlegen
|
|
</button>
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)
|
|
)}
|
|
</section>
|
|
|
|
<section className="block">
|
|
<div className="page-head">
|
|
<div>
|
|
<h2>Ohne zugeordneten Spieler ({ohneSpieler?.total ?? 0})</h2>
|
|
<p className="muted small">
|
|
Einzelne Anträge, deren Ersteller hier nicht geführt wird. Sie bleiben aus der
|
|
Arbeitsliste heraus, gehen aber nicht verloren.
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="btn btn-small btn-ghost"
|
|
onClick={() => setZeigeOhneSpieler((v) => !v)}
|
|
>
|
|
{zeigeOhneSpieler ? 'Einklappen' : 'Anzeigen'}
|
|
</button>
|
|
</div>
|
|
|
|
{zeigeOhneSpieler && (
|
|
<>
|
|
<div className="list-tools">
|
|
<input
|
|
type="search"
|
|
placeholder="Nach Titel oder Ersteller suchen …"
|
|
value={sucheOhne}
|
|
onChange={(event) => setSucheOhne(event.target.value)}
|
|
/>
|
|
<select
|
|
value={sortOhne}
|
|
onChange={(event) => setSortOhne(event.target.value as ThreadOrder)}
|
|
aria-label="Sortierung"
|
|
>
|
|
<option value="newest">Neueste zuerst</option>
|
|
<option value="oldest">Älteste zuerst</option>
|
|
</select>
|
|
</div>
|
|
|
|
{ohneSpieler === null || ohneSpieler.items.length === 0 ? (
|
|
<p className="muted">
|
|
{sucheOhne ? 'Nichts gefunden.' : 'Alle erfassten Anträge sind zugeordnet.'}
|
|
</p>
|
|
) : (
|
|
<>
|
|
<ul className="cc-list">
|
|
{ohneSpieler.items.map((thread) => (
|
|
<li key={thread.threadId} className="cc-item">
|
|
<div>
|
|
<a
|
|
href={thread.url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="link-strong"
|
|
title="Im Forum öffnen"
|
|
>
|
|
{thread.title}
|
|
</a>
|
|
<span className="badge">{BOARD_LABEL[thread.currentBoard]}</span>
|
|
<p className="muted small">
|
|
{thread.postedAt ? formatDate(thread.postedAt) : 'ohne Datum'} · von{' '}
|
|
{thread.authorName ?? 'unbekannt'}
|
|
{thread.labels && ` · ${thread.labels}`}
|
|
</p>
|
|
</div>
|
|
{darfEntscheiden && (
|
|
<div className="cc-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-small"
|
|
onClick={() => setAssigning(thread)}
|
|
>
|
|
Spieler zuordnen
|
|
</button>
|
|
</div>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
{seiten > 1 && (
|
|
<div className="pager">
|
|
<button
|
|
type="button"
|
|
className="btn btn-small btn-ghost"
|
|
disabled={seite === 0}
|
|
onClick={() => setSeite((n) => Math.max(0, n - 1))}
|
|
>
|
|
Zurück
|
|
</button>
|
|
<span className="muted small">
|
|
Seite {seite + 1} von {seiten} · {ohneSpieler.total} Anträge
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="btn btn-small btn-ghost"
|
|
disabled={seite + 1 >= seiten}
|
|
onClick={() => setSeite((n) => n + 1)}
|
|
>
|
|
Weiter
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
{dialog && (
|
|
<ClassifyModal
|
|
thread={dialog}
|
|
onClose={() => setDialog(null)}
|
|
onDone={(message) => {
|
|
setDialog(null);
|
|
setFlash(message);
|
|
void load();
|
|
window.setTimeout(() => setFlash(null), 6000);
|
|
}}
|
|
onError={setError}
|
|
/>
|
|
)}
|
|
|
|
{assigning && (
|
|
<AssignModal
|
|
thread={assigning}
|
|
onClose={() => setAssigning(null)}
|
|
onDone={() => {
|
|
setAssigning(null);
|
|
void load();
|
|
}}
|
|
onError={setError}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|