profit-planet-frontend/src/app/admin/language-management/components/TranslationCoverageEditor.tsx
DeathKaioken e769132f84 fml
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 04:52:11 +02:00

527 lines
26 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.

'use client';
import type { MutableRefObject, RefObject } from 'react';
import { getEnglishValue, useTranslation } from '../../../i18n/useTranslation';
import type { NamespaceCategory } from '../hooks/useNamespaceCategories';
type LanguageEntry = {
code: string;
name: string;
};
type Props = {
activeCategory: string;
setActiveCategory: (category: string) => void;
categoriesWithKnownNamespaces: NamespaceCategory[];
allTabStats: { total: number; translated: number; missing: number };
categoryTabStats: Record<string, { total: number; translated: number; missing: number }>;
globalTabStats: { total: number; translated: number; missing: number };
activeLang: string;
allLanguages: LanguageEntry[];
search: string;
setSearch: (value: string) => void;
autoScrollOnPanelOpen: boolean;
setAutoScrollOnPanelOpen: (value: boolean) => void;
autoScrollOnSave: boolean;
setAutoScrollOnSave: (value: boolean) => void;
newGlobalKeySelection: string;
setNewGlobalKeySelection: (value: string) => void;
availableGlobalKeyOptions: string[];
addGlobalKey: (key: string) => void;
globalFilteredKeys: string[];
removeGlobalKey: (key: string) => void;
getDisplayValue: (key: string) => string;
translations: Record<string, Record<string, string>>;
handleChange: (key: string, value: string) => void;
globalKeySet: Set<string>;
englishReferenceKeySet: Set<string>;
setEnglishReferenceForKey: (key: string, enabled: boolean) => void;
filteredNs: string[];
filteredGroups: Record<string, string[]>;
activeNamespacePanel: string | null;
setActiveNamespacePanel: (next: string | null | ((prev: string | null) => string | null)) => void;
namespaceTranslationStats: Record<string, { total: number; translated: number; missing: number }>;
openedNamespacePanelRef: RefObject<HTMLDivElement | null>;
openFromPanelClickRef: MutableRefObject<boolean>;
onBackToPanels: () => void;
onOpenCategoryManager: () => void;
};
export default function TranslationCoverageEditor({
activeCategory,
setActiveCategory,
categoriesWithKnownNamespaces,
allTabStats,
categoryTabStats,
globalTabStats,
activeLang,
allLanguages,
search,
setSearch,
autoScrollOnPanelOpen,
setAutoScrollOnPanelOpen,
autoScrollOnSave,
setAutoScrollOnSave,
newGlobalKeySelection,
setNewGlobalKeySelection,
availableGlobalKeyOptions,
addGlobalKey,
globalFilteredKeys,
removeGlobalKey,
getDisplayValue,
translations,
handleChange,
globalKeySet,
englishReferenceKeySet,
setEnglishReferenceForKey,
filteredNs,
filteredGroups,
activeNamespacePanel,
setActiveNamespacePanel,
namespaceTranslationStats,
openedNamespacePanelRef,
openFromPanelClickRef,
onBackToPanels,
onOpenCategoryManager,
}: Props) {
const { t } = useTranslation();
// Shared tab button renderer
const renderCategoryTab = (
key: string,
label: string,
stats: { total: number; translated: number; missing: number }
) => {
const isActive = activeCategory === key;
const hasMissing = activeLang !== 'en' && stats.missing > 0;
return (
<button
key={key}
onClick={() => setActiveCategory(key)}
className={`flex shrink-0 items-center gap-2 px-4 py-2.5 rounded-2xl text-sm font-medium transition whitespace-nowrap ${
isActive
? hasMissing
? 'bg-red-600 text-white shadow-[0_18px_40px_-24px_rgba(220,38,38,0.7)]'
: 'bg-slate-900 text-white shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)]'
: hasMissing
? 'border border-red-200 bg-red-50/80 text-red-700 hover:bg-red-100'
: 'bg-transparent text-slate-600 hover:bg-slate-100 hover:text-slate-900 border border-transparent hover:border-slate-200'
}`}
>
<span>{label}</span>
{hasMissing ? (
<span className={`rounded-full px-2 py-0.5 text-[10px] leading-none font-semibold tabular-nums ${
isActive ? 'bg-white/20 text-white' : 'bg-red-100 text-red-600'
}`}>
{stats.missing}
</span>
) : (
<span className={`rounded-full px-2 py-0.5 text-[10px] leading-none font-medium tabular-nums ${
isActive ? 'bg-white/15 text-white/80' : 'bg-slate-100 text-slate-400'
}`}>
{stats.total}
</span>
)}
</button>
);
};
return (
<>
{/* ── Category tabs card ───────────────────────────────── */}
<div className="rounded-[28px] border border-white/80 bg-white/85 px-5 py-4 shadow-[0_22px_60px_-34px_rgba(15,23,42,0.28)] backdrop-blur">
<div className="flex items-center justify-between gap-3 flex-wrap mb-3">
<div>
<h2 className="text-sm font-semibold text-slate-950">{t('autofix.k5f978731')}</h2>
<p className="text-xs text-slate-500 mt-0.5">{t('autofix.kb7a30760')}</p>
</div>
<button
type="button"
onClick={onOpenCategoryManager}
className="rounded-2xl border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50 transition"
>
{t('autofix.kd6e42900')}
</button>
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{renderCategoryTab('all', 'All', allTabStats)}
{renderCategoryTab('global', 'Global', globalTabStats)}
{categoriesWithKnownNamespaces.map((cat) =>
renderCategoryTab(cat.id, cat.label, categoryTabStats[cat.id] ?? { total: 0, translated: 0, missing: 0 })
)}
</div>
</div>
{/* ── Search + options row ─────────────────────────────── */}
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 min-w-[14rem] max-w-sm">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
</svg>
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('autofix.kbf49d59b')}
className="w-full rounded-2xl border border-slate-200 bg-white/90 pl-9 pr-3 py-2 text-sm shadow-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900/20 focus:border-slate-300 transition"
/>
</div>
<div className="flex items-center gap-4 flex-wrap">
<label className="inline-flex items-center gap-2 text-sm text-slate-600 select-none">
<input
type="checkbox"
checked={autoScrollOnPanelOpen}
onChange={(e) => setAutoScrollOnPanelOpen(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900/20"
/>
{t('autofix.kfd1e0089')}
</label>
<label className="inline-flex items-center gap-2 text-sm text-slate-600 select-none">
<input
type="checkbox"
checked={autoScrollOnSave}
onChange={(e) => setAutoScrollOnSave(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900/20"
/>
{t('autofix.k23e95df1')}
</label>
</div>
</div>
{/* ── Content area ────────────────────────────────────── */}
<div className="space-y-3">
{/* Global keys table */}
{activeCategory === 'global' && (
<div className="rounded-[28px] border border-white/80 bg-white/85 overflow-hidden shadow-[0_22px_60px_-34px_rgba(15,23,42,0.28)] backdrop-blur">
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between gap-3 flex-wrap">
<div>
<span className="font-semibold text-slate-950">{t('autofix.k6cfeedd3')}</span>
<p className="text-xs text-slate-500 mt-0.5">{t('autofix.kad7d8c49')}</p>
</div>
<div className="flex items-center gap-2">
<select
value={newGlobalKeySelection}
onChange={(e) => setNewGlobalKeySelection(e.target.value)}
className="w-72 max-w-full rounded-2xl border border-slate-200 bg-white px-3 py-1.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-slate-900/20"
>
<option value="">{t('autofix.k47bce570')}</option>
{availableGlobalKeyOptions.map((key) => (
<option key={key} value={key}>{key}</option>
))}
</select>
<button
type="button"
onClick={() => {
if (!newGlobalKeySelection) return;
addGlobalKey(newGlobalKeySelection);
setNewGlobalKeySelection('');
}}
className="rounded-2xl border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50 transition"
>
Add
</button>
</div>
</div>
{globalFilteredKeys.length > 0 ? (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 bg-slate-50/60">
<th className="px-5 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 w-1/3">Key</th>
{activeLang !== 'en' && (
<th className="px-5 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 w-1/3">English</th>
)}
<th className="px-5 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
{allLanguages.find((l) => l.code === activeLang)?.name ?? activeLang}
</th>
</tr>
</thead>
<tbody>
{globalFilteredKeys.map((key) => {
const enVal = getEnglishValue(key);
const currentVal = getDisplayValue(key);
const hasOverride = (translations[activeLang]?.[key] ?? '') !== '';
return (
<tr key={key} className="border-b border-slate-50 last:border-0 hover:bg-slate-50/60 transition-colors">
<td className="px-5 py-2.5 font-mono text-xs text-slate-500 align-top pt-3">
<div className="flex items-start justify-between gap-2">
<span>{key}</span>
<button
type="button"
title={t('autofix.kc02b17c3')}
onClick={() => removeGlobalKey(key)}
className="shrink-0 rounded-xl border border-red-200 px-1.5 py-0.5 text-[10px] text-red-500 hover:bg-red-50 transition"
>
Remove
</button>
</div>
</td>
{activeLang !== 'en' && (
<td className="px-5 py-2.5 text-slate-500 align-top pt-3 text-xs">{enVal}</td>
)}
<td className="px-5 py-2.5">
<div className="relative">
<textarea
rows={1}
value={activeLang === 'en' ? currentVal : (translations[activeLang]?.[key] ?? '')}
onChange={(e) => handleChange(key, e.target.value)}
placeholder={activeLang === 'en' ? '' : enVal}
className={`w-full rounded-xl border px-3 py-1.5 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-slate-900/20 transition ${
hasOverride && activeLang !== 'en'
? 'border-emerald-300 bg-emerald-50/60'
: 'border-slate-200 bg-white'
}`}
style={{ minHeight: '2.25rem', fieldSizing: 'content' } as React.CSSProperties}
onInput={(e) => {
const target = e.currentTarget;
target.style.height = 'auto';
target.style.height = `${target.scrollHeight}px`;
}}
/>
{hasOverride && activeLang !== 'en' && (
<button
type="button"
title={t('autofix.k644d9ea8')}
onClick={() => handleChange(key, '')}
className="absolute top-1.5 right-2 text-xs text-slate-400 hover:text-red-500 transition"
>
×
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<div className="p-10 text-center text-sm text-slate-400">{t('autofix.k0700b1f2')}</div>
)}
</div>
)}
{/* Namespace grid + open panel */}
{activeCategory !== 'global' && filteredNs.length > 0 && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
{filteredNs.map((ns, idx) => {
const keys = filteredGroups[ns] ?? [];
const nsStats = namespaceTranslationStats[ns] ?? {
total: keys.length,
translated: keys.length,
missing: 0,
};
const hasMissing = activeLang !== 'en' && nsStats.missing > 0;
const isActive = activeNamespacePanel === ns;
const activeIdx = activeNamespacePanel ? filteredNs.indexOf(activeNamespacePanel) : -1;
const shiftClass = !activeNamespacePanel || isActive
? 'translate-x-0'
: idx < activeIdx ? '-translate-x-1' : 'translate-x-1';
return (
<button
key={ns}
type="button"
onClick={() => {
openFromPanelClickRef.current = true;
setActiveNamespacePanel((prev) => (prev === ns ? null : ns));
}}
className={`w-full rounded-[20px] border px-4 py-3 text-left transition-all duration-300 backdrop-blur ${shiftClass} ${
isActive
? hasMissing
? 'border-red-300 bg-red-50/90 shadow-[0_12px_30px_-18px_rgba(220,38,38,0.5)]'
: 'border-slate-300 bg-white/95 shadow-[0_12px_30px_-18px_rgba(15,23,42,0.4)]'
: hasMissing
? 'border-red-200 bg-red-50/60 hover:bg-red-50/90'
: 'border-white/80 bg-white/80 hover:bg-white/95 hover:border-slate-200'
}`}
>
<div className="flex items-center justify-between gap-2">
<span className={`text-sm font-semibold capitalize ${isActive ? 'text-slate-950' : 'text-slate-800'}`}>
{ns}
</span>
<div className="flex items-center gap-1">
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${
isActive
? hasMissing ? 'bg-red-500 text-white' : 'bg-slate-900 text-white'
: hasMissing ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-600'
}`}>
{nsStats.translated}/{nsStats.total}
</span>
{hasMissing && (
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${
isActive ? 'bg-red-200 text-red-800' : 'bg-red-100 text-red-700'
}`}>
{nsStats.missing}
</span>
)}
</div>
</div>
<p className="mt-1 text-[11px] text-slate-400">
{isActive ? t('autofix.k77d01d6a') : t('autofix.kcfb5fb54')}
</p>
</button>
);
})}
</div>
{activeNamespacePanel && filteredGroups[activeNamespacePanel] && (
<div
ref={openedNamespacePanelRef}
className="rounded-[28px] border border-white/80 bg-white/90 overflow-hidden shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur transition-all duration-300"
>
{/* Panel header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-100 bg-slate-50/60">
<span className="font-bold text-slate-950 capitalize text-base">{activeNamespacePanel}</span>
<div className="flex items-center gap-1.5">
<span className="text-xs rounded-full border border-slate-200 bg-white px-2 py-0.5 text-slate-600 font-medium">
{(namespaceTranslationStats[activeNamespacePanel]?.translated ?? 0)}/{(namespaceTranslationStats[activeNamespacePanel]?.total ?? 0)}
</span>
{activeLang !== 'en' && (namespaceTranslationStats[activeNamespacePanel]?.missing ?? 0) > 0 && (
<span className="text-xs rounded-full border border-red-200 bg-red-50 px-2 py-0.5 text-red-700 font-medium">
{namespaceTranslationStats[activeNamespacePanel]?.missing} missing
</span>
)}
</div>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 bg-slate-50/40">
<th className="px-5 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 w-1/3">Key</th>
{activeLang !== 'en' && (
<th className="px-5 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 w-1/3">English</th>
)}
<th className="px-5 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
{allLanguages.find((l) => l.code === activeLang)?.name ?? activeLang}
</th>
</tr>
</thead>
<tbody>
{filteredGroups[activeNamespacePanel].map((key) => {
const enVal = getEnglishValue(key);
const currentVal = getDisplayValue(key);
const isGlobal = globalKeySet.has(key);
const isEnglishReference = englishReferenceKeySet.has(key);
const visibleValue = activeLang === 'en'
? currentVal
: (translations[activeLang]?.[key] ?? '');
const hasOverride = (translations[activeLang]?.[key] ?? '') !== '';
const isMissingInOpenedPanel =
activeLang !== 'en' &&
!isGlobal &&
!isEnglishReference && (
visibleValue.trim() === '' ||
visibleValue.trim() === enVal.trim()
);
return (
<tr key={key} className={`border-b border-slate-50 last:border-0 transition-colors ${
isMissingInOpenedPanel ? 'bg-red-50/50 hover:bg-red-50/80' : 'hover:bg-slate-50/60'
}`}>
<td className={`px-5 py-2.5 font-mono text-xs align-top pt-3 ${
isMissingInOpenedPanel ? 'text-red-700' : 'text-slate-500'
}`}>
<div className="space-y-2.5">
<div className="flex items-center gap-1.5">
<span className="block">{key}</span>
{isGlobal && (
<span className="rounded-full border border-indigo-200 bg-indigo-50 px-1.5 py-0.5 text-[10px] font-semibold text-indigo-700">
Global
</span>
)}
{isEnglishReference && activeLang !== 'en' && (
<span className="rounded-full border border-sky-200 bg-sky-50 px-1.5 py-0.5 text-[10px] font-semibold text-sky-700">{t('autofix.k8de6d3df')}</span>
)}
</div>
<label className="flex items-center gap-1.5 text-[11px] font-sans text-slate-500 select-none">
<input
type="checkbox"
checked={isGlobal}
onChange={(e) => {
if (e.target.checked) { addGlobalKey(key); return; }
removeGlobalKey(key);
}}
className="h-3.5 w-3.5 rounded border-slate-300 text-slate-900 focus:ring-slate-900/20"
/>
{t('autofix.kb1cf599b')}
</label>
{activeLang !== 'en' && (
<label className="flex items-center gap-1.5 text-[11px] font-sans text-slate-500 select-none">
<input
type="checkbox"
checked={isEnglishReference}
onChange={(e) => setEnglishReferenceForKey(key, e.target.checked)}
className="h-3.5 w-3.5 rounded border-slate-300 text-slate-900 focus:ring-slate-900/20"
/>{t('autofix.k6d79b1df')}</label>
)}
</div>
</td>
{activeLang !== 'en' && (
<td className="px-5 py-2.5 text-slate-400 align-top pt-3 text-xs">{enVal}</td>
)}
<td className="px-5 py-2.5">
<div className="relative">
<textarea
rows={1}
value={visibleValue}
onChange={(e) => handleChange(key, e.target.value)}
placeholder={activeLang === 'en' ? '' : enVal}
className={`w-full rounded-xl border px-3 py-1.5 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-slate-900/20 transition ${
isMissingInOpenedPanel
? 'border-red-300 bg-red-50/60'
: hasOverride && activeLang !== 'en'
? 'border-emerald-300 bg-emerald-50/60'
: 'border-slate-200 bg-white'
}`}
style={{ minHeight: '2.25rem', fieldSizing: 'content' } as React.CSSProperties}
onInput={(e) => {
const target = e.currentTarget;
target.style.height = 'auto';
target.style.height = `${target.scrollHeight}px`;
}}
/>
{hasOverride && activeLang !== 'en' && (
<button
type="button"
title={t('autofix.k644d9ea8')}
onClick={() => handleChange(key, '')}
className="absolute top-1.5 right-2 text-xs text-slate-400 hover:text-red-500 transition"
>
×
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<div className="border-t border-slate-100 px-5 py-3 bg-slate-50/40 flex justify-end">
<button
type="button"
onClick={onBackToPanels}
className="rounded-2xl border border-slate-200 bg-white px-4 py-1.5 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50 transition"
>
{t('autofix.k6aba2cb0')}
</button>
</div>
</div>
)}
</>
)}
{activeCategory !== 'global' && filteredNs.length === 0 && (
<div className="rounded-[28px] border border-white/80 bg-white/85 p-10 text-center text-sm text-slate-400 shadow-[0_22px_60px_-34px_rgba(15,23,42,0.18)] backdrop-blur">
{t('autofix.k6a892262')}
</div>
)}
</div>
</>
);
}