profit-planet-frontend/src/app/admin/language-management/page.tsx
DeathKaioken 4074ea4eee Bibelbumser
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 23:48:09 +02:00

966 lines
37 KiB
TypeScript

'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import PageLayout from '../../components/PageLayout';
import TranslationWizardModal from './components/TranslationWizardModal';
import AddLanguageModal from './components/AddLanguageModal';
import DeleteLanguageModal from './components/DeleteLanguageModal';
import CategoryManagerModal from './components/CategoryManagerModal';
import ScanResultsModal from './components/ScanResultsModal';
import TranslationCoverageEditor from './components/TranslationCoverageEditor';
import LanguageManagementTopSection from './components/LanguageManagementTopSection';
import { useI18nScanWorkflow } from './hooks/useI18nScanWorkflow';
import { useLanguageManagementTranslations } from './hooks/useLanguageManagementTranslations';
import { useNamespaceCategories } from './hooks/useNamespaceCategories';
import { useModalAnimation } from './hooks/useModalAnimation';
import { useToast } from '../../components/toast/toastComponent';
import { isPageTransitioning } from '../../components/animation/pageTransitionEffect';
import {
getAllTranslationKeys,
getEnglishValue,
useTranslation,
} from '../../i18n/useTranslation';
const CORE_LANGUAGES = new Set(['en', 'de']);
const AUTO_SCROLL_ON_SAVE_STORAGE_KEY = 'language-management-auto-scroll-on-save';
// ── namespace categories
const NAMESPACE_CATEGORIES: { label: string; namespaces: string[] }[] = [
{ label: 'General', namespaces: ['common', 'nav', 'footer', 'home'] },
{ label: 'Auth', namespaces: ['login', 'register', 'passwordReset'] },
{ label: 'Pages', namespaces: ['dashboard', 'profile', 'community', 'shop', 'memberships', 'affiliateLinks', 'aboutUs', 'news'] },
{ label: 'Coffee ABO', namespaces: ['coffeeSelection', 'coffeeSummary'] },
{ label: 'Account', namespaces: ['personalMatrix', 'referralManagement', 'quickactionDashboard', 'suspended'] },
{ label: 'Admin', namespaces: ['adminDashboard', 'userManagement', 'languageManagement', 'contractManagement'] },
{ label: 'Notifications', namespaces: ['toasts'] },
];
function groupKeys(keys: string[]): Record<string, string[]> {
const groups: Record<string, string[]> = {};
for (const key of keys) {
const ns = key.split('.')[0];
if (!groups[ns]) groups[ns] = [];
groups[ns].push(key);
}
return groups;
}
export default function LanguageManagementPage() {
const { t } = useTranslation();
const router = useRouter();
const { showToast } = useToast();
const notifyAction = useCallback((notice: { variant: 'success' | 'error' | 'info' | 'warning'; message: string }) => {
showToast({
variant: notice.variant,
message: notice.message,
duration: notice.variant === 'error' ? 6000 : 3500,
});
}, [showToast]);
const handleUnauthorized = useCallback(() => {
router.push('/login');
}, [router]);
const {
showScanModal,
setShowScanModal,
lastScanTime,
isScanning,
isAutoFixing,
isAddingMissingKeys,
scanError,
workspaceScan,
selectedFiles,
fixableFiles,
forceConvertToClient,
setForceConvertToClient,
scan,
runFixSelected,
addMissingKeys,
toggleFileSelection,
selectAllFiles,
clearSelectedFiles,
} = useI18nScanWorkflow({ onUnauthorized: handleUnauthorized });
// ── all flat keys from the English source-of-truth
const allKeys = useMemo(() => getAllTranslationKeys(), []);
const keyGroups = useMemo(() => groupKeys(allKeys), [allKeys]);
const namespaces = useMemo(() => Object.keys(keyGroups).sort(), [keyGroups]);
const {
data,
isLoading: isTranslationsLoading,
loadingPhase: translationsLoadingPhase,
loadingProgress: translationsLoadingProgress,
loadingLogs: translationsLoadingLogs,
allLanguages,
activeLang,
setActiveLang,
getDisplayValue,
isDirty,
saved,
saveError,
handleChange,
handleSave,
showAddModal,
setShowAddModal,
newCode,
setNewCode,
newName,
setNewName,
addError,
setAddError,
handleAddLanguage,
deleteTarget,
setDeleteTarget,
handleDeleteLanguage,
isBuiltin,
} = useLanguageManagementTranslations({
coreLanguages: CORE_LANGUAGES,
onAction: notifyAction,
onUnauthorized: handleUnauthorized,
});
const [showTranslationWizard, setShowTranslationWizard] = useState(false);
const [wizardIndex, setWizardIndex] = useState(0);
const [wizardInput, setWizardInput] = useState('');
const [wizardMarkGlobal, setWizardMarkGlobal] = useState(false);
const [wizardUseEnglishReference, setWizardUseEnglishReference] = useState(false);
const [isWizardSavingStep, setIsWizardSavingStep] = useState(false);
const [newGlobalKeySelection, setNewGlobalKeySelection] = useState('');
const [reloadAfterScanClose, setReloadAfterScanClose] = useState(false);
const [pendingAutoFixResult, setPendingAutoFixResult] = useState(false);
const {
activeCategory,
setActiveCategory,
showCategoryManagerModal,
setShowCategoryManagerModal,
isPreferencesDirty,
preferencesHydrated,
preferencesLoadingPhase,
preferencesLoadingProgress,
preferencesLoadingLogs,
isSavingPreferences,
savePreferences,
categoriesWithKnownNamespaces,
uncategorizedNamespaces,
addNamespaceToCategory,
removeNamespaceFromCategory,
newCategoryLabel,
setNewCategoryLabel,
assignNamespaceByCategory,
setAssignNamespaceByCategory,
expandedCategoryId,
setExpandedCategoryId,
dragNamespace,
setDragNamespace,
handleCreateCategory,
deleteCategory,
globalKnownKeys,
globalKeySet,
addGlobalKey,
removeGlobalKey,
englishReferenceKeysByLanguage,
englishReferenceKeySetByLanguage,
setEnglishReferenceKey,
} = useNamespaceCategories({
namespaces,
allKeys,
defaultCategories: NAMESPACE_CATEGORIES,
onAction: notifyAction,
onUnauthorized: handleUnauthorized,
});
const hasUnsavedChanges = isDirty || isPreferencesDirty;
const isInitialDataLoading = isTranslationsLoading || !preferencesHydrated;
const initialLoadingProgress = Math.round((translationsLoadingProgress + preferencesLoadingProgress) / 2);
const [isPageTransitionDone, setIsPageTransitionDone] = useState(() => !isPageTransitioning);
const [showDelayedFetchingScreen, setShowDelayedFetchingScreen] = useState(false);
const [showDelayedFetchLogs, setShowDelayedFetchLogs] = useState(false);
const combinedLoadingLogs = useMemo(() => {
const preferenceLines = preferencesLoadingLogs.map((line) => `[PREF] ${line}`);
const translationLines = translationsLoadingLogs.map((line) => `[I18N] ${line}`);
return [...preferenceLines, ...translationLines].slice(-18);
}, [preferencesLoadingLogs, translationsLoadingLogs]);
const { isRendered: isSaveBarRendered, isVisible: isSaveBarVisible } = useModalAnimation(hasUnsavedChanges);
useEffect(() => {
if (isPageTransitionDone) return;
let rafId: number | null = null;
const pollTransition = () => {
if (!isPageTransitioning) {
setIsPageTransitionDone(true);
return;
}
rafId = window.requestAnimationFrame(pollTransition);
};
rafId = window.requestAnimationFrame(pollTransition);
return () => {
if (rafId) window.cancelAnimationFrame(rafId);
};
}, [isPageTransitionDone]);
useEffect(() => {
if (!isInitialDataLoading || !isPageTransitionDone) {
setShowDelayedFetchingScreen(false);
return;
}
const timeoutId = window.setTimeout(() => setShowDelayedFetchingScreen(true), 350);
return () => window.clearTimeout(timeoutId);
}, [isInitialDataLoading, isPageTransitionDone]);
useEffect(() => {
if (!showDelayedFetchingScreen) {
setShowDelayedFetchLogs(false);
return;
}
const timeoutId = window.setTimeout(() => setShowDelayedFetchLogs(true), 1400);
return () => window.clearTimeout(timeoutId);
}, [showDelayedFetchingScreen]);
const showFetchingScreen = isInitialDataLoading && isPageTransitionDone && showDelayedFetchingScreen;
const showWarmGap = isInitialDataLoading && isPageTransitionDone && !showDelayedFetchingScreen;
// ── search / filter
const [search, setSearch] = useState('');
const [autoScrollOnPanelOpen, setAutoScrollOnPanelOpen] = useState(true);
const [autoScrollOnSave, setAutoScrollOnSave] = useState(() => {
if (typeof window === 'undefined') return false;
return window.localStorage.getItem(AUTO_SCROLL_ON_SAVE_STORAGE_KEY) === '1';
});
const [activeNamespacePanel, setActiveNamespacePanel] = useState<string | null>(null);
const languageManagementHeaderRef = useRef<HTMLDivElement | null>(null);
const openedNamespacePanelRef = useRef<HTMLDivElement | null>(null);
const openFromPanelClickRef = useRef(false);
const saveBarRef = useRef<HTMLDivElement | null>(null);
const [showScrollHint, setShowScrollHint] = useState(false);
const scrollHintTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { isRendered: isHintRendered, isVisible: isHintVisible } = useModalAnimation(showScrollHint);
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(AUTO_SCROLL_ON_SAVE_STORAGE_KEY, autoScrollOnSave ? '1' : '0');
}, [autoScrollOnSave]);
const filteredGroups = useMemo(() => {
const q = search.toLowerCase();
if (!q) return keyGroups;
const result: Record<string, string[]> = {};
for (const [ns, keys] of Object.entries(keyGroups)) {
const filtered = keys.filter(
(k) =>
k.toLowerCase().includes(q) ||
getEnglishValue(k).toLowerCase().includes(q)
);
if (filtered.length > 0) result[ns] = filtered;
}
return result;
}, [keyGroups, search]);
// ── scan results: coverage per namespace
const scanResults = useMemo(() => {
return namespaces.map((ns) => {
const keys = keyGroups[ns] ?? [];
let translated = 0;
if (activeLang === 'en') {
translated = keys.length; // English is the source
} else {
translated = keys.filter((k) => getDisplayValue(k) !== '').length;
}
return { ns, total: keys.length, translated, missing: keys.length - translated };
});
}, [namespaces, keyGroups, activeLang, getDisplayValue]);
const categoryNamespaces = useMemo(() => {
if (activeCategory === 'all') return null; // null = no filter
if (activeCategory === 'global') return [];
return categoriesWithKnownNamespaces.find((c) => c.id === activeCategory)?.namespaces ?? [];
}, [activeCategory, categoriesWithKnownNamespaces]);
const filteredNs = useMemo(() => {
if (activeCategory === 'global') return [];
const base = Object.keys(filteredGroups).sort();
if (!categoryNamespaces) return base;
return base.filter((ns) => categoryNamespaces.includes(ns));
}, [activeCategory, filteredGroups, categoryNamespaces]);
// Keep active panel in sync with currently visible namespaces
useEffect(() => {
if (activeCategory === 'global') {
setActiveNamespacePanel(null);
return;
}
if (filteredNs.length === 0) {
setActiveNamespacePanel(null);
return;
}
if (!activeNamespacePanel || !filteredNs.includes(activeNamespacePanel)) {
setActiveNamespacePanel(filteredNs[0]);
}
}, [activeCategory, filteredNs, activeNamespacePanel]);
const totalKeys = allKeys.length;
const isMissingForLanguage = (key: string, languageCode: string) => {
if (languageCode === 'en') return false;
if (globalKeySet.has(key)) return false;
if (englishReferenceKeySetByLanguage[languageCode]?.has(key)) return false;
const value = (data.translations[languageCode]?.[key] ?? '').trim();
const englishValue = getEnglishValue(key).trim();
return value === '' || value === englishValue;
};
const namespaceTranslationStats = useMemo(() => {
const stats: Record<string, { total: number; translated: number; missing: number }> = {};
for (const ns of namespaces) {
const keys = keyGroups[ns] ?? [];
const total = keys.length;
let translated = 0;
if (activeLang === 'en') {
translated = total;
} else {
translated = keys.filter((k) => !isMissingForLanguage(k, activeLang)).length;
}
stats[ns] = { total, translated, missing: Math.max(total - translated, 0) };
}
return stats;
}, [namespaces, keyGroups, activeLang, data.translations, globalKeySet]);
const allTabStats = useMemo(() => {
return Object.values(namespaceTranslationStats).reduce(
(acc, cur) => ({
total: acc.total + cur.total,
translated: acc.translated + cur.translated,
missing: acc.missing + cur.missing,
}),
{ total: 0, translated: 0, missing: 0 }
);
}, [namespaceTranslationStats]);
const categoryTabStats = useMemo(() => {
const result: Record<string, { total: number; translated: number; missing: number }> = {};
for (const cat of categoriesWithKnownNamespaces) {
result[cat.id] = cat.namespaces.reduce(
(acc, ns) => {
const st = namespaceTranslationStats[ns] ?? { total: 0, translated: 0, missing: 0 };
return {
total: acc.total + st.total,
translated: acc.translated + st.translated,
missing: acc.missing + st.missing,
};
},
{ total: 0, translated: 0, missing: 0 }
);
}
return result;
}, [categoriesWithKnownNamespaces, namespaceTranslationStats]);
const translationProgressPercent = useMemo(() => {
if (allTabStats.total === 0) return 100;
return Math.round((allTabStats.translated / allTabStats.total) * 100);
}, [allTabStats]);
const wizardMissingKeys = useMemo(() => {
if (activeLang === 'en') return [] as string[];
return allKeys.filter((k) => isMissingForLanguage(k, activeLang));
}, [activeLang, allKeys, data.translations, globalKeySet]);
const currentWizardKey = showTranslationWizard ? wizardMissingKeys[wizardIndex] ?? null : null;
const globalFilteredKeys = useMemo(() => {
const q = search.toLowerCase().trim();
if (!q) return [...globalKnownKeys].sort();
return globalKnownKeys
.filter((key) => key.toLowerCase().includes(q) || getEnglishValue(key).toLowerCase().includes(q))
.sort();
}, [globalKnownKeys, search]);
const availableGlobalKeyOptions = useMemo(() => {
const globalSet = new Set(globalKnownKeys);
return allKeys.filter((key) => !globalSet.has(key)).sort();
}, [allKeys, globalKnownKeys]);
const globalTabStats = useMemo(() => {
const total = globalKnownKeys.length;
// Global keys are intentionally the same across languages — never count as missing
return { total, translated: total, missing: 0 };
}, [globalKnownKeys]);
useEffect(() => {
if (!showTranslationWizard) return;
if (wizardMissingKeys.length === 0) {
setShowTranslationWizard(false);
return;
}
if (wizardIndex > wizardMissingKeys.length - 1) {
setWizardIndex(0);
return;
}
const key = wizardMissingKeys[wizardIndex];
setWizardInput(data.translations[activeLang]?.[key] ?? '');
setWizardMarkGlobal(globalKnownKeys.includes(key));
setWizardUseEnglishReference(Boolean(englishReferenceKeySetByLanguage[activeLang]?.has(key)));
}, [showTranslationWizard, wizardIndex, wizardMissingKeys, data.translations, activeLang, globalKnownKeys, englishReferenceKeySetByLanguage]);
const saveCurrentWizardValue = () => {
if (!currentWizardKey) return;
const trimmedWizardInput = wizardInput.trim();
const englishValue = getEnglishValue(currentWizardKey).trim();
const nextTranslations: Record<string, Record<string, string>> = {
...data.translations,
[activeLang]: {
...(data.translations[activeLang] ?? {}),
},
};
const nextGlobalKeysSet = new Set(globalKnownKeys);
const nextEnglishReferenceKeysByLanguage = {
...englishReferenceKeysByLanguage,
[activeLang]: [...(englishReferenceKeysByLanguage[activeLang] ?? [])],
};
const nextEnglishReferenceSet = new Set(nextEnglishReferenceKeysByLanguage[activeLang] ?? []);
if (wizardUseEnglishReference) {
nextTranslations[activeLang][currentWizardKey] = englishValue;
handleChange(currentWizardKey, englishValue);
setEnglishReferenceKey(activeLang, currentWizardKey, true);
nextEnglishReferenceSet.add(currentWizardKey);
if (wizardMarkGlobal) {
addGlobalKey(currentWizardKey);
nextGlobalKeysSet.add(currentWizardKey);
} else {
removeGlobalKey(currentWizardKey);
nextGlobalKeysSet.delete(currentWizardKey);
}
nextEnglishReferenceKeysByLanguage[activeLang] = [...nextEnglishReferenceSet].sort((a, b) => a.localeCompare(b));
return {
nextTranslations,
preferencesSnapshot: {
globalKeys: [...nextGlobalKeysSet].sort((a, b) => a.localeCompare(b)),
englishReferenceKeysByLanguage: nextEnglishReferenceKeysByLanguage,
},
};
}
// Global keys intentionally reuse the source term and don't require a local override.
const nextValue = wizardMarkGlobal && trimmedWizardInput === englishValue
? ''
: trimmedWizardInput;
nextTranslations[activeLang][currentWizardKey] = nextValue;
handleChange(currentWizardKey, nextValue);
setEnglishReferenceKey(activeLang, currentWizardKey, false);
nextEnglishReferenceSet.delete(currentWizardKey);
if (wizardMarkGlobal) {
addGlobalKey(currentWizardKey);
nextGlobalKeysSet.add(currentWizardKey);
} else {
removeGlobalKey(currentWizardKey);
nextGlobalKeysSet.delete(currentWizardKey);
}
const normalizedEnglishReferenceKeysByLanguage = { ...nextEnglishReferenceKeysByLanguage };
const normalizedKeys = [...nextEnglishReferenceSet].sort((a, b) => a.localeCompare(b));
if (normalizedKeys.length === 0) {
delete normalizedEnglishReferenceKeysByLanguage[activeLang];
} else {
normalizedEnglishReferenceKeysByLanguage[activeLang] = normalizedKeys;
}
return {
nextTranslations,
preferencesSnapshot: {
globalKeys: [...nextGlobalKeysSet].sort((a, b) => a.localeCompare(b)),
englishReferenceKeysByLanguage: normalizedEnglishReferenceKeysByLanguage,
},
};
};
const openTranslationWizard = () => {
if (wizardMissingKeys.length === 0) return;
setWizardIndex(0);
setShowTranslationWizard(true);
};
const goToNextWizardStep = async () => {
if (isWizardSavingStep) return;
setIsWizardSavingStep(true);
const saveSnapshot = saveCurrentWizardValue();
try {
if (saveSnapshot) {
// Persist both translation content and preferences on every wizard step.
await handleSave(saveSnapshot.nextTranslations);
await savePreferences(saveSnapshot.preferencesSnapshot);
}
if (wizardIndex >= wizardMissingKeys.length - 1) {
setShowTranslationWizard(false);
return;
}
setWizardIndex((prev) => prev + 1);
} finally {
setIsWizardSavingStep(false);
}
};
const skipWizardStep = () => {
if (wizardIndex >= wizardMissingKeys.length - 1) {
setShowTranslationWizard(false);
return;
}
setWizardIndex((prev) => prev + 1);
};
const goToPreviousWizardStep = () => {
if (wizardIndex <= 0) return;
setWizardIndex((prev) => prev - 1);
};
useEffect(() => {
if (!activeNamespacePanel) return;
if (!openFromPanelClickRef.current) return;
if (!autoScrollOnPanelOpen || !openedNamespacePanelRef.current) {
openFromPanelClickRef.current = false;
return;
}
openedNamespacePanelRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
openFromPanelClickRef.current = false;
}, [activeNamespacePanel, autoScrollOnPanelOpen]);
const scrollToLanguageManagementHeader = () => {
if (!languageManagementHeaderRef.current) return;
languageManagementHeaderRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
// Show scroll-to-save hint whenever a namespace panel is opened and there are unsaved changes
// It stays visible until explicitly dismissed (button click) or save bar disappears
useEffect(() => {
if (!activeNamespacePanel || !isSaveBarRendered) {
setShowScrollHint(false);
return;
}
if (scrollHintTimerRef.current) clearTimeout(scrollHintTimerRef.current);
setShowScrollHint(true);
}, [activeNamespacePanel, isSaveBarRendered]);
// Hide scroll hint when the save bar scrolls into view
useEffect(() => {
const el = saveBarRef.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) setShowScrollHint(false); },
{ threshold: 0.5 }
);
observer.observe(el);
return () => observer.disconnect();
}, [isSaveBarRendered]);
useEffect(() => {
if (!pendingAutoFixResult || !workspaceScan) return;
const changedFiles = workspaceScan.changedFileCount ?? 0;
const createdKeys = workspaceScan.createdKeyCount ?? 0;
if (changedFiles > 0 || createdKeys > 0) {
setReloadAfterScanClose(true);
showToast({
variant: 'success',
message: `Auto-fix finished: ${changedFiles} files updated, ${createdKeys} keys created.`,
duration: 4200,
});
}
setPendingAutoFixResult(false);
}, [pendingAutoFixResult, workspaceScan, showToast]);
const handleRunFixSelected = async () => {
setPendingAutoFixResult(true);
await runFixSelected();
};
const closeScanModal = () => {
setShowScanModal(false);
if (!reloadAfterScanClose) return;
showToast({
variant: 'info',
message: t('autofix.k3871d88e'),
duration: 1800,
});
setTimeout(() => {
if (typeof window !== 'undefined') {
window.location.reload();
}
}, 260);
setReloadAfterScanClose(false);
};
const handleSaveAll = async () => {
const hadChangesToSave = isDirty || isPreferencesDirty;
console.debug('[LanguageManagement][SaveAll] start', {
isDirty,
isPreferencesDirty,
hasUnsavedChanges,
autoScrollOnSave,
});
if (isDirty) {
console.debug('[LanguageManagement][SaveAll] saving:translations');
await handleSave();
console.debug('[LanguageManagement][SaveAll] saved:translations');
}
if (isPreferencesDirty) {
console.debug('[LanguageManagement][SaveAll] saving:preferences');
const savedPreferences = await savePreferences();
console.debug('[LanguageManagement][SaveAll] saved:preferences', { savedPreferences });
}
if (autoScrollOnSave && hadChangesToSave) {
scrollToLanguageManagementHeader();
}
console.debug('[LanguageManagement][SaveAll] done');
};
return (
<PageLayout contentClassName="flex-1 relative w-full">
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.10),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.10),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_50%,#eef2ff_100%)]">
<div className="max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
{showFetchingScreen ? (
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
<div className="flex items-center gap-4">
<div className="relative h-10 w-10">
<span className="absolute inset-0 rounded-full border-2 border-slate-200" />
<span className="absolute inset-0 animate-spin rounded-full border-2 border-transparent border-t-slate-700" />
</div>
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900">{t('autofix.k78e1bf35')}</p>
<p className="text-xs text-slate-500">
I18N: {translationsLoadingPhase} | PREF: {preferencesLoadingPhase}
</p>
</div>
<div className="ml-auto text-sm font-semibold text-slate-700">{initialLoadingProgress}%</div>
</div>
<div className="mt-5 h-2 w-full overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-slate-600 transition-[width] duration-300"
style={{ width: `${Math.max(6, initialLoadingProgress)}%` }}
/>
</div>
{showDelayedFetchLogs && (
<div className="mt-5 rounded-2xl border border-slate-200 bg-slate-950/95 p-3 text-slate-100 shadow-inner">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold tracking-wide text-slate-200">{t('autofix.k1c7ec4f2')}</p>
<p className="text-[11px] text-slate-400">{t('autofix.k057b3dbd')}</p>
</div>
<div className="mt-2 h-40 overflow-y-auto rounded-lg bg-black/30 p-2 font-mono text-[11px] leading-5">
{combinedLoadingLogs.length === 0 ? (
<p className="text-slate-400">{t('autofix.k835d3cbf')}</p>
) : (
combinedLoadingLogs.map((line, idx) => (
<p key={`${line}-${idx}`} className="text-slate-200">{line}</p>
))
)}
</div>
</div>
)}
</div>
) : showWarmGap ? (
<div className="h-24" />
) : (
<>
<LanguageManagementTopSection
headerRef={languageManagementHeaderRef}
totalKeys={totalKeys}
onScan={scan}
isScanning={isScanning}
isAutoFixing={isAutoFixing}
onBackToAdmin={() => router.push('/admin')}
isDirty={hasUnsavedChanges}
onSave={handleSaveAll}
saved={saved}
saveError={saveError}
allLanguages={allLanguages}
activeLang={activeLang}
setActiveLang={setActiveLang}
isBuiltin={isBuiltin}
onDeleteLanguageRequest={setDeleteTarget}
onOpenAddLanguage={() => setShowAddModal(true)}
allTabStats={allTabStats}
translationProgressPercent={translationProgressPercent}
wizardMissingKeysCount={wizardMissingKeys.length}
onOpenTranslationWizard={openTranslationWizard}
/>
<TranslationCoverageEditor
activeCategory={activeCategory}
setActiveCategory={setActiveCategory}
categoriesWithKnownNamespaces={categoriesWithKnownNamespaces}
allTabStats={allTabStats}
categoryTabStats={categoryTabStats}
globalTabStats={globalTabStats}
activeLang={activeLang}
allLanguages={allLanguages}
search={search}
setSearch={setSearch}
autoScrollOnPanelOpen={autoScrollOnPanelOpen}
setAutoScrollOnPanelOpen={setAutoScrollOnPanelOpen}
autoScrollOnSave={autoScrollOnSave}
setAutoScrollOnSave={setAutoScrollOnSave}
newGlobalKeySelection={newGlobalKeySelection}
setNewGlobalKeySelection={setNewGlobalKeySelection}
availableGlobalKeyOptions={availableGlobalKeyOptions}
addGlobalKey={addGlobalKey}
globalFilteredKeys={globalFilteredKeys}
removeGlobalKey={removeGlobalKey}
getDisplayValue={getDisplayValue}
translations={data.translations}
handleChange={handleChange}
globalKeySet={globalKeySet}
englishReferenceKeySet={englishReferenceKeySetByLanguage[activeLang] ?? new Set<string>()}
setEnglishReferenceForKey={(key, enabled) => {
setEnglishReferenceKey(activeLang, key, enabled);
if (enabled) {
handleChange(key, getEnglishValue(key));
}
}}
filteredNs={filteredNs}
filteredGroups={filteredGroups}
activeNamespacePanel={activeNamespacePanel}
setActiveNamespacePanel={setActiveNamespacePanel}
namespaceTranslationStats={namespaceTranslationStats}
openedNamespacePanelRef={openedNamespacePanelRef}
openFromPanelClickRef={openFromPanelClickRef}
onBackToPanels={scrollToLanguageManagementHeader}
onOpenCategoryManager={() => setShowCategoryManagerModal(true)}
/>
</>
)}
</div>
</div>
{/* Scroll-to-top floating button */}
<button
type="button"
aria-label={t('autofix.kb494ddd8')}
onClick={scrollToLanguageManagementHeader}
className="group fixed bottom-36 right-5 z-50 flex h-10 w-10 items-center justify-center rounded-full border border-white/70 bg-white/80 shadow-[0_8px_24px_-8px_rgba(15,23,42,0.28)] backdrop-blur-md transition-all duration-300 ease-out hover:-translate-y-0.5 hover:scale-105 hover:bg-white hover:shadow-[0_12px_32px_-10px_rgba(15,23,42,0.36)]"
>
<svg className="h-4 w-4 text-slate-700 transition-transform duration-300 ease-out group-hover:-translate-y-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 15l7-7 7 7" />
</svg>
</button>
{/* Scroll-to-save floating button */}
<button
type="button"
aria-label={t('autofix.k889cc3e3')}
onClick={() => {
if (saveBarRef.current) {
saveBarRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
} else {
const roots = [
document.scrollingElement,
document.documentElement,
document.body,
].filter(Boolean) as HTMLElement[];
const maxScrollTop = Math.max(
0,
...roots.map((root) => root.scrollHeight - window.innerHeight)
);
// Trigger scroll on all possible roots so it works regardless of which element owns scrolling.
roots.forEach((root) => {
root.scrollTo({ top: maxScrollTop, behavior: 'smooth' });
});
window.scrollTo({ top: maxScrollTop, behavior: 'smooth' });
}
setShowScrollHint(false);
}}
className="group fixed bottom-24 right-5 z-50 flex h-10 w-10 items-center justify-center rounded-full border border-white/70 bg-white/80 shadow-[0_8px_24px_-8px_rgba(15,23,42,0.28)] backdrop-blur-md transition-all duration-300 ease-out hover:translate-y-0.5 hover:scale-105 hover:bg-white hover:shadow-[0_12px_32px_-10px_rgba(15,23,42,0.36)]"
>
<svg className="h-4 w-4 text-slate-700 transition-transform duration-300 ease-out group-hover:translate-y-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Scroll-to-save hint tooltip */}
{isHintRendered && (
<button
type="button"
aria-label={t('autofix.k0b27fdf8')}
onClick={() => {
saveBarRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
setShowScrollHint(false);
}}
className={`fixed bottom-[5.25rem] right-[4rem] z-50 transition-all duration-500 ${
isHintVisible ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-3 pointer-events-none'
}`}
>
{/* outer pulsing ring */}
<span className="absolute inset-0 rounded-2xl animate-pulse bg-gradient-to-br from-sky-400/20 via-blue-300/15 to-indigo-400/20" />
<div className="relative flex items-center gap-2.5 rounded-2xl border border-blue-300/80 bg-gradient-to-br from-sky-50 via-blue-50 to-indigo-50 px-3.5 py-2.5 shadow-[0_8px_28px_-8px_rgba(37,99,235,0.35)] backdrop-blur-md">
{/* logo */}
<img
src="/images/logos/PP_Logo_BW_round.png"
alt={t('autofix.k788633d1')}
className="h-7 w-7 rounded-full border border-blue-200/70 shadow-sm shrink-0 object-cover"
/>
<div className="flex flex-col leading-tight">
<span className="text-[11px] font-bold text-blue-950 whitespace-nowrap">{t('autofix.k5188f06f')}</span>
<span className="text-[10px] font-medium text-blue-700 whitespace-nowrap">Click to scroll &amp; save </span>
</div>
{/* pulsing dot */}
<span className="relative flex h-2 w-2 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-500 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span>
{/* caret pointing right toward the chevron button */}
<span className="absolute -right-[5px] top-1/2 -translate-y-1/2 h-2.5 w-2.5 rotate-45 border-r border-t border-blue-300/80 bg-indigo-50" />
</div>
</button>
)}
{/* Glassy save bar */}
{isSaveBarRendered && (
<div
ref={saveBarRef}
className={`w-full border-t border-white/60 bg-white/70 backdrop-blur-md px-4 py-4 transition-opacity duration-300 ${
isSaveBarVisible ? 'opacity-100' : 'opacity-0'
}`}
>
<div className="max-w-[1820px] mx-auto flex items-center justify-between gap-4">
<span className="text-sm font-medium text-slate-700">{t('autofix.kd63c8219')}</span>
<button
onClick={handleSaveAll}
disabled={isSavingPreferences}
className="rounded-2xl bg-slate-900 text-white px-5 py-2 text-sm font-semibold shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)] hover:bg-slate-800 transition disabled:opacity-50"
>{isSavingPreferences ? t('autofix.kac6cedc7') : 'Save'}</button>
</div>
</div>
)}
<TranslationWizardModal
isOpen={showTranslationWizard}
currentWizardKey={currentWizardKey}
wizardIndex={wizardIndex}
wizardMissingCount={wizardMissingKeys.length}
activeLang={activeLang}
allLanguages={allLanguages}
wizardInput={wizardInput}
setWizardInput={setWizardInput}
wizardMarkGlobal={wizardMarkGlobal}
setWizardMarkGlobal={setWizardMarkGlobal}
wizardUseEnglishReference={wizardUseEnglishReference}
setWizardUseEnglishReference={setWizardUseEnglishReference}
englishValue={currentWizardKey ? getEnglishValue(currentWizardKey) : ''}
addGlobalKey={addGlobalKey}
removeGlobalKey={removeGlobalKey}
onClose={() => setShowTranslationWizard(false)}
onPrevious={goToPreviousWizardStep}
onSkip={skipWizardStep}
onNext={goToNextWizardStep}
isSavingStep={isWizardSavingStep}
/>
<AddLanguageModal
isOpen={showAddModal}
newCode={newCode}
setNewCode={setNewCode}
newName={newName}
setNewName={setNewName}
addError={addError}
setAddError={setAddError}
onClose={() => setShowAddModal(false)}
onAdd={handleAddLanguage}
/>
<DeleteLanguageModal
deleteTarget={deleteTarget}
allLanguages={allLanguages}
onClose={() => setDeleteTarget(null)}
onDelete={handleDeleteLanguage}
/>
<CategoryManagerModal
isOpen={showCategoryManagerModal}
onClose={() => {
setShowCategoryManagerModal(false);
if (isPreferencesDirty) void savePreferences();
}}
newCategoryLabel={newCategoryLabel}
setNewCategoryLabel={setNewCategoryLabel}
onCreateCategory={handleCreateCategory}
uncategorizedNamespaces={uncategorizedNamespaces}
categoriesWithKnownNamespaces={categoriesWithKnownNamespaces}
namespaces={namespaces}
assignNamespaceByCategory={assignNamespaceByCategory}
setAssignNamespaceByCategory={setAssignNamespaceByCategory}
expandedCategoryId={expandedCategoryId}
setExpandedCategoryId={setExpandedCategoryId}
dragNamespace={dragNamespace}
setDragNamespace={setDragNamespace}
addNamespaceToCategory={addNamespaceToCategory}
removeNamespaceFromCategory={removeNamespaceFromCategory}
deleteCategory={deleteCategory}
/>
<ScanResultsModal
isOpen={showScanModal}
onClose={closeScanModal}
lastScanTime={lastScanTime}
workspaceScan={workspaceScan}
totalKeys={totalKeys}
namespacesCount={namespaces.length}
allLanguages={allLanguages}
activeLang={activeLang}
scanResults={scanResults}
scanError={scanError}
isScanning={isScanning}
isAutoFixing={isAutoFixing}
isAddingMissingKeys={isAddingMissingKeys}
fixableFiles={fixableFiles}
selectedFiles={selectedFiles}
forceConvertToClient={forceConvertToClient}
onToggleFile={toggleFileSelection}
onSelectAll={selectAllFiles}
onClear={clearSelectedFiles}
onToggleForceConvertToClient={() => setForceConvertToClient((prev) => !prev)}
onRunFixSelected={handleRunFixSelected}
onAddMissingKeys={addMissingKeys}
/>
</PageLayout>
);
}