Antraege vollstaendig erfassen und zum Forum verlinken
Threads ohne zuordenbaren Spieler wurden bisher verworfen. Von 502 Antraegen im Forum kamen dadurch nur die wenigen an, deren Ersteller schon als Spieler mit Forum-Namen erfasst war - der Rest war schlicht unsichtbar. Sie werden jetzt erfasst und stehen unter "Ohne zugeordneten Spieler", durchsuchbar nach Titel und Ersteller, aber ausserhalb der Arbeitsliste: ohne Spieler laesst sich nichts buchen. Wird spaeter ein Spieler mit passendem Forum-Namen angelegt, der Name nachgetragen oder ein frueheres Konto ergaenzt, wandern seine wartenden Antraege von selbst hinueber. Ein erneuter Abgleich ist dafuer nicht noetig, und eine bereits getroffene Zuordnung bleibt unberuehrt. Der Name eines CCs fuehrt auf der Spielerseite jetzt zum Forumsbeitrag, sofern einer verknuepft ist. Weitere Korrekturen am Abgleich: - Wer sein Forumskonto geloescht hat, stand ohne Profil-Link in der Zeile und hatte damit keinen Autor. Solche Antraege liessen sich niemandem zuordnen. - Bei einer abgewiesenen Anfrage brach das ganze Board ab; die bis dahin gelesenen Seiten gingen mit verloren. Jetzt wird die genannte Wartezeit eingehalten, das Tempo fuer den Rest des Laufs gedrosselt und die Seite erneut versucht. Was gelesen wurde, bleibt in jedem Fall erhalten. - Derselbe Schutz im GExtension-Abgleich, der sonst den Spieler uebersprungen haette. - Der Board-Filter fragte nach einem Feld "board", das es nicht gibt. Ein voller Lauf dauert rund eine halbe Minute und laenger, wenn das Forum bremst. Er laeuft deshalb im Hintergrund weiter und meldet seinen Fortschritt, statt an einer HTTP-Zeitgrenze zu sterben. Gelesen und geschrieben wird gebuendelt statt Thread fuer Thread.
This commit is contained in:
+190
-20
@@ -12,8 +12,17 @@ import type {
|
||||
ForumThread,
|
||||
PlayerDetail,
|
||||
PlayerSummary,
|
||||
ScanState,
|
||||
UnassignedThreads,
|
||||
} 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',
|
||||
};
|
||||
|
||||
export default function Forum() {
|
||||
const { can } = useSession();
|
||||
const darfEntscheiden = can('EDITOR');
|
||||
@@ -25,6 +34,21 @@ export default function Forum() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [dialog, setDialog] = useState<ForumThread | null>(null);
|
||||
const [assigning, setAssigning] = useState<ForumThread | null>(null);
|
||||
const [ohneSpieler, setOhneSpieler] = useState<UnassignedThreads | null>(null);
|
||||
const [suche, setSuche] = useState('');
|
||||
const [zeigeOhneSpieler, setZeigeOhneSpieler] = useState(false);
|
||||
/// Hochzählen heißt: die Liste ohne Spieler neu holen.
|
||||
const [stand, setStand] = useState(0);
|
||||
const [scanZustand, setScan] = useState<ScanState | null>(null);
|
||||
|
||||
const ladeOhneSpieler = useCallback(async (query: string) => {
|
||||
const param = query.trim() ? `?q=${encodeURIComponent(query.trim())}` : '';
|
||||
try {
|
||||
setOhneSpieler(await api<UnassignedThreads>(`/api/forum/threads/unassigned${param}`));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -38,33 +62,88 @@ export default function Forum() {
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
|
||||
}
|
||||
setStand((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// Beim Tippen kurz abwarten, statt jede Taste an den Server zu schicken.
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => void ladeOhneSpieler(suche), suche ? 250 : 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [suche, stand, ladeOhneSpieler]);
|
||||
|
||||
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 timer = window.setInterval(() => {
|
||||
void (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');
|
||||
}
|
||||
})();
|
||||
}, 2000);
|
||||
|
||||
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 () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api<ForumScanSummary>('/api/forum/scan', { method: 'POST', body: {} });
|
||||
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.skippedUnknownAuthor > 0) {
|
||||
parts.push(`${result.skippedUnknownAuthor} ohne bekannten Spieler übersprungen`);
|
||||
}
|
||||
if (result.skippedLabels > 0) parts.push(`${result.skippedLabels} Ankündigungen`);
|
||||
if (result.skippedStubs > 0) parts.push(`${result.skippedStubs} Verweise`);
|
||||
if (result.failedBoards.length > 0) parts.push(`${result.failedBoards.length} Board-Fehler`);
|
||||
setFlash(parts.join(', ') + '.');
|
||||
await load();
|
||||
window.setTimeout(() => setFlash(null), 8000);
|
||||
setScan(await api<ScanState>('/api/forum/scan', { method: 'POST', body: {} }));
|
||||
setBusy(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -90,7 +169,9 @@ export default function Forum() {
|
||||
</div>
|
||||
{darfEntscheiden && (
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void scan()}>
|
||||
{busy ? 'Lese Forum …' : 'Forum abgleichen'}
|
||||
{busy
|
||||
? `Lese Forum … (${scanZustand?.pagesRead ?? 0} Seiten)`
|
||||
: 'Forum abgleichen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -98,6 +179,15 @@ export default function Forum() {
|
||||
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
||||
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
|
||||
|
||||
{busy && (
|
||||
<Notice kind="warn">
|
||||
Der Abgleich liest gerade {BOARD_PFAD[scanZustand?.currentBoard ?? ''] ?? 'das Forum'} -
|
||||
bisher {scanZustand?.pagesRead ?? 0} Seiten. Das Archiv umfasst über zwanzig Seiten, und
|
||||
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.
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{notices.length > 0 && (
|
||||
<section className="block block-flag">
|
||||
<h2>Verschoben nach der Entscheidung ({notices.length})</h2>
|
||||
@@ -191,6 +281,86 @@ export default function Forum() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="block">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h2>Ohne zugeordneten Spieler ({ohneSpieler?.total ?? 0})</h2>
|
||||
<p className="muted small">
|
||||
Anträge, deren Ersteller hier unter keinem Forum-Namen geführt wird. Sie bleiben
|
||||
aus der Arbeitsliste heraus, gehen aber nicht verloren. Sobald ein Spieler mit
|
||||
passendem Forum-Namen angelegt wird, wandern sie von selbst hinüber.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-small btn-ghost"
|
||||
onClick={() => setZeigeOhneSpieler((v) => !v)}
|
||||
>
|
||||
{zeigeOhneSpieler ? 'Einklappen' : 'Anzeigen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{zeigeOhneSpieler && (
|
||||
<>
|
||||
<input
|
||||
type="search"
|
||||
className="input"
|
||||
placeholder="Nach Titel oder Ersteller suchen …"
|
||||
value={suche}
|
||||
onChange={(event) => setSuche(event.target.value)}
|
||||
/>
|
||||
|
||||
{ohneSpieler === null || ohneSpieler.items.length === 0 ? (
|
||||
<p className="muted" style={{ marginTop: '0.75rem' }}>
|
||||
{suche ? 'Nichts gefunden.' : 'Alle erfassten Anträge sind zugeordnet.'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="cc-list" style={{ marginTop: '0.75rem' }}>
|
||||
{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">
|
||||
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>
|
||||
{ohneSpieler.total > ohneSpieler.items.length && (
|
||||
<p className="muted small">
|
||||
Zeigt {ohneSpieler.items.length} von {ohneSpieler.total} - über die Suche
|
||||
eingrenzen.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{dialog && (
|
||||
<ClassifyModal
|
||||
thread={dialog}
|
||||
@@ -296,9 +466,9 @@ function ClassifyModal({
|
||||
<div className="radio-group">
|
||||
{(
|
||||
[
|
||||
['NEW_CC', 'Angenommen – neuer CC', 'verbraucht 1 CC-Guthaben'],
|
||||
['REDESIGN', 'Angenommen – Redesign', 'verbraucht 1 Redesign-Guthaben'],
|
||||
['REACTIVATION', 'Angenommen – Reaktivierung', 'ein inaktiver CC kehrt zurück'],
|
||||
['NEW_CC', 'Angenommen - neuer CC', 'verbraucht 1 CC-Guthaben'],
|
||||
['REDESIGN', 'Angenommen - Redesign', 'verbraucht 1 Redesign-Guthaben'],
|
||||
['REACTIVATION', 'Angenommen - Reaktivierung', 'ein inaktiver CC kehrt zurück'],
|
||||
['REJECTED', 'Abgelehnt', 'keine Buchung'],
|
||||
['IGNORE', 'Kein Antrag', 'keine Buchung'],
|
||||
] as const
|
||||
|
||||
@@ -26,6 +26,27 @@ import type {
|
||||
|
||||
type DeactivateReason = 'PLAYER_INACTIVITY' | 'OTHER' | 'NO_FREE_CLAIM' | 'DEAD';
|
||||
|
||||
/**
|
||||
* Der Name eines CCs. Hängt ein Forums-Antrag daran, führt ein Klick direkt
|
||||
* dorthin - den Thread sonst wiederzufinden kostet jedes Mal Sucherei.
|
||||
*/
|
||||
function CharacterName({ character }: { character: Character }) {
|
||||
const thread = character.forumThreads?.[0];
|
||||
if (!thread) return <strong>{character.name}</strong>;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={thread.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="link-strong"
|
||||
title={`Antrag im Forum öffnen: ${thread.title}`}
|
||||
>
|
||||
{character.name}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogState =
|
||||
| { type: 'none' }
|
||||
| { type: 'newCharacter' }
|
||||
@@ -247,7 +268,7 @@ export default function PlayerDetail() {
|
||||
{active.map((character) => (
|
||||
<li key={character.id} className="cc-item">
|
||||
<div>
|
||||
<strong>{character.name}</strong>
|
||||
<CharacterName character={character} />
|
||||
{character.members && character.members.filter((m) => !m.leftAt).length > 1 && (
|
||||
<span className="badge badge-new">
|
||||
Gruppen-CC · {character.members.filter((m) => !m.leftAt).length}
|
||||
@@ -316,7 +337,7 @@ export default function PlayerDetail() {
|
||||
return (
|
||||
<li key={character.id} className="cc-item is-inactive">
|
||||
<div>
|
||||
<strong>{character.name}</strong>
|
||||
<CharacterName character={character} />
|
||||
<span
|
||||
className={`badge ${
|
||||
art === 'gratis'
|
||||
|
||||
Reference in New Issue
Block a user