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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user