Ketten mit der Maus ordnen, Verweise an CCs, Umzug per Tabelle

Die Spielerseite zeigt CCs nicht mehr als zwei Listen "aktiv" und "inaktiv",
sondern als das, was sie sind: je Platz eine Kette, von unten nach oben
gelesen. Unten der erste CC, darueber seine Redesigns.

Gefunden wird selten alles auf einmal. Ein Redesign taucht Jahre spaeter auf,
weil sein Antrag in einem anderen Board lag oder weil der Spieler es fuer den
Beitritt zu einem fremden Gruppen-CC benutzt hat. Dann steht in der Kette
schon "3 auf 5", und 4 fehlt. Deshalb laesst sich jeder Eintrag mit der Maus
an seinen Platz ziehen - auch mitten in eine Kette und in jede andere Kette
desselben Spielers. Das Verschieben ordnet nur ein: es bucht nichts und
aendert keinen Status.

Dafuer traegt jeder CC jetzt eine Position in seiner Kette. Sie laesst sich
verschieben, ohne die ganze Kette neu zu haengen; der Vorgaenger-Verweis, an
dem der Rest der Anwendung haengt, wird danach aus den Positionen neu
abgeleitet. So koennen beide Sichten nicht auseinanderlaufen. Der Bestand
bekommt seine Positionen in der Migration aus der vorhandenen Verkettung.

An jedem CC haengen jetzt beliebig viele Verweise: ein Name und eine Adresse,
der Name wird zum Link. Nur http und https - ein "javascript:"-Link in einer
Oberflaeche, die mehrere Leute bedienen, waere ein offenes Scheunentor.

Die SteamID fuehrt auf der Spielerseite zum GExtension-Profil, der Forum-Name
zum Forumsprofil. Dessen Adresse kommt aus dem Abgleich: die Profile liegen
nicht unter dem Forums-Pfad, sondern eine Ebene darueber, und das laesst sich
nicht verlaesslich erraten - also wird uebernommen, worauf das Forum selbst
verweist.

