frontend_nyla/src/pages/admin/AdminLanguagesPage.tsx
2026-04-10 12:33:32 +02:00

766 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* SysAdmin: UI language sets (DB-backed i18n).
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FaDownload, FaFileExport, FaFileImport, FaRedo, FaSync, FaTrash } from 'react-icons/fa';
import api from '../../api';
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable/FormGeneratorTable';
import { useConfirm } from '../../hooks/useConfirm';
import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './Admin.module.css';
type LangRow = {
id: string;
label: string;
status: string;
entriesCount: number;
uiCount: number;
gatewayCount: number;
};
type ProgressInfo = {
message: string;
current: number;
total: number;
error?: string;
done?: boolean;
/** Short title (e.g. language label) to avoid long sentences in the overlay. */
progressHeading?: string;
/** Keys in language set before sync (from sync-diff). */
keysCurrent?: number;
/** New keys vs xx before PUT (from sync-diff). */
keysPending?: number;
/** Master (xx) key count (from sync-diff). */
keysMasterTotal?: number;
/** Keys AI-translated in last PUT (optional). */
keysTranslated?: number;
};
function _getColumns(t: (key: string) => string): ColumnConfig[] {
return [
{ key: 'id', label: t('adminLanguages.code'), type: 'text', sortable: true, filterable: true, width: 90 },
{ key: 'label', label: t('adminLanguages.bezeichnung'), type: 'text', sortable: true, filterable: true, width: 200 },
{ key: 'status', label: t('adminLanguages.status'), type: 'text', sortable: true, filterable: true, width: 120 },
{ key: 'uiCount', label: t('adminLanguages.ui'), type: 'number', sortable: true, width: 80 },
{ key: 'gatewayCount', label: t('adminLanguages.api'), type: 'number', sortable: true, width: 80 },
{ key: 'entriesCount', label: t('adminLanguages.total'), type: 'number', sortable: true, width: 80 },
];
}
const _PRIORITY_CODES = ['de', 'en', 'fr', 'it'];
const _isoChoices: { value: string; label: string }[] = [
{ value: 'de', label: 'de — Deutsch' }, { value: 'en', label: 'en — English' },
{ value: 'fr', label: 'fr — Français' }, { value: 'it', label: 'it — Italiano' },
{ value: 'es', label: 'es — Español' }, { value: 'pt', label: 'pt — Português' },
{ value: 'nl', label: 'nl — Nederlands' }, { value: 'pl', label: 'pl — Polski' },
{ value: 'cs', label: 'cs — Čeština' }, { value: 'sk', label: 'sk — Slovenčina' },
{ value: 'sv', label: 'sv — Svenska' }, { value: 'no', label: 'no — Norsk' },
{ value: 'da', label: 'da — Dansk' }, { value: 'fi', label: 'fi — Suomi' },
{ value: 'hu', label: 'hu — Magyar' }, { value: 'ro', label: 'ro — Română' },
{ value: 'bg', label: 'bg — Български' }, { value: 'hr', label: 'hr — Hrvatski' },
{ value: 'sl', label: 'sl — Slovenščina' }, { value: 'et', label: 'et — Eesti' },
{ value: 'lv', label: 'lv — Latviešu' }, { value: 'lt', label: 'lt — Lietuvių' },
{ value: 'el', label: 'el — Ελληνικά' }, { value: 'tr', label: 'tr — Türkçe' },
{ value: 'ru', label: 'ru — Русский' }, { value: 'uk', label: 'uk — Українська' },
{ value: 'ar', label: 'ar — العربية' }, { value: 'he', label: 'he — עברית' },
{ value: 'zh', label: 'zh — 中文' }, { value: 'ja', label: 'ja — 日本語' },
{ value: 'ko', label: 'ko — 한국어' }, { value: 'hi', label: 'hi — हिन्दी' },
{ value: 'th', label: 'th — ไทย' }, { value: 'vi', label: 'vi — Tiếng Việt' },
{ value: 'id', label: 'id — Bahasa Indonesia' }, { value: 'ms', label: 'ms — Bahasa Melayu' },
{ value: 'tl', label: 'tl — Filipino' }, { value: 'sw', label: 'sw — Kiswahili' },
{ value: 'af', label: 'af — Afrikaans' }, { value: 'sq', label: 'sq — Shqip' },
{ value: 'am', label: 'am — አማርኛ' }, { value: 'hy', label: 'hy — Հայերեն' },
{ value: 'az', label: 'az — Azərbaycan' }, { value: 'eu', label: 'eu — Euskara' },
{ value: 'be', label: 'be — Беларуская' }, { value: 'bn', label: 'bn — বাংলা' },
{ value: 'bs', label: 'bs — Bosanski' }, { value: 'ca', label: 'ca — Català' },
{ value: 'cy', label: 'cy — Cymraeg' }, { value: 'eo', label: 'eo — Esperanto' },
{ value: 'fa', label: 'fa — فارسی' }, { value: 'ga', label: 'ga — Gaeilge' },
{ value: 'gl', label: 'gl — Galego' }, { value: 'gu', label: 'gu — ગુજરાતી' },
{ value: 'ha', label: 'ha — Hausa' }, { value: 'is', label: 'is — Íslenska' },
{ value: 'jv', label: 'jv — Basa Jawa' }, { value: 'ka', label: 'ka — ქართული' },
{ value: 'kk', label: 'kk — Қазақ' }, { value: 'km', label: 'km — ខ្មែរ' },
{ value: 'kn', label: 'kn — ಕನ್ನಡ' }, { value: 'ku', label: 'ku — Kurdî' },
{ value: 'ky', label: 'ky — Кыргызча' }, { value: 'la', label: 'la — Latina' },
{ value: 'lb', label: 'lb — Lëtzebuergesch' }, { value: 'lo', label: 'lo — ລາວ' },
{ value: 'mk', label: 'mk — Македонски' }, { value: 'ml', label: 'ml — മലയാളം' },
{ value: 'mn', label: 'mn — Монгол' }, { value: 'mr', label: 'mr — मराठी' },
{ value: 'mt', label: 'mt — Malti' }, { value: 'my', label: 'my — မြန်မာ' },
{ value: 'ne', label: 'ne — नेपाली' }, { value: 'or', label: 'or — ଓଡ଼ିଆ' },
{ value: 'pa', label: 'pa — ਪੰਜਾਬੀ' }, { value: 'ps', label: 'ps — پښتو' },
{ value: 'si', label: 'si — සිංහල' }, { value: 'so', label: 'so — Soomaali' },
{ value: 'sr', label: 'sr — Српски' }, { value: 'su', label: 'su — Basa Sunda' },
{ value: 'ta', label: 'ta — தமிழ்' }, { value: 'te', label: 'te — తెలుగు' },
{ value: 'tg', label: 'tg — Тоҷикӣ' }, { value: 'tk', label: 'tk — Türkmen' },
{ value: 'ur', label: 'ur — اردو' }, { value: 'uz', label: 'uz — Oʻzbek' },
{ value: 'yo', label: 'yo — Yorùbá' }, { value: 'zu', label: 'zu — isiZulu' },
];
// ---------------------------------------------------------------------------
// Progress overlay component
// ---------------------------------------------------------------------------
const _ProgressOverlay: React.FC<{ progress: ProgressInfo }> = ({ progress }) => {
const master = progress.keysMasterTotal;
const pending = progress.keysPending ?? 0;
const cur = progress.keysCurrent ?? 0;
const translated = progress.keysTranslated;
const hasMaster = master != null && master > 0;
const entryJ = pending > 0 ? (translated ?? 0) : cur;
const entryTotal = pending > 0 ? pending : (master ?? 0);
const showEntryMeter = hasMaster && entryTotal > 0;
const awaitingAi =
pending > 0 &&
translated === undefined &&
!progress.done &&
!progress.error;
const [softEntryPct, setSoftEntryPct] = useState(0);
useEffect(() => {
if (!awaitingAi) {
setSoftEntryPct(0);
return;
}
const started = Date.now();
const id = window.setInterval(() => {
const elapsed = Date.now() - started;
const cap = 94;
setSoftEntryPct(Math.min(cap, cap * (1 - Math.exp(-elapsed / 14000))));
}, 220);
return () => clearInterval(id);
}, [awaitingAi]);
let entryPct = 0;
if (pending > 0) {
if (translated !== undefined && pending > 0) {
entryPct = Math.min(100, Math.round((translated / pending) * 100));
}
} else if (master && master > 0) {
entryPct = Math.min(100, Math.round((cur / master) * 100));
}
const entryBarPct = awaitingAi ? softEntryPct : entryPct;
const entryJShown = awaitingAi
? Math.min(pending, Math.max(0, Math.floor((softEntryPct / 100) * pending)))
: entryJ;
const showLangMeter = progress.total > 1 || !showEntryMeter;
const pctLang = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0;
const title = progress.progressHeading ?? progress.message;
const barBg = progress.error ? 'var(--error-color, #c53030)' : 'var(--primary-color, #3182ce)';
return (
<div
style={{
position: 'absolute',
inset: 0,
background: 'rgba(var(--bg-rgb, 255,255,255), 0.85)',
backdropFilter: 'blur(2px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 50,
borderRadius: 'var(--border-radius, 8px)',
}}
>
<div
style={{
background: 'var(--card-bg, #fff)',
border: '1px solid var(--border-color, #ddd)',
borderRadius: 'var(--border-radius, 8px)',
padding: '2rem 2.5rem',
minWidth: 320,
maxWidth: 420,
boxShadow: '0 4px 24px rgba(0,0,0,0.12)',
textAlign: 'center',
}}
>
<p style={{ fontWeight: 600, fontSize: '1.05rem', margin: '0 0 1rem', lineHeight: 1.3 }}>
{title}
</p>
{showLangMeter && (
<>
<p
style={{
margin: '0 0 0.35rem',
fontSize: '1rem',
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
color: 'var(--text-primary, #1e293b)',
}}
>
{progress.current} / {progress.total}
</p>
<div
style={{
width: '100%',
height: 8,
background: 'var(--border-color, #e2e8f0)',
borderRadius: 4,
overflow: 'hidden',
marginBottom: showEntryMeter ? '0.85rem' : 0,
}}
>
<div
style={{
width: `${pctLang}%`,
height: '100%',
background: barBg,
borderRadius: 4,
transition: 'width 0.3s ease',
}}
/>
</div>
</>
)}
{showEntryMeter && (
<>
<p
style={{
margin: '0 0 0.35rem',
fontSize: '1rem',
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
color: 'var(--text-primary, #1e293b)',
}}
>
{entryJShown} / {entryTotal}
</p>
<div className={styles.i18nKeysProgressTrack}>
<div
className={styles.i18nKeysProgressFill}
style={{
width: `${entryBarPct}%`,
background: barBg,
transition: awaitingAi ? 'width 0.25s ease-out' : 'width 0.35s ease',
}}
/>
</div>
</>
)}
{progress.error && (
<p style={{ color: 'var(--error-color, #c53030)', fontSize: '0.85rem', marginTop: '0.75rem' }}>
{progress.error}
</p>
)}
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export const AdminLanguagesPage: React.FC = () => {
const { t, reloadLanguage, refreshAvailableLanguages } = useLanguage();
const { confirm, ConfirmDialog } = useConfirm();
const [rows, setRows] = useState<LangRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [addCode, setAddCode] = useState('');
const [progress, setProgress] = useState<ProgressInfo | null>(null);
const [search, setSearch] = useState('');
const busyRef = useRef(false);
const _load = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await api.get('/api/i18n/codes');
const list = (res.data || []) as any[];
setRows(
list.map((r) => ({
id: r.code,
label: r.label || r.code,
status: r.status || '',
entriesCount: r.entriesCount ?? 0,
uiCount: r.uiCount ?? 0,
gatewayCount: r.gatewayCount ?? 0,
})),
);
} catch (e: any) {
setError(e.response?.data?.detail || e.message || 'Laden fehlgeschlagen');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
_load();
}, [_load]);
const displayRows = useMemo(() => {
const term = search.trim().toLowerCase();
const filtered = term
? rows.filter((r) => r.id.toLowerCase().includes(term) || r.label.toLowerCase().includes(term) || r.status.toLowerCase().includes(term))
: rows;
return [...filtered].sort((a, b) => {
if (a.id === 'xx') return -1;
if (b.id === 'xx') return 1;
return a.id.localeCompare(b.id);
});
}, [rows, search]);
const existingCodes = useMemo(() => new Set(rows.map((r) => r.id)), [rows]);
const addChoices = useMemo(() => {
const available = _isoChoices.filter((c) => !existingCodes.has(c.value));
available.sort((a, b) => {
const aPrio = _PRIORITY_CODES.indexOf(a.value);
const bPrio = _PRIORITY_CODES.indexOf(b.value);
if (aPrio !== -1 && bPrio !== -1) return aPrio - bPrio;
if (aPrio !== -1) return -1;
if (bPrio !== -1) return 1;
return a.label.localeCompare(b.label);
});
return available;
}, [existingCodes]);
useEffect(() => {
if (addChoices.length > 0 && (!addCode || !addChoices.find((c) => c.value === addCode))) {
setAddCode(addChoices[0].value);
}
}, [addChoices, addCode]);
const _fetchI18nEntriesFromBundle = useCallback(async (): Promise<any[]> => {
const base = import.meta.env.BASE_URL || '/';
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
const res = await fetch(`${normalizedBase}i18n-keys.json`);
if (!res.ok) {
throw new Error(
t('i18n-keys.json nicht gefunden. Bitte Frontend neu bauen oder Dev-Server starten.'),
);
}
const data = await res.json();
if (!Array.isArray(data)) {
throw new Error(t('Ungültiges i18n-keys.json'));
}
return data;
}, [t]);
// --- Actions with progress ------------------------------------------------
const _syncXx = async () => {
if (busyRef.current) return;
busyRef.current = true;
setError(null);
setProgress({ message: t('Basisset wird eingelesen…'), current: 0, total: 1 });
try {
const entries = await _fetchI18nEntriesFromBundle();
const res = await api.put('/api/i18n/sets/sync-xx', { entries });
const d = res.data || {};
const addedCount = d.added?.length ?? 0;
const removedCount = d.removed?.length ?? 0;
const entriesCount = d.entriesCount ?? 0;
setProgress({
message: t('Basisset synchronisiert: {added} neu, {removed} entfernt, {total} Einträge.', {
added: String(addedCount),
removed: String(removedCount),
total: String(entriesCount),
}),
current: 1,
total: 1,
done: true,
});
await _load();
await refreshAvailableLanguages();
await reloadLanguage();
} catch (e: any) {
const msg = e.response?.data?.detail || e.message;
setProgress({ message: t('Fehler beim Einlesen'), current: 0, total: 1, error: msg, done: true });
setError(msg);
} finally {
setTimeout(() => { setProgress(null); busyRef.current = false; }, 2500);
}
};
const _updateOne = async (code: string) => {
if (busyRef.current) return;
busyRef.current = true;
setError(null);
const label = rows.find((r) => r.id === code)?.label || code;
let keysCurrent: number | undefined;
let keysPending: number | undefined;
let keysMasterTotal: number | undefined;
try {
const dr = await api.get(`/api/i18n/sets/${encodeURIComponent(code)}/sync-diff`);
keysCurrent = dr.data?.currentEntryCount;
keysPending = dr.data?.addedCount;
keysMasterTotal = dr.data?.masterEntryCount;
} catch {
/* sync-diff optional */
}
setProgress({
message: t('Aktualisiere {lang}…', { lang: label }),
progressHeading: label,
current: 0,
total: 1,
keysCurrent,
keysPending,
keysMasterTotal,
});
try {
const putRes = await api.put(`/api/i18n/sets/${encodeURIComponent(code)}`);
const d = putRes.data || {};
const pendingAfterPut = Array.isArray(d.added) ? d.added.length : (keysPending ?? 0);
setProgress({
message: t('{lang} aktualisiert.', { lang: label }),
progressHeading: label,
current: 1,
total: 1,
done: true,
keysCurrent: typeof d.entriesCount === 'number' ? d.entriesCount : keysCurrent,
keysPending: pendingAfterPut,
keysMasterTotal,
keysTranslated: typeof d.translated === 'number' ? d.translated : undefined,
});
await _load();
await refreshAvailableLanguages();
await reloadLanguage();
} catch (e: any) {
const msg = e.response?.data?.detail || e.message;
setProgress({
message: t('Fehler bei {lang}', { lang: label }),
progressHeading: label,
current: 0,
total: 1,
error: msg,
done: true,
});
setError(msg);
} finally {
setTimeout(() => { setProgress(null); busyRef.current = false; }, 2000);
}
};
const _updateAll = async () => {
if (busyRef.current) return;
const ok = await confirm(t('Alle Sprachsets jetzt mit dem Basisset synchronisieren und per KI aktualisieren?'), {
confirmLabel: t('Alle aktualisieren'),
cancelLabel: t('Abbrechen'),
});
if (!ok) return;
busyRef.current = true;
setError(null);
const langCodes = rows.filter((r) => r.id !== 'xx').map((r) => r.id);
const totalSteps = 1 + langCodes.length;
let step = 0;
setProgress({ message: t('Basisset wird eingelesen…'), current: step, total: totalSteps });
try {
const entries = await _fetchI18nEntriesFromBundle();
await api.put('/api/i18n/sets/sync-xx', { entries });
step++;
setProgress({ message: t('Basisset synchronisiert.'), current: step, total: totalSteps });
} catch (e: any) {
const msg = e.response?.data?.detail || e.message;
setProgress({ message: t('Fehler beim Basisset'), current: step, total: totalSteps, error: msg, done: true });
setError(msg);
setTimeout(() => { setProgress(null); busyRef.current = false; }, 3000);
return;
}
const errors: string[] = [];
for (const code of langCodes) {
const label = rows.find((r) => r.id === code)?.label || code;
let keysCurrent: number | undefined;
let keysPending: number | undefined;
let keysMasterTotal: number | undefined;
try {
const dr = await api.get(`/api/i18n/sets/${encodeURIComponent(code)}/sync-diff`);
keysCurrent = dr.data?.currentEntryCount;
keysPending = dr.data?.addedCount;
keysMasterTotal = dr.data?.masterEntryCount;
} catch {
/* sync-diff optional */
}
setProgress({
message: t('Aktualisiere {lang}…', { lang: label }),
current: step + 1,
total: totalSteps,
keysCurrent,
keysPending,
keysMasterTotal,
});
try {
const putRes = await api.put(`/api/i18n/sets/${encodeURIComponent(code)}`);
const d = putRes.data || {};
const pendingAfterPut = Array.isArray(d.added) ? d.added.length : (keysPending ?? 0);
setProgress({
message: t('Aktualisiere {lang}…', { lang: label }),
progressHeading: label,
current: step + 1,
total: totalSteps,
keysCurrent: typeof d.entriesCount === 'number' ? d.entriesCount : keysCurrent,
keysPending: pendingAfterPut,
keysMasterTotal,
keysTranslated: typeof d.translated === 'number' ? d.translated : undefined,
});
} catch (e: any) {
errors.push(`${code}: ${e.response?.data?.detail || e.message}`);
}
step++;
}
if (errors.length > 0) {
const errMsg = errors.join('; ');
setProgress({
message: t('{ok} von {total} Sprachen aktualisiert.', { ok: String(langCodes.length - errors.length), total: String(langCodes.length) }),
current: step,
total: totalSteps,
error: errMsg,
done: true,
});
setError(errMsg);
} else {
setProgress({
message: t('Alle {count} Sprachen erfolgreich aktualisiert.', { count: String(langCodes.length) }),
current: totalSteps,
total: totalSteps,
done: true,
});
}
await _load();
await refreshAvailableLanguages();
await reloadLanguage();
setTimeout(() => { setProgress(null); busyRef.current = false; }, errors.length > 0 ? 5000 : 2500);
};
// --- Other actions (unchanged logic, but with busy guard) -----------------
const _delete = async (code: string) => {
if (busyRef.current) return;
if (code === 'xx') return;
const ok = await confirm(t('Sprachset {code} wirklich löschen?', { code }), {
confirmLabel: t('Löschen'),
cancelLabel: t('Abbrechen'),
variant: 'danger',
});
if (!ok) return;
try {
await api.delete(`/api/i18n/sets/${encodeURIComponent(code)}`);
await _load();
await refreshAvailableLanguages();
await reloadLanguage();
} catch (e: any) {
setError(e.response?.data?.detail || e.message);
}
};
const _download = async (code: string) => {
try {
const response = await api.get(`/api/i18n/sets/${encodeURIComponent(code)}/download`, {
responseType: 'blob',
});
const blob = new Blob([response.data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ui-language-${code}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e: any) {
setError(e.response?.data?.detail || e.message);
}
};
const _add = async () => {
if (busyRef.current) return;
const code = String(addCode).trim().toLowerCase();
if (!code) return;
const go = await confirm(
t('Die Erstellung einer neuen Sprache kann AI-Guthaben auf Ihrem Mandats-Pool belasten. Fortfahren?'),
{ confirmLabel: t('Fortfahren'), cancelLabel: t('Abbrechen') },
);
if (!go) return;
busyRef.current = true;
setProgress({ message: t('Sprache wird erstellt…'), current: 0, total: 1 });
try {
await api.post('/api/i18n/sets', { code });
setProgress({ message: t('Sprache erstellt. KI-Übersetzung läuft im Hintergrund.'), current: 1, total: 1, done: true });
await _load();
await refreshAvailableLanguages();
} catch (e: any) {
const msg = e.response?.data?.detail || e.message;
setProgress({ message: t('Fehler'), current: 0, total: 1, error: msg, done: true });
setError(msg);
} finally {
setTimeout(() => { setProgress(null); busyRef.current = false; }, 2500);
}
};
const _exportAll = async () => {
try {
const response = await api.get('/api/i18n/export', { responseType: 'blob' });
const blob = new Blob([response.data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'ui-languages-export.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e: any) {
setError(e.response?.data?.detail || e.message);
}
};
const _importFile = async () => {
if (busyRef.current) return;
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const ok = await confirm(
t('Sprachdatenbank aus Datei importieren? Bestehende Sets werden überschrieben.'),
{ confirmLabel: t('Importieren'), cancelLabel: t('Abbrechen') },
);
if (!ok) return;
busyRef.current = true;
setProgress({ message: t('Importiere…'), current: 0, total: 1 });
try {
const formData = new FormData();
formData.append('file', file);
const res = await api.post('/api/i18n/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const d = res.data || {};
setError(null);
setProgress({
message: t('Import abgeschlossen: {created} erstellt, {updated} aktualisiert.', {
created: String(d.created?.length ?? 0),
updated: String(d.updated?.length ?? 0),
}),
current: 1,
total: 1,
done: true,
});
await _load();
await refreshAvailableLanguages();
await reloadLanguage();
} catch (e: any) {
const msg = e.response?.data?.detail || e.message;
setProgress({ message: t('Import fehlgeschlagen'), current: 0, total: 1, error: msg, done: true });
setError(msg);
} finally {
setTimeout(() => { setProgress(null); busyRef.current = false; }, 2500);
}
};
input.click();
};
const isBusy = progress !== null;
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`} style={{ gap: '1rem', position: 'relative' }}>
<header>
<h1 className={styles.pageTitle}>{t('UI-Sprachen')}</h1>
<p className={styles.pageSubtitle}>{t('Globale Sprachsets verwalten (SysAdmin).')}</p>
{error && !progress && (
<p style={{ color: 'var(--error-color, #c53030)' }}>
{error}
</p>
)}
</header>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', alignItems: 'center' }}>
<button type="button" className={styles.secondaryButton} onClick={_load} disabled={isBusy || loading} title={t('Daten neu laden')}>
<FaSync style={loading ? { animation: 'spin 1s linear infinite' } : undefined} />
</button>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('Suche…')}
style={{ padding: '0.35rem 0.5rem', minWidth: 140, maxWidth: 200 }}
/>
<button type="button" className={styles.primaryButton} onClick={_updateAll} disabled={isBusy}>
{t('Alle aktualisieren')}
</button>
<button type="button" className={styles.secondaryButton} onClick={_exportAll} disabled={isBusy}>
<FaFileExport /> {t('Export')}
</button>
<button type="button" className={styles.secondaryButton} onClick={_importFile} disabled={isBusy}>
<FaFileImport /> {t('Import')}
</button>
<span style={{ borderLeft: '1px solid var(--border-color)', height: '1.5rem' }} />
<span style={{ opacity: 0.7 }}>{t('Neue Sprache')}</span>
<select
value={addCode}
onChange={(e) => setAddCode(e.target.value)}
style={{ padding: '0.35rem 0.5rem' }}
disabled={isBusy}
>
{addChoices.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
<button type="button" className={styles.primaryButton} onClick={_add} disabled={addChoices.length === 0 || isBusy}>
{t('Hinzufügen')}
</button>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<FormGeneratorTable
data={displayRows}
columns={_getColumns(t)}
loading={loading}
pagination={false}
selectable={false}
searchable={false}
customActions={[
{
id: 'sync-xx',
title: t('UI-Keys einlesen'),
icon: <FaSync />,
onClick: () => _syncXx(),
visible: (row: LangRow) => row.id === 'xx',
},
{
id: 'upd',
title: t('Aktualisieren'),
icon: <FaRedo />,
onClick: (row: LangRow) => _updateOne(row.id),
visible: (row: LangRow) => row.id !== 'xx',
},
{
id: 'dl',
title: t('Herunterladen'),
icon: <FaDownload />,
onClick: (row: LangRow) => _download(row.id),
},
{
id: 'del',
title: t('Löschen'),
icon: <FaTrash />,
onClick: (row: LangRow) => _delete(row.id),
visible: (row: LangRow) => row.id !== 'xx',
},
]}
emptyMessage={t('Keine Einträge')}
/>
{progress && <_ProgressOverlay progress={progress} />}
</div>
<ConfirmDialog />
</div>
);
};
export default AdminLanguagesPage;