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
+7 -1
View File
@@ -198,7 +198,13 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/auth/me', async (request) => {
if (!request.user) return { user: null };
return { user: request.user };
// Die Adressen der fremden Dienste kommen mit, damit die Oberfläche
// Profil-Links bauen kann, ohne sie ein zweites Mal zu kennen.
return {
user: request.user,
gextensionBaseUrl: env.GEXTENSION_BASE_URL,
forumBaseUrl: env.FORUM_BASE_URL,
};
});
// --- Eigener Account ---
+62
View File
@@ -19,6 +19,11 @@ import {
deactivateMember,
reactivateMember,
redesignMembership,
addLink,
updateLink,
removeLink,
moveInChain,
detachFromChain,
} from '../services/characters.js';
import { parseThreadId } from '../lib/forum.js';
import { linkThreadToCharacter } from '../services/forum.js';
@@ -140,6 +145,63 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
return reviveDeadCharacter(id, body, contextOf(request));
});
// --- Verweise ---
app.post('/api/characters/:id/links', { preHandler: requireEditor }, async (request, reply) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
label: z.string().trim().min(1).max(120),
url: z.string().trim().min(1).max(2000),
}),
request.body,
);
reply.code(201);
return addLink(id, body, contextOf(request));
});
app.patch('/api/characters/links/:id', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
label: z.string().trim().min(1).max(120).optional(),
url: z.string().trim().min(1).max(2000).optional(),
}),
request.body,
);
return updateLink(id, body, contextOf(request));
});
app.delete('/api/characters/links/:id', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
await removeLink(id, contextOf(request));
return { ok: true };
});
// --- Ketten ---
/**
* Schiebt einen CC in eine Kette, hinter ein bestimmtes Glied oder an den
* Anfang. Für das Nachtragen von Lücken, die sich erst später auftun.
*/
app.post('/api/characters/:id/move', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
lineageId: z.string().trim().min(1).max(64),
afterId: z.number().int().positive().nullable().optional(),
}),
request.body,
);
return moveInChain(id, body, contextOf(request));
});
/** Löst einen CC aus seiner Kette und stellt ihn allein. */
app.post('/api/characters/:id/detach', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
return detachFromChain(id, contextOf(request));
});
// --- Gruppen-CCs ---
app.get('/api/characters/:id/members', { preHandler: requireAuth }, async (request) => {
+25 -1
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { requireEditor } from '../auth/guard.js';
import { requireAdmin, requireEditor } from '../auth/guard.js';
import { badRequest } from '../errors.js';
import {
applyMapping,
@@ -14,6 +14,7 @@ import {
dismissRows,
pendingOverview,
} from '../services/imports.js';
import { exportAll, importAll } from '../services/backup.js';
import { contextOf, idParam, parseWith } from './helpers.js';
const mappingBody = z.object({
@@ -44,6 +45,29 @@ export async function importRoutes(app: FastifyInstance): Promise<void> {
};
});
/**
* Der gesamte Bestand als eine Tabelle - für den Umzug auf einen anderen
* Server oder den Wechsel auf ein anderes Programm.
*/
app.get('/api/backup/export', { preHandler: requireAdmin }, async (_request, reply) => {
const inhalt = await exportAll();
const datum = new Date().toISOString().slice(0, 10);
reply.header('content-type', 'text/csv; charset=utf-8');
reply.header('content-disposition', `attachment; filename="cc-manager-${datum}.csv"`);
// Das Byte-Order-Mark, damit Excel die Umlaute richtig liest.
return `\uFEFF${inhalt}`;
});
/** Spielt eine zuvor exportierte Tabelle ein und ergänzt, was fehlt. */
app.post('/api/backup/import', { preHandler: requireAdmin }, async (request) => {
const datei = await request.file();
if (!datei) throw badRequest('NO_FILE', 'Es wurde keine Datei übertragen');
const inhalt = (await datei.toBuffer()).toString('utf8');
return importAll(inhalt, contextOf(request));
});
/** Wie viele Pakete insgesamt noch auf eine Buchung warten. */
app.get('/api/imports/pending/overview', { preHandler: requireEditor }, async () => {
return pendingOverview();