966 lines
37 KiB
TypeScript
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 & 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>
|
|
);
|
|
}
|