Umlaute statt Umschreibungen

Oberflaeche, Fehlermeldungen und Kommentare schrieben durchgehend ae, oe, ue
und ss. Dafuer gab es keinen Grund - die Anwendung ist deutschsprachig und
laeuft durchgehend in UTF-8.

Betroffen waren 55 Dateien, darunter alle sichtbaren Texte: Guthaben-Zaehler,
Import, Nutzerverwaltung, Passwortwechsel und die Zwei-Faktor-Einrichtung.

Der Name einer Migration bleibt im README-Beispiel bewusst bei ASCII, weil
daraus ein Verzeichnisname wird.
This commit is contained in:
2026-09-18 16:57:39 +02:00
parent b9364151b0
commit 724f68644b
49 changed files with 256 additions and 254 deletions
+3 -3
View File
@@ -24,11 +24,11 @@ export default function BalanceCards({ balances }: { balances: Balances }) {
<div className="eligibility">
<span className={canCreate ? 'ok' : 'no'}>
{canCreate ? '✓' : '✕'} Neuer CC {canCreate ? 'moeglich' : 'nicht moeglich'}
{canCreate ? '✓' : '✕'} Neuer CC {canCreate ? 'möglich' : 'nicht möglich'}
</span>
<span className={canRedesign ? 'ok' : 'no'}>
{canRedesign ? '✓' : '✕'} Redesign {canRedesign ? 'moeglich' : 'nicht moeglich'}
{canRedesign && ` (${balances.redesignTotal} verfuegbar)`}
{canRedesign ? '✓' : '✕'} Redesign {canRedesign ? 'möglich' : 'nicht möglich'}
{canRedesign && ` (${balances.redesignTotal} verfügbar)`}
</span>
</div>
+1 -1
View File
@@ -28,7 +28,7 @@ export default function Modal({ title, onClose, children, footer }: ModalProps)
>
<div className="modal-head">
<h2>{title}</h2>
<button type="button" className="btn btn-ghost" onClick={onClose} aria-label="Schliessen">
<button type="button" className="btn btn-ghost" onClick={onClose} aria-label="Schließen">
✕
</button>
</div>
+2 -2
View File
@@ -47,10 +47,10 @@ export default function UnmatchedBox({
return (
<section className="block block-flag">
<h2>Ungeklaerte Eintraege ({items.length})</h2>
<h2>Ungeklärte Einträge ({items.length})</h2>
<Notice kind="warn">
Diese Pakete stammen aus dem GExtension-Abgleich und klingen nach CC oder Redesign,
liessen sich aber keiner Guthaben-Art sicher zuordnen. Sie werden erst gebucht, wenn du
ließen sich aber keiner Guthaben-Art sicher zuordnen. Sie werden erst gebucht, wenn du
sie hier einordnest.
</Notice>
+1 -1
View File
@@ -62,7 +62,7 @@ export async function api<T>(path: string, options: RequestOptions = {}): Promis
return payload as T;
}
/** Datei-Upload laeuft ohne JSON-Header, aber mit demselben CSRF-Schutz. */
/** Datei-Upload läuft ohne JSON-Header, aber mit demselben CSRF-Schutz. */
export async function uploadFile<T>(path: string, file: File): Promise<T> {
const form = new FormData();
form.append('file', file);
+1 -1
View File
@@ -16,7 +16,7 @@ export const SOURCE_LABEL: Record<CreditSource, string> = {
};
export const REASON_LABEL: Record<InactiveReason, string> = {
PLAYER_INACTIVITY: 'Spieler-Inaktivitaet',
PLAYER_INACTIVITY: 'Spieler-Inaktivität',
REWORKED: 'Redesignt',
OTHER: 'Anderer Grund',
};
+1 -1
View File
@@ -58,6 +58,6 @@ export function SessionProvider({ children }: { children: ReactNode }) {
export function useSession(): SessionState {
const context = useContext(SessionContext);
if (!context) throw new Error('useSession benoetigt einen SessionProvider');
if (!context) throw new Error('useSession benötigt einen SessionProvider');
return context;
}
+11 -11
View File
@@ -23,7 +23,7 @@ export default function Account() {
setError(null);
if (next !== repeat) {
setError('Die beiden neuen Passwoerter stimmen nicht ueberein');
setError('Die beiden neuen Passwörter stimmen nicht überein');
return;
}
@@ -37,10 +37,10 @@ export default function Account() {
setNext('');
setRepeat('');
await refresh();
setFlash('Passwort geaendert. Andere Sitzungen wurden beendet.');
setFlash('Passwort geändert. Andere Sitzungen wurden beendet.');
if (user?.mustChangePassword) navigate('/', { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Aenderung fehlgeschlagen');
setError(err instanceof ApiError ? err.message : 'Änderung fehlgeschlagen');
} finally {
setBusy(false);
}
@@ -97,8 +97,8 @@ export default function Account() {
{user?.mustChangePassword && (
<Notice kind="warn">
Dieses Konto verwendet noch ein vergebenes Erstpasswort. Bitte jetzt aendern — die
uebrigen Seiten sind bis dahin gesperrt.
Dieses Konto verwendet noch ein vergebenes Erstpasswort. Bitte jetzt ändern — die
übrigen Seiten sind bis dahin gesperrt.
</Notice>
)}
@@ -106,7 +106,7 @@ export default function Account() {
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
<section className="block">
<h2>Passwort aendern</h2>
<h2>Passwort ändern</h2>
<form onSubmit={changePassword}>
<label htmlFor="current">Aktuelles Passwort</label>
<input
@@ -128,7 +128,7 @@ export default function Account() {
required
/>
<p className="muted small">
Mindestens 12 Zeichen, Gross- und Kleinbuchstaben sowie eine Ziffer.
Mindestens 12 Zeichen, Groß- und Kleinbuchstaben sowie eine Ziffer.
</p>
<label htmlFor="repeat">Neues Passwort wiederholen</label>
@@ -142,7 +142,7 @@ export default function Account() {
/>
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Speichere …' : 'Passwort aendern'}
{busy ? 'Speichere …' : 'Passwort ändern'}
</button>
</form>
</section>
@@ -152,7 +152,7 @@ export default function Account() {
{user?.totpEnabled ? (
<>
<Notice kind="success">Zwei-Faktor-Anmeldung ist fuer dieses Konto aktiv.</Notice>
<Notice kind="success">Zwei-Faktor-Anmeldung ist für dieses Konto aktiv.</Notice>
<button type="button" className="btn btn-danger" onClick={() => void disableTotp()}>
Deaktivieren
</button>
@@ -164,7 +164,7 @@ export default function Account() {
</p>
<img className="qr" src={totpSetup.qr} alt="QR-Code zur Einrichtung" />
<p className="muted small">
Falls Scannen nicht geht, Schluessel manuell eintragen:{' '}
Falls Scannen nicht geht, Schlüssel manuell eintragen:{' '}
<code className="mono">{totpSetup.secret}</code>
</p>
@@ -190,7 +190,7 @@ export default function Account() {
) : (
<>
<p className="muted">
Schuetzt das Konto zusaetzlich mit einem Einmalcode. Fuer Administratoren empfohlen.
Schützt das Konto zusätzlich mit einem Einmalcode. Für Administratoren empfohlen.
</p>
<button type="button" className="btn" onClick={() => void startTotp()}>
Einrichten
+19 -19
View File
@@ -99,7 +99,7 @@ function UsersTab({ onError, onDone }: TabProps) {
const resetPassword = (target: AdminUser) => {
const password = window.prompt(
`Neues Passwort fuer "${target.username}" (min. 12 Zeichen, Gross/Klein, Ziffer):`,
`Neues Passwort für "${target.username}" (min. 12 Zeichen, Groß/Klein, Ziffer):`,
);
if (!password) return;
void act(
@@ -108,7 +108,7 @@ function UsersTab({ onError, onDone }: TabProps) {
method: 'POST',
body: { newPassword: password },
}),
`Passwort fuer "${target.username}" gesetzt. Muss beim naechsten Login geaendert werden.`,
`Passwort für "${target.username}" gesetzt. Muss beim nächsten Login geändert werden.`,
);
};
@@ -122,7 +122,7 @@ function UsersTab({ onError, onDone }: TabProps) {
</div>
<Notice kind="info">
Es gibt keine oeffentliche Registrierung. Konten werden ausschliesslich hier angelegt.
Es gibt keine öffentliche Registrierung. Konten werden ausschließlich hier angelegt.
</Notice>
<div className="table-wrap">
@@ -156,7 +156,7 @@ function UsersTab({ onError, onDone }: TabProps) {
method: 'PATCH',
body: { role: event.target.value as Role },
}),
`Rolle von "${row.username}" geaendert.`,
`Rolle von "${row.username}" geändert.`,
)
}
>
@@ -200,14 +200,14 @@ function UsersTab({ onError, onDone }: TabProps) {
type="button"
className="btn btn-tiny btn-danger"
onClick={() => {
if (!window.confirm(`Konto "${row.username}" wirklich loeschen?`)) return;
if (!window.confirm(`Konto "${row.username}" wirklich löschen?`)) return;
void act(
() => api(`/api/admin/users/${row.id}`, { method: 'DELETE' }),
`"${row.username}" geloescht.`,
`"${row.username}" gelöscht.`,
);
}}
>
Loeschen
Löschen
</button>
</>
)}
@@ -280,8 +280,8 @@ function CreateUserModal({
required
/>
<p className="muted small">
Min. 12 Zeichen, Gross- und Kleinbuchstaben, eine Ziffer. Muss beim ersten Login
geaendert werden.
Min. 12 Zeichen, Groß- und Kleinbuchstaben, eine Ziffer. Muss beim ersten Login
geändert werden.
</p>
<label htmlFor="new-role">Rolle</label>
@@ -342,13 +342,13 @@ function MappingsTab({ onError, onDone }: TabProps) {
};
const remove = async (row: ProductMapping) => {
if (!window.confirm(`Mapping "${row.matchPattern}" loeschen?`)) return;
if (!window.confirm(`Mapping "${row.matchPattern}" löschen?`)) return;
try {
await api(`/api/admin/product-mappings/${row.id}`, { method: 'DELETE' });
await load();
onDone('Mapping geloescht.');
onDone('Mapping gelöscht.');
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Loeschen fehlgeschlagen');
onError(err instanceof ApiError ? err.message : 'Löschen fehlgeschlagen');
}
};
@@ -356,8 +356,8 @@ function MappingsTab({ onError, onDone }: TabProps) {
<section className="block">
<h2>Produkt-Mappings</h2>
<Notice kind="info">
Beim Import wird der Produktname gegen diese Muster geprueft (Teiltreffer, Gross- und
Kleinschreibung egal). Das erste passende Muster mit der niedrigsten Prioritaet gewinnt.
Beim Import wird der Produktname gegen diese Muster geprüft (Teiltreffer, Groß- und
Kleinschreibung egal). Das erste passende Muster mit der niedrigsten Priorität gewinnt.
</Notice>
<form className="inline-form" onSubmit={add}>
@@ -373,7 +373,7 @@ function MappingsTab({ onError, onDone }: TabProps) {
<option value="IGNORE">Ignorieren</option>
</select>
<button type="submit" className="btn btn-primary">
Hinzufuegen
Hinzufügen
</button>
</form>
@@ -383,7 +383,7 @@ function MappingsTab({ onError, onDone }: TabProps) {
<tr>
<th>Muster</th>
<th>Bedeutung</th>
<th className="num">Prioritaet</th>
<th className="num">Priorität</th>
<th>Aktiv</th>
<th />
</tr>
@@ -401,7 +401,7 @@ function MappingsTab({ onError, onDone }: TabProps) {
className="btn btn-tiny btn-danger"
onClick={() => void remove(row)}
>
Loeschen
Löschen
</button>
</td>
</tr>
@@ -489,7 +489,7 @@ function AuditTab({ onError }: { onError: (message: string) => void }) {
{items.length === 0 && (
<tr>
<td colSpan={5} className="empty">
Keine Eintraege.
Keine Einträge.
</td>
</tr>
)}
@@ -499,7 +499,7 @@ function AuditTab({ onError }: { onError: (message: string) => void }) {
{total > items.length && (
<p className="muted">
{items.length} von {total} Eintraegen angezeigt.
{items.length} von {total} Einträgen angezeigt.
</p>
)}
</section>
+7 -7
View File
@@ -49,7 +49,7 @@ export default function Dashboard() {
try {
setFlagged(await api<UnmatchedOverview>('/api/scrape/unmatched/overview'));
} catch {
// Der Hinweis ist Beiwerk - ein Fehler hier darf die Liste nicht stoeren.
// Der Hinweis ist Beiwerk - ein Fehler hier darf die Liste nicht stören.
}
}, []);
@@ -62,9 +62,9 @@ export default function Dashboard() {
setSyncResult(null);
try {
const result = await api<ScrapeSummary>('/api/scrape/run', { method: 'POST', body: {} });
const parts = [`${result.scannedPlayers} Profile geprueft`];
if (result.newRows > 0) parts.push(`${result.newRows} neue Pakete zur Bestaetigung`);
if (result.unclear > 0) parts.push(`${result.unclear} ungeklaert`);
const parts = [`${result.scannedPlayers} Profile geprüft`];
if (result.newRows > 0) parts.push(`${result.newRows} neue Pakete zur Bestätigung`);
if (result.unclear > 0) parts.push(`${result.unclear} ungeklärt`);
if (result.duplicates > 0) parts.push(`${result.duplicates} schon gebucht`);
if (result.failedPlayers.length > 0) parts.push(`${result.failedPlayers.length} nicht erreichbar`);
setSyncResult(parts.join(', ') + '.');
@@ -76,7 +76,7 @@ export default function Dashboard() {
}
};
// Tippen entprellen, damit nicht jeder Anschlag eine Anfrage ausloest.
// Tippen entprellen, damit nicht jeder Anschlag eine Anfrage auslöst.
useEffect(() => {
const controller = new AbortController();
const timer = setTimeout(() => void load(query, controller.signal), 250);
@@ -119,7 +119,7 @@ export default function Dashboard() {
{flagged.total > 0 && (
<Notice kind="warn">
<strong>
{flagged.total} ungeklaerte {flagged.total === 1 ? 'Eintrag' : 'Eintraege'} bei{' '}
{flagged.total} ungeklärte {flagged.total === 1 ? 'Eintrag' : 'Einträge'} bei{' '}
{flagged.players.length} {flagged.players.length === 1 ? 'Spieler' : 'Spielern'}:
</strong>{' '}
{flagged.players.map((entry, index) => (
@@ -251,7 +251,7 @@ function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCrea
placeholder="76561198... oder STEAM_0:1:..."
/>
<p className="muted small">
Beide Schreibweisen sind moeglich, auch <code className="mono">[U:1:...]</code>.
Beide Schreibweisen sind möglich, auch <code className="mono">[U:1:...]</code>.
Gespeichert wird immer die 64-Bit-Form, damit derselbe Spieler nicht doppelt anlegt.
</p>
+8 -8
View File
@@ -29,7 +29,7 @@ const EMPTY_MAPPING: MappingForm = {
createMissingPlayers: false,
};
// Trifft die ueblichen Spaltennamen, damit das Formular meist schon passt.
// Trifft die üblichen Spaltennamen, damit das Formular meist schon passt.
const GUESSES: Record<keyof Omit<MappingForm, 'createMissingPlayers'>, string[]> = {
playerKey: ['steamid', 'steam_id', 'steam', 'identifier', 'user', 'kunde'],
playerName: ['name', 'username', 'displayname', 'spieler'],
@@ -158,8 +158,8 @@ export default function Import() {
<section className="block">
<Notice kind="info">
CSV oder JSON hochladen. Danach werden die Spalten zugeordnet und eine Vorschau
gezeigt — gebucht wird erst nach Bestaetigung. Bereits importierte Bestell-IDs
werden automatisch uebersprungen.
gezeigt — gebucht wird erst nach Bestätigung. Bereits importierte Bestell-IDs
werden automatisch übersprungen.
</Notice>
<label className="file-drop">
@@ -172,7 +172,7 @@ export default function Import() {
}}
disabled={busy}
/>
<span>{busy ? 'Lade hoch …' : 'Datei auswaehlen'}</span>
<span>{busy ? 'Lade hoch …' : 'Datei auswählen'}</span>
</label>
</section>
)}
@@ -207,7 +207,7 @@ export default function Import() {
onChange={(value) => setMapping({ ...mapping, playerName: value })}
/>
<MappingSelect
label="Bestell-ID (fuer Dedup)"
label="Bestell-ID (für Dedup)"
value={mapping.orderId}
columns={upload.columns}
onChange={(value) => setMapping({ ...mapping, orderId: value })}
@@ -233,7 +233,7 @@ export default function Import() {
{!mapping.orderId && (
<Notice kind="warn">
Ohne Bestell-ID kann ein zweiter Import dieselben Kaeufe erneut buchen.
Ohne Bestell-ID kann ein zweiter Import dieselben Käufe erneut buchen.
</Notice>
)}
@@ -293,7 +293,7 @@ export default function Import() {
{bookable === 0 ? (
<Notice kind="warn">
Keine Zeile wuerde gebucht. Pruefe die Spaltenzuordnung oder die Produkt-Mappings
Keine Zeile würde gebucht. Prüfe die Spaltenzuordnung oder die Produkt-Mappings
in der Verwaltung.
</Notice>
) : (
@@ -342,7 +342,7 @@ export default function Import() {
Verwerfen
</button>
<button type="button" className="btn btn-ghost" onClick={() => setPreview(null)}>
Zuordnung aendern
Zuordnung ändern
</button>
<button
type="button"
+3 -3
View File
@@ -75,7 +75,7 @@ export default function Login() {
<div className="login-card">
<h1>CC-Manager</h1>
<p className="login-sub">
{challenge ? 'Bestaetigungscode eingeben' : 'Bitte anmelden'}
{challenge ? 'Bestätigungscode eingeben' : 'Bitte anmelden'}
</p>
{error && <Notice kind="error">{error}</Notice>}
@@ -94,7 +94,7 @@ export default function Login() {
required
/>
<button type="submit" className="btn btn-primary btn-block" disabled={busy}>
{busy ? 'Pruefe …' : 'Bestaetigen'}
{busy ? 'Prüfe …' : 'Bestätigen'}
</button>
<button
type="button"
@@ -105,7 +105,7 @@ export default function Login() {
setError(null);
}}
>
Zurueck
Zurück
</button>
</form>
) : (
+18 -18
View File
@@ -215,7 +215,7 @@ export default function PlayerDetail() {
className="btn btn-small btn-danger"
onClick={() => setDialog({ type: 'revert', character })}
>
Zuruecknehmen
Zurücknehmen
</button>
</div>
)}
@@ -254,7 +254,7 @@ export default function PlayerDetail() {
)}
<p className="muted small">
{free
? 'Kommt gratis zurueck — kostet kein Guthaben.'
? 'Kommt gratis zurück — kostet kein Guthaben.'
: 'Reaktivierung kostet ein Redesign- oder CC-Guthaben.'}
</p>
</div>
@@ -363,7 +363,7 @@ export default function PlayerDetail() {
<CharacterFormModal
title="Neuen CC anlegen"
submitLabel="Anlegen"
hint="Kostet 1 CC-Guthaben. Kulanz-Guthaben ist dafuer nicht einsetzbar."
hint="Kostet 1 CC-Guthaben. Kulanz-Guthaben ist dafür nicht einsetzbar."
busy={busy}
onClose={() => setDialog({ type: 'none' })}
onSubmit={(values) =>
@@ -382,7 +382,7 @@ export default function PlayerDetail() {
{dialog.type === 'redesign' && (
<CharacterFormModal
title={`Redesign von "${dialog.character.name}"`}
submitLabel="Redesign durchfuehren"
submitLabel="Redesign durchführen"
hint={`"${dialog.character.name}" wird inaktiv (redesignt), der neue CC tritt an seine Stelle.`}
busy={busy}
withKind={['REDESIGN_KULANZ', 'REDESIGN_PAID']}
@@ -442,9 +442,9 @@ export default function PlayerDetail() {
{dialog.type === 'revert' && (
<ReasonModal
title={`"${dialog.character.name}" zuruecknehmen`}
hint="Der CC wird geloescht, verbrauchte Guthaben werden zurueckgebucht. Fuer Fehleintraege gedacht."
confirmLabel="Zuruecknehmen"
title={`"${dialog.character.name}" zurücknehmen`}
hint="Der CC wird gelöscht, verbrauchte Guthaben werden zurückgebucht. Für Fehleinträge gedacht."
confirmLabel="Zurücknehmen"
danger
busy={busy}
onClose={() => setDialog({ type: 'none' })}
@@ -455,7 +455,7 @@ export default function PlayerDetail() {
method: 'POST',
body: { reason },
}),
`"${dialog.character.name}" wurde zurueckgenommen.`,
`"${dialog.character.name}" wurde zurückgenommen.`,
)
}
/>
@@ -562,7 +562,7 @@ function CharacterFormModal({
{KIND_LABEL[kind]}
<span className={balances[kind] < 1 ? 'neg' : 'muted'}>
{' '}
({balances[kind]} verfuegbar)
({balances[kind]} verfügbar)
</span>
</span>
</label>
@@ -627,7 +627,7 @@ function KindChoiceModal({
{KIND_LABEL[kind]}
<span className={balances[kind] < 1 ? 'neg' : 'muted'}>
{' '}
({balances[kind]} verfuegbar)
({balances[kind]} verfügbar)
</span>
</span>
</label>
@@ -639,7 +639,7 @@ function KindChoiceModal({
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Speichere …' : 'Bestaetigen'}
{busy ? 'Speichere …' : 'Bestätigen'}
</button>
</div>
</form>
@@ -670,8 +670,8 @@ function DeactivateModal({
}}
>
<Notice kind="info">
Der CC bleibt unveraendert und kommt spaeter in beiden Faellen gratis zurueck. Nur ein
Redesign macht ihn kostenpflichtig — das laeuft aber ueber die Redesign-Aktion.
Der CC bleibt unverändert und kommt später in beiden Fällen gratis zurück. Nur ein
Redesign macht ihn kostenpflichtig — das läuft aber über die Redesign-Aktion.
</Notice>
<div className="radio-group">
@@ -683,8 +683,8 @@ function DeactivateModal({
onChange={() => setReason('PLAYER_INACTIVITY')}
/>
<span>
<strong>Spieler-Inaktivitaet</strong>
<span className="muted"> — vom Server geputzt, weil der Spieler laenger weg war</span>
<strong>Spieler-Inaktivität</strong>
<span className="muted"> — vom Server geputzt, weil der Spieler länger weg war</span>
</span>
</label>
<label className="radio">
@@ -696,7 +696,7 @@ function DeactivateModal({
/>
<span>
<strong>Anderer Grund</strong>
<span className="muted"> — etwa eine voruebergehend entfernte Einheit</span>
<span className="muted"> — etwa eine vorübergehend entfernte Einheit</span>
</span>
</label>
</div>
@@ -708,7 +708,7 @@ function DeactivateModal({
id="deactivate-note"
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="z. B. Einheit bis Maerz entfernt"
placeholder="z. B. Einheit bis März entfernt"
/>
<div className="form-actions">
@@ -753,7 +753,7 @@ function ReasonModal({
>
<Notice kind={danger ? 'warn' : 'info'}>{hint}</Notice>
<label htmlFor="reason">Begruendung</label>
<label htmlFor="reason">Begründung</label>
<input
id="reason"
value={reason}