Zuletzt der Umzug: unter Import gibt es fuer Administratoren den gesamten
Bestand als eine Tabelle - Spieler, Guthaben, CCs samt Ketten, frueherer
Konten, Mitgliedschaften und Verweise. Eingespielt wird ergaenzend: was da
ist, bleibt unangetastet, zweimal dieselbe Datei aendert nichts, und krumme
Zeilen werden mit Zeilennummer gemeldet statt den Lauf abzubrechen. Ein Test
spielt den vollen Weg durch - exportieren, Bestand loeschen, zurueckholen.
This commit is contained in:
2026-09-21 13:18:11 +02:00
parent 029d380abf
commit db37d3388a
22 changed files with 2472 additions and 357 deletions
+391
View File
@@ -0,0 +1,391 @@
import { useState } from 'react';
import MemberList from './MemberList';
import { ApiError, api } from '../lib/api';
import { REASON_LABEL, formatDate, rueckkehrArt } from '../lib/format';
import type { Character } from '../lib/types';
/**
* Die CCs eines Spielers als Ketten.
*
* Ein CC-Paket ist ein Platz, und jeder Platz hat seine eigene Geschichte:
* erster CC, Redesign, noch ein Redesign. Gelesen wird von unten nach oben,
* der älteste Eintrag unten - so wächst die Kette in die Richtung, in die
* auch die Zeit läuft.
*
* Gefunden wird selten alles auf einmal. Ein Redesign taucht Jahre später
* auf, weil sein Antrag woanders lag oder unter einem fremden Namen lief.
* Deshalb lässt sich jeder Eintrag mit der Maus an seinen Platz ziehen -
* auch mitten in eine Kette oder in eine andere.
*/
export type ChainAction =
| 'redesign'
| 'memberRedesign'
| 'addMember'
| 'deactivate'
| 'memberDeactivate'
| 'revert'
| 'reactivateFree'
| 'reactivatePaid'
| 'reviveDead'
| 'links';
interface Kette {
lineageId: string;
glieder: Character[];
}
function baueKetten(characters: Character[]): Kette[] {
const nach = new Map<string, Character[]>();
for (const c of characters) {
const liste = nach.get(c.lineageId) ?? [];
liste.push(c);
nach.set(c.lineageId, liste);
}
return [...nach.entries()]
.map(([lineageId, glieder]) => ({
lineageId,
glieder: [...glieder].sort((a, b) => a.chainPosition - b.chainPosition),
}))
// Ketten mit einem aktiven CC zuerst - daran arbeitet man.
.sort((a, b) => {
const aktivA = a.glieder.some((c) => c.status === 'ACTIVE') ? 0 : 1;
const aktivB = b.glieder.some((c) => c.status === 'ACTIVE') ? 0 : 1;
if (aktivA !== aktivB) return aktivA - aktivB;
return (a.glieder[0]?.createdAt ?? '').localeCompare(b.glieder[0]?.createdAt ?? '');
});
}
export default function ChainView({
characters,
playerId,
canEdit,
admin,
onAction,
onMoved,
onError,
}: {
characters: Character[];
playerId: number;
canEdit: boolean;
admin: boolean;
onAction: (action: ChainAction, character: Character) => void;
onMoved: () => void;
onError: (message: string) => void;
}) {
const [ziehe, setZiehe] = useState<Character | null>(null);
const [ueber, setUeber] = useState<string | null>(null);
const ketten = baueKetten(characters);
if (ketten.length === 0) {
return (
<section className="block">
<h2>CCs</h2>
<p className="muted">Noch keine CCs. Über „Neuer CC" den ersten anlegen.</p>
</section>
);
}
/** Nach `afterId` einsortieren; null heisst an den Anfang. */
const verschieben = async (lineageId: string, afterId: number | null) => {
if (!ziehe) return;
setUeber(null);
const bewegt = ziehe;
setZiehe(null);
// Der Platz, auf den gezogen wurde, ist schon seiner.
if (bewegt.lineageId === lineageId) {
const kette = ketten.find((k) => k.lineageId === lineageId)?.glieder ?? [];
const index = kette.findIndex((c) => c.id === bewegt.id);
const zielIndex = afterId === null ? 0 : kette.findIndex((c) => c.id === afterId) + 1;
if (index === zielIndex || index === zielIndex - 1) return;
}
try {
await api(`/api/characters/${bewegt.id}/move`, {
method: 'POST',
body: { lineageId, afterId },
});
onMoved();
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Verschieben fehlgeschlagen');
}
};
const herausloesen = async (character: Character) => {
if (!window.confirm(`"${character.name}" aus seiner Kette lösen und allein stellen?`)) return;
try {
await api(`/api/characters/${character.id}/detach`, { method: 'POST', body: {} });
onMoved();
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Herauslösen fehlgeschlagen');
}
};
const zone = (lineageId: string, afterId: number | null, key: string) => {
if (!canEdit || !ziehe) return null;
return (
<div
className={ueber === key ? 'chain-drop is-over' : 'chain-drop'}
onDragOver={(event) => {
event.preventDefault();
setUeber(key);
}}
onDragLeave={() => setUeber((v) => (v === key ? null : v))}
onDrop={(event) => {
event.preventDefault();
void verschieben(lineageId, afterId);
}}
>
hierher
</div>
);
};
return (
<section className="block">
<div className="page-head">
<div>
<h2>CCs ({characters.length})</h2>
<p className="muted small">
Jede Spalte ist ein Platz, von unten nach oben gelesen: unten der erste CC, darüber
seine Redesigns.
{canEdit && ' Einträge lassen sich mit der Maus an ihren Platz ziehen - auch mitten in eine Kette oder in eine andere.'}
</p>
</div>
</div>
<div className="chains">
{ketten.map((kette, index) => {
const aktiv = kette.glieder.find((c) => c.status === 'ACTIVE');
// Von unten nach oben: der jüngste Eintrag steht oben.
const vonObenNachUnten = [...kette.glieder].reverse();
return (
<div key={kette.lineageId} className="chain">
<div className="chain-head">
<strong>Platz {index + 1}</strong>
<span className="muted small">
{aktiv ? `aktiv: ${aktiv.name}` : 'kein aktiver CC'}
</span>
</div>
{zone(kette.lineageId, kette.glieder[kette.glieder.length - 1]?.id ?? null, `${kette.lineageId}-oben`)}
{vonObenNachUnten.map((character, i) => (
<div key={character.id}>
<ChainEntry
character={character}
playerId={playerId}
canEdit={canEdit}
admin={admin}
ziehend={ziehe?.id === character.id}
onDragStart={() => setZiehe(character)}
onDragEnd={() => {
setZiehe(null);
setUeber(null);
}}
onAction={onAction}
onDetach={() => void herausloesen(character)}
onChanged={onMoved}
onError={onError}
alleinInKette={kette.glieder.length === 1}
/>
{i < vonObenNachUnten.length - 1 && (
<>
<div className="chain-arrow" aria-hidden="true">
↑
</div>
{zone(
kette.lineageId,
vonObenNachUnten[i + 1]?.id ?? null,
`${kette.lineageId}-${character.id}`,
)}
</>
)}
</div>
))}
{zone(kette.lineageId, null, `${kette.lineageId}-unten`)}
</div>
);
})}
</div>
</section>
);
}
function ChainEntry({
character,
playerId,
canEdit,
admin,
ziehend,
alleinInKette,
onDragStart,
onDragEnd,
onAction,
onDetach,
onChanged,
onError,
}: {
character: Character;
playerId: number;
canEdit: boolean;
admin: boolean;
ziehend: boolean;
alleinInKette: boolean;
onDragStart: () => void;
onDragEnd: () => void;
onAction: (action: ChainAction, character: Character) => void;
onDetach: () => void;
onChanged: () => void;
onError: (message: string) => void;
}) {
const aktiv = character.status === 'ACTIVE';
const art = rueckkehrArt(character.inactiveReason);
const mitglieder = (character.members ?? []).filter((m) => !m.leftAt);
const gruppe = mitglieder.length > 1;
const eigeneMitgliedschaft = (character.members ?? []).find((m) => m.playerId === playerId);
const ruht = Boolean(eigeneMitgliedschaft?.leftAt && eigeneMitgliedschaft.inactiveReason);
const thread = character.forumThreads?.[0];
const klassen = ['chain-entry'];
if (!aktiv) klassen.push('is-inactive');
if (ziehend) klassen.push('is-dragging');
if (ruht) klassen.push('is-resting');
return (
<article
className={klassen.join(' ')}
draggable={canEdit}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<div className="chain-entry-head">
{canEdit && (
<span className="chain-grip" title="Zum Verschieben ziehen" aria-hidden="true">
⠿
</span>
)}
{thread ? (
<a
href={thread.url}
target="_blank"
rel="noreferrer"
className="link-strong"
title={`Antrag im Forum öffnen: ${thread.title}`}
>
{character.name}
</a>
) : (
<strong>{character.name}</strong>
)}
{aktiv ? (
gruppe && <span className="badge badge-new">Gruppe · {mitglieder.length}</span>
) : (
<span
className={`badge ${
art === 'gratis' ? 'badge-free' : art === 'admin' ? 'badge-dead' : 'badge-paid'
}`}
>
{character.inactiveReason ? REASON_LABEL[character.inactiveReason] : 'Inaktiv'}
</span>
)}
{ruht && <span className="badge badge-dead">dein Platz ruht</span>}
</div>
{character.loreText && <p className="lore">{character.loreText}</p>}
{character.inactiveNote && <p className="muted small">{character.inactiveNote}</p>}
{(character.links?.length ?? 0) > 0 && (
<p className="chain-links">
{character.links?.map((link) => (
<a key={link.id} href={link.url} target="_blank" rel="noreferrer" className="badge">
{link.label}
</a>
))}
</p>
)}
<MemberList
character={character}
currentPlayerId={playerId}
canEdit={canEdit}
onChanged={onChanged}
onError={onError}
/>
<p className="muted small">Seit {formatDate(character.createdAt)}</p>
{canEdit && (
<div className="chain-actions">
{aktiv ? (
<>
{gruppe ? (
<>
<button type="button" className="btn btn-tiny" onClick={() => onAction('memberRedesign', character)}>
Anteil redesignen
</button>
<button type="button" className="btn btn-tiny" onClick={() => onAction('redesign', character)}>
Gruppe redesignen
</button>
<button type="button" className="btn btn-tiny btn-ghost" onClick={() => onAction('memberDeactivate', character)}>
Nur diesen Nutzer
</button>
</>
) : (
<button type="button" className="btn btn-tiny" onClick={() => onAction('redesign', character)}>
Redesign
</button>
)}
<button type="button" className="btn btn-tiny" onClick={() => onAction('addMember', character)}>
Nutzer aufnehmen
</button>
<button type="button" className="btn btn-tiny btn-ghost" onClick={() => onAction('deactivate', character)}>
Deaktivieren
</button>
</>
) : art === 'admin' ? (
admin ? (
<button type="button" className="btn btn-tiny btn-primary" onClick={() => onAction('reviveDead', character)}>
Zurückholen
</button>
) : (
<span className="muted small">Nur ein Administrator</span>
)
) : art === 'gratis' ? (
<button type="button" className="btn btn-tiny btn-primary" onClick={() => onAction('reactivateFree', character)}>
Gratis reaktivieren
</button>
) : (
<button type="button" className="btn btn-tiny" onClick={() => onAction('reactivatePaid', character)}>
Bezahlt reaktivieren
</button>
)}
<button type="button" className="btn btn-tiny btn-ghost" onClick={() => onAction('links', character)}>
Verweise
</button>
{!alleinInKette && (
<button type="button" className="btn btn-tiny btn-ghost" onClick={onDetach} title="Aus dieser Kette lösen und allein stellen">
Herauslösen
</button>
)}
{aktiv && (
<button type="button" className="btn btn-tiny btn-danger" onClick={() => onAction('revert', character)}>
Zurücknehmen
</button>
)}
</div>
)}
{character.predecessorId !== null && !character.predecessor && (
<p className="muted small">Vorgänger liegt in einer anderen Kette.</p>
)}
</article>
);
}
+160
View File
@@ -0,0 +1,160 @@
import { useState } from 'react';
import Modal from './Modal';
import Notice from './Notice';
import { ApiError, api } from '../lib/api';
import type { Character, CharacterLink } from '../lib/types';
/**
* Verweise an einem CC: das Addon des Erstellers, ein Modell, eine Absprache.
* Eingetragen wird ein Name und eine Adresse - in der Liste steht dann der
* Name als Link, damit man sieht, worum es geht, und nicht eine nackte
* Adresse lesen muss.
*/
export default function LinksModal({
character,
canEdit,
onClose,
onChanged,
}: {
character: Character;
canEdit: boolean;
onClose: () => void;
onChanged: () => void;
}) {
const [links, setLinks] = useState<CharacterLink[]>(character.links ?? []);
const [label, setLabel] = useState('');
const [url, setUrl] = useState('');
const [bearbeitet, setBearbeitet] = useState<CharacterLink | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const melden = (err: unknown) => {
setError(err instanceof ApiError ? err.message : 'Hat nicht geklappt');
};
const hinzufuegen = async (event: React.FormEvent) => {
event.preventDefault();
setBusy(true);
setError(null);
try {
if (bearbeitet) {
const geaendert = await api<CharacterLink>(`/api/characters/links/${bearbeitet.id}`, {
method: 'PATCH',
body: { label: label.trim(), url: url.trim() },
});
setLinks((alt) => alt.map((l) => (l.id === geaendert.id ? geaendert : l)));
setBearbeitet(null);
} else {
const neu = await api<CharacterLink>(`/api/characters/${character.id}/links`, {
method: 'POST',
body: { label: label.trim(), url: url.trim() },
});
setLinks((alt) => [...alt, neu]);
}
setLabel('');
setUrl('');
onChanged();
} catch (err) {
melden(err);
} finally {
setBusy(false);
}
};
const entfernen = async (link: CharacterLink) => {
if (!window.confirm(`Verweis "${link.label}" entfernen?`)) return;
try {
await api(`/api/characters/links/${link.id}`, { method: 'DELETE' });
setLinks((alt) => alt.filter((l) => l.id !== link.id));
onChanged();
} catch (err) {
melden(err);
}
};
return (
<Modal title={`Verweise an "${character.name}"`} onClose={onClose}>
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
{links.length === 0 ? (
<p className="muted">Noch keine Verweise.</p>
) : (
<ul className="cc-list">
{links.map((link) => (
<li key={link.id} className="cc-item">
<div>
<a href={link.url} target="_blank" rel="noreferrer" className="link-strong">
{link.label}
</a>
<p className="muted small mono">{link.url}</p>
</div>
{canEdit && (
<div className="cc-actions">
<button
type="button"
className="btn btn-small btn-ghost"
onClick={() => {
setBearbeitet(link);
setLabel(link.label);
setUrl(link.url);
}}
>
Ändern
</button>
<button
type="button"
className="btn btn-small btn-danger"
onClick={() => void entfernen(link)}
>
Entfernen
</button>
</div>
)}
</li>
))}
</ul>
)}
{canEdit && (
<form onSubmit={hinzufuegen} style={{ marginTop: '1rem' }}>
<label htmlFor="link-label">Name</label>
<input
id="link-label"
value={label}
onChange={(event) => setLabel(event.target.value)}
placeholder="z. B. Ersteller-Addon"
required
/>
<label htmlFor="link-url">Adresse</label>
<input
id="link-url"
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="https://steamcommunity.com/sharedfiles/…"
required
/>
<div className="form-actions">
{bearbeitet && (
<button
type="button"
className="btn btn-ghost"
onClick={() => {
setBearbeitet(null);
setLabel('');
setUrl('');
}}
>
Abbrechen
</button>
)}
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Speichere …' : bearbeitet ? 'Ändern' : 'Hinzufügen'}
</button>
</div>
</form>
)}
</Modal>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { Link } from 'react-router-dom';
import { ApiError, api } from '../lib/api';
import { REASON_LABEL, rueckkehrArt } from '../lib/format';
import type { Character, CharacterMember } from '../lib/types';
/**
* Wer einen CC nutzt. Bei einem Gruppen-CC kann ein Einzelner ausfallen,
* ohne die übrigen zu berühren - dann steht er hier, bis er zurückkehrt.
*/
export default function MemberList({
character,
currentPlayerId,
canEdit,
onChanged,
onError,
}: {
character: Character;
currentPlayerId: number;
canEdit: boolean;
onChanged: () => void;
onError: (message: string) => void;
}) {
const alle = character.members ?? [];
const aktiv = alle.filter((m) => !m.leftAt);
// Wer ausgefallen ist, kann zurückkehren - wer gegangen ist, nicht.
const ruhend = alle.filter((m) => m.leftAt && m.inactiveReason);
const ehemalig = alle.filter((m) => m.leftAt && !m.inactiveReason);
if (aktiv.length <= 1 && ruhend.length === 0 && ehemalig.length === 0) return null;
const austragen = async (playerId: number, name: string) => {
if (!window.confirm(`"${name}" aus "${character.name}" austragen?`)) return;
try {
await api(`/api/characters/${character.id}/members/${playerId}`, { method: 'DELETE' });
onChanged();
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Austragen fehlgeschlagen');
}
};
const zurueckholen = async (mitglied: CharacterMember) => {
const art = rueckkehrArt(mitglied.inactiveReason);
const name = mitglied.player.displayName;
let reason: string | undefined;
if (art === 'admin') {
const eingabe = window.prompt(
`"${name}" ist im Rollenspiel gestorben. Womit kehrt er zurück?`,
);
if (!eingabe?.trim()) return;
reason = eingabe.trim();
}
let useKind: string | undefined;
if (art === 'bezahlt') {
const kulanz = window.confirm(
`Die Rückkehr von "${name}" kostet ein Guthaben.\n\nOK = Kulanz-Redesign, Abbrechen = bezahltes Redesign.`,
);
useKind = kulanz ? 'REDESIGN_KULANZ' : 'REDESIGN_PAID';
}
try {
await api(`/api/characters/${character.id}/members/${mitglied.playerId}/reactivate`, {
method: 'POST',
body: { ...(useKind ? { useKind } : {}), ...(reason ? { reason } : {}) },
});
onChanged();
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Rückkehr fehlgeschlagen');
}
};
return (
<div className="member-list">
{aktiv.map((mitglied) => (
<span key={mitglied.id} className="member-chip">
{mitglied.playerId === currentPlayerId ? (
<strong>{mitglied.player.displayName}</strong>
) : (
<Link to={`/players/${mitglied.playerId}`}>{mitglied.player.displayName}</Link>
)}
{canEdit && aktiv.length > 1 && (
<button
type="button"
className="member-remove"
title="Austragen"
onClick={() => void austragen(mitglied.playerId, mitglied.player.displayName)}
>
✕
</button>
)}
</span>
))}
{ruhend.map((mitglied) => (
<span key={mitglied.id} className="member-chip is-inactive">
<span className="muted">{mitglied.player.displayName}</span>
<span
className={`badge ${
rueckkehrArt(mitglied.inactiveReason) === 'gratis'
? 'badge-free'
: rueckkehrArt(mitglied.inactiveReason) === 'admin'
? 'badge-dead'
: 'badge-paid'
}`}
title={mitglied.inactiveNote ?? undefined}
>
{mitglied.inactiveReason ? REASON_LABEL[mitglied.inactiveReason] : 'Inaktiv'}
</span>
{canEdit && (
<button
type="button"
className="btn btn-tiny btn-ghost"
onClick={() => void zurueckholen(mitglied)}
>
Zurückholen
</button>
)}
</span>
))}
{ehemalig.length > 0 && (
<span className="muted small">
früher:{' '}
{ehemalig
.map((m) =>
m.successor ? `${m.player.displayName} (jetzt ${m.successor.name})` : m.player.displayName,
)
.join(', ')}
</span>
)}
</div>
);
}
+9 -3
View File
@@ -6,6 +6,8 @@ import type { Role, SessionUser } from './types';
interface SessionState {
user: SessionUser | null;
loading: boolean;
/// Die Adressen der fremden Dienste, für Profil-Links.
gextensionBaseUrl: string | null;
setUser: (user: SessionUser | null) => void;
refresh: () => Promise<void>;
logout: () => Promise<void>;
@@ -19,11 +21,15 @@ const SessionContext = createContext<SessionState | null>(null);
export function SessionProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<SessionUser | null>(null);
const [loading, setLoading] = useState(true);
const [gextensionBaseUrl, setGextension] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const result = await api<{ user: SessionUser | null }>('/api/auth/me');
const result = await api<{ user: SessionUser | null; gextensionBaseUrl?: string }>(
'/api/auth/me',
);
setUser(result.user);
setGextension(result.gextensionBaseUrl ?? null);
} catch {
setUser(null);
} finally {
@@ -49,8 +55,8 @@ export function SessionProvider({ children }: { children: ReactNode }) {
);
const value = useMemo<SessionState>(
() => ({ user, loading, setUser, refresh, logout, can }),
[user, loading, refresh, logout, can],
() => ({ user, loading, gextensionBaseUrl, setUser, refresh, logout, can }),
[user, loading, gextensionBaseUrl, refresh, logout, can],
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
+19
View File
@@ -43,11 +43,19 @@ export interface PlayerSummary {
balances: Balances;
}
export interface CharacterLink {
id: number;
label: string;
url: string;
}
export interface Player {
id: number;
displayName: string;
steamId: string | null;
forumName: string | null;
/// Sein Forumsprofil, aus dem Abgleich übernommen.
forumProfileUrl: string | null;
notes: string | null;
/// Ein Fall, der sich gerade nicht klären lässt. Reine Merkhilfe.
isProblem: boolean;
@@ -131,10 +139,13 @@ export interface Character {
inactiveReason: InactiveReason | null;
inactiveNote: string | null;
lineageId: string;
/// Platz in der Redesign-Kette, aufsteigend.
chainPosition: number;
predecessorId: number | null;
predecessor: CharacterRef | null;
successors: CharacterRef[];
forumThreads?: ThreadRef[];
links?: CharacterLink[];
members?: CharacterMember[];
createdAt: string;
}
@@ -365,3 +376,11 @@ export interface ScrapeState {
summary: ScrapeSummary | null;
error: string | null;
}
/// Ergebnis einer eingespielten Sicherung.
export interface BackupReport {
gelesen: number;
angelegt: Record<string, number>;
uebersprungen: Record<string, number>;
fehler: { zeile: number; grund: string }[];
}
+121 -1
View File
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useState } from 'react';
import Notice from '../components/Notice';
import { ApiError, api, uploadFile } from '../lib/api';
import { PLAN_LABEL, formatDate } from '../lib/format';
import type { ImportPlan, ImportRecord } from '../lib/types';
import { useSession } from '../lib/session';
import type { BackupReport, ImportPlan, ImportRecord } from '../lib/types';
interface UploadResult {
importId: number;
@@ -53,6 +54,8 @@ function guessMapping(columns: string[]): MappingForm {
}
export default function Import() {
const { can } = useSession();
const admin = can('ADMIN');
const [upload, setUpload] = useState<UploadResult | null>(null);
const [mapping, setMapping] = useState<MappingForm>(EMPTY_MAPPING);
const [preview, setPreview] = useState<{
@@ -253,6 +256,8 @@ export default function Import() {
</section>
)}
{!upload && admin && <BackupBox onError={setError} onFlash={setFlash} />}
{upload && !preview && (
<section className="block">
<h2>Spalten zuordnen</h2>
@@ -462,3 +467,118 @@ function MappingSelect({
</div>
);
}
/**
* Vollständige Sicherung: alles in eine Tabelle, und zurück.
*
* Gedacht für den Umzug auf einen anderen Server oder den Wechsel auf ein
* anderes Programm - nicht für den täglichen Gebrauch. Deshalb steht es
* unten und bleibt Administratoren vorbehalten.
*/
function BackupBox({
onError,
onFlash,
}: {
onError: (message: string) => void;
onFlash: (message: string) => void;
}) {
const [busy, setBusy] = useState(false);
const [bericht, setBericht] = useState<BackupReport | null>(null);
const einspielen = async (file: File) => {
if (
!window.confirm(
`"${file.name}" einspielen?\n\nWas es hier schon gibt, bleibt unangetastet - ergänzt wird nur, was fehlt.`,
)
) {
return;
}
setBusy(true);
setBericht(null);
try {
const ergebnis = await uploadFile<BackupReport>('/api/backup/import', file);
setBericht(ergebnis);
const angelegt = Object.values(ergebnis.angelegt).reduce((a, b) => a + b, 0);
onFlash(
angelegt > 0
? `${angelegt} Einträge ergänzt.`
: 'Nichts zu ergänzen - der Bestand war vollständig.',
);
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Einspielen fehlgeschlagen');
} finally {
setBusy(false);
}
};
return (
<section className="block">
<h2>Alles sichern und zurückholen</h2>
<Notice kind="info">
Der gesamte Bestand als eine Tabelle: Spieler, Guthaben, CCs, frühere Konten,
Mitgliedschaften und Verweise. Für den Umzug auf einen anderen Server oder den Wechsel auf
ein anderes Programm. Beim Einspielen wird ergänzt, was fehlt - vorhandene Einträge
bleiben, wie sie sind.
</Notice>
<div className="form-actions" style={{ justifyContent: 'flex-start' }}>
<a className="btn" href="/api/backup/export" download>
Alles herunterladen
</a>
<label className="btn">
<input
type="file"
accept=".csv"
style={{ display: 'none' }}
disabled={busy}
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void einspielen(file);
event.target.value = '';
}}
/>
{busy ? 'Spiele ein …' : 'Sicherung einspielen'}
</label>
</div>
{bericht && (
<div className="table-wrap" style={{ marginTop: '0.75rem' }}>
<table className="table small">
<thead>
<tr>
<th>Satzart</th>
<th className="num">Ergänzt</th>
<th className="num">War schon da</th>
</tr>
</thead>
<tbody>
{Object.keys(bericht.angelegt).map((satzart) => (
<tr key={satzart}>
<td>{satzart}</td>
<td className="num">{bericht.angelegt[satzart] ?? 0}</td>
<td className="num muted">{bericht.uebersprungen[satzart] ?? 0}</td>
</tr>
))}
</tbody>
</table>
{bericht.fehler.length > 0 && (
<Notice kind="warn">
<strong>{bericht.fehler.length} Zeilen liessen sich nicht zuordnen:</strong>
<ul>
{bericht.fehler.slice(0, 10).map((f) => (
<li key={f.zeile}>
Zeile {f.zeile}: {f.grund}
</li>
))}
</ul>
{bericht.fehler.length > 10 && <p>… und {bericht.fehler.length - 10} weitere.</p>}
</Notice>
)}
</div>
)}
</section>
);
}
+69 -346
View File
@@ -3,6 +3,8 @@ import { Link, useNavigate, useParams } from 'react-router-dom';
import BalanceCards from '../components/BalanceCards';
import UnmatchedBox from '../components/UnmatchedBox';
import AliasBox from '../components/AliasBox';
import ChainView from '../components/ChainView';
import LinksModal from '../components/LinksModal';
import PendingRowsBox from '../components/PendingRowsBox';
import ThreadsBox from '../components/ThreadsBox';
import Modal from '../components/Modal';
@@ -14,14 +16,12 @@ import {
SOURCE_LABEL,
formatAmount,
formatDate,
rueckkehrArt,
} from '../lib/format';
import RunProgress from '../components/RunProgress';
import { useScrape } from '../lib/useScrape';
import { useSession } from '../lib/session';
import type {
Character,
CharacterMember,
Player,
PlayerSummary,
CreditKind,
@@ -31,32 +31,7 @@ import type {
type DeactivateReason = 'PLAYER_INACTIVITY' | 'OTHER' | 'NO_FREE_CLAIM' | 'DEAD';
/// Ein CC mit mehr als einem aktiven Nutzer. Dort gibt es zwei Ebenen: den CC
/// als Ganzes und den Anteil eines Einzelnen.
function istGruppe(character: Character): boolean {
return (character.members ?? []).filter((m) => !m.leftAt).length > 1;
}
/**
* 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' }
@@ -73,12 +48,13 @@ type DialogState =
| { type: 'reviveDead'; character: Character }
| { type: 'problem' }
| { type: 'memberRedesign'; character: Character }
| { type: 'memberDeactivate'; character: Character };
| { type: 'memberDeactivate'; character: Character }
| { type: 'links'; character: Character };
export default function PlayerDetail() {
const { id } = useParams<{ id: string }>();
const playerId = Number(id);
const { can } = useSession();
const { can, gextensionBaseUrl } = useSession();
const navigate = useNavigate();
const [detail, setDetail] = useState<Detail | null>(null);
@@ -143,8 +119,6 @@ export default function PlayerDetail() {
const editor = can('EDITOR');
const admin = can('ADMIN');
const active = detail.characters.filter((c) => c.status === 'ACTIVE');
const inactive = detail.characters.filter((c) => c.status === 'INACTIVE');
const playerName = detail.player.displayName;
@@ -157,8 +131,39 @@ export default function PlayerDetail() {
</Link>
<h1>{detail.player.displayName}</h1>
<p className="muted mono">
{detail.player.steamId ?? 'keine SteamID'}
{detail.player.forumName && ` · Forum: ${detail.player.forumName}`}
{detail.player.steamId ? (
gextensionBaseUrl ? (
<a
href={`${gextensionBaseUrl.replace(/\/+$/, '')}/index.php?t=user&id=${detail.player.steamId}`}
target="_blank"
rel="noreferrer"
title="GExtension-Profil öffnen"
>
{detail.player.steamId}
</a>
) : (
detail.player.steamId
)
) : (
'keine SteamID'
)}
{detail.player.forumName && (
<>
{' · Forum: '}
{detail.player.forumProfileUrl ? (
<a
href={detail.player.forumProfileUrl}
target="_blank"
rel="noreferrer"
title="Forumsprofil öffnen"
>
{detail.player.forumName}
</a>
) : (
detail.player.forumName
)}
</>
)}
</p>
{detail.player.notes && <p className="lore">{detail.player.notes}</p>}
</div>
@@ -297,193 +302,28 @@ export default function PlayerDetail() {
</section>
)}
<section className="block">
<h2>Aktive CCs ({active.length})</h2>
{active.length === 0 ? (
<p className="muted">Keine aktiven CCs.</p>
) : (
<ul className="cc-list">
{active.map((character) => (
<li key={character.id} className="cc-item">
<div>
<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}
</span>
)}
{character.predecessor && (
<span className="muted"> · Nachfolger von {character.predecessor.name}</span>
)}
{character.loreText && <p className="lore">{character.loreText}</p>}
<MemberList
character={character}
currentPlayerId={playerId}
canEdit={editor}
onChanged={() => void load()}
onError={setError}
/>
<span className="muted small">Seit {formatDate(character.createdAt)}</span>
</div>
{editor && (
<div className="cc-actions">
{istGruppe(character) ? (
<>
<button
type="button"
className="btn btn-small"
title="Nur dieser Spieler steigt aus und bekommt einen eigenen CC"
onClick={() => setDialog({ type: 'memberRedesign', character })}
>
Anteil redesignen
</button>
<button
type="button"
className="btn btn-small"
title="Die ganze Gruppe wandert in einen neuen CC"
onClick={() => setDialog({ type: 'redesign', character })}
>
Gruppe redesignen
</button>
<button
type="button"
className="btn btn-small btn-ghost"
title="Nur dieser Spieler fällt aus, die übrigen spielen weiter"
onClick={() => setDialog({ type: 'memberDeactivate', character })}
>
Nur diesen Nutzer
</button>
</>
) : (
<button
type="button"
className="btn btn-small"
onClick={() => setDialog({ type: 'redesign', character })}
>
Redesign
</button>
)}
<button
type="button"
className="btn btn-small"
onClick={() => setDialog({ type: 'addMember', character })}
>
Nutzer aufnehmen
</button>
<button
type="button"
className="btn btn-small btn-ghost"
onClick={() => setDialog({ type: 'deactivate', character })}
>
Deaktivieren
</button>
<button
type="button"
className="btn btn-small btn-danger"
onClick={() => setDialog({ type: 'revert', character })}
>
Zurücknehmen
</button>
</div>
)}
</li>
))}
</ul>
)}
</section>
<section className="block">
<h2>Inaktive CCs ({inactive.length})</h2>
{inactive.length === 0 ? (
<p className="muted">Keine inaktiven CCs.</p>
) : (
<ul className="cc-list">
{inactive.map((character) => {
const art = rueckkehrArt(character.inactiveReason);
const free = art === 'gratis';
return (
<li key={character.id} className="cc-item is-inactive">
<div>
<CharacterName character={character} />
<span
className={`badge ${
art === 'gratis'
? 'badge-free'
: art === 'admin'
? 'badge-dead'
: 'badge-paid'
}`}
>
{character.inactiveReason
? REASON_LABEL[character.inactiveReason]
: 'Unbekannt'}
</span>
{character.successors.length > 0 && (
<span className="muted">
{' '}
· Ersetzt durch {character.successors.map((s) => s.name).join(', ')}
</span>
)}
{character.inactiveNote && (
<p className="lore">{character.inactiveNote}</p>
)}
<p className="muted small">
{art === 'admin'
? 'Im Rollenspiel gestorben - die Rückkehr entscheidet ein Administrator.'
: free
? 'Kommt gratis zurück - kostet kein Guthaben.'
: 'Reaktivierung kostet ein Redesign- oder CC-Guthaben.'}
</p>
</div>
{editor && (
<div className="cc-actions">
{art === 'admin' ? (
admin ? (
<button
type="button"
className="btn btn-small btn-primary"
onClick={() => setDialog({ type: 'reviveDead', character })}
>
Zurückholen
</button>
) : (
<span className="muted small">Nur ein Administrator</span>
)
) : free ? (
<button
type="button"
className="btn btn-small btn-primary"
disabled={busy}
onClick={() =>
void run(
() =>
api(`/api/characters/${character.id}/reactivate-free`, {
method: 'POST',
body: {},
}),
`"${character.name}" ist wieder aktiv - kostenlos.`,
)
}
>
Gratis reaktivieren
</button>
) : (
<button
type="button"
className="btn btn-small"
onClick={() => setDialog({ type: 'reactivatePaid', character })}
>
Bezahlt reaktivieren
</button>
)}
</div>
)}
</li>
);
})}
</ul>
)}
</section>
<ChainView
characters={detail.characters}
playerId={playerId}
canEdit={editor}
admin={admin}
onAction={(action, character) => {
if (action === 'reactivateFree') {
void run(
() =>
api(`/api/characters/${character.id}/reactivate-free`, {
method: 'POST',
body: {},
}),
`"${character.name}" ist wieder aktiv - kostenlos.`,
);
return;
}
setDialog({ type: action, character });
}}
onMoved={() => void load()}
onError={setError}
/>
<ThreadsBox
threads={detail.threads ?? []}
@@ -617,6 +457,15 @@ export default function PlayerDetail() {
/>
)}
{dialog.type === 'links' && (
<LinksModal
character={dialog.character}
canEdit={editor}
onClose={() => setDialog({ type: 'none' })}
onChanged={() => void load()}
/>
)}
{dialog.type === 'memberRedesign' && (
<CharacterFormModal
title={`Anteil an "${dialog.character.name}" redesignen`}
@@ -1416,132 +1265,6 @@ function DeletePlayerModal({
* Wer einen CC nutzt. Bei einem gewöhnlichen CC ist das eine Zeile und wird
* ausgeblendet; erst bei mehreren Nutzern lohnt die Liste.
*/
function MemberList({
character,
currentPlayerId,
canEdit,
onChanged,
onError,
}: {
character: Character;
currentPlayerId: number;
canEdit: boolean;
onChanged: () => void;
onError: (message: string) => void;
}) {
const alle = character.members ?? [];
const aktiv = alle.filter((m) => !m.leftAt);
// Wer ausgefallen ist, kann zurückkehren - wer gegangen ist, nicht.
const ruhend = alle.filter((m) => m.leftAt && m.inactiveReason);
const ehemalig = alle.filter((m) => m.leftAt && !m.inactiveReason);
if (aktiv.length <= 1 && ruhend.length === 0 && ehemalig.length === 0) return null;
const austragen = async (playerId: number, name: string) => {
if (!window.confirm(`"${name}" aus "${character.name}" austragen?`)) return;
try {
await api(`/api/characters/${character.id}/members/${playerId}`, { method: 'DELETE' });
onChanged();
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Austragen fehlgeschlagen');
}
};
const zurueckholen = async (mitglied: CharacterMember) => {
const art = rueckkehrArt(mitglied.inactiveReason);
const name = mitglied.player.displayName;
let reason: string | undefined;
if (art === 'admin') {
const eingabe = window.prompt(
`"${name}" ist im Rollenspiel gestorben. Womit kehrt er zurück?`,
);
if (!eingabe?.trim()) return;
reason = eingabe.trim();
}
let useKind: string | undefined;
if (art === 'bezahlt') {
const kulanz = window.confirm(
`Die Rückkehr von "${name}" kostet ein Guthaben.\n\nOK = Kulanz-Redesign, Abbrechen = bezahltes Redesign.`,
);
useKind = kulanz ? 'REDESIGN_KULANZ' : 'REDESIGN_PAID';
}
try {
await api(`/api/characters/${character.id}/members/${mitglied.playerId}/reactivate`, {
method: 'POST',
body: { ...(useKind ? { useKind } : {}), ...(reason ? { reason } : {}) },
});
onChanged();
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Rückkehr fehlgeschlagen');
}
};
return (
<div className="member-list">
{aktiv.map((mitglied) => (
<span key={mitglied.id} className="member-chip">
{mitglied.playerId === currentPlayerId ? (
<strong>{mitglied.player.displayName}</strong>
) : (
<Link to={`/players/${mitglied.playerId}`}>{mitglied.player.displayName}</Link>
)}
{canEdit && aktiv.length > 1 && (
<button
type="button"
className="member-remove"
title="Austragen"
onClick={() => void austragen(mitglied.playerId, mitglied.player.displayName)}
>
✕
</button>
)}
</span>
))}
{ruhend.map((mitglied) => (
<span key={mitglied.id} className="member-chip is-inactive">
<span className="muted">{mitglied.player.displayName}</span>
<span
className={`badge ${
rueckkehrArt(mitglied.inactiveReason) === 'gratis'
? 'badge-free'
: rueckkehrArt(mitglied.inactiveReason) === 'admin'
? 'badge-dead'
: 'badge-paid'
}`}
title={mitglied.inactiveNote ?? undefined}
>
{mitglied.inactiveReason ? REASON_LABEL[mitglied.inactiveReason] : 'Inaktiv'}
</span>
{canEdit && (
<button
type="button"
className="btn btn-tiny btn-ghost"
onClick={() => void zurueckholen(mitglied)}
>
Zurückholen
</button>
)}
</span>
))}
{ehemalig.length > 0 && (
<span className="muted small">
früher:{' '}
{ehemalig
.map((m) =>
m.successor ? `${m.player.displayName} (jetzt ${m.successor.name})` : m.player.displayName,
)
.join(', ')}
</span>
)}
</div>
);
}
function AddMemberModal({
character,
busy,
+109
View File
@@ -1103,3 +1103,112 @@ textarea {
opacity: 0.75;
border-style: dashed;
}
/* --- Ketten: ein Platz je Spalte, gelesen von unten nach oben --- */
.chains {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
gap: 1rem;
align-items: start;
}
.chain {
display: flex;
flex-direction: column;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.75rem;
}
.chain-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.6rem;
}
.chain-entry {
background: var(--bg-raised);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0.6rem 0.75rem;
cursor: grab;
}
.chain-entry:active {
cursor: grabbing;
}
.chain-entry.is-inactive {
opacity: 0.8;
border-style: dashed;
}
.chain-entry.is-dragging {
opacity: 0.4;
}
.chain-entry.is-resting {
border-color: var(--negative);
}
.chain-entry-head {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.chain-grip {
color: var(--text-muted);
cursor: grab;
letter-spacing: -0.1em;
}
/* Der Pfeil zeigt zum jüngeren Eintrag, also nach oben. */
.chain-arrow {
text-align: center;
color: var(--text-muted);
line-height: 1;
padding: 0.2rem 0;
}
/* Erscheint nur, solange etwas gezogen wird. */
.chain-drop {
border: 1px dashed var(--border-strong);
border-radius: var(--radius);
padding: 0.35rem;
margin: 0.2rem 0;
text-align: center;
font-size: 0.78rem;
color: var(--text-muted);
}
.chain-drop.is-over {
border-color: var(--accent);
border-style: solid;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.chain-actions {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.5rem;
}
.chain-links {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin: 0.4rem 0 0;
}
.chain-links a.badge {
color: var(--accent);
border-color: var(--accent);
}