dev #21
1
global.d.ts
vendored
Normal file
1
global.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
declare module '*.css';
|
||||||
1759
package-lock.json
generated
1759
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@ -6,7 +6,9 @@
|
|||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build --turbopack",
|
"build": "next build --turbopack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gsap/react": "^2.1.2",
|
"@gsap/react": "^2.1.2",
|
||||||
@ -54,18 +56,22 @@
|
|||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
"@eslint/js": "^9.0.1",
|
"@eslint/js": "^9.0.1",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@types/node": "^25",
|
"@types/node": "^25",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@vitest/coverage-v8": "^4.1.5",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
"baseline-browser-mapping": "^2.9.19",
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-next": "^16.1.6",
|
"eslint-config-next": "^16.1.6",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"globals": "^17.3.0",
|
"globals": "^17.3.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"postcss-preset-env": "^11.1.3",
|
"postcss-preset-env": "^11.1.3",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -449,6 +449,7 @@ export default function AffiliateManagementPage() {
|
|||||||
|
|
||||||
// Create Modal Component
|
// Create Modal Component
|
||||||
function CreateAffiliateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (affiliate: Omit<Affiliate, 'id' | 'createdAt'> & { logoFile?: File }) => void }) {
|
function CreateAffiliateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (affiliate: Omit<Affiliate, 'id' | 'createdAt'> & { logoFile?: File }) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
@ -702,6 +703,7 @@ function EditAffiliateModal({ affiliate, onClose, onUpdate }: {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onUpdate: (affiliate: Affiliate & { logoFile?: File; removeLogo?: boolean }) => void
|
onUpdate: (affiliate: Affiliate & { logoFile?: File; removeLogo?: boolean }) => void
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [name, setName] = useState(affiliate.name)
|
const [name, setName] = useState(affiliate.name)
|
||||||
const [description, setDescription] = useState(affiliate.description)
|
const [description, setDescription] = useState(affiliate.description)
|
||||||
const [url, setUrl] = useState(affiliate.url)
|
const [url, setUrl] = useState(affiliate.url)
|
||||||
@ -964,6 +966,7 @@ function DeleteConfirmModal({ affiliateName, onClose, onConfirm, isDeleting }: {
|
|||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
isDeleting: boolean;
|
isDeleting: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
<div className="relative w-full max-w-md mx-0 sm:mx-4 bg-white rounded-none sm:rounded-2xl shadow-2xl p-4 sm:p-6">
|
<div className="relative w-full max-w-md mx-0 sm:mx-4 bg-white rounded-none sm:rounded-2xl shadow-2xl p-4 sm:p-6">
|
||||||
|
|||||||
@ -32,35 +32,52 @@ export default function AddLanguageModal({
|
|||||||
if (!isRendered) return null;
|
if (!isRendered) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm transition-opacity duration-200 ${
|
<div className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/30 backdrop-blur-md transition-opacity duration-200 ${
|
||||||
isVisible ? 'opacity-100' : 'opacity-0'
|
isVisible ? 'opacity-100' : 'opacity-0'
|
||||||
}`}>
|
}`}>
|
||||||
<div className={`mx-4 w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl transform transition-all duration-200 ${
|
<div className={`mx-4 w-full max-w-sm rounded-[28px] border border-white/80 bg-white/90 backdrop-blur p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] transform transition-all duration-200 ${
|
||||||
isVisible ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-2 scale-[0.98]'
|
isVisible ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-2 scale-[0.98]'
|
||||||
}`}>
|
}`}>
|
||||||
<h2 className="text-lg font-bold text-[#1C2B4A] mb-4">{t('autofix.kf4e45236')}</h2>
|
<div className="flex items-start justify-between gap-3 mb-5">
|
||||||
|
<div>
|
||||||
|
<span className="inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500 shadow-sm mb-2">
|
||||||
|
Languages
|
||||||
|
</span>
|
||||||
|
<h2 className="text-xl font-black tracking-tight text-slate-950">{t('autofix.kf4e45236')}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-xl border border-slate-200 bg-white/80 px-2.5 py-1.5 text-slate-400 hover:text-slate-700 hover:bg-white transition shadow-sm text-base leading-none"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">{t('autofix.k92639a9a')}</label>
|
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wide mb-1.5">{t('autofix.k92639a9a')}</label>
|
||||||
<input
|
<input
|
||||||
value={newCode}
|
value={newCode}
|
||||||
onChange={(e) => setNewCode(e.target.value)}
|
onChange={(e) => setNewCode(e.target.value)}
|
||||||
placeholder={t('autofix.k03538639')}
|
placeholder={t('autofix.k03538639')}
|
||||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
|
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-2.5 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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">{t('autofix.k926966d0')}</label>
|
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wide mb-1.5">{t('autofix.k926966d0')}</label>
|
||||||
<input
|
<input
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
placeholder={t('autofix.ka019b3c0')}
|
placeholder={t('autofix.ka019b3c0')}
|
||||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
|
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-2.5 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>
|
||||||
{addError && <p className="text-xs text-red-600">{addError}</p>}
|
{addError && (
|
||||||
|
<p className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-600">{addError}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex justify-end gap-3">
|
|
||||||
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onClose();
|
onClose();
|
||||||
@ -68,11 +85,14 @@ export default function AddLanguageModal({
|
|||||||
setNewCode('');
|
setNewCode('');
|
||||||
setNewName('');
|
setNewName('');
|
||||||
}}
|
}}
|
||||||
className="rounded-md border border-gray-300 px-4 py-2 text-sm hover:bg-gray-50"
|
className="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition shadow-sm"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onAdd} className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90">
|
<button
|
||||||
|
onClick={onAdd}
|
||||||
|
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"
|
||||||
|
>
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -49,47 +49,59 @@ export default function CategoryManagerModal({
|
|||||||
if (!isRendered) return null;
|
if (!isRendered) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`fixed inset-0 z-[150] flex items-center justify-center bg-black/45 backdrop-blur-sm transition-opacity duration-200 ${
|
<div className={`fixed inset-0 z-[150] flex items-center justify-center bg-black/30 backdrop-blur-md transition-opacity duration-200 ${
|
||||||
isVisible ? 'opacity-100' : 'opacity-0'
|
isVisible ? 'opacity-100' : 'opacity-0'
|
||||||
}`}>
|
}`}>
|
||||||
<div className={`mx-4 w-full max-w-5xl rounded-2xl border border-slate-200 bg-white shadow-2xl overflow-hidden transform transition-all duration-200 ${
|
<div className={`mx-4 w-full max-w-5xl rounded-[30px] border border-white/80 bg-white/88 backdrop-blur overflow-hidden shadow-[0_32px_80px_-40px_rgba(15,23,42,0.42)] transform transition-all duration-200 ${
|
||||||
isVisible ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-2 scale-[0.98]'
|
isVisible ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-2 scale-[0.98]'
|
||||||
}`}>
|
}`}>
|
||||||
<div className="px-6 py-4 border-b border-slate-200 bg-slate-50 flex items-start justify-between gap-4">
|
{/* Header */}
|
||||||
|
<div className="px-6 py-5 border-b border-white/60 flex items-start justify-between gap-4 bg-white/40">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-[#1C2B4A]">{t('autofix.kef9de7f0')}</h2>
|
<span className="inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500 shadow-sm mb-2">
|
||||||
<p className="text-xs text-slate-600 mt-1">{t('autofix.kc4671abe')}</p>
|
Admin
|
||||||
|
</span>
|
||||||
|
<h2 className="text-xl font-black tracking-tight text-slate-950">{t('autofix.kef9de7f0')}</h2>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">{t('autofix.kc4671abe')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-700 text-xl leading-none">
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-xl border border-slate-200 bg-white/80 px-2.5 py-1.5 text-slate-400 hover:text-slate-700 hover:bg-white transition shadow-sm text-base leading-none shrink-0"
|
||||||
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
<div className="px-6 py-5 max-h-[70vh] overflow-y-auto space-y-4">
|
<div className="px-6 py-5 max-h-[70vh] overflow-y-auto space-y-4">
|
||||||
|
{/* Create category row */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<input
|
<input
|
||||||
value={newCategoryLabel}
|
value={newCategoryLabel}
|
||||||
onChange={(e) => setNewCategoryLabel(e.target.value)}
|
onChange={(e) => setNewCategoryLabel(e.target.value)}
|
||||||
placeholder={t('autofix.ke52ed6e9')}
|
placeholder={t('autofix.ke52ed6e9')}
|
||||||
className="w-56 rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
|
className="w-56 rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm shadow-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900/20 transition"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCreateCategory}
|
onClick={onCreateCategory}
|
||||||
className="rounded-md bg-[#1C2B4A] text-white px-3 py-1.5 text-sm font-medium hover:bg-[#152344]"
|
className="rounded-2xl bg-slate-900 text-white px-4 py-2 text-sm font-semibold shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)] hover:bg-slate-800 transition"
|
||||||
>{t('autofix.k1db86f96')}</button>
|
>
|
||||||
|
{t('autofix.k1db86f96')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-3">
|
{/* Uncategorized pool */}
|
||||||
<p className="text-xs font-semibold text-slate-700 mb-2">{t('autofix.k505ebdae')}</p>
|
<div className="rounded-[20px] border border-white/80 bg-white/70 backdrop-blur p-4 shadow-sm">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500 mb-2.5">{t('autofix.k505ebdae')}</p>
|
||||||
<div className="flex flex-wrap gap-2 min-h-10">
|
<div className="flex flex-wrap gap-2 min-h-10">
|
||||||
{uncategorizedNamespaces.map((ns) => (
|
{uncategorizedNamespaces.map((ns) => (
|
||||||
<span
|
<span
|
||||||
key={ns}
|
key={ns}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={() => setDragNamespace(ns)}
|
onDragStart={() => setDragNamespace(ns)}
|
||||||
className="cursor-grab rounded-full border border-slate-300 bg-slate-50 px-2 py-1 text-xs text-slate-700"
|
className="cursor-grab rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50 transition"
|
||||||
title={t('autofix.k66edf1eb')}
|
title={t('autofix.k66edf1eb')}
|
||||||
>
|
>
|
||||||
{ns}
|
{ns}
|
||||||
@ -101,6 +113,7 @@ export default function CategoryManagerModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Category list */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{categoriesWithKnownNamespaces.map((cat) => {
|
{categoriesWithKnownNamespaces.map((cat) => {
|
||||||
const availableToAssign = namespaces.filter((ns) => !cat.namespaces.includes(ns));
|
const availableToAssign = namespaces.filter((ns) => !cat.namespaces.includes(ns));
|
||||||
@ -112,9 +125,7 @@ export default function CategoryManagerModal({
|
|||||||
key={cat.id}
|
key={cat.id}
|
||||||
onDragEnter={() => {
|
onDragEnter={() => {
|
||||||
if (!dragNamespace) return;
|
if (!dragNamespace) return;
|
||||||
if (expandedCategoryId !== cat.id) {
|
if (expandedCategoryId !== cat.id) setExpandedCategoryId(cat.id);
|
||||||
setExpandedCategoryId(cat.id);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onDrop={() => {
|
onDrop={() => {
|
||||||
@ -123,18 +134,19 @@ export default function CategoryManagerModal({
|
|||||||
setDragNamespace(null);
|
setDragNamespace(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="rounded-xl border border-slate-200 bg-white"
|
className="rounded-[20px] border border-white/80 bg-white/70 backdrop-blur overflow-hidden shadow-sm"
|
||||||
>
|
>
|
||||||
|
{/* Category header row */}
|
||||||
<div
|
<div
|
||||||
className="px-3 py-2.5 border-b border-slate-100 flex items-center justify-between gap-2 cursor-pointer"
|
className="px-4 py-3 flex items-center justify-between gap-2 cursor-pointer hover:bg-white/60 transition"
|
||||||
onClick={() => setExpandedCategoryId((prev) => (prev === cat.id ? null : cat.id))}
|
onClick={() => setExpandedCategoryId((prev) => (prev === cat.id ? null : cat.id))}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 text-left">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-[#1C2B4A]">{cat.label}</span>
|
<span className="text-sm font-bold text-slate-950">{cat.label}</span>
|
||||||
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-700">
|
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-semibold text-slate-500 shadow-sm">
|
||||||
{cat.namespaces.length}
|
{cat.namespaces.length}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-slate-500">{isExpanded ? 'Hide' : 'Manage'}</span>
|
<span className="text-xs text-slate-400">{isExpanded ? '▴ Hide' : '▾ Manage'}</span>
|
||||||
</div>
|
</div>
|
||||||
{cat.isCustom && (
|
{cat.isCustom && (
|
||||||
<button
|
<button
|
||||||
@ -143,7 +155,7 @@ export default function CategoryManagerModal({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
deleteCategory(cat.id);
|
deleteCategory(cat.id);
|
||||||
}}
|
}}
|
||||||
className="text-xs rounded border border-red-200 bg-red-50 px-2 py-0.5 text-red-600 hover:bg-red-100"
|
className="rounded-2xl border border-red-200 bg-red-50 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-100 transition"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@ -151,12 +163,12 @@ export default function CategoryManagerModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="p-3 space-y-2">
|
<div className="border-t border-white/60 p-4 space-y-3 bg-white/40">
|
||||||
<div className="flex flex-col lg:flex-row items-stretch gap-2">
|
<div className="flex flex-col lg:flex-row items-stretch gap-2">
|
||||||
<select
|
<select
|
||||||
value={selectValue}
|
value={selectValue}
|
||||||
onChange={(e) => setAssignNamespaceByCategory((prev) => ({ ...prev, [cat.id]: e.target.value }))}
|
onChange={(e) => setAssignNamespaceByCategory((prev) => ({ ...prev, [cat.id]: e.target.value }))}
|
||||||
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-xs"
|
className="w-full rounded-2xl border border-slate-200 bg-white px-3 py-2 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-slate-900/20 transition"
|
||||||
>
|
>
|
||||||
<option value="">{t('autofix.k0cdc3ee9')}</option>
|
<option value="">{t('autofix.k0cdc3ee9')}</option>
|
||||||
{availableToAssign.map((ns) => (
|
{availableToAssign.map((ns) => (
|
||||||
@ -170,25 +182,25 @@ export default function CategoryManagerModal({
|
|||||||
addNamespaceToCategory(cat.id, selectValue);
|
addNamespaceToCategory(cat.id, selectValue);
|
||||||
setAssignNamespaceByCategory((prev) => ({ ...prev, [cat.id]: '' }));
|
setAssignNamespaceByCategory((prev) => ({ ...prev, [cat.id]: '' }));
|
||||||
}}
|
}}
|
||||||
className="rounded-md border border-slate-300 bg-white px-3 py-2 text-xs text-slate-700 hover:bg-slate-50"
|
className="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50 transition"
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 min-h-10 rounded-md border border-dashed border-slate-200 p-2">
|
<div className="flex flex-wrap gap-2 min-h-10 rounded-2xl border border-dashed border-slate-200 bg-white/60 p-3">
|
||||||
{cat.namespaces.map((ns) => (
|
{cat.namespaces.map((ns) => (
|
||||||
<span
|
<span
|
||||||
key={ns}
|
key={ns}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={() => setDragNamespace(ns)}
|
onDragStart={() => setDragNamespace(ns)}
|
||||||
className="group cursor-grab rounded-full border border-indigo-200 bg-indigo-50 px-2 py-1 text-xs text-indigo-700"
|
className="group cursor-grab inline-flex items-center rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium text-indigo-700 hover:border-indigo-300 transition"
|
||||||
>
|
>
|
||||||
{ns}
|
{ns}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeNamespaceFromCategory(cat.id, ns)}
|
onClick={() => removeNamespaceFromCategory(cat.id, ns)}
|
||||||
className="ml-1 text-indigo-500 group-hover:text-red-500"
|
className="ml-1.5 text-indigo-400 group-hover:text-red-500 transition leading-none"
|
||||||
title={t('autofix.ka6791a02')}
|
title={t('autofix.ka6791a02')}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
@ -208,8 +220,12 @@ export default function CategoryManagerModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-4 border-t border-slate-200 bg-white flex justify-end">
|
{/* Footer */}
|
||||||
<button onClick={onClose} className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90">
|
<div className="px-6 py-4 border-t border-white/60 bg-white/40 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
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"
|
||||||
|
>
|
||||||
Done
|
Done
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -27,22 +27,45 @@ export default function DeleteLanguageModal({ deleteTarget, allLanguages, onClos
|
|||||||
const languageName = allLanguages.find((l) => l.code === displayTarget)?.name ?? displayTarget;
|
const languageName = allLanguages.find((l) => l.code === displayTarget)?.name ?? displayTarget;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm transition-opacity duration-200 ${
|
<div className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/30 backdrop-blur-md transition-opacity duration-200 ${
|
||||||
isVisible ? 'opacity-100' : 'opacity-0'
|
isVisible ? 'opacity-100' : 'opacity-0'
|
||||||
}`}>
|
}`}>
|
||||||
<div className={`mx-4 w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl transform transition-all duration-200 ${
|
<div className={`mx-4 w-full max-w-sm rounded-[28px] border border-white/80 bg-white/90 backdrop-blur p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] transform transition-all duration-200 ${
|
||||||
isVisible ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-2 scale-[0.98]'
|
isVisible ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-2 scale-[0.98]'
|
||||||
}`}>
|
}`}>
|
||||||
<h2 className="text-lg font-bold text-red-600 mb-3">{t('autofix.kda5f982e')}</h2>
|
<div className="flex items-start justify-between gap-3 mb-5">
|
||||||
<p className="text-sm text-gray-600 mb-5">
|
<div>
|
||||||
Delete <strong>{languageName}</strong>?
|
<span className="inline-flex items-center rounded-full border border-red-200 bg-red-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-red-600 shadow-sm mb-2">
|
||||||
All translations for this language will be removed.
|
Destructive
|
||||||
|
</span>
|
||||||
|
<h2 className="text-xl font-black tracking-tight text-slate-950">{t('autofix.kda5f982e')}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-xl border border-slate-200 bg-white/80 px-2.5 py-1.5 text-slate-400 hover:text-slate-700 hover:bg-white transition shadow-sm text-base leading-none"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-red-100 bg-red-50/60 px-4 py-3 mb-5">
|
||||||
|
<p className="text-sm text-slate-700">
|
||||||
|
Delete <strong className="text-slate-950">{languageName}</strong>?{' '}
|
||||||
|
All translations for this language will be permanently removed.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end gap-3">
|
</div>
|
||||||
<button onClick={onClose} className="rounded-md border border-gray-300 px-4 py-2 text-sm hover:bg-gray-50">
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition shadow-sm"
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => onDelete(displayTarget)} className="rounded-md bg-red-600 text-white px-4 py-2 text-sm font-semibold hover:bg-red-700">
|
<button
|
||||||
|
onClick={() => onDelete(displayTarget)}
|
||||||
|
className="rounded-2xl bg-red-600 text-white px-5 py-2 text-sm font-semibold shadow-[0_18px_40px_-24px_rgba(220,38,38,0.7)] hover:bg-red-700 transition"
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -60,11 +60,9 @@ export default function LanguageManagementTopSection({
|
|||||||
const byCode = new Map(allLanguages.map((lang) => [lang.code, lang]));
|
const byCode = new Map(allLanguages.map((lang) => [lang.code, lang]));
|
||||||
const english = byCode.get('en');
|
const english = byCode.get('en');
|
||||||
const german = byCode.get('de');
|
const german = byCode.get('de');
|
||||||
|
|
||||||
const rest = allLanguages
|
const rest = allLanguages
|
||||||
.filter((lang) => lang.code !== 'en' && lang.code !== 'de')
|
.filter((lang) => lang.code !== 'en' && lang.code !== 'de')
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
return [english, german, ...rest].filter((lang): lang is LanguageEntry => Boolean(lang));
|
return [english, german, ...rest].filter((lang): lang is LanguageEntry => Boolean(lang));
|
||||||
}, [allLanguages]);
|
}, [allLanguages]);
|
||||||
|
|
||||||
@ -76,124 +74,168 @@ export default function LanguageManagementTopSection({
|
|||||||
<button
|
<button
|
||||||
key={lang.code}
|
key={lang.code}
|
||||||
onClick={() => setActiveLang(lang.code)}
|
onClick={() => setActiveLang(lang.code)}
|
||||||
className={`relative rounded-lg px-4 py-2 text-sm font-medium transition flex items-center gap-2 ${
|
className={`flex shrink-0 items-center gap-2 px-4 py-2.5 rounded-2xl text-sm font-medium transition whitespace-nowrap ${
|
||||||
activeLang === lang.code
|
activeLang === lang.code
|
||||||
? 'bg-[#1C2B4A] text-white shadow'
|
? 'bg-slate-900 text-white shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)]'
|
||||||
: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
|
: 'bg-transparent text-slate-700 hover:bg-slate-100 hover:text-slate-900 border border-transparent hover:border-slate-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{lang.name}
|
{lang.name}
|
||||||
<span className="text-xs opacity-60">({lang.code})</span>
|
<span className={`text-xs ${activeLang === lang.code ? 'opacity-50' : 'opacity-40'}`}>
|
||||||
|
{lang.code}
|
||||||
|
</span>
|
||||||
{!isBuiltin(lang.code) && (
|
{!isBuiltin(lang.code) && (
|
||||||
<button
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDeleteLanguageRequest(lang.code);
|
onDeleteLanguageRequest(lang.code);
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteLanguageRequest(lang.code);
|
||||||
|
}
|
||||||
|
}}
|
||||||
title={t('autofix.k5fcc9b0e')}
|
title={t('autofix.k5fcc9b0e')}
|
||||||
className={`ml-1 inline-flex items-center justify-center rounded-full w-4 h-4 text-xs leading-none ${
|
className={`ml-0.5 inline-flex items-center justify-center rounded-full w-4 h-4 text-xs leading-none cursor-pointer transition ${
|
||||||
activeLang === lang.code
|
activeLang === lang.code
|
||||||
? 'bg-white/20 hover:bg-white/40 text-white'
|
? 'bg-white/20 hover:bg-white/40 text-white'
|
||||||
: 'bg-gray-200 hover:bg-red-100 text-gray-500 hover:text-red-600'
|
: 'bg-slate-200 hover:bg-red-100 text-slate-500 hover:text-red-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={headerRef} className="flex items-center justify-between flex-wrap gap-4">
|
{/* ── Hero header card ─────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
ref={headerRef}
|
||||||
|
className="rounded-[30px] border border-white/80 bg-white/85 px-5 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] backdrop-blur md:px-8 md:py-8"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">
|
||||||
|
{t('autofix.ka8c928ac')}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-[#1C2B4A]">{t('autofix.k346a2c64')}</h1>
|
<h1 className="text-3xl font-black tracking-tight text-slate-950 md:text-4xl">
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
{t('autofix.k346a2c64')}
|
||||||
Manage UI translations. All {totalKeys} keys scanned from the English source file.
|
</h1>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
|
||||||
|
{t('autofix.k7227f13d')} {totalKeys} {t('autofix.k511d7fab')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={onScan}
|
onClick={onScan}
|
||||||
disabled={isScanning || isAutoFixing}
|
disabled={isScanning || isAutoFixing}
|
||||||
className="rounded-md border border-[#1C2B4A] text-[#1C2B4A] px-3 py-2 text-sm font-medium hover:bg-[#1C2B4A] hover:text-white transition-colors flex items-center gap-2 disabled:opacity-50"
|
className="flex items-center gap-2 rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50 transition disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||||
</svg>
|
</svg>
|
||||||
{isScanning ? 'Scanning...' : 'Scan & review fixes'}
|
{isScanning ? t('autofix.kf191f6df5') : t('autofix.k9863fa5')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onBackToAdmin}
|
onClick={onBackToAdmin}
|
||||||
className="rounded-md border border-gray-300 px-3 py-2 text-sm hover:bg-gray-50"
|
className="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50 transition"
|
||||||
>{t('autofix.kea7cde7a')}</button>
|
>
|
||||||
{isDirty && (
|
{t('autofix.kea7cde7a')}
|
||||||
<button
|
</button>
|
||||||
onClick={onSave}
|
|
||||||
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90"
|
|
||||||
>{t('autofix.k4be6f631')}</button>
|
|
||||||
)}
|
|
||||||
{saved && !isDirty && (
|
{saved && !isDirty && (
|
||||||
<span className="rounded-md bg-green-50 border border-green-200 text-green-700 px-3 py-2 text-sm font-medium">
|
<span className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-700">{t('autofix.kac6aab53')}</span>
|
||||||
Saved
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* stat pills */}
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs text-slate-600">
|
||||||
|
<span className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1.5 font-medium text-slate-700 shadow-sm">
|
||||||
|
{allLanguages.length} {allLanguages.length === 1 ? t('autofix.k20eb1f87') : t('autofix.ka6cf3286')}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1.5 font-medium text-slate-700 shadow-sm">
|
||||||
|
{totalKeys} {t('autofix.k3931709b')}
|
||||||
|
</span>
|
||||||
|
{activeLang !== 'en' && (
|
||||||
|
<span className={`inline-flex items-center rounded-full border px-3 py-1.5 font-medium shadow-sm ${
|
||||||
|
allTabStats.missing > 0
|
||||||
|
? 'border-red-200 bg-red-50 text-red-700'
|
||||||
|
: 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||||
|
}`}>
|
||||||
|
{allTabStats.missing > 0 ? `${allTabStats.missing} ${t('autofix.k571ffd91')}` : t('autofix.kdcc78d97')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Save error banner ────────────────────────────────── */}
|
||||||
{saveError && (
|
{saveError && (
|
||||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
<div className="rounded-2xl border border-red-200 bg-red-50/80 backdrop-blur px-5 py-3 text-sm text-red-700 shadow-sm">
|
||||||
{saveError}
|
{saveError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
{/* ── Language tabs card ───────────────────────────────── */}
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/85 px-4 py-3 shadow-[0_22px_60px_-34px_rgba(15,23,42,0.28)] backdrop-blur md:px-5">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
{englishLanguage && renderLanguageButton(englishLanguage)}
|
{englishLanguage && renderLanguageButton(englishLanguage)}
|
||||||
{germanLanguage && renderLanguageButton(germanLanguage)}
|
{germanLanguage && renderLanguageButton(germanLanguage)}
|
||||||
|
{otherLanguages.map((lang) => renderLanguageButton(lang))}
|
||||||
<button
|
<button
|
||||||
onClick={onOpenAddLanguage}
|
onClick={onOpenAddLanguage}
|
||||||
className="rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-sm text-gray-500 hover:border-[#1C2B4A] hover:text-[#1C2B4A] transition"
|
className="flex shrink-0 items-center gap-1.5 px-4 py-2.5 rounded-2xl text-sm font-medium text-slate-400 border border-dashed border-slate-300 hover:border-slate-400 hover:text-slate-600 transition whitespace-nowrap"
|
||||||
>
|
>
|
||||||
+ Add language
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</button>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
{otherLanguages.map((lang) => renderLanguageButton(lang))}
|
</svg>{t('autofix.k7a515516')}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Translation progress card ────────────────────────── */}
|
||||||
{activeLang !== 'en' && (
|
{activeLang !== 'en' && (
|
||||||
<div className="rounded-xl border border-gray-200 bg-white p-4 flex items-center gap-4">
|
<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 flex items-center gap-5">
|
||||||
<div className="flex-1">
|
<div className="flex-1 space-y-1.5">
|
||||||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
<div className="flex justify-between text-xs text-slate-500">
|
||||||
<span>{t('autofix.kb8f33873')}</span>
|
<span>{t('autofix.kb8f33873')}</span>
|
||||||
<span>{allTabStats.translated} / {allTabStats.total} keys translated</span>
|
<span className="font-medium text-slate-700">{allTabStats.translated} / {allTabStats.total} {t('autofix.k33f55455')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 rounded-full bg-gray-100 overflow-hidden">
|
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-[#1C2B4A] transition-all"
|
className="h-full rounded-full bg-slate-900 transition-all duration-500"
|
||||||
style={{ width: `${translationProgressPercent}%` }}
|
style={{ width: `${translationProgressPercent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg font-bold text-[#1C2B4A]">
|
<span className="text-2xl font-black tracking-tight text-slate-950 tabular-nums">
|
||||||
{translationProgressPercent}%
|
{translationProgressPercent}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Wizard nudge card ────────────────────────────────── */}
|
||||||
{activeLang !== 'en' && allTabStats.missing > 0 && wizardMissingKeysCount > 0 && (
|
{activeLang !== 'en' && allTabStats.missing > 0 && wizardMissingKeysCount > 0 && (
|
||||||
<div className="rounded-xl border border-indigo-200 bg-indigo-50 p-4 flex items-start justify-between gap-4">
|
<div className="rounded-[28px] border border-indigo-200/80 bg-indigo-50/80 backdrop-blur px-5 py-4 shadow-[0_22px_60px_-34px_rgba(99,102,241,0.3)] flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-indigo-900">{t('autofix.k5e5e8744')}</p>
|
<p className="text-sm font-semibold text-indigo-950">{t('autofix.k5e5e8744')}</p>
|
||||||
<p className="text-xs text-indigo-800 mt-1">
|
<p className="text-xs text-indigo-800/80 mt-1">
|
||||||
{allLanguages.find((l) => l.code === activeLang)?.name ?? activeLang} still has {wizardMissingKeysCount} missing keys. Start the wizard to fill them step by step.
|
{allLanguages.find((l) => l.code === activeLang)?.name ?? activeLang} still has{' '}
|
||||||
</p>
|
<span className="font-semibold">{wizardMissingKeysCount}</span>{t('autofix.k0a50d234')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenTranslationWizard}
|
onClick={onOpenTranslationWizard}
|
||||||
className="rounded-md bg-[#1C2B4A] text-white px-3 py-1.5 text-xs font-semibold hover:bg-[#1C2B4A]/90"
|
className="shrink-0 rounded-2xl bg-slate-900 text-white px-4 py-2 text-xs font-semibold shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)] hover:bg-slate-800 transition"
|
||||||
>{t('autofix.k725dd1d6')}</button>
|
>
|
||||||
</div>
|
{t('autofix.k725dd1d6')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -22,6 +22,8 @@ type Props = {
|
|||||||
setSearch: (value: string) => void;
|
setSearch: (value: string) => void;
|
||||||
autoScrollOnPanelOpen: boolean;
|
autoScrollOnPanelOpen: boolean;
|
||||||
setAutoScrollOnPanelOpen: (value: boolean) => void;
|
setAutoScrollOnPanelOpen: (value: boolean) => void;
|
||||||
|
autoScrollOnSave: boolean;
|
||||||
|
setAutoScrollOnSave: (value: boolean) => void;
|
||||||
newGlobalKeySelection: string;
|
newGlobalKeySelection: string;
|
||||||
setNewGlobalKeySelection: (value: string) => void;
|
setNewGlobalKeySelection: (value: string) => void;
|
||||||
availableGlobalKeyOptions: string[];
|
availableGlobalKeyOptions: string[];
|
||||||
@ -32,6 +34,8 @@ type Props = {
|
|||||||
translations: Record<string, Record<string, string>>;
|
translations: Record<string, Record<string, string>>;
|
||||||
handleChange: (key: string, value: string) => void;
|
handleChange: (key: string, value: string) => void;
|
||||||
globalKeySet: Set<string>;
|
globalKeySet: Set<string>;
|
||||||
|
englishReferenceKeySet: Set<string>;
|
||||||
|
setEnglishReferenceForKey: (key: string, enabled: boolean) => void;
|
||||||
filteredNs: string[];
|
filteredNs: string[];
|
||||||
filteredGroups: Record<string, string[]>;
|
filteredGroups: Record<string, string[]>;
|
||||||
activeNamespacePanel: string | null;
|
activeNamespacePanel: string | null;
|
||||||
@ -56,6 +60,8 @@ export default function TranslationCoverageEditor({
|
|||||||
setSearch,
|
setSearch,
|
||||||
autoScrollOnPanelOpen,
|
autoScrollOnPanelOpen,
|
||||||
setAutoScrollOnPanelOpen,
|
setAutoScrollOnPanelOpen,
|
||||||
|
autoScrollOnSave,
|
||||||
|
setAutoScrollOnSave,
|
||||||
newGlobalKeySelection,
|
newGlobalKeySelection,
|
||||||
setNewGlobalKeySelection,
|
setNewGlobalKeySelection,
|
||||||
availableGlobalKeyOptions,
|
availableGlobalKeyOptions,
|
||||||
@ -66,6 +72,8 @@ export default function TranslationCoverageEditor({
|
|||||||
translations,
|
translations,
|
||||||
handleChange,
|
handleChange,
|
||||||
globalKeySet,
|
globalKeySet,
|
||||||
|
englishReferenceKeySet,
|
||||||
|
setEnglishReferenceForKey,
|
||||||
filteredNs,
|
filteredNs,
|
||||||
filteredGroups,
|
filteredGroups,
|
||||||
activeNamespacePanel,
|
activeNamespacePanel,
|
||||||
@ -78,145 +86,124 @@ export default function TranslationCoverageEditor({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-2xl border border-slate-200 bg-slate-50/50 p-4">
|
{/* ── 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 className="flex items-center justify-between gap-3 flex-wrap mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-semibold text-[#1C2B4A]">{t('autofix.k5f978731')}</h2>
|
<h2 className="text-sm font-semibold text-slate-950">{t('autofix.k5f978731')}</h2>
|
||||||
<p className="text-xs text-slate-600 mt-1">{t('autofix.kb7a30760')}</p>
|
<p className="text-xs text-slate-500 mt-0.5">{t('autofix.kb7a30760')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenCategoryManager}
|
onClick={onOpenCategoryManager}
|
||||||
className="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-100"
|
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>
|
>
|
||||||
|
{t('autofix.kd6e42900')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
<button
|
{renderCategoryTab('all', 'All', allTabStats)}
|
||||||
onClick={() => setActiveCategory('all')}
|
{renderCategoryTab('global', 'Global', globalTabStats)}
|
||||||
className={`rounded-md px-3 py-1.5 text-sm font-medium transition ${
|
{categoriesWithKnownNamespaces.map((cat) =>
|
||||||
activeCategory === 'all'
|
renderCategoryTab(cat.id, cat.label, categoryTabStats[cat.id] ?? { total: 0, translated: 0, missing: 0 })
|
||||||
? (activeLang !== 'en' && allTabStats.missing > 0
|
|
||||||
? 'bg-red-600 text-white shadow'
|
|
||||||
: 'bg-[#1C2B4A] text-white shadow')
|
|
||||||
: (activeLang !== 'en' && allTabStats.missing > 0
|
|
||||||
? 'border border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
|
|
||||||
: 'text-gray-500 hover:text-[#1C2B4A] hover:bg-gray-100')
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<span>All</span>
|
|
||||||
<span className={`rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
|
|
||||||
activeCategory === 'all' ? 'bg-white/20 text-white' : 'bg-slate-200 text-slate-700'
|
|
||||||
}`}>
|
|
||||||
{allTabStats.translated}/{allTabStats.total}
|
|
||||||
</span>
|
|
||||||
{activeLang !== 'en' && allTabStats.missing > 0 && (
|
|
||||||
<span className={`rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
|
|
||||||
activeCategory === 'all' ? 'bg-red-500/70 text-white' : 'bg-red-100 text-red-700'
|
|
||||||
}`}>
|
|
||||||
{allTabStats.missing} missing
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveCategory('global')}
|
|
||||||
className={`rounded-md px-3 py-1.5 text-sm font-medium transition ${
|
|
||||||
activeCategory === 'global'
|
|
||||||
? (activeLang !== 'en' && globalTabStats.missing > 0
|
|
||||||
? 'bg-red-600 text-white shadow'
|
|
||||||
: 'bg-[#1C2B4A] text-white shadow')
|
|
||||||
: (activeLang !== 'en' && globalTabStats.missing > 0
|
|
||||||
? 'border border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
|
|
||||||
: 'text-gray-500 hover:text-[#1C2B4A] hover:bg-gray-100')
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<span>Global</span>
|
|
||||||
<span className={`rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
|
|
||||||
activeCategory === 'global' ? 'bg-white/20 text-white' : 'bg-slate-200 text-slate-700'
|
|
||||||
}`}>
|
|
||||||
{globalTabStats.translated}/{globalTabStats.total}
|
|
||||||
</span>
|
|
||||||
{activeLang !== 'en' && globalTabStats.missing > 0 && (
|
|
||||||
<span className={`rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
|
|
||||||
activeCategory === 'global' ? 'bg-red-500/70 text-white' : 'bg-red-100 text-red-700'
|
|
||||||
}`}>
|
|
||||||
{globalTabStats.missing} missing
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{categoriesWithKnownNamespaces.map((cat) => {
|
|
||||||
const catStats = categoryTabStats[cat.id] ?? { total: 0, translated: 0, missing: 0 };
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={cat.id}
|
|
||||||
onClick={() => setActiveCategory(cat.id)}
|
|
||||||
className={`rounded-md px-3 py-1.5 text-sm font-medium transition ${
|
|
||||||
activeCategory === cat.id
|
|
||||||
? (activeLang !== 'en' && catStats.missing > 0
|
|
||||||
? 'bg-red-600 text-white shadow'
|
|
||||||
: 'bg-[#1C2B4A] text-white shadow')
|
|
||||||
: (activeLang !== 'en' && catStats.missing > 0
|
|
||||||
? 'border border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
|
|
||||||
: 'text-gray-500 hover:text-[#1C2B4A] hover:bg-gray-100')
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<span>{cat.label}</span>
|
|
||||||
<span className={`rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
|
|
||||||
activeCategory === cat.id ? 'bg-white/20 text-white' : 'bg-slate-200 text-slate-700'
|
|
||||||
}`}>
|
|
||||||
{catStats.translated}/{catStats.total}
|
|
||||||
</span>
|
|
||||||
{activeLang !== 'en' && catStats.missing > 0 && (
|
|
||||||
<span className={`rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
|
|
||||||
activeCategory === cat.id ? 'bg-red-500/70 text-white' : 'bg-red-100 text-red-700'
|
|
||||||
}`}>
|
|
||||||
{catStats.missing} missing
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Search + options row ─────────────────────────────── */}
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<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
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder={t('autofix.kbf49d59b')}
|
placeholder={t('autofix.kbf49d59b')}
|
||||||
className="w-full max-w-sm rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
|
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"
|
||||||
/>
|
/>
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-slate-700">
|
</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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={autoScrollOnPanelOpen}
|
checked={autoScrollOnPanelOpen}
|
||||||
onChange={(e) => setAutoScrollOnPanelOpen(e.target.checked)}
|
onChange={(e) => setAutoScrollOnPanelOpen(e.target.checked)}
|
||||||
className="h-4 w-4 rounded border-slate-300 text-[#1C2B4A] focus:ring-[#1C2B4A]"
|
className="h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900/20"
|
||||||
/>{t('autofix.kfd1e0089')}
|
/>
|
||||||
|
{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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Content area ────────────────────────────────────── */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
||||||
|
{/* Global keys table */}
|
||||||
{activeCategory === 'global' && (
|
{activeCategory === 'global' && (
|
||||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden shadow-sm">
|
<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-3 bg-gray-50 border-b border-gray-100 flex items-center justify-between gap-3 flex-wrap">
|
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold text-[#1C2B4A]">{t('autofix.k6cfeedd3')}</span>
|
<span className="font-semibold text-slate-950">{t('autofix.k6cfeedd3')}</span>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">{t('autofix.kad7d8c49')}</p>
|
<p className="text-xs text-slate-500 mt-0.5">{t('autofix.kad7d8c49')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<select
|
<select
|
||||||
value={newGlobalKeySelection}
|
value={newGlobalKeySelection}
|
||||||
onChange={(e) => setNewGlobalKeySelection(e.target.value)}
|
onChange={(e) => setNewGlobalKeySelection(e.target.value)}
|
||||||
className="w-72 max-w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-xs"
|
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>
|
<option value="">{t('autofix.k47bce570')}</option>
|
||||||
{availableGlobalKeyOptions.map((key) => (
|
{availableGlobalKeyOptions.map((key) => (
|
||||||
@ -230,7 +217,7 @@ export default function TranslationCoverageEditor({
|
|||||||
addGlobalKey(newGlobalKeySelection);
|
addGlobalKey(newGlobalKeySelection);
|
||||||
setNewGlobalKeySelection('');
|
setNewGlobalKeySelection('');
|
||||||
}}
|
}}
|
||||||
className="rounded-md border border-slate-300 bg-white px-2 py-1.5 text-xs text-slate-700 hover:bg-slate-50"
|
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
|
Add
|
||||||
</button>
|
</button>
|
||||||
@ -240,12 +227,12 @@ export default function TranslationCoverageEditor({
|
|||||||
{globalFilteredKeys.length > 0 ? (
|
{globalFilteredKeys.length > 0 ? (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-100 bg-gray-50/50">
|
<tr className="border-b border-slate-100 bg-slate-50/60">
|
||||||
<th className="px-5 py-2 text-left font-medium text-gray-500 w-1/3">Key</th>
|
<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' && (
|
{activeLang !== 'en' && (
|
||||||
<th className="px-5 py-2 text-left font-medium text-gray-500 w-1/3">English (reference)</th>
|
<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 text-left font-medium text-gray-500">
|
<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}
|
{allLanguages.find((l) => l.code === activeLang)?.name ?? activeLang}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -257,34 +244,34 @@ export default function TranslationCoverageEditor({
|
|||||||
const hasOverride = (translations[activeLang]?.[key] ?? '') !== '';
|
const hasOverride = (translations[activeLang]?.[key] ?? '') !== '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={key} className="border-b border-gray-50 last:border-0 hover:bg-blue-50/30">
|
<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 font-mono text-xs text-gray-500 align-top pt-3">
|
<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">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<span>{key}</span>
|
<span>{key}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title={t('autofix.kc02b17c3')}
|
title={t('autofix.kc02b17c3')}
|
||||||
onClick={() => removeGlobalKey(key)}
|
onClick={() => removeGlobalKey(key)}
|
||||||
className="text-[10px] rounded border border-red-200 px-1.5 py-0.5 text-red-500 hover:bg-red-50"
|
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
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{activeLang !== 'en' && (
|
{activeLang !== 'en' && (
|
||||||
<td className="px-5 py-2 text-gray-500 align-top pt-3 text-xs">{enVal}</td>
|
<td className="px-5 py-2.5 text-slate-500 align-top pt-3 text-xs">{enVal}</td>
|
||||||
)}
|
)}
|
||||||
<td className="px-5 py-2">
|
<td className="px-5 py-2.5">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<textarea
|
<textarea
|
||||||
rows={1}
|
rows={1}
|
||||||
value={activeLang === 'en' ? currentVal : (translations[activeLang]?.[key] ?? '')}
|
value={activeLang === 'en' ? currentVal : (translations[activeLang]?.[key] ?? '')}
|
||||||
onChange={(e) => handleChange(key, e.target.value)}
|
onChange={(e) => handleChange(key, e.target.value)}
|
||||||
placeholder={activeLang === 'en' ? '' : enVal}
|
placeholder={activeLang === 'en' ? '' : enVal}
|
||||||
className={`w-full rounded border px-2 py-1.5 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-[#1C2B4A] ${
|
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'
|
hasOverride && activeLang !== 'en'
|
||||||
? 'border-green-300 bg-green-50'
|
? 'border-emerald-300 bg-emerald-50/60'
|
||||||
: 'border-gray-200 bg-white'
|
: 'border-slate-200 bg-white'
|
||||||
}`}
|
}`}
|
||||||
style={{ minHeight: '2.25rem', fieldSizing: 'content' } as React.CSSProperties}
|
style={{ minHeight: '2.25rem', fieldSizing: 'content' } as React.CSSProperties}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
@ -296,9 +283,9 @@ export default function TranslationCoverageEditor({
|
|||||||
{hasOverride && activeLang !== 'en' && (
|
{hasOverride && activeLang !== 'en' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="Clear override (revert to built-in)"
|
title={t('autofix.k644d9ea8')}
|
||||||
onClick={() => handleChange(key, '')}
|
onClick={() => handleChange(key, '')}
|
||||||
className="absolute top-1 right-1 text-xs text-gray-400 hover:text-red-500"
|
className="absolute top-1.5 right-2 text-xs text-slate-400 hover:text-red-500 transition"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@ -311,11 +298,12 @@ export default function TranslationCoverageEditor({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-8 text-center text-sm text-gray-500">{t('autofix.k0700b1f2')}</div>
|
<div className="p-10 text-center text-sm text-slate-400">{t('autofix.k0700b1f2')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Namespace grid + open panel */}
|
||||||
{activeCategory !== 'global' && filteredNs.length > 0 && (
|
{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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
|
||||||
@ -331,9 +319,7 @@ export default function TranslationCoverageEditor({
|
|||||||
const activeIdx = activeNamespacePanel ? filteredNs.indexOf(activeNamespacePanel) : -1;
|
const activeIdx = activeNamespacePanel ? filteredNs.indexOf(activeNamespacePanel) : -1;
|
||||||
const shiftClass = !activeNamespacePanel || isActive
|
const shiftClass = !activeNamespacePanel || isActive
|
||||||
? 'translate-x-0'
|
? 'translate-x-0'
|
||||||
: idx < activeIdx
|
: idx < activeIdx ? '-translate-x-1' : 'translate-x-1';
|
||||||
? '-translate-x-1'
|
|
||||||
: 'translate-x-1';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -343,45 +329,59 @@ export default function TranslationCoverageEditor({
|
|||||||
openFromPanelClickRef.current = true;
|
openFromPanelClickRef.current = true;
|
||||||
setActiveNamespacePanel((prev) => (prev === ns ? null : ns));
|
setActiveNamespacePanel((prev) => (prev === ns ? null : ns));
|
||||||
}}
|
}}
|
||||||
className={`w-full rounded-xl border px-3 py-2 text-left transition-all duration-300 ${shiftClass} ${
|
className={`w-full rounded-[20px] border px-4 py-3 text-left transition-all duration-300 backdrop-blur ${shiftClass} ${
|
||||||
isActive
|
isActive
|
||||||
? (hasMissing ? 'border-red-400 bg-red-50 shadow-sm' : 'border-[#1C2B4A] bg-[#1C2B4A]/5 shadow-sm')
|
? hasMissing
|
||||||
: (hasMissing ? 'border-red-200 bg-red-50/70 hover:bg-red-50' : 'border-gray-200 bg-white hover:border-slate-300 hover:bg-slate-50')
|
? '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">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-sm font-semibold text-[#1C2B4A] capitalize">{ns}</span>
|
<span className={`text-sm font-semibold capitalize ${isActive ? 'text-slate-950' : 'text-slate-800'}`}>
|
||||||
|
{ns}
|
||||||
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${
|
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${
|
||||||
isActive
|
isActive
|
||||||
? (hasMissing ? 'bg-red-500 text-white' : 'bg-[#1C2B4A] text-white')
|
? hasMissing ? 'bg-red-500 text-white' : 'bg-slate-900 text-white'
|
||||||
: (hasMissing ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-700')
|
: hasMissing ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-600'
|
||||||
}`}>
|
}`}>
|
||||||
{nsStats.translated}/{nsStats.total}
|
{nsStats.translated}/{nsStats.total}
|
||||||
</span>
|
</span>
|
||||||
{hasMissing && (
|
{hasMissing && (
|
||||||
<span className="rounded-full bg-red-100 px-1.5 py-0.5 text-[10px] font-semibold text-red-700">
|
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${
|
||||||
{nsStats.missing} missing
|
isActive ? 'bg-red-200 text-red-800' : 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{nsStats.missing}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[11px] text-slate-500">{isActive ? 'Open' : 'Click to open'}</p>
|
<p className="mt-1 text-[11px] text-slate-400">
|
||||||
|
{isActive ? t('autofix.k77d01d6a') : t('autofix.kcfb5fb54')}
|
||||||
|
</p>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeNamespacePanel && filteredGroups[activeNamespacePanel] && (
|
{activeNamespacePanel && filteredGroups[activeNamespacePanel] && (
|
||||||
<div ref={openedNamespacePanelRef} className="rounded-xl border border-gray-200 bg-white overflow-hidden shadow-sm transition-all duration-300">
|
<div
|
||||||
<div className="w-full flex items-center justify-between px-5 py-3 bg-gray-50 border-b border-gray-100">
|
ref={openedNamespacePanelRef}
|
||||||
<span className="font-semibold text-[#1C2B4A] capitalize">{activeNamespacePanel}</span>
|
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"
|
||||||
<div className="flex items-center gap-1">
|
>
|
||||||
<span className="text-xs rounded-full bg-slate-100 px-1.5 py-0.5 text-slate-700">
|
{/* 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)}
|
{(namespaceTranslationStats[activeNamespacePanel]?.translated ?? 0)}/{(namespaceTranslationStats[activeNamespacePanel]?.total ?? 0)}
|
||||||
</span>
|
</span>
|
||||||
{activeLang !== 'en' && (namespaceTranslationStats[activeNamespacePanel]?.missing ?? 0) > 0 && (
|
{activeLang !== 'en' && (namespaceTranslationStats[activeNamespacePanel]?.missing ?? 0) > 0 && (
|
||||||
<span className="text-xs rounded-full bg-red-100 px-1.5 py-0.5 text-red-700">
|
<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
|
{namespaceTranslationStats[activeNamespacePanel]?.missing} missing
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -390,12 +390,12 @@ export default function TranslationCoverageEditor({
|
|||||||
|
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-100 bg-gray-50/50">
|
<tr className="border-b border-slate-100 bg-slate-50/40">
|
||||||
<th className="px-5 py-2 text-left font-medium text-gray-500 w-1/3">Key</th>
|
<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' && (
|
{activeLang !== 'en' && (
|
||||||
<th className="px-5 py-2 text-left font-medium text-gray-500 w-1/3">English (reference)</th>
|
<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 text-left font-medium text-gray-500">
|
<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}
|
{allLanguages.find((l) => l.code === activeLang)?.name ?? activeLang}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -405,25 +405,27 @@ export default function TranslationCoverageEditor({
|
|||||||
const enVal = getEnglishValue(key);
|
const enVal = getEnglishValue(key);
|
||||||
const currentVal = getDisplayValue(key);
|
const currentVal = getDisplayValue(key);
|
||||||
const isGlobal = globalKeySet.has(key);
|
const isGlobal = globalKeySet.has(key);
|
||||||
|
const isEnglishReference = englishReferenceKeySet.has(key);
|
||||||
const visibleValue = activeLang === 'en'
|
const visibleValue = activeLang === 'en'
|
||||||
? currentVal
|
? currentVal
|
||||||
: (translations[activeLang]?.[key] ?? '');
|
: (translations[activeLang]?.[key] ?? '');
|
||||||
const hasOverride = (translations[activeLang]?.[key] ?? '') !== '';
|
const hasOverride = (translations[activeLang]?.[key] ?? '') !== '';
|
||||||
const isMissingInOpenedPanel =
|
const isMissingInOpenedPanel =
|
||||||
activeLang !== 'en' &&
|
activeLang !== 'en' &&
|
||||||
!isGlobal && (
|
!isGlobal &&
|
||||||
|
!isEnglishReference && (
|
||||||
visibleValue.trim() === '' ||
|
visibleValue.trim() === '' ||
|
||||||
visibleValue.trim() === enVal.trim()
|
visibleValue.trim() === enVal.trim()
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={key} className={`border-b border-gray-50 last:border-0 ${
|
<tr key={key} className={`border-b border-slate-50 last:border-0 transition-colors ${
|
||||||
isMissingInOpenedPanel ? 'bg-red-50/50 hover:bg-red-50' : 'hover:bg-blue-50/30'
|
isMissingInOpenedPanel ? 'bg-red-50/50 hover:bg-red-50/80' : 'hover:bg-slate-50/60'
|
||||||
}`}>
|
}`}>
|
||||||
<td className={`px-5 py-2 font-mono text-xs align-top pt-3 ${
|
<td className={`px-5 py-2.5 font-mono text-xs align-top pt-3 ${
|
||||||
isMissingInOpenedPanel ? 'text-red-700' : 'text-gray-500'
|
isMissingInOpenedPanel ? 'text-red-700' : 'text-slate-500'
|
||||||
}`}>
|
}`}>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2.5">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="block">{key}</span>
|
<span className="block">{key}</span>
|
||||||
{isGlobal && (
|
{isGlobal && (
|
||||||
@ -431,38 +433,49 @@ export default function TranslationCoverageEditor({
|
|||||||
Global
|
Global
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<label className="inline-flex items-center gap-1.5 text-[11px] font-sans text-slate-600">
|
<label className="flex items-center gap-1.5 text-[11px] font-sans text-slate-500 select-none">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isGlobal}
|
checked={isGlobal}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (e.target.checked) {
|
if (e.target.checked) { addGlobalKey(key); return; }
|
||||||
addGlobalKey(key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
removeGlobalKey(key);
|
removeGlobalKey(key);
|
||||||
}}
|
}}
|
||||||
className="h-3.5 w-3.5 rounded border-slate-300 text-[#1C2B4A] focus:ring-[#1C2B4A]"
|
className="h-3.5 w-3.5 rounded border-slate-300 text-slate-900 focus:ring-slate-900/20"
|
||||||
/>{t('autofix.kb1cf599b')}</label>
|
/>
|
||||||
|
{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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{activeLang !== 'en' && (
|
{activeLang !== 'en' && (
|
||||||
<td className="px-5 py-2 text-gray-500 align-top pt-3 text-xs">{enVal}</td>
|
<td className="px-5 py-2.5 text-slate-400 align-top pt-3 text-xs">{enVal}</td>
|
||||||
)}
|
)}
|
||||||
<td className="px-5 py-2">
|
<td className="px-5 py-2.5">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<textarea
|
<textarea
|
||||||
rows={1}
|
rows={1}
|
||||||
value={visibleValue}
|
value={visibleValue}
|
||||||
onChange={(e) => handleChange(key, e.target.value)}
|
onChange={(e) => handleChange(key, e.target.value)}
|
||||||
placeholder={activeLang === 'en' ? '' : enVal}
|
placeholder={activeLang === 'en' ? '' : enVal}
|
||||||
className={`w-full rounded border px-2 py-1.5 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-[#1C2B4A] ${
|
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
|
isMissingInOpenedPanel
|
||||||
? 'border-red-300 bg-red-50'
|
? 'border-red-300 bg-red-50/60'
|
||||||
: hasOverride && activeLang !== 'en'
|
: hasOverride && activeLang !== 'en'
|
||||||
? 'border-green-300 bg-green-50'
|
? 'border-emerald-300 bg-emerald-50/60'
|
||||||
: 'border-gray-200 bg-white'
|
: 'border-slate-200 bg-white'
|
||||||
}`}
|
}`}
|
||||||
style={{ minHeight: '2.25rem', fieldSizing: 'content' } as React.CSSProperties}
|
style={{ minHeight: '2.25rem', fieldSizing: 'content' } as React.CSSProperties}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
@ -474,9 +487,9 @@ export default function TranslationCoverageEditor({
|
|||||||
{hasOverride && activeLang !== 'en' && (
|
{hasOverride && activeLang !== 'en' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="Clear override (revert to built-in)"
|
title={t('autofix.k644d9ea8')}
|
||||||
onClick={() => handleChange(key, '')}
|
onClick={() => handleChange(key, '')}
|
||||||
className="absolute top-1 right-1 text-xs text-gray-400 hover:text-red-500"
|
className="absolute top-1.5 right-2 text-xs text-slate-400 hover:text-red-500 transition"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@ -489,11 +502,11 @@ export default function TranslationCoverageEditor({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div className="border-t border-gray-100 px-5 py-3 bg-white flex justify-end">
|
<div className="border-t border-slate-100 px-5 py-3 bg-slate-50/40 flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onBackToPanels}
|
onClick={onBackToPanels}
|
||||||
className="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50"
|
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')}
|
{t('autofix.k6aba2cb0')}
|
||||||
</button>
|
</button>
|
||||||
@ -502,8 +515,11 @@ export default function TranslationCoverageEditor({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeCategory !== 'global' && filteredNs.length === 0 && (
|
{activeCategory !== 'global' && filteredNs.length === 0 && (
|
||||||
<div className="rounded-xl border border-gray-200 bg-white p-8 text-center text-sm text-gray-500">{t('autofix.k6a892262')}</div>
|
<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>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -15,13 +15,16 @@ type Props = {
|
|||||||
setWizardInput: (value: string) => void;
|
setWizardInput: (value: string) => void;
|
||||||
wizardMarkGlobal: boolean;
|
wizardMarkGlobal: boolean;
|
||||||
setWizardMarkGlobal: (value: boolean) => void;
|
setWizardMarkGlobal: (value: boolean) => void;
|
||||||
|
wizardUseEnglishReference: boolean;
|
||||||
|
setWizardUseEnglishReference: (value: boolean) => void;
|
||||||
englishValue: string;
|
englishValue: string;
|
||||||
addGlobalKey: (key: string) => void;
|
addGlobalKey: (key: string) => void;
|
||||||
removeGlobalKey: (key: string) => void;
|
removeGlobalKey: (key: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPrevious: () => void;
|
onPrevious: () => void;
|
||||||
onSkip: () => void;
|
onSkip: () => void;
|
||||||
onNext: () => void;
|
onNext: () => void | Promise<void>;
|
||||||
|
isSavingStep?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TranslationWizardModal({
|
export default function TranslationWizardModal({
|
||||||
@ -35,6 +38,8 @@ export default function TranslationWizardModal({
|
|||||||
setWizardInput,
|
setWizardInput,
|
||||||
wizardMarkGlobal,
|
wizardMarkGlobal,
|
||||||
setWizardMarkGlobal,
|
setWizardMarkGlobal,
|
||||||
|
wizardUseEnglishReference,
|
||||||
|
setWizardUseEnglishReference,
|
||||||
englishValue,
|
englishValue,
|
||||||
addGlobalKey,
|
addGlobalKey,
|
||||||
removeGlobalKey,
|
removeGlobalKey,
|
||||||
@ -42,6 +47,7 @@ export default function TranslationWizardModal({
|
|||||||
onPrevious,
|
onPrevious,
|
||||||
onSkip,
|
onSkip,
|
||||||
onNext,
|
onNext,
|
||||||
|
isSavingStep = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isRendered, isVisible } = useModalAnimation(isOpen && Boolean(currentWizardKey));
|
const { isRendered, isVisible } = useModalAnimation(isOpen && Boolean(currentWizardKey));
|
||||||
@ -107,13 +113,31 @@ export default function TranslationWizardModal({
|
|||||||
/>
|
/>
|
||||||
Counts as global key (same value as English)
|
Counts as global key (same value as English)
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{activeLang !== 'en' && (
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={wizardUseEnglishReference}
|
||||||
|
onChange={(e) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
setWizardUseEnglishReference(checked);
|
||||||
|
if (checked) {
|
||||||
|
setWizardInput(englishValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-4 w-4 rounded border-slate-300 text-[#1C2B4A] focus:ring-[#1C2B4A]"
|
||||||
|
/>
|
||||||
|
Use English value (this language only, not global)
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-4 border-t border-slate-200 bg-white flex items-center justify-between gap-3">
|
<div className="px-6 py-4 border-t border-slate-200 bg-white flex items-center justify-between gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onPrevious}
|
onClick={onPrevious}
|
||||||
disabled={wizardIndex === 0}
|
disabled={wizardIndex === 0 || isSavingStep}
|
||||||
className="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
|
className="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
@ -122,6 +146,7 @@ export default function TranslationWizardModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSkip}
|
onClick={onSkip}
|
||||||
|
disabled={isSavingStep}
|
||||||
className="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"
|
className="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
Skip
|
Skip
|
||||||
@ -129,10 +154,12 @@ export default function TranslationWizardModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
disabled={wizardInput.trim() === '' && !wizardMarkGlobal}
|
disabled={isSavingStep || (wizardInput.trim() === '' && !wizardMarkGlobal && !wizardUseEnglishReference)}
|
||||||
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90 disabled:opacity-50"
|
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{wizardIndex >= wizardMissingCount - 1 ? 'Save and finish' : 'Save and next'}
|
{isSavingStep
|
||||||
|
? t('common.saving')
|
||||||
|
: (wizardIndex >= wizardMissingCount - 1 ? 'Save and finish' : 'Save and next')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,27 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { authFetch } from '../../../utils/authFetch'
|
||||||
|
|
||||||
|
const SCAN_EXCLUDED_FILE_MARKERS = ['src/app/components/toast/toastComponent.tsx'] as const
|
||||||
|
|
||||||
|
function normalizeScanPath(path: string): string {
|
||||||
|
return path.replace(/\\/g, '/').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExcludedScanFile(path: string): boolean {
|
||||||
|
const normalized = normalizeScanPath(path)
|
||||||
|
return SCAN_EXCLUDED_FILE_MARKERS.some((marker) => normalized.endsWith(marker.toLowerCase()))
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterScanFiles(files: unknown): string[] {
|
||||||
|
if (!Array.isArray(files)) return []
|
||||||
|
return files
|
||||||
|
.filter((file): file is string => typeof file === 'string')
|
||||||
|
.filter((file) => !isExcludedScanFile(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseI18nScanWorkflowOptions = {
|
||||||
|
onUnauthorized?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkspaceScanResult = {
|
export type WorkspaceScanResult = {
|
||||||
scannedFiles: number
|
scannedFiles: number
|
||||||
@ -25,19 +48,37 @@ export type WorkspaceScanResult = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeScanResult(result: any): WorkspaceScanResult {
|
function normalizeScanResult(result: any): WorkspaceScanResult {
|
||||||
|
const missingKeys = Array.isArray(result?.missingKeys)
|
||||||
|
? result.missingKeys
|
||||||
|
.filter((entry: any) => entry && typeof entry === 'object' && typeof entry.key === 'string')
|
||||||
|
.map((entry: any) => ({ key: entry.key, files: filterScanFiles(entry.files) }))
|
||||||
|
.filter((entry: { key: string; files: string[] }) => entry.files.length > 0)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const untranslatedLiterals = Array.isArray(result?.untranslatedLiterals)
|
||||||
|
? result.untranslatedLiterals
|
||||||
|
.filter((entry: any) => entry && typeof entry === 'object' && typeof entry.text === 'string')
|
||||||
|
.map((entry: any) => ({ text: entry.text, files: filterScanFiles(entry.files) }))
|
||||||
|
.filter((entry: { text: string; files: string[] }) => entry.files.length > 0)
|
||||||
|
: []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scannedFiles: Number(result?.scannedFiles ?? 0),
|
scannedFiles: Number(result?.scannedFiles ?? 0),
|
||||||
scannedDirectories: Number(result?.scannedDirectories ?? 0),
|
scannedDirectories: Number(result?.scannedDirectories ?? 0),
|
||||||
translationCallCount: Number(result?.translationCallCount ?? 0),
|
translationCallCount: Number(result?.translationCallCount ?? 0),
|
||||||
uniqueKeyCount: Number(result?.uniqueKeyCount ?? 0),
|
uniqueKeyCount: Number(result?.uniqueKeyCount ?? 0),
|
||||||
missingKeys: Array.isArray(result?.missingKeys) ? result.missingKeys : [],
|
missingKeys,
|
||||||
untranslatedLiterals: Array.isArray(result?.untranslatedLiterals) ? result.untranslatedLiterals : [],
|
untranslatedLiterals,
|
||||||
autoFixEligibleFiles: Array.isArray(result?.autoFixEligibleFiles) ? result.autoFixEligibleFiles : undefined,
|
autoFixEligibleFiles: Array.isArray(result?.autoFixEligibleFiles) ? filterScanFiles(result.autoFixEligibleFiles) : undefined,
|
||||||
autoFixForceConvertibleFiles: Array.isArray(result?.autoFixForceConvertibleFiles) ? result.autoFixForceConvertibleFiles : undefined,
|
autoFixForceConvertibleFiles: Array.isArray(result?.autoFixForceConvertibleFiles) ? filterScanFiles(result.autoFixForceConvertibleFiles) : undefined,
|
||||||
changedFileCount: Number(result?.changedFileCount ?? 0),
|
changedFileCount: Number(result?.changedFileCount ?? 0),
|
||||||
createdKeyCount: Number(result?.createdKeyCount ?? 0),
|
createdKeyCount: Number(result?.createdKeyCount ?? 0),
|
||||||
changedFiles: Array.isArray(result?.changedFiles) ? result.changedFiles : [],
|
changedFiles: Array.isArray(result?.changedFiles)
|
||||||
skippedFiles: Array.isArray(result?.skippedFiles) ? result.skippedFiles : [],
|
? result.changedFiles.filter((entry: any) => entry && typeof entry.file === 'string' && !isExcludedScanFile(entry.file))
|
||||||
|
: [],
|
||||||
|
skippedFiles: Array.isArray(result?.skippedFiles)
|
||||||
|
? result.skippedFiles.filter((entry: any) => entry && typeof entry.file === 'string' && !isExcludedScanFile(entry.file))
|
||||||
|
: [],
|
||||||
autoFixDebug: Array.isArray(result?.autoFixDebug) ? result.autoFixDebug : [],
|
autoFixDebug: Array.isArray(result?.autoFixDebug) ? result.autoFixDebug : [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -64,7 +105,13 @@ function getFixableFiles(scan: WorkspaceScanResult | null, forceConvertToClient:
|
|||||||
return Array.from(fileSet).sort((a, b) => a.localeCompare(b))
|
return Array.from(fileSet).sort((a, b) => a.localeCompare(b))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useI18nScanWorkflow() {
|
export function useI18nScanWorkflow({ onUnauthorized }: UseI18nScanWorkflowOptions = {}) {
|
||||||
|
const onUnauthorizedRef = useRef(onUnauthorized)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onUnauthorizedRef.current = onUnauthorized
|
||||||
|
}, [onUnauthorized])
|
||||||
|
|
||||||
const [showScanModal, setShowScanModal] = useState(false)
|
const [showScanModal, setShowScanModal] = useState(false)
|
||||||
const [lastScanTime, setLastScanTime] = useState<Date | null>(null)
|
const [lastScanTime, setLastScanTime] = useState<Date | null>(null)
|
||||||
const [isScanning, setIsScanning] = useState(false)
|
const [isScanning, setIsScanning] = useState(false)
|
||||||
@ -98,7 +145,11 @@ export function useI18nScanWorkflow() {
|
|||||||
setScanError(null)
|
setScanError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/i18n/scan', { method: 'GET' })
|
const response = await authFetch('/api/i18n/scan', { method: 'GET' })
|
||||||
|
if (response.status === 401) {
|
||||||
|
onUnauthorizedRef.current?.()
|
||||||
|
throw new Error('Session expired. Redirecting to login.')
|
||||||
|
}
|
||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
|
|
||||||
if (!response.ok || !result?.ok) {
|
if (!response.ok || !result?.ok) {
|
||||||
@ -114,7 +165,7 @@ export function useI18nScanWorkflow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const runFixSelected = async () => {
|
const runFixSelected = async () => {
|
||||||
const selectedEligible = selectedFiles.filter((file) => fixableFiles.includes(file))
|
const selectedEligible = selectedFiles.filter((file) => fixableFiles.includes(file) && !isExcludedScanFile(file))
|
||||||
|
|
||||||
if (selectedEligible.length === 0) {
|
if (selectedEligible.length === 0) {
|
||||||
setScanError('Select at least one file before running auto-fix.')
|
setScanError('Select at least one file before running auto-fix.')
|
||||||
@ -126,11 +177,19 @@ export function useI18nScanWorkflow() {
|
|||||||
setScanError(null)
|
setScanError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/i18n/scan', {
|
const response = await authFetch('/api/i18n/scan', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ targetFiles: selectedEligible, forceConvertToClient }),
|
body: JSON.stringify({
|
||||||
|
targetFiles: selectedEligible,
|
||||||
|
forceConvertToClient,
|
||||||
|
excludedFiles: [...SCAN_EXCLUDED_FILE_MARKERS],
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
if (response.status === 401) {
|
||||||
|
onUnauthorizedRef.current?.()
|
||||||
|
throw new Error('Session expired. Redirecting to login.')
|
||||||
|
}
|
||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
|
|
||||||
if (!response.ok || !result?.ok) {
|
if (!response.ok || !result?.ok) {
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { authFetch } from '../../../utils/authFetch';
|
||||||
|
import useAuthStore from '../../../store/authStore';
|
||||||
|
|
||||||
export type LanguageEntry = {
|
export type LanguageEntry = {
|
||||||
code: string;
|
code: string;
|
||||||
@ -13,15 +15,51 @@ export type FileBackedI18nData = {
|
|||||||
type UseLanguageManagementTranslationsOptions = {
|
type UseLanguageManagementTranslationsOptions = {
|
||||||
coreLanguages: Set<string>;
|
coreLanguages: Set<string>;
|
||||||
onAction?: (notice: { variant: 'success' | 'error' | 'info' | 'warning'; message: string }) => void;
|
onAction?: (notice: { variant: 'success' | 'error' | 'info' | 'warning'; message: string }) => void;
|
||||||
|
onUnauthorized?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useLanguageManagementTranslations({ coreLanguages, onAction }: UseLanguageManagementTranslationsOptions) {
|
const ACTIVE_LANG_STORAGE_KEY = 'language-management-active-lang';
|
||||||
|
|
||||||
|
export function useLanguageManagementTranslations({ coreLanguages, onAction, onUnauthorized }: UseLanguageManagementTranslationsOptions) {
|
||||||
|
const onActionRef = useRef(onAction);
|
||||||
|
const onUnauthorizedRef = useRef(onUnauthorized);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onUnauthorizedRef.current = onUnauthorized;
|
||||||
|
}, [onUnauthorized]);
|
||||||
|
|
||||||
|
const emitAction = useCallback((notice: { variant: 'success' | 'error' | 'info' | 'warning'; message: string }) => {
|
||||||
|
try {
|
||||||
|
onActionRef.current?.(notice);
|
||||||
|
} catch {
|
||||||
|
// Keep persistence flow running even if notification UI crashes.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onActionRef.current = onAction;
|
||||||
|
}, [onAction]);
|
||||||
|
|
||||||
const [data, setData] = useState<FileBackedI18nData>({ languages: [], translations: {} });
|
const [data, setData] = useState<FileBackedI18nData>({ languages: [], translations: {} });
|
||||||
|
const dataRef = useRef<FileBackedI18nData>({ languages: [], translations: {} });
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [loadingPhase, setLoadingPhase] = useState('idle');
|
||||||
|
const [loadingProgress, setLoadingProgress] = useState(0);
|
||||||
|
const [loadingLogs, setLoadingLogs] = useState<string[]>([]);
|
||||||
const [isDirty, setIsDirty] = useState(false);
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const [saveError, setSaveError] = useState('');
|
const [saveError, setSaveError] = useState('');
|
||||||
|
|
||||||
const [activeLang, setActiveLang] = useState('en');
|
// Base translations from en.ts — used to detect which keys are genuine overrides (delta).
|
||||||
|
const enBaseRef = useRef<Record<string, string>>({});
|
||||||
|
// Overrides currently stored in DB per custom language — used to track what needs deletion on revert.
|
||||||
|
const dbOverridesRef = useRef<Record<string, Record<string, string>>>({});
|
||||||
|
|
||||||
|
const [activeLang, setActiveLang] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') return 'en';
|
||||||
|
const stored = window.localStorage.getItem(ACTIVE_LANG_STORAGE_KEY);
|
||||||
|
return stored && stored.trim() !== '' ? stored : 'en';
|
||||||
|
});
|
||||||
|
|
||||||
const [showAddModal, setShowAddModal] = useState(false);
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
const [newCode, setNewCode] = useState('');
|
const [newCode, setNewCode] = useState('');
|
||||||
@ -29,9 +67,32 @@ export function useLanguageManagementTranslations({ coreLanguages, onAction }: U
|
|||||||
const [addError, setAddError] = useState('');
|
const [addError, setAddError] = useState('');
|
||||||
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||||
|
const hasFetchedOnceRef = useRef(false);
|
||||||
|
|
||||||
|
const appendLoadingLog = useCallback((message: string) => {
|
||||||
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
|
setLoadingLogs((prev) => [...prev, `[${timestamp}] ${message}`].slice(-30));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchTranslationFiles = useCallback(async () => {
|
const fetchTranslationFiles = useCallback(async () => {
|
||||||
const response = await fetch('/api/i18n/translations', { cache: 'no-store' });
|
setLoadingPhase('refresh-auth');
|
||||||
|
setLoadingProgress(15);
|
||||||
|
appendLoadingLog('Refreshing auth session before translation bootstrap');
|
||||||
|
|
||||||
|
// Preflight refresh avoids immediate 401/refresh/retry loops during initial mount.
|
||||||
|
await useAuthStore.getState().refreshAuthToken(true).catch(() => null);
|
||||||
|
|
||||||
|
setLoadingPhase('fetch-translations');
|
||||||
|
setLoadingProgress(35);
|
||||||
|
appendLoadingLog('Fetching translation files from /api/i18n/translations');
|
||||||
|
|
||||||
|
const response = await authFetch('/api/i18n/translations', { cache: 'no-store' });
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
onUnauthorizedRef.current?.();
|
||||||
|
throw new Error('Session expired. Redirecting to login.');
|
||||||
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (!response.ok || !result?.ok) {
|
if (!response.ok || !result?.ok) {
|
||||||
@ -50,12 +111,85 @@ export function useLanguageManagementTranslations({ coreLanguages, onAction }: U
|
|||||||
? result.translations as Record<string, Record<string, string>>
|
? result.translations as Record<string, Record<string, string>>
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
|
// Store en.ts as the immutable base for delta calculation.
|
||||||
|
enBaseRef.current = translations['en'] ?? {};
|
||||||
|
setLoadingProgress(60);
|
||||||
|
|
||||||
|
// Fetch DB overrides and merge them on top of .ts file values for custom languages.
|
||||||
|
// This ensures admin edits from previous sessions are restored on load.
|
||||||
|
try {
|
||||||
|
setLoadingPhase('fetch-preferences');
|
||||||
|
setLoadingProgress(75);
|
||||||
|
appendLoadingLog('Fetching DB overrides from /api/i18n/preferences');
|
||||||
|
|
||||||
|
const prefResponse = await authFetch('/api/i18n/preferences', { cache: 'no-store' });
|
||||||
|
if (prefResponse.ok) {
|
||||||
|
const prefResult = await prefResponse.json().catch(() => null);
|
||||||
|
const payload = prefResult?.preferences && typeof prefResult.preferences === 'object'
|
||||||
|
? prefResult.preferences
|
||||||
|
: prefResult;
|
||||||
|
const overridesArray: unknown[] = Array.isArray(payload?.translations) ? payload.translations : [];
|
||||||
|
|
||||||
|
const dbOverrides: Record<string, Record<string, string>> = {};
|
||||||
|
for (const entry of overridesArray) {
|
||||||
|
if (!entry || typeof entry !== 'object') continue;
|
||||||
|
const e = entry as Record<string, unknown>;
|
||||||
|
if (typeof e.languageCode !== 'string' || typeof e.namespace !== 'string' ||
|
||||||
|
typeof e.key !== 'string' || typeof e.value !== 'string') continue;
|
||||||
|
const flatKey = `${e.namespace}.${e.key}`;
|
||||||
|
if (!dbOverrides[e.languageCode]) dbOverrides[e.languageCode] = {};
|
||||||
|
dbOverrides[e.languageCode][flatKey] = e.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
dbOverridesRef.current = dbOverrides;
|
||||||
|
|
||||||
|
// Deep-merge: DB overrides win on conflict, for custom languages only.
|
||||||
|
const mergedTranslations = { ...translations };
|
||||||
|
for (const [langCode, overrides] of Object.entries(dbOverrides)) {
|
||||||
|
if (coreLanguages.has(langCode)) continue;
|
||||||
|
mergedTranslations[langCode] = { ...(translations[langCode] ?? {}), ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
setData({ languages, translations: mergedTranslations });
|
||||||
|
setLoadingProgress(95);
|
||||||
|
appendLoadingLog('Translations + DB overrides merged successfully');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// DB overrides unavailable — fall through to file-only data.
|
||||||
|
appendLoadingLog('DB override fetch failed, continuing with file-only translations');
|
||||||
|
}
|
||||||
|
|
||||||
setData({ languages, translations });
|
setData({ languages, translations });
|
||||||
}, []);
|
setLoadingProgress(95);
|
||||||
|
appendLoadingLog('Loaded file-backed translations without DB overrides');
|
||||||
|
}, [appendLoadingLog, coreLanguages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchTranslationFiles();
|
if (hasFetchedOnceRef.current) return;
|
||||||
}, [fetchTranslationFiles]);
|
hasFetchedOnceRef.current = true;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setLoadingPhase('initializing');
|
||||||
|
setLoadingProgress(5);
|
||||||
|
appendLoadingLog('Starting language management bootstrap');
|
||||||
|
|
||||||
|
void fetchTranslationFiles()
|
||||||
|
.catch((error) => {
|
||||||
|
setLoadingPhase('error');
|
||||||
|
appendLoadingLog(`Bootstrap error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
emitAction({
|
||||||
|
variant: 'warning',
|
||||||
|
message: error instanceof Error ? error.message : 'Failed to load translation files.',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoadingPhase('ready');
|
||||||
|
setLoadingProgress(100);
|
||||||
|
appendLoadingLog('Translation bootstrap complete');
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, [appendLoadingLog, emitAction, fetchTranslationFiles]);
|
||||||
|
|
||||||
const allLanguages: LanguageEntry[] = useMemo(() => data.languages, [data.languages]);
|
const allLanguages: LanguageEntry[] = useMemo(() => data.languages, [data.languages]);
|
||||||
|
|
||||||
@ -67,6 +201,15 @@ export function useLanguageManagementTranslations({ coreLanguages, onAction }: U
|
|||||||
setActiveLang(fallback);
|
setActiveLang(fallback);
|
||||||
}, [allLanguages, activeLang]);
|
}, [allLanguages, activeLang]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.localStorage.setItem(ACTIVE_LANG_STORAGE_KEY, activeLang);
|
||||||
|
}, [activeLang]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dataRef.current = data;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
const getDisplayValue = useCallback(
|
const getDisplayValue = useCallback(
|
||||||
(key: string): string => data.translations[activeLang]?.[key] ?? '',
|
(key: string): string => data.translations[activeLang]?.[key] ?? '',
|
||||||
[activeLang, data.translations]
|
[activeLang, data.translations]
|
||||||
@ -76,23 +219,32 @@ export function useLanguageManagementTranslations({ coreLanguages, onAction }: U
|
|||||||
setData((prev) => {
|
setData((prev) => {
|
||||||
const langTranslations = { ...(prev.translations[activeLang] ?? {}) };
|
const langTranslations = { ...(prev.translations[activeLang] ?? {}) };
|
||||||
langTranslations[key] = value;
|
langTranslations[key] = value;
|
||||||
return {
|
const next = {
|
||||||
...prev,
|
...prev,
|
||||||
translations: { ...prev.translations, [activeLang]: langTranslations },
|
translations: { ...prev.translations, [activeLang]: langTranslations },
|
||||||
};
|
};
|
||||||
|
dataRef.current = next;
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
setIsDirty(true);
|
setIsDirty(true);
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
}, [activeLang]);
|
}, [activeLang]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async (translationsOverride?: Record<string, Record<string, string>>) => {
|
||||||
try {
|
try {
|
||||||
setSaveError('');
|
setSaveError('');
|
||||||
const response = await fetch('/api/i18n/translations', {
|
const effectiveTranslations = translationsOverride ?? dataRef.current.translations;
|
||||||
|
const response = await authFetch('/api/i18n/translations', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ translations: data.translations }),
|
body: JSON.stringify({ translations: effectiveTranslations }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
onUnauthorizedRef.current?.();
|
||||||
|
throw new Error('Session expired. Redirecting to login.');
|
||||||
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (!response.ok || !result?.ok) {
|
if (!response.ok || !result?.ok) {
|
||||||
@ -107,15 +259,69 @@ export function useLanguageManagementTranslations({ coreLanguages, onAction }: U
|
|||||||
setData({ languages, translations });
|
setData({ languages, translations });
|
||||||
setIsDirty(false);
|
setIsDirty(false);
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
onAction?.({ variant: 'success', message: 'Translations saved successfully.' });
|
emitAction({ variant: 'success', message: 'Translations saved successfully.' });
|
||||||
setTimeout(() => setSaved(false), 2500);
|
setTimeout(() => setSaved(false), 2500);
|
||||||
|
|
||||||
|
// Best-effort: sync ONLY delta overrides for custom languages to DB.
|
||||||
|
// Delta = keys whose value differs from the en.ts base. Core languages are .ts-only.
|
||||||
|
const enBase = enBaseRef.current;
|
||||||
|
const customLangCodes = Object.keys(translations).filter((c) => !coreLanguages.has(c));
|
||||||
|
if (customLangCodes.length > 0) {
|
||||||
|
const overrides: Array<{ languageCode: string; namespace: string; key: string; value: string; isCustom: boolean }> = [];
|
||||||
|
const nextDbOverrides: Record<string, Record<string, string>> = { ...dbOverridesRef.current };
|
||||||
|
|
||||||
|
for (const langCode of customLangCodes) {
|
||||||
|
const flat = translations[langCode] ?? {};
|
||||||
|
const prevDbLang = dbOverridesRef.current[langCode] ?? {};
|
||||||
|
const nextDbLang: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const [flatKey, value] of Object.entries(flat)) {
|
||||||
|
const baseValue = enBase[flatKey] ?? '';
|
||||||
|
if (!value || value === baseValue) {
|
||||||
|
// Key matches base or is empty — not a genuine override; omit from DB.
|
||||||
|
// If it was previously stored, it will be absent from the next PUT batch
|
||||||
|
// (backends using full-replace semantics will remove it automatically).
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dotIdx = flatKey.indexOf('.');
|
||||||
|
const namespace = dotIdx !== -1 ? flatKey.slice(0, dotIdx) : flatKey;
|
||||||
|
const key = dotIdx !== -1 ? flatKey.slice(dotIdx + 1) : flatKey;
|
||||||
|
overrides.push({ languageCode: langCode, namespace, key, value, isCustom: true });
|
||||||
|
nextDbLang[flatKey] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carry forward any DB overrides for keys not present in flat (shouldn't happen normally).
|
||||||
|
for (const [flatKey, value] of Object.entries(prevDbLang)) {
|
||||||
|
if (!(flatKey in nextDbLang)) {
|
||||||
|
// Key was reverted to base; drop from our local cache.
|
||||||
|
} else {
|
||||||
|
nextDbLang[flatKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextDbOverrides[langCode] = nextDbLang;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local DB overrides cache regardless of whether the PUT succeeds.
|
||||||
|
dbOverridesRef.current = nextDbOverrides;
|
||||||
|
|
||||||
|
if (overrides.length > 0) {
|
||||||
|
authFetch('/api/i18n/preferences', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ translations: overrides }),
|
||||||
|
}).catch(() => {
|
||||||
|
// Non-fatal: .ts files are already saved successfully.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to save translation files.';
|
const message = error instanceof Error ? error.message : 'Failed to save translation files.';
|
||||||
setSaveError(message);
|
setSaveError(message);
|
||||||
onAction?.({ variant: 'error', message });
|
emitAction({ variant: 'error', message });
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
}
|
}
|
||||||
}, [data.translations, onAction]);
|
}, [emitAction]);
|
||||||
|
|
||||||
const handleAddLanguage = useCallback(async () => {
|
const handleAddLanguage = useCallback(async () => {
|
||||||
const code = newCode.trim().toLowerCase();
|
const code = newCode.trim().toLowerCase();
|
||||||
@ -132,11 +338,17 @@ export function useLanguageManagementTranslations({ coreLanguages, onAction }: U
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/i18n/translations', {
|
const response = await authFetch('/api/i18n/translations', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ code, name }),
|
body: JSON.stringify({ code, name }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
onUnauthorizedRef.current?.();
|
||||||
|
throw new Error('Session expired. Redirecting to login.');
|
||||||
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (!response.ok || !result?.ok) {
|
if (!response.ok || !result?.ok) {
|
||||||
@ -156,23 +368,41 @@ export function useLanguageManagementTranslations({ coreLanguages, onAction }: U
|
|||||||
setActiveLang(code);
|
setActiveLang(code);
|
||||||
setIsDirty(false);
|
setIsDirty(false);
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
onAction?.({ variant: 'success', message: `Language ${name} (${code}) added successfully.` });
|
emitAction({ variant: 'success', message: `Language ${name} (${code}) added successfully.` });
|
||||||
|
|
||||||
|
// Best-effort: register the new language in the DB.
|
||||||
|
authFetch('/api/i18n/preferences', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
language: { languageCode: code, label: name, isEnabled: true, isCustom: true },
|
||||||
|
}),
|
||||||
|
}).catch(() => {
|
||||||
|
// Non-fatal: .ts file was created successfully.
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to create language file.';
|
const message = error instanceof Error ? error.message : 'Failed to create language file.';
|
||||||
setAddError(message);
|
setAddError(message);
|
||||||
onAction?.({ variant: 'error', message });
|
emitAction({ variant: 'error', message });
|
||||||
}
|
}
|
||||||
}, [allLanguages, newCode, newName, onAction]);
|
}, [allLanguages, emitAction, newCode, newName]);
|
||||||
|
|
||||||
const handleDeleteLanguage = useCallback(async (code: string) => {
|
const handleDeleteLanguage = useCallback(async (code: string) => {
|
||||||
if (coreLanguages.has(code)) return;
|
if (coreLanguages.has(code)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/i18n/translations', {
|
// 1. Delete the .ts translation file
|
||||||
|
const response = await authFetch('/api/i18n/translations', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ code }),
|
body: JSON.stringify({ code }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
onUnauthorizedRef.current?.();
|
||||||
|
throw new Error('Session expired. Redirecting to login.');
|
||||||
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (!response.ok || !result?.ok) {
|
if (!response.ok || !result?.ok) {
|
||||||
@ -188,19 +418,63 @@ export function useLanguageManagementTranslations({ coreLanguages, onAction }: U
|
|||||||
setIsDirty(false);
|
setIsDirty(false);
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
onAction?.({ variant: 'success', message: `Language ${code} deleted successfully.` });
|
|
||||||
if (activeLang === code) setActiveLang('en');
|
if (activeLang === code) setActiveLang('en');
|
||||||
|
|
||||||
|
// 2. Best-effort: remove language-specific records from the DB
|
||||||
|
try {
|
||||||
|
await authFetch(
|
||||||
|
`/api/i18n/preferences?languageCode=${encodeURIComponent(code)}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// DB cleanup failure is non-fatal; file is already deleted.
|
||||||
|
emitAction({ variant: 'warning', message: `Language "${code}" file deleted, but DB cleanup failed. You may need to remove DB records manually.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
emitAction({ variant: 'success', message: `Language ${code} deleted successfully.` });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to delete language file.';
|
const message = error instanceof Error ? error.message : 'Failed to delete language file.';
|
||||||
setAddError(message);
|
setAddError(message);
|
||||||
onAction?.({ variant: 'error', message });
|
emitAction({ variant: 'error', message });
|
||||||
}
|
}
|
||||||
}, [activeLang, coreLanguages, onAction]);
|
}, [activeLang, coreLanguages, emitAction]);
|
||||||
|
|
||||||
const isBuiltin = useCallback((code: string) => coreLanguages.has(code), [coreLanguages]);
|
const isBuiltin = useCallback((code: string) => coreLanguages.has(code), [coreLanguages]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given flat key for the currently active language (or a specified language)
|
||||||
|
* is a genuine DB override — i.e. its value differs from the en.ts base.
|
||||||
|
*/
|
||||||
|
const isOverridden = useCallback(
|
||||||
|
(flatKey: string, langCode?: string): boolean => {
|
||||||
|
const lang = langCode ?? activeLang;
|
||||||
|
if (coreLanguages.has(lang)) return false;
|
||||||
|
const currentValue = data.translations[lang]?.[flatKey] ?? '';
|
||||||
|
const baseValue = enBaseRef.current[flatKey] ?? '';
|
||||||
|
return currentValue !== '' && currentValue !== baseValue;
|
||||||
|
},
|
||||||
|
[activeLang, coreLanguages, data.translations]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverts a key to its en.ts base value (removes the override from the working state).
|
||||||
|
* The override will be excluded from the next DB sync automatically.
|
||||||
|
*/
|
||||||
|
const handleRevertKey = useCallback(
|
||||||
|
(flatKey: string) => {
|
||||||
|
if (coreLanguages.has(activeLang)) return;
|
||||||
|
const baseValue = enBaseRef.current[flatKey] ?? '';
|
||||||
|
handleChange(flatKey, baseValue);
|
||||||
|
},
|
||||||
|
[activeLang, coreLanguages, handleChange]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
|
isLoading,
|
||||||
|
loadingPhase,
|
||||||
|
loadingProgress,
|
||||||
|
loadingLogs,
|
||||||
allLanguages,
|
allLanguages,
|
||||||
activeLang,
|
activeLang,
|
||||||
setActiveLang,
|
setActiveLang,
|
||||||
@ -223,5 +497,7 @@ export function useLanguageManagementTranslations({ coreLanguages, onAction }: U
|
|||||||
setDeleteTarget,
|
setDeleteTarget,
|
||||||
handleDeleteLanguage,
|
handleDeleteLanguage,
|
||||||
isBuiltin,
|
isBuiltin,
|
||||||
|
isOverridden,
|
||||||
|
handleRevertKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { authFetch } from '../../../utils/authFetch';
|
||||||
|
import useAuthStore from '../../../store/authStore';
|
||||||
|
|
||||||
type NamespaceCategorySeed = { label: string; namespaces: string[] };
|
type NamespaceCategorySeed = { label: string; namespaces: string[] };
|
||||||
|
|
||||||
@ -30,6 +32,13 @@ type UseNamespaceCategoriesOptions = {
|
|||||||
allKeys: string[];
|
allKeys: string[];
|
||||||
defaultCategories: NamespaceCategorySeed[];
|
defaultCategories: NamespaceCategorySeed[];
|
||||||
onAction?: (notice: { variant: 'success' | 'error' | 'info' | 'warning'; message: string }) => void;
|
onAction?: (notice: { variant: 'success' | 'error' | 'info' | 'warning'; message: string }) => void;
|
||||||
|
onUnauthorized?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PreferencesSaveSnapshot = {
|
||||||
|
categories?: NamespaceCategory[];
|
||||||
|
globalKeys?: string[];
|
||||||
|
englishReferenceKeysByLanguage?: Record<string, string[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeCategories(value: unknown): NamespaceCategory[] {
|
function normalizeCategories(value: unknown): NamespaceCategory[] {
|
||||||
@ -52,10 +61,37 @@ function normalizeGlobalKeys(value: unknown): string[] {
|
|||||||
return value.filter((k): k is string => typeof k === 'string');
|
return value.filter((k): k is string => typeof k === 'string');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNamespaceCategories({ namespaces, allKeys, defaultCategories, onAction }: UseNamespaceCategoriesOptions) {
|
function normalizeEnglishReferenceKeysByLanguage(value: unknown): Record<string, string[]> {
|
||||||
|
if (!value || typeof value !== 'object') return {};
|
||||||
|
const result: Record<string, string[]> = {};
|
||||||
|
for (const [languageCode, keys] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
if (!Array.isArray(keys)) continue;
|
||||||
|
const normalizedKeys = keys.filter((k): k is string => typeof k === 'string');
|
||||||
|
if (normalizedKeys.length > 0) {
|
||||||
|
result[languageCode] = Array.from(new Set(normalizedKeys)).sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNamespaceCategories({ namespaces, allKeys, defaultCategories, onAction, onUnauthorized }: UseNamespaceCategoriesOptions) {
|
||||||
const fallbackCategories = useMemo(() => createDefaultCategories(defaultCategories), [defaultCategories]);
|
const fallbackCategories = useMemo(() => createDefaultCategories(defaultCategories), [defaultCategories]);
|
||||||
const skipFirstPersistRef = useRef(true);
|
|
||||||
const onActionRef = useRef(onAction);
|
const onActionRef = useRef(onAction);
|
||||||
|
const onUnauthorizedRef = useRef(onUnauthorized);
|
||||||
|
const lastSavedPreferencesRef = useRef('');
|
||||||
|
const hasLoadedPreferencesOnceRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onUnauthorizedRef.current = onUnauthorized;
|
||||||
|
}, [onUnauthorized]);
|
||||||
|
|
||||||
|
const emitAction = (notice: { variant: 'success' | 'error' | 'info' | 'warning'; message: string }) => {
|
||||||
|
try {
|
||||||
|
onActionRef.current?.(notice);
|
||||||
|
} catch {
|
||||||
|
// Never block DB persistence because of notification rendering failures.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onActionRef.current = onAction;
|
onActionRef.current = onAction;
|
||||||
@ -66,90 +102,226 @@ export function useNamespaceCategories({ namespaces, allKeys, defaultCategories,
|
|||||||
const [categories, setCategories] = useState<NamespaceCategory[]>(fallbackCategories);
|
const [categories, setCategories] = useState<NamespaceCategory[]>(fallbackCategories);
|
||||||
|
|
||||||
const [globalKeys, setGlobalKeys] = useState<string[]>([]);
|
const [globalKeys, setGlobalKeys] = useState<string[]>([]);
|
||||||
|
const [englishReferenceKeysByLanguage, setEnglishReferenceKeysByLanguage] = useState<Record<string, string[]>>({});
|
||||||
const [preferencesHydrated, setPreferencesHydrated] = useState(false);
|
const [preferencesHydrated, setPreferencesHydrated] = useState(false);
|
||||||
|
const [preferencesLoadingPhase, setPreferencesLoadingPhase] = useState('idle');
|
||||||
|
const [preferencesLoadingProgress, setPreferencesLoadingProgress] = useState(0);
|
||||||
|
const [preferencesLoadingLogs, setPreferencesLoadingLogs] = useState<string[]>([]);
|
||||||
|
const [isPreferencesDirty, setIsPreferencesDirty] = useState(false);
|
||||||
|
const [isSavingPreferences, setIsSavingPreferences] = useState(false);
|
||||||
|
|
||||||
const [newCategoryLabel, setNewCategoryLabel] = useState('');
|
const [newCategoryLabel, setNewCategoryLabel] = useState('');
|
||||||
const [assignNamespaceByCategory, setAssignNamespaceByCategory] = useState<Record<string, string>>({});
|
const [assignNamespaceByCategory, setAssignNamespaceByCategory] = useState<Record<string, string>>({});
|
||||||
const [expandedCategoryId, setExpandedCategoryId] = useState<string | null>(null);
|
const [expandedCategoryId, setExpandedCategoryId] = useState<string | null>(null);
|
||||||
const [dragNamespace, setDragNamespace] = useState<string | null>(null);
|
const [dragNamespace, setDragNamespace] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const appendPreferencesLoadingLog = (message: string) => {
|
||||||
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
|
setPreferencesLoadingLogs((prev) => [...prev, `[${timestamp}] ${message}`].slice(-30));
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializePreferences = (
|
||||||
|
nextCategories: NamespaceCategory[],
|
||||||
|
nextGlobalKeys: string[],
|
||||||
|
nextEnglishReferenceKeysByLanguage: Record<string, string[]>
|
||||||
|
) =>
|
||||||
|
JSON.stringify({
|
||||||
|
categories: nextCategories,
|
||||||
|
globalKeys: nextGlobalKeys,
|
||||||
|
englishReferenceKeysByLanguage: nextEnglishReferenceKeysByLanguage,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
if (hasLoadedPreferencesOnceRef.current) return;
|
||||||
|
hasLoadedPreferencesOnceRef.current = true;
|
||||||
|
|
||||||
|
setPreferencesLoadingPhase('initializing');
|
||||||
|
setPreferencesLoadingProgress(5);
|
||||||
|
appendPreferencesLoadingLog('Starting preferences bootstrap');
|
||||||
|
|
||||||
const loadPreferences = async () => {
|
const loadPreferences = async () => {
|
||||||
|
console.debug('[LanguageManagement][Preferences] load:start');
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/i18n/preferences', { cache: 'no-store' });
|
setPreferencesLoadingPhase('refresh-auth');
|
||||||
|
setPreferencesLoadingProgress(15);
|
||||||
|
appendPreferencesLoadingLog('Refreshing auth session before preferences fetch');
|
||||||
|
|
||||||
|
// Preflight refresh avoids immediate 401/refresh/retry loops during initial mount.
|
||||||
|
await useAuthStore.getState().refreshAuthToken(true).catch(() => null);
|
||||||
|
|
||||||
|
setPreferencesLoadingPhase('fetch-preferences');
|
||||||
|
setPreferencesLoadingProgress(35);
|
||||||
|
appendPreferencesLoadingLog('Fetching language preferences from /api/i18n/preferences');
|
||||||
|
|
||||||
|
const response = await authFetch('/api/i18n/preferences', { cache: 'no-store' });
|
||||||
|
console.debug('[LanguageManagement][Preferences] load:response', {
|
||||||
|
status: response.status,
|
||||||
|
ok: response.ok,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
onUnauthorizedRef.current?.();
|
||||||
|
throw new Error('Session expired. Redirecting to login.');
|
||||||
|
}
|
||||||
|
|
||||||
const result = await response.json().catch(() => null);
|
const result = await response.json().catch(() => null);
|
||||||
|
|
||||||
if (!response.ok || !result?.ok) {
|
if (!response.ok || !result?.ok) {
|
||||||
throw new Error(result?.message || 'Failed to load language preferences.');
|
throw new Error(result?.message || 'Failed to load language preferences.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
const payload = result.preferences && typeof result.preferences === 'object'
|
const payload = result.preferences && typeof result.preferences === 'object'
|
||||||
? result.preferences
|
? result.preferences
|
||||||
: result;
|
: result;
|
||||||
|
|
||||||
const loadedCategories = normalizeCategories(payload?.categories);
|
const loadedCategories = normalizeCategories(payload?.categories);
|
||||||
const loadedGlobalKeys = normalizeGlobalKeys(payload?.globalKeys);
|
const loadedGlobalKeys = normalizeGlobalKeys(payload?.globalKeys);
|
||||||
|
const loadedEnglishReferenceKeysByLanguage =
|
||||||
|
normalizeEnglishReferenceKeysByLanguage(payload?.englishReferenceKeysByLanguage);
|
||||||
|
|
||||||
setCategories(loadedCategories.length > 0 ? loadedCategories : fallbackCategories);
|
const effectiveCategories = loadedCategories.length > 0 ? loadedCategories : fallbackCategories;
|
||||||
setGlobalKeys(loadedGlobalKeys);
|
const effectiveGlobalKeys = loadedGlobalKeys;
|
||||||
|
const effectiveEnglishReferenceKeysByLanguage = loadedEnglishReferenceKeysByLanguage;
|
||||||
|
|
||||||
|
lastSavedPreferencesRef.current = serializePreferences(
|
||||||
|
effectiveCategories,
|
||||||
|
effectiveGlobalKeys,
|
||||||
|
effectiveEnglishReferenceKeysByLanguage
|
||||||
|
);
|
||||||
|
|
||||||
|
console.debug('[LanguageManagement][Preferences] load:applied', {
|
||||||
|
categoriesCount: effectiveCategories.length,
|
||||||
|
globalKeysCount: effectiveGlobalKeys.length,
|
||||||
|
englishReferenceLanguagesCount: Object.keys(effectiveEnglishReferenceKeysByLanguage).length,
|
||||||
|
});
|
||||||
|
|
||||||
|
setCategories(effectiveCategories);
|
||||||
|
setGlobalKeys(effectiveGlobalKeys);
|
||||||
|
setEnglishReferenceKeysByLanguage(effectiveEnglishReferenceKeysByLanguage);
|
||||||
|
setIsPreferencesDirty(false);
|
||||||
|
setPreferencesLoadingProgress(90);
|
||||||
|
appendPreferencesLoadingLog('Preferences loaded and applied');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!cancelled) {
|
console.debug('[LanguageManagement][Preferences] load:error', {
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
setPreferencesLoadingPhase('error');
|
||||||
|
appendPreferencesLoadingLog(`Preferences bootstrap error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
setCategories(fallbackCategories);
|
setCategories(fallbackCategories);
|
||||||
setGlobalKeys([]);
|
setGlobalKeys([]);
|
||||||
onActionRef.current?.({
|
setEnglishReferenceKeysByLanguage({});
|
||||||
|
lastSavedPreferencesRef.current = serializePreferences(fallbackCategories, [], {});
|
||||||
|
setIsPreferencesDirty(false);
|
||||||
|
emitAction({
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
message: error instanceof Error
|
message: error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: 'Failed to load language preferences from backend.',
|
: 'Failed to load language preferences from backend.',
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setPreferencesHydrated(true);
|
setPreferencesHydrated(true);
|
||||||
|
setPreferencesLoadingPhase('ready');
|
||||||
|
setPreferencesLoadingProgress(100);
|
||||||
|
appendPreferencesLoadingLog('Preferences bootstrap complete');
|
||||||
|
console.debug('[LanguageManagement][Preferences] load:hydrated');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void loadPreferences();
|
void loadPreferences();
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [fallbackCategories]);
|
}, [fallbackCategories]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!preferencesHydrated) return;
|
if (!preferencesHydrated) return;
|
||||||
if (skipFirstPersistRef.current) {
|
const currentSerialized = serializePreferences(categories, globalKeys, englishReferenceKeysByLanguage);
|
||||||
skipFirstPersistRef.current = false;
|
const nextDirty = currentSerialized !== lastSavedPreferencesRef.current;
|
||||||
return;
|
setIsPreferencesDirty(nextDirty);
|
||||||
|
console.debug('[LanguageManagement][Preferences] dirty:recompute', {
|
||||||
|
preferencesHydrated,
|
||||||
|
categoriesCount: categories.length,
|
||||||
|
globalKeysCount: globalKeys.length,
|
||||||
|
englishReferenceLanguagesCount: Object.keys(englishReferenceKeysByLanguage).length,
|
||||||
|
isDirty: nextDirty,
|
||||||
|
});
|
||||||
|
}, [categories, globalKeys, englishReferenceKeysByLanguage, preferencesHydrated]);
|
||||||
|
|
||||||
|
const savePreferences = async (snapshot?: PreferencesSaveSnapshot) => {
|
||||||
|
const effectiveCategories = snapshot?.categories ?? categories;
|
||||||
|
const effectiveGlobalKeys = snapshot?.globalKeys ?? globalKeys;
|
||||||
|
const effectiveEnglishReferenceKeysByLanguage =
|
||||||
|
snapshot?.englishReferenceKeysByLanguage ?? englishReferenceKeysByLanguage;
|
||||||
|
|
||||||
|
if (!preferencesHydrated) {
|
||||||
|
console.debug('[LanguageManagement][Preferences] save:skipped:notHydrated');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeoutId = setTimeout(async () => {
|
const currentSerialized = serializePreferences(
|
||||||
|
effectiveCategories,
|
||||||
|
effectiveGlobalKeys,
|
||||||
|
effectiveEnglishReferenceKeysByLanguage
|
||||||
|
);
|
||||||
|
if (currentSerialized === lastSavedPreferencesRef.current) {
|
||||||
|
console.debug('[LanguageManagement][Preferences] save:skipped:notDirty', {
|
||||||
|
categoriesCount: effectiveCategories.length,
|
||||||
|
globalKeysCount: effectiveGlobalKeys.length,
|
||||||
|
englishReferenceLanguagesCount: Object.keys(effectiveEnglishReferenceKeysByLanguage).length,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug('[LanguageManagement][Preferences] save:start', {
|
||||||
|
categoriesCount: effectiveCategories.length,
|
||||||
|
globalKeysCount: effectiveGlobalKeys.length,
|
||||||
|
globalKeysPreview: effectiveGlobalKeys.slice(0, 8),
|
||||||
|
englishReferenceLanguagesCount: Object.keys(effectiveEnglishReferenceKeysByLanguage).length,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsSavingPreferences(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/i18n/preferences', {
|
const response = await authFetch('/api/i18n/preferences', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ categories, globalKeys }),
|
body: JSON.stringify({
|
||||||
|
categories: effectiveCategories,
|
||||||
|
globalKeys: effectiveGlobalKeys,
|
||||||
|
englishReferenceKeysByLanguage: effectiveEnglishReferenceKeysByLanguage,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.debug('[LanguageManagement][Preferences] save:response', {
|
||||||
|
status: response.status,
|
||||||
|
ok: response.ok,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
onUnauthorizedRef.current?.();
|
||||||
|
throw new Error('Session expired. Redirecting to login.');
|
||||||
|
}
|
||||||
|
|
||||||
const result = await response.json().catch(() => null);
|
const result = await response.json().catch(() => null);
|
||||||
if (!response.ok || !result?.ok) {
|
if (!response.ok || !result?.ok) {
|
||||||
throw new Error(result?.message || 'Failed to save language preferences.');
|
throw new Error(result?.message || 'Failed to save language preferences.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastSavedPreferencesRef.current = currentSerialized;
|
||||||
|
setIsPreferencesDirty(false);
|
||||||
|
console.debug('[LanguageManagement][Preferences] save:success');
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onActionRef.current?.({
|
console.debug('[LanguageManagement][Preferences] save:error', {
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
emitAction({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
message: error instanceof Error
|
message: error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: 'Failed to save language preferences.',
|
: 'Failed to save language preferences.',
|
||||||
});
|
});
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsSavingPreferences(false);
|
||||||
}
|
}
|
||||||
}, 350);
|
};
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
|
||||||
}, [categories, globalKeys, preferencesHydrated]);
|
|
||||||
|
|
||||||
const categoriesWithKnownNamespaces = useMemo(() => {
|
const categoriesWithKnownNamespaces = useMemo(() => {
|
||||||
const namespaceSet = new Set(namespaces);
|
const namespaceSet = new Set(namespaces);
|
||||||
@ -224,11 +396,68 @@ export function useNamespaceCategories({ namespaces, allKeys, defaultCategories,
|
|||||||
|
|
||||||
const addGlobalKey = (key: string) => {
|
const addGlobalKey = (key: string) => {
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
setGlobalKeys((prev) => (prev.includes(key) ? prev : [...prev, key]));
|
setGlobalKeys((prev) => {
|
||||||
|
const next = prev.includes(key) ? prev : [...prev, key];
|
||||||
|
console.debug('[LanguageManagement][Preferences] global:add', {
|
||||||
|
key,
|
||||||
|
previousCount: prev.length,
|
||||||
|
nextCount: next.length,
|
||||||
|
changed: next !== prev,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeGlobalKey = (key: string) => {
|
const removeGlobalKey = (key: string) => {
|
||||||
setGlobalKeys((prev) => prev.filter((k) => k !== key));
|
setGlobalKeys((prev) => {
|
||||||
|
const next = prev.filter((k) => k !== key);
|
||||||
|
console.debug('[LanguageManagement][Preferences] global:remove', {
|
||||||
|
key,
|
||||||
|
previousCount: prev.length,
|
||||||
|
nextCount: next.length,
|
||||||
|
changed: next.length !== prev.length,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const englishReferenceKeySetByLanguage = useMemo(() => {
|
||||||
|
const result: Record<string, Set<string>> = {};
|
||||||
|
for (const [languageCode, keys] of Object.entries(englishReferenceKeysByLanguage)) {
|
||||||
|
result[languageCode] = new Set(keys);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [englishReferenceKeysByLanguage]);
|
||||||
|
|
||||||
|
const setEnglishReferenceKey = (languageCode: string, key: string, enabled: boolean) => {
|
||||||
|
if (!languageCode || languageCode === 'en' || !key) return;
|
||||||
|
setEnglishReferenceKeysByLanguage((prev) => {
|
||||||
|
const prevKeys = prev[languageCode] ?? [];
|
||||||
|
const prevSet = new Set(prevKeys);
|
||||||
|
if (enabled) {
|
||||||
|
prevSet.add(key);
|
||||||
|
} else {
|
||||||
|
prevSet.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextKeys = Array.from(prevSet).sort((a, b) => a.localeCompare(b));
|
||||||
|
const next = { ...prev };
|
||||||
|
if (nextKeys.length === 0) {
|
||||||
|
delete next[languageCode];
|
||||||
|
} else {
|
||||||
|
next[languageCode] = nextKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug('[LanguageManagement][Preferences] english-reference:set', {
|
||||||
|
languageCode,
|
||||||
|
key,
|
||||||
|
enabled,
|
||||||
|
previousCount: prevKeys.length,
|
||||||
|
nextCount: nextKeys.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -236,6 +465,13 @@ export function useNamespaceCategories({ namespaces, allKeys, defaultCategories,
|
|||||||
setActiveCategory,
|
setActiveCategory,
|
||||||
showCategoryManagerModal,
|
showCategoryManagerModal,
|
||||||
setShowCategoryManagerModal,
|
setShowCategoryManagerModal,
|
||||||
|
isPreferencesDirty,
|
||||||
|
preferencesHydrated,
|
||||||
|
preferencesLoadingPhase,
|
||||||
|
preferencesLoadingProgress,
|
||||||
|
preferencesLoadingLogs,
|
||||||
|
isSavingPreferences,
|
||||||
|
savePreferences,
|
||||||
categoriesWithKnownNamespaces,
|
categoriesWithKnownNamespaces,
|
||||||
uncategorizedNamespaces,
|
uncategorizedNamespaces,
|
||||||
addNamespaceToCategory,
|
addNamespaceToCategory,
|
||||||
@ -254,5 +490,8 @@ export function useNamespaceCategories({ namespaces, allKeys, defaultCategories,
|
|||||||
globalKeySet,
|
globalKeySet,
|
||||||
addGlobalKey,
|
addGlobalKey,
|
||||||
removeGlobalKey,
|
removeGlobalKey,
|
||||||
|
englishReferenceKeysByLanguage,
|
||||||
|
englishReferenceKeySetByLanguage,
|
||||||
|
setEnglishReferenceKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,9 @@ import LanguageManagementTopSection from './components/LanguageManagementTopSect
|
|||||||
import { useI18nScanWorkflow } from './hooks/useI18nScanWorkflow';
|
import { useI18nScanWorkflow } from './hooks/useI18nScanWorkflow';
|
||||||
import { useLanguageManagementTranslations } from './hooks/useLanguageManagementTranslations';
|
import { useLanguageManagementTranslations } from './hooks/useLanguageManagementTranslations';
|
||||||
import { useNamespaceCategories } from './hooks/useNamespaceCategories';
|
import { useNamespaceCategories } from './hooks/useNamespaceCategories';
|
||||||
|
import { useModalAnimation } from './hooks/useModalAnimation';
|
||||||
import { useToast } from '../../components/toast/toastComponent';
|
import { useToast } from '../../components/toast/toastComponent';
|
||||||
|
import { isPageTransitioning } from '../../components/animation/pageTransitionEffect';
|
||||||
import {
|
import {
|
||||||
getAllTranslationKeys,
|
getAllTranslationKeys,
|
||||||
getEnglishValue,
|
getEnglishValue,
|
||||||
@ -21,6 +23,7 @@ import {
|
|||||||
} from '../../i18n/useTranslation';
|
} from '../../i18n/useTranslation';
|
||||||
|
|
||||||
const CORE_LANGUAGES = new Set(['en', 'de']);
|
const CORE_LANGUAGES = new Set(['en', 'de']);
|
||||||
|
const AUTO_SCROLL_ON_SAVE_STORAGE_KEY = 'language-management-auto-scroll-on-save';
|
||||||
|
|
||||||
// ── namespace categories
|
// ── namespace categories
|
||||||
const NAMESPACE_CATEGORIES: { label: string; namespaces: string[] }[] = [
|
const NAMESPACE_CATEGORIES: { label: string; namespaces: string[] }[] = [
|
||||||
@ -56,6 +59,10 @@ export default function LanguageManagementPage() {
|
|||||||
});
|
});
|
||||||
}, [showToast]);
|
}, [showToast]);
|
||||||
|
|
||||||
|
const handleUnauthorized = useCallback(() => {
|
||||||
|
router.push('/login');
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
showScanModal,
|
showScanModal,
|
||||||
setShowScanModal,
|
setShowScanModal,
|
||||||
@ -73,7 +80,7 @@ export default function LanguageManagementPage() {
|
|||||||
toggleFileSelection,
|
toggleFileSelection,
|
||||||
selectAllFiles,
|
selectAllFiles,
|
||||||
clearSelectedFiles,
|
clearSelectedFiles,
|
||||||
} = useI18nScanWorkflow();
|
} = useI18nScanWorkflow({ onUnauthorized: handleUnauthorized });
|
||||||
|
|
||||||
// ── all flat keys from the English source-of-truth
|
// ── all flat keys from the English source-of-truth
|
||||||
const allKeys = useMemo(() => getAllTranslationKeys(), []);
|
const allKeys = useMemo(() => getAllTranslationKeys(), []);
|
||||||
@ -82,6 +89,10 @@ export default function LanguageManagementPage() {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
|
isLoading: isTranslationsLoading,
|
||||||
|
loadingPhase: translationsLoadingPhase,
|
||||||
|
loadingProgress: translationsLoadingProgress,
|
||||||
|
loadingLogs: translationsLoadingLogs,
|
||||||
allLanguages,
|
allLanguages,
|
||||||
activeLang,
|
activeLang,
|
||||||
setActiveLang,
|
setActiveLang,
|
||||||
@ -107,12 +118,15 @@ export default function LanguageManagementPage() {
|
|||||||
} = useLanguageManagementTranslations({
|
} = useLanguageManagementTranslations({
|
||||||
coreLanguages: CORE_LANGUAGES,
|
coreLanguages: CORE_LANGUAGES,
|
||||||
onAction: notifyAction,
|
onAction: notifyAction,
|
||||||
|
onUnauthorized: handleUnauthorized,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [showTranslationWizard, setShowTranslationWizard] = useState(false);
|
const [showTranslationWizard, setShowTranslationWizard] = useState(false);
|
||||||
const [wizardIndex, setWizardIndex] = useState(0);
|
const [wizardIndex, setWizardIndex] = useState(0);
|
||||||
const [wizardInput, setWizardInput] = useState('');
|
const [wizardInput, setWizardInput] = useState('');
|
||||||
const [wizardMarkGlobal, setWizardMarkGlobal] = useState(false);
|
const [wizardMarkGlobal, setWizardMarkGlobal] = useState(false);
|
||||||
|
const [wizardUseEnglishReference, setWizardUseEnglishReference] = useState(false);
|
||||||
|
const [isWizardSavingStep, setIsWizardSavingStep] = useState(false);
|
||||||
|
|
||||||
const [newGlobalKeySelection, setNewGlobalKeySelection] = useState('');
|
const [newGlobalKeySelection, setNewGlobalKeySelection] = useState('');
|
||||||
const [reloadAfterScanClose, setReloadAfterScanClose] = useState(false);
|
const [reloadAfterScanClose, setReloadAfterScanClose] = useState(false);
|
||||||
@ -123,6 +137,13 @@ export default function LanguageManagementPage() {
|
|||||||
setActiveCategory,
|
setActiveCategory,
|
||||||
showCategoryManagerModal,
|
showCategoryManagerModal,
|
||||||
setShowCategoryManagerModal,
|
setShowCategoryManagerModal,
|
||||||
|
isPreferencesDirty,
|
||||||
|
preferencesHydrated,
|
||||||
|
preferencesLoadingPhase,
|
||||||
|
preferencesLoadingProgress,
|
||||||
|
preferencesLoadingLogs,
|
||||||
|
isSavingPreferences,
|
||||||
|
savePreferences,
|
||||||
categoriesWithKnownNamespaces,
|
categoriesWithKnownNamespaces,
|
||||||
uncategorizedNamespaces,
|
uncategorizedNamespaces,
|
||||||
addNamespaceToCategory,
|
addNamespaceToCategory,
|
||||||
@ -141,21 +162,93 @@ export default function LanguageManagementPage() {
|
|||||||
globalKeySet,
|
globalKeySet,
|
||||||
addGlobalKey,
|
addGlobalKey,
|
||||||
removeGlobalKey,
|
removeGlobalKey,
|
||||||
|
englishReferenceKeysByLanguage,
|
||||||
|
englishReferenceKeySetByLanguage,
|
||||||
|
setEnglishReferenceKey,
|
||||||
} = useNamespaceCategories({
|
} = useNamespaceCategories({
|
||||||
namespaces,
|
namespaces,
|
||||||
allKeys,
|
allKeys,
|
||||||
defaultCategories: NAMESPACE_CATEGORIES,
|
defaultCategories: NAMESPACE_CATEGORIES,
|
||||||
onAction: notifyAction,
|
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
|
// ── search / filter
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [autoScrollOnPanelOpen, setAutoScrollOnPanelOpen] = useState(true);
|
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 [activeNamespacePanel, setActiveNamespacePanel] = useState<string | null>(null);
|
||||||
|
|
||||||
const languageManagementHeaderRef = useRef<HTMLDivElement | null>(null);
|
const languageManagementHeaderRef = useRef<HTMLDivElement | null>(null);
|
||||||
const openedNamespacePanelRef = useRef<HTMLDivElement | null>(null);
|
const openedNamespacePanelRef = useRef<HTMLDivElement | null>(null);
|
||||||
const openFromPanelClickRef = useRef(false);
|
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 filteredGroups = useMemo(() => {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
@ -222,6 +315,7 @@ export default function LanguageManagementPage() {
|
|||||||
const isMissingForLanguage = (key: string, languageCode: string) => {
|
const isMissingForLanguage = (key: string, languageCode: string) => {
|
||||||
if (languageCode === 'en') return false;
|
if (languageCode === 'en') return false;
|
||||||
if (globalKeySet.has(key)) return false;
|
if (globalKeySet.has(key)) return false;
|
||||||
|
if (englishReferenceKeySetByLanguage[languageCode]?.has(key)) return false;
|
||||||
|
|
||||||
const value = (data.translations[languageCode]?.[key] ?? '').trim();
|
const value = (data.translations[languageCode]?.[key] ?? '').trim();
|
||||||
const englishValue = getEnglishValue(key).trim();
|
const englishValue = getEnglishValue(key).trim();
|
||||||
@ -322,25 +416,87 @@ export default function LanguageManagementPage() {
|
|||||||
const key = wizardMissingKeys[wizardIndex];
|
const key = wizardMissingKeys[wizardIndex];
|
||||||
setWizardInput(data.translations[activeLang]?.[key] ?? '');
|
setWizardInput(data.translations[activeLang]?.[key] ?? '');
|
||||||
setWizardMarkGlobal(globalKnownKeys.includes(key));
|
setWizardMarkGlobal(globalKnownKeys.includes(key));
|
||||||
}, [showTranslationWizard, wizardIndex, wizardMissingKeys, data.translations, activeLang, globalKnownKeys]);
|
setWizardUseEnglishReference(Boolean(englishReferenceKeySetByLanguage[activeLang]?.has(key)));
|
||||||
|
}, [showTranslationWizard, wizardIndex, wizardMissingKeys, data.translations, activeLang, globalKnownKeys, englishReferenceKeySetByLanguage]);
|
||||||
|
|
||||||
const saveCurrentWizardValue = () => {
|
const saveCurrentWizardValue = () => {
|
||||||
if (!currentWizardKey) return;
|
if (!currentWizardKey) return;
|
||||||
|
|
||||||
const trimmedWizardInput = wizardInput.trim();
|
const trimmedWizardInput = wizardInput.trim();
|
||||||
const englishValue = getEnglishValue(currentWizardKey).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.
|
// Global keys intentionally reuse the source term and don't require a local override.
|
||||||
const nextValue = wizardMarkGlobal && trimmedWizardInput === englishValue
|
const nextValue = wizardMarkGlobal && trimmedWizardInput === englishValue
|
||||||
? ''
|
? ''
|
||||||
: trimmedWizardInput;
|
: trimmedWizardInput;
|
||||||
|
|
||||||
|
nextTranslations[activeLang][currentWizardKey] = nextValue;
|
||||||
handleChange(currentWizardKey, nextValue);
|
handleChange(currentWizardKey, nextValue);
|
||||||
|
|
||||||
|
setEnglishReferenceKey(activeLang, currentWizardKey, false);
|
||||||
|
nextEnglishReferenceSet.delete(currentWizardKey);
|
||||||
|
|
||||||
if (wizardMarkGlobal) {
|
if (wizardMarkGlobal) {
|
||||||
addGlobalKey(currentWizardKey);
|
addGlobalKey(currentWizardKey);
|
||||||
|
nextGlobalKeysSet.add(currentWizardKey);
|
||||||
} else {
|
} else {
|
||||||
removeGlobalKey(currentWizardKey);
|
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 = () => {
|
const openTranslationWizard = () => {
|
||||||
@ -349,13 +505,25 @@ export default function LanguageManagementPage() {
|
|||||||
setShowTranslationWizard(true);
|
setShowTranslationWizard(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToNextWizardStep = () => {
|
const goToNextWizardStep = async () => {
|
||||||
saveCurrentWizardValue();
|
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) {
|
if (wizardIndex >= wizardMissingKeys.length - 1) {
|
||||||
setShowTranslationWizard(false);
|
setShowTranslationWizard(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setWizardIndex((prev) => prev + 1);
|
setWizardIndex((prev) => prev + 1);
|
||||||
|
} finally {
|
||||||
|
setIsWizardSavingStep(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const skipWizardStep = () => {
|
const skipWizardStep = () => {
|
||||||
@ -388,6 +556,29 @@ export default function LanguageManagementPage() {
|
|||||||
languageManagementHeaderRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
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(() => {
|
useEffect(() => {
|
||||||
if (!pendingAutoFixResult || !workspaceScan) return;
|
if (!pendingAutoFixResult || !workspaceScan) return;
|
||||||
|
|
||||||
@ -429,9 +620,82 @@ export default function LanguageManagementPage() {
|
|||||||
setReloadAfterScanClose(false);
|
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 (
|
return (
|
||||||
<PageLayout contentClassName="flex-1 relative w-full">
|
<PageLayout contentClassName="flex-1 relative w-full">
|
||||||
<div className="mx-auto max-w-7xl px-4 py-8 space-y-6">
|
<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="mx-auto max-w-7xl px-4 py-8 space-y-5 sm:px-6 lg:px-8">
|
||||||
|
{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
|
<LanguageManagementTopSection
|
||||||
headerRef={languageManagementHeaderRef}
|
headerRef={languageManagementHeaderRef}
|
||||||
totalKeys={totalKeys}
|
totalKeys={totalKeys}
|
||||||
@ -439,8 +703,8 @@ export default function LanguageManagementPage() {
|
|||||||
isScanning={isScanning}
|
isScanning={isScanning}
|
||||||
isAutoFixing={isAutoFixing}
|
isAutoFixing={isAutoFixing}
|
||||||
onBackToAdmin={() => router.push('/admin')}
|
onBackToAdmin={() => router.push('/admin')}
|
||||||
isDirty={isDirty}
|
isDirty={hasUnsavedChanges}
|
||||||
onSave={handleSave}
|
onSave={handleSaveAll}
|
||||||
saved={saved}
|
saved={saved}
|
||||||
saveError={saveError}
|
saveError={saveError}
|
||||||
allLanguages={allLanguages}
|
allLanguages={allLanguages}
|
||||||
@ -468,6 +732,8 @@ export default function LanguageManagementPage() {
|
|||||||
setSearch={setSearch}
|
setSearch={setSearch}
|
||||||
autoScrollOnPanelOpen={autoScrollOnPanelOpen}
|
autoScrollOnPanelOpen={autoScrollOnPanelOpen}
|
||||||
setAutoScrollOnPanelOpen={setAutoScrollOnPanelOpen}
|
setAutoScrollOnPanelOpen={setAutoScrollOnPanelOpen}
|
||||||
|
autoScrollOnSave={autoScrollOnSave}
|
||||||
|
setAutoScrollOnSave={setAutoScrollOnSave}
|
||||||
newGlobalKeySelection={newGlobalKeySelection}
|
newGlobalKeySelection={newGlobalKeySelection}
|
||||||
setNewGlobalKeySelection={setNewGlobalKeySelection}
|
setNewGlobalKeySelection={setNewGlobalKeySelection}
|
||||||
availableGlobalKeyOptions={availableGlobalKeyOptions}
|
availableGlobalKeyOptions={availableGlobalKeyOptions}
|
||||||
@ -478,6 +744,13 @@ export default function LanguageManagementPage() {
|
|||||||
translations={data.translations}
|
translations={data.translations}
|
||||||
handleChange={handleChange}
|
handleChange={handleChange}
|
||||||
globalKeySet={globalKeySet}
|
globalKeySet={globalKeySet}
|
||||||
|
englishReferenceKeySet={englishReferenceKeySetByLanguage[activeLang] ?? new Set<string>()}
|
||||||
|
setEnglishReferenceForKey={(key, enabled) => {
|
||||||
|
setEnglishReferenceKey(activeLang, key, enabled);
|
||||||
|
if (enabled) {
|
||||||
|
handleChange(key, getEnglishValue(key));
|
||||||
|
}
|
||||||
|
}}
|
||||||
filteredNs={filteredNs}
|
filteredNs={filteredNs}
|
||||||
filteredGroups={filteredGroups}
|
filteredGroups={filteredGroups}
|
||||||
activeNamespacePanel={activeNamespacePanel}
|
activeNamespacePanel={activeNamespacePanel}
|
||||||
@ -488,22 +761,115 @@ export default function LanguageManagementPage() {
|
|||||||
onBackToPanels={scrollToLanguageManagementHeader}
|
onBackToPanels={scrollToLanguageManagementHeader}
|
||||||
onOpenCategoryManager={() => setShowCategoryManagerModal(true)}
|
onOpenCategoryManager={() => setShowCategoryManagerModal(true)}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Sticky save bar */}
|
</div>
|
||||||
{isDirty && (
|
</div>
|
||||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50">
|
|
||||||
<div className="flex items-center gap-3 rounded-xl bg-[#1C2B4A] text-white px-6 py-3 shadow-2xl">
|
{/* Scroll-to-top floating button */}
|
||||||
<span className="text-sm">{t('autofix.kd63c8219')}</span>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
type="button"
|
||||||
className="rounded-md bg-white text-[#1C2B4A] px-4 py-1.5 text-sm font-semibold hover:bg-gray-100"
|
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)]"
|
||||||
>
|
>
|
||||||
Save
|
<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="mx-auto max-w-7xl 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 ? 'Saving…' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<TranslationWizardModal
|
<TranslationWizardModal
|
||||||
isOpen={showTranslationWizard}
|
isOpen={showTranslationWizard}
|
||||||
@ -516,6 +882,8 @@ export default function LanguageManagementPage() {
|
|||||||
setWizardInput={setWizardInput}
|
setWizardInput={setWizardInput}
|
||||||
wizardMarkGlobal={wizardMarkGlobal}
|
wizardMarkGlobal={wizardMarkGlobal}
|
||||||
setWizardMarkGlobal={setWizardMarkGlobal}
|
setWizardMarkGlobal={setWizardMarkGlobal}
|
||||||
|
wizardUseEnglishReference={wizardUseEnglishReference}
|
||||||
|
setWizardUseEnglishReference={setWizardUseEnglishReference}
|
||||||
englishValue={currentWizardKey ? getEnglishValue(currentWizardKey) : ''}
|
englishValue={currentWizardKey ? getEnglishValue(currentWizardKey) : ''}
|
||||||
addGlobalKey={addGlobalKey}
|
addGlobalKey={addGlobalKey}
|
||||||
removeGlobalKey={removeGlobalKey}
|
removeGlobalKey={removeGlobalKey}
|
||||||
@ -523,6 +891,7 @@ export default function LanguageManagementPage() {
|
|||||||
onPrevious={goToPreviousWizardStep}
|
onPrevious={goToPreviousWizardStep}
|
||||||
onSkip={skipWizardStep}
|
onSkip={skipWizardStep}
|
||||||
onNext={goToNextWizardStep}
|
onNext={goToNextWizardStep}
|
||||||
|
isSavingStep={isWizardSavingStep}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddLanguageModal
|
<AddLanguageModal
|
||||||
@ -546,7 +915,10 @@ export default function LanguageManagementPage() {
|
|||||||
|
|
||||||
<CategoryManagerModal
|
<CategoryManagerModal
|
||||||
isOpen={showCategoryManagerModal}
|
isOpen={showCategoryManagerModal}
|
||||||
onClose={() => setShowCategoryManagerModal(false)}
|
onClose={() => {
|
||||||
|
setShowCategoryManagerModal(false);
|
||||||
|
if (isPreferencesDirty) void savePreferences();
|
||||||
|
}}
|
||||||
newCategoryLabel={newCategoryLabel}
|
newCategoryLabel={newCategoryLabel}
|
||||||
setNewCategoryLabel={setNewCategoryLabel}
|
setNewCategoryLabel={setNewCategoryLabel}
|
||||||
onCreateCategory={handleCreateCategory}
|
onCreateCategory={handleCreateCategory}
|
||||||
|
|||||||
@ -49,8 +49,17 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentUser = useAuthStore.getState().user
|
let currentUser = useAuthStore.getState().user
|
||||||
const ok = isUserAdmin(currentUser)
|
let ok = isUserAdmin(currentUser)
|
||||||
|
|
||||||
|
if (currentUser && !ok) {
|
||||||
|
try {
|
||||||
|
console.log('🔐 AdminLayout: user present but not admin, revalidating via refresh')
|
||||||
|
await refreshAuthToken?.()
|
||||||
|
} catch {}
|
||||||
|
currentUser = useAuthStore.getState().user
|
||||||
|
ok = isUserAdmin(currentUser)
|
||||||
|
}
|
||||||
|
|
||||||
console.log('🔐 AdminLayout guard:resolved', {
|
console.log('🔐 AdminLayout guard:resolved', {
|
||||||
hasUser: !!currentUser,
|
hasUser: !!currentUser,
|
||||||
|
|||||||
@ -15,11 +15,21 @@ function resolveBackendBaseUrl(): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function proxyPreferencesRequest(request: Request, method: 'GET' | 'POST' | 'PUT' | 'DELETE') {
|
async function proxyPreferencesRequest(request: Request, method: 'GET' | 'POST' | 'PUT' | 'DELETE') {
|
||||||
|
console.info('[API][i18n/preferences] start', { method });
|
||||||
|
|
||||||
const access = await requireAdminSession(request);
|
const access = await requireAdminSession(request);
|
||||||
if (!access.ok) return access.response;
|
if (!access.ok) {
|
||||||
|
console.warn('[API][i18n/preferences] denied:admin-session', { method });
|
||||||
|
return access.response;
|
||||||
|
}
|
||||||
|
|
||||||
const tokenResult = await fetchBackendAccessToken(request);
|
const tokenResult = await fetchBackendAccessToken(request);
|
||||||
if (!tokenResult.ok || !tokenResult.accessToken) {
|
if (!tokenResult.ok || !tokenResult.accessToken) {
|
||||||
|
console.warn('[API][i18n/preferences] denied:access-token', {
|
||||||
|
method,
|
||||||
|
status: tokenResult.status,
|
||||||
|
message: tokenResult.message,
|
||||||
|
});
|
||||||
const denied = NextResponse.json(
|
const denied = NextResponse.json(
|
||||||
{ ok: false, message: tokenResult.message ?? 'Unable to obtain backend access token.' },
|
{ ok: false, message: tokenResult.message ?? 'Unable to obtain backend access token.' },
|
||||||
{ status: tokenResult.status === 401 ? 401 : 403 }
|
{ status: tokenResult.status === 401 ? 401 : 403 }
|
||||||
@ -34,6 +44,7 @@ async function proxyPreferencesRequest(request: Request, method: 'GET' | 'POST'
|
|||||||
|
|
||||||
const apiBase = resolveBackendBaseUrl();
|
const apiBase = resolveBackendBaseUrl();
|
||||||
if (!apiBase) {
|
if (!apiBase) {
|
||||||
|
console.error('[API][i18n/preferences] missing NEXT_PUBLIC_API_BASE_URL');
|
||||||
return NextResponse.json({ ok: false, message: 'Missing NEXT_PUBLIC_API_BASE_URL.' }, { status: 500 });
|
return NextResponse.json({ ok: false, message: 'Missing NEXT_PUBLIC_API_BASE_URL.' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,13 +57,36 @@ async function proxyPreferencesRequest(request: Request, method: 'GET' | 'POST'
|
|||||||
};
|
};
|
||||||
let body: string | undefined;
|
let body: string | undefined;
|
||||||
|
|
||||||
|
// Build the backend URL; for DELETE we forward the languageCode query param if present.
|
||||||
|
const incomingUrl = new URL(request.url);
|
||||||
|
const languageCode = incomingUrl.searchParams.get('languageCode');
|
||||||
|
const backendQuery = languageCode ? `?languageCode=${encodeURIComponent(languageCode)}` : '';
|
||||||
|
const backendUrl = `${apiBase}${backendPath}${backendQuery}`;
|
||||||
|
|
||||||
if (method !== 'GET' && method !== 'DELETE') {
|
if (method !== 'GET' && method !== 'DELETE') {
|
||||||
const payload = await request.json().catch(() => ({}));
|
const payload = await request.json().catch(() => ({}));
|
||||||
body = JSON.stringify(payload ?? {});
|
body = JSON.stringify(payload ?? {});
|
||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
|
|
||||||
|
const categoriesCount = Array.isArray((payload as { categories?: unknown[] })?.categories)
|
||||||
|
? (payload as { categories?: unknown[] }).categories!.length
|
||||||
|
: 0;
|
||||||
|
const globalKeysCount = Array.isArray((payload as { globalKeys?: unknown[] })?.globalKeys)
|
||||||
|
? (payload as { globalKeys?: unknown[] }).globalKeys!.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
console.info('[API][i18n/preferences] outgoing-payload', {
|
||||||
|
method,
|
||||||
|
categoriesCount,
|
||||||
|
globalKeysCount,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const backendResponse = await fetch(`${apiBase}${backendPath}`, {
|
if (method === 'DELETE' && languageCode) {
|
||||||
|
console.info('[API][i18n/preferences] delete-language', { languageCode });
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendResponse = await fetch(backendUrl, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
@ -60,6 +94,10 @@ async function proxyPreferencesRequest(request: Request, method: 'GET' | 'POST'
|
|||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
if (!backendResponse) {
|
if (!backendResponse) {
|
||||||
|
console.error('[API][i18n/preferences] backend unreachable', {
|
||||||
|
method,
|
||||||
|
backendPath,
|
||||||
|
});
|
||||||
const failed = NextResponse.json({ ok: false, message: 'Preferences backend is unreachable.' }, { status: 502 });
|
const failed = NextResponse.json({ ok: false, message: 'Preferences backend is unreachable.' }, { status: 502 });
|
||||||
for (const setCookie of tokenResult.setCookies) {
|
for (const setCookie of tokenResult.setCookies) {
|
||||||
failed.headers.append('set-cookie', setCookie);
|
failed.headers.append('set-cookie', setCookie);
|
||||||
@ -67,6 +105,12 @@ async function proxyPreferencesRequest(request: Request, method: 'GET' | 'POST'
|
|||||||
return failed;
|
return failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.info('[API][i18n/preferences] backend-response', {
|
||||||
|
method,
|
||||||
|
status: backendResponse.status,
|
||||||
|
ok: backendResponse.ok,
|
||||||
|
});
|
||||||
|
|
||||||
const payload = await backendResponse.json().catch(() => null);
|
const payload = await backendResponse.json().catch(() => null);
|
||||||
const out = NextResponse.json(payload ?? { ok: backendResponse.ok }, { status: backendResponse.status });
|
const out = NextResponse.json(payload ?? { ok: backendResponse.ok }, { status: backendResponse.status });
|
||||||
|
|
||||||
|
|||||||
@ -210,6 +210,9 @@ function isAutoFixCandidatePath(relPath: string): boolean {
|
|||||||
if (!relPath.startsWith('src/app/')) return false;
|
if (!relPath.startsWith('src/app/')) return false;
|
||||||
if (relPath.startsWith('src/app/api/')) return false;
|
if (relPath.startsWith('src/app/api/')) return false;
|
||||||
if (relPath.startsWith('src/app/i18n/')) return false;
|
if (relPath.startsWith('src/app/i18n/')) return false;
|
||||||
|
// Portal component renders outside any React context provider — hooks like
|
||||||
|
// useTranslation must not be injected here by the autofix.
|
||||||
|
if (relPath.endsWith('components/toast/toastComponent.tsx')) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -321,6 +324,130 @@ function ensureUseTranslation(
|
|||||||
return { content: next, addedImport, addedHook };
|
return { content: next, addedImport, addedHook };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureUseTranslationHooksInComponents(content: string): { content: string; addedHooks: number } {
|
||||||
|
if (!/\bt\(\s*['"`][^'"`]+['"`]/.test(content)) {
|
||||||
|
return { content, addedHooks: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentPatterns = [
|
||||||
|
/export\s+default\s+function\s+\w+\s*\([\s\S]*?\)\s*\{/gm,
|
||||||
|
/export\s+function\s+[A-Z]\w*\s*\([\s\S]*?\)\s*\{/gm,
|
||||||
|
/function\s+[A-Z]\w*\s*\([\s\S]*?\)\s*\{/gm,
|
||||||
|
/const\s+[A-Z]\w*\s*=\s*\([\s\S]*?\)\s*=>\s*\{/gm,
|
||||||
|
/const\s+[A-Z]\w*\s*:\s*[^=\n]+\s*=\s*\([\s\S]*?\)\s*=>\s*\{/gm,
|
||||||
|
];
|
||||||
|
|
||||||
|
const hookPrefixRegex = /^\s*(?:\/\/[^\n]*\n\s*|\/\*[\s\S]*?\*\/\s*)*const\s*\{\s*t\s*\}\s*=\s*useTranslation\s*\(\);/;
|
||||||
|
const insertPositions = new Set<number>();
|
||||||
|
|
||||||
|
const findFunctionBodyEnd = (source: string, bodyStart: number): number | null => {
|
||||||
|
let depth = 1;
|
||||||
|
let inSingleQuote = false;
|
||||||
|
let inDoubleQuote = false;
|
||||||
|
let inTemplate = false;
|
||||||
|
let inLineComment = false;
|
||||||
|
let inBlockComment = false;
|
||||||
|
|
||||||
|
for (let index = bodyStart; index < source.length; index += 1) {
|
||||||
|
const char = source[index];
|
||||||
|
const prev = index > 0 ? source[index - 1] : '';
|
||||||
|
const next = index + 1 < source.length ? source[index + 1] : '';
|
||||||
|
|
||||||
|
if (inLineComment) {
|
||||||
|
if (char === '\n') inLineComment = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inBlockComment) {
|
||||||
|
if (prev === '*' && char === '/') inBlockComment = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inSingleQuote) {
|
||||||
|
if (char === '\'' && prev !== '\\') inSingleQuote = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inDoubleQuote) {
|
||||||
|
if (char === '"' && prev !== '\\') inDoubleQuote = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inTemplate) {
|
||||||
|
if (char === '`' && prev !== '\\') inTemplate = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '/' && next === '/') {
|
||||||
|
inLineComment = true;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '/' && next === '*') {
|
||||||
|
inBlockComment = true;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '\'') {
|
||||||
|
inSingleQuote = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
inDoubleQuote = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '`') {
|
||||||
|
inTemplate = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '{') {
|
||||||
|
depth += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '}') {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const pattern of componentPatterns) {
|
||||||
|
for (const match of content.matchAll(pattern)) {
|
||||||
|
if (typeof match.index !== 'number') continue;
|
||||||
|
const bodyStart = match.index + match[0].length;
|
||||||
|
const bodyEnd = findFunctionBodyEnd(content, bodyStart);
|
||||||
|
if (bodyEnd === null) continue;
|
||||||
|
const bodyContent = content.slice(bodyStart, bodyEnd);
|
||||||
|
if (!/\bt\(\s*['"`][^'"`]+['"`]/.test(bodyContent)) continue;
|
||||||
|
const bodyPrefix = content.slice(bodyStart, bodyStart + 200);
|
||||||
|
if (hookPrefixRegex.test(bodyPrefix)) continue;
|
||||||
|
insertPositions.add(bodyStart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (insertPositions.size === 0) {
|
||||||
|
return { content, addedHooks: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let next = content;
|
||||||
|
const positions = Array.from(insertPositions).sort((a, b) => b - a);
|
||||||
|
for (const position of positions) {
|
||||||
|
next = `${next.slice(0, position)}\n const { t } = useTranslation();${next.slice(position)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content: next, addedHooks: positions.length };
|
||||||
|
}
|
||||||
|
|
||||||
function replaceJsxAttributeLiterals(content: string): { content: string; replacements: number; keyValueMap: Map<string, string> } {
|
function replaceJsxAttributeLiterals(content: string): { content: string; replacements: number; keyValueMap: Map<string, string> } {
|
||||||
const keyValueMap = new Map<string, string>();
|
const keyValueMap = new Map<string, string>();
|
||||||
let replacements = 0;
|
let replacements = 0;
|
||||||
@ -509,8 +636,9 @@ async function runAutoFix(options: AutoFixOptions = {}): Promise<AutoFixResult>
|
|||||||
|
|
||||||
const textReplaced = replaceJsxTextNodes(ensured.content);
|
const textReplaced = replaceJsxTextNodes(ensured.content);
|
||||||
const attrReplaced = replaceJsxAttributeLiterals(textReplaced.content);
|
const attrReplaced = replaceJsxAttributeLiterals(textReplaced.content);
|
||||||
|
const ensuredHooks = ensureUseTranslationHooksInComponents(attrReplaced.content);
|
||||||
const totalReplacements = textReplaced.replacements + attrReplaced.replacements;
|
const totalReplacements = textReplaced.replacements + attrReplaced.replacements;
|
||||||
const afterLiteralCount = extractPotentialUiLiterals(attrReplaced.content).filter((text) => !shouldIgnoreLiteral(text)).length;
|
const afterLiteralCount = extractPotentialUiLiterals(ensuredHooks.content).filter((text) => !shouldIgnoreLiteral(text)).length;
|
||||||
if (totalReplacements === 0) {
|
if (totalReplacements === 0) {
|
||||||
const reason = 'No supported replacement patterns matched these literals (likely complex JSX/expression cases).';
|
const reason = 'No supported replacement patterns matched these literals (likely complex JSX/expression cases).';
|
||||||
skippedFiles.push({ file: relPath, reason });
|
skippedFiles.push({ file: relPath, reason });
|
||||||
@ -533,13 +661,13 @@ async function runAutoFix(options: AutoFixOptions = {}): Promise<AutoFixResult>
|
|||||||
createdEntries.set(k, v);
|
createdEntries.set(k, v);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attrReplaced.content !== raw) {
|
if (ensuredHooks.content !== raw) {
|
||||||
await fs.writeFile(absPath, attrReplaced.content, 'utf8');
|
await fs.writeFile(absPath, ensuredHooks.content, 'utf8');
|
||||||
changedFiles.push({
|
changedFiles.push({
|
||||||
file: relPath,
|
file: relPath,
|
||||||
replacements: totalReplacements,
|
replacements: totalReplacements,
|
||||||
addedImport: ensured.addedImport,
|
addedImport: ensured.addedImport,
|
||||||
addedHook: ensured.addedHook,
|
addedHook: ensured.addedHook || ensuredHooks.addedHooks > 0,
|
||||||
});
|
});
|
||||||
debugEntries.push({
|
debugEntries.push({
|
||||||
file: relPath,
|
file: relPath,
|
||||||
|
|||||||
@ -4,12 +4,7 @@ import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
|
|||||||
import { ChevronDownIcon } from '@heroicons/react/20/solid';
|
import { ChevronDownIcon } from '@heroicons/react/20/solid';
|
||||||
import { useTranslation } from '../i18n/useTranslation';
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
|
|
||||||
interface LangEntry { code: string; name: string; flag: string }
|
interface LangEntry { code: string; name: string }
|
||||||
|
|
||||||
const FALLBACK_LANG_INFO: Record<string, { name: string; flag: string }> = {
|
|
||||||
en: { name: 'English', flag: '🇬🇧' },
|
|
||||||
de: { name: 'Deutsch', flag: '🇩🇪' },
|
|
||||||
};
|
|
||||||
|
|
||||||
interface LanguageSwitcherProps {
|
interface LanguageSwitcherProps {
|
||||||
variant?: 'light' | 'dark';
|
variant?: 'light' | 'dark';
|
||||||
@ -21,40 +16,63 @@ export default function LanguageSwitcher({ variant = 'light' }: LanguageSwitcher
|
|||||||
const allLangs: LangEntry[] = languages.map((lang) => ({
|
const allLangs: LangEntry[] = languages.map((lang) => ({
|
||||||
code: lang.code,
|
code: lang.code,
|
||||||
name: lang.name,
|
name: lang.name,
|
||||||
flag: FALLBACK_LANG_INFO[lang.code]?.flag ?? '🏳️',
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const activeLang: LangEntry =
|
const activeLang: LangEntry =
|
||||||
allLangs.find((l) => l.code === language) ?? { code: language, name: language, flag: '🏳️' };
|
allLangs.find((l) => l.code === language) ?? { code: language, name: language };
|
||||||
|
|
||||||
const buttonCls =
|
const buttonCls =
|
||||||
variant === 'dark'
|
variant === 'dark'
|
||||||
? 'inline-flex items-center gap-x-1.5 rounded-md bg-white/10 px-3 py-2 text-sm font-semibold text-white hover:bg-white/20 transition-colors'
|
? 'group inline-flex min-w-[168px] items-center justify-between gap-3 rounded-xl border border-white/15 bg-white/10 px-3 py-2 text-sm font-semibold text-white shadow-sm backdrop-blur-sm transition hover:bg-white/15 data-[open]:bg-white/15 data-[open]:border-white/25'
|
||||||
: 'inline-flex items-center gap-x-1.5 rounded-md bg-gray-100 px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-gray-300 hover:bg-gray-200 transition-colors';
|
: 'group inline-flex min-w-[168px] items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm font-semibold text-slate-900 shadow-sm transition hover:border-slate-300 hover:bg-slate-50 data-[open]:bg-slate-50 data-[open]:border-slate-300';
|
||||||
|
|
||||||
const menuCls =
|
const menuCls =
|
||||||
variant === 'dark'
|
variant === 'dark'
|
||||||
? 'absolute right-0 z-50 mt-2 w-52 origin-top-right rounded-xl bg-gray-800/95 backdrop-blur-sm border border-white/10 shadow-2xl py-1 transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in'
|
? 'absolute right-0 z-50 mt-2.5 w-60 origin-top-right rounded-2xl border border-white/15 bg-slate-900/95 p-1.5 shadow-2xl backdrop-blur-md transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in'
|
||||||
: 'absolute right-0 z-50 mt-2 w-52 origin-top-right rounded-xl bg-white border border-gray-200 shadow-xl py-1 transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in';
|
: 'absolute right-0 z-50 mt-2.5 w-60 origin-top-right rounded-2xl border border-slate-200 bg-white p-1.5 shadow-xl transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in';
|
||||||
|
|
||||||
const itemCls = (isActive: boolean) =>
|
const itemCls = (isActive: boolean) =>
|
||||||
variant === 'dark'
|
variant === 'dark'
|
||||||
? `flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors ${isActive ? 'bg-[#8D6B1D] text-white' : 'text-gray-200 hover:bg-white/10 hover:text-white'}`
|
? `flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition ${isActive ? 'bg-[#8D6B1D]/25 text-white ring-1 ring-[#8D6B1D]/50' : 'text-slate-200 hover:bg-white/10 hover:text-white'}`
|
||||||
: `flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors ${isActive ? 'bg-[#8D6B1D] text-white' : 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'}`;
|
: `flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition ${isActive ? 'bg-[#8D6B1D]/10 text-[#7A5E1A] ring-1 ring-[#8D6B1D]/30' : 'text-slate-700 hover:bg-slate-50 hover:text-slate-900'}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu as="div" className="relative inline-block">
|
<Menu as="div" className="relative inline-block">
|
||||||
<MenuButton className={buttonCls}>
|
<MenuButton className={buttonCls}>
|
||||||
<span>{activeLang.name}</span>
|
<span className="inline-flex items-center gap-2 min-w-0">
|
||||||
<ChevronDownIcon aria-hidden="true" className="size-4 opacity-60" />
|
<span className="truncate">{activeLang.name}</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
variant === 'dark'
|
||||||
|
? 'rounded-md bg-white/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-white/80'
|
||||||
|
: 'rounded-md bg-slate-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-slate-600'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{activeLang.code}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon aria-hidden="true" className="size-4 shrink-0 opacity-70 transition group-data-[open]:rotate-180" />
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
|
||||||
<MenuItems transition className={menuCls}>
|
<MenuItems transition className={menuCls}>
|
||||||
{allLangs.map((lang) => (
|
{allLangs.map((lang) => (
|
||||||
<MenuItem key={lang.code}>
|
<MenuItem key={lang.code}>
|
||||||
<button onClick={() => setLanguage(lang.code)} className={itemCls(language === lang.code)}>
|
<button onClick={() => setLanguage(lang.code)} className={itemCls(language === lang.code)}>
|
||||||
<span className="flex-1 text-left">{lang.name}</span>
|
<span className="flex-1 text-left truncate">{lang.name}</span>
|
||||||
{language === lang.code && <span className="text-xs font-bold">✓</span>}
|
<span
|
||||||
|
className={
|
||||||
|
variant === 'dark'
|
||||||
|
? 'rounded-md bg-white/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-white/75'
|
||||||
|
: 'rounded-md bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-slate-500'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{lang.code}
|
||||||
|
</span>
|
||||||
|
{language === lang.code && (
|
||||||
|
<span className={variant === 'dark' ? 'text-[11px] font-bold text-amber-300' : 'text-[11px] font-bold text-[#8D6B1D]'}>
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -9,6 +9,22 @@ import Image from 'next/image';
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
// ── Global transition gate ─────────────────────────────────────────────────
|
||||||
|
export let isPageTransitioning = false
|
||||||
|
const transitionEndCallbacks = new Set<() => void>()
|
||||||
|
export function onPageTransitionEnd(cb: () => void) {
|
||||||
|
transitionEndCallbacks.add(cb)
|
||||||
|
}
|
||||||
|
export function startPageTransition() {
|
||||||
|
isPageTransitioning = true
|
||||||
|
}
|
||||||
|
function flushTransitionCallbacks() {
|
||||||
|
isPageTransitioning = false
|
||||||
|
for (const cb of transitionEndCallbacks) cb()
|
||||||
|
transitionEndCallbacks.clear()
|
||||||
|
}
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const PageTransitionEffect = ({ children }: { children: React.ReactNode }) => {
|
const PageTransitionEffect = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@ -24,10 +40,14 @@ const PageTransitionEffect = ({ children }: { children: React.ReactNode }) => {
|
|||||||
|
|
||||||
// Exit overlay shortly after route change (200ms)
|
// Exit overlay shortly after route change (200ms)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
isPageTransitioning = true
|
||||||
setShowOverlay(true);
|
setShowOverlay(true);
|
||||||
setOverlayExit(false);
|
setOverlayExit(false);
|
||||||
if (delayT.current) clearTimeout(delayT.current);
|
if (delayT.current) clearTimeout(delayT.current);
|
||||||
delayT.current = window.setTimeout(() => setOverlayExit(true), DELAY_MS);
|
delayT.current = window.setTimeout(() => {
|
||||||
|
setOverlayExit(true)
|
||||||
|
flushTransitionCallbacks()
|
||||||
|
}, DELAY_MS);
|
||||||
return () => {
|
return () => {
|
||||||
if (delayT.current) clearTimeout(delayT.current);
|
if (delayT.current) clearTimeout(delayT.current);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -23,6 +23,8 @@ import useAuthStore from '../../store/authStore'
|
|||||||
import { Avatar } from '../avatar'
|
import { Avatar } from '../avatar'
|
||||||
import LanguageSwitcher from '../LanguageSwitcher'
|
import LanguageSwitcher from '../LanguageSwitcher'
|
||||||
import { useTranslation } from '../../i18n/useTranslation'
|
import { useTranslation } from '../../i18n/useTranslation'
|
||||||
|
import { useToast } from '../toast/toastComponent'
|
||||||
|
import { startPageTransition } from '../animation/pageTransitionEffect'
|
||||||
|
|
||||||
// ENV-BASED FEATURE FLAGS (string envs: treat "false" as off, everything else as on)
|
// ENV-BASED FEATURE FLAGS (string envs: treat "false" as off, everything else as on)
|
||||||
const DISPLAY_NEWS = process.env.NEXT_PUBLIC_DISPLAY_NEWS !== 'false'
|
const DISPLAY_NEWS = process.env.NEXT_PUBLIC_DISPLAY_NEWS !== 'false'
|
||||||
@ -58,6 +60,7 @@ interface HeaderProps {
|
|||||||
|
|
||||||
export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { showToast } = useToast()
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
const [animateIn, setAnimateIn] = useState(false)
|
const [animateIn, setAnimateIn] = useState(false)
|
||||||
@ -103,10 +106,13 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
setGlobalLoggingOut?.(true)
|
setGlobalLoggingOut?.(true)
|
||||||
await logout()
|
await logout()
|
||||||
setMobileMenuOpen(false)
|
setMobileMenuOpen(false)
|
||||||
|
startPageTransition() // mark transition active BEFORE toast so it defers
|
||||||
|
showToast({ variant: 'success', title: t('nav.logout'), message: 'You have been logged out successfully.' })
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Logout failed:', err)
|
console.error('Logout failed:', err)
|
||||||
setGlobalLoggingOut?.(false)
|
setGlobalLoggingOut?.(false)
|
||||||
|
showToast({ variant: 'error', message: 'Logout failed. Please try again.' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import React, {
|
import React, {
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
@ -11,6 +13,7 @@ import React, {
|
|||||||
type ReactNode
|
type ReactNode
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { isPageTransitioning, onPageTransitionEnd } from '../animation/pageTransitionEffect'
|
||||||
|
|
||||||
type ToastVariant = 'success' | 'error' | 'info' | 'warning'
|
type ToastVariant = 'success' | 'error' | 'info' | 'warning'
|
||||||
|
|
||||||
@ -84,6 +87,12 @@ function removeToastInternal(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addToast(options: ToastOptions) {
|
function addToast(options: ToastOptions) {
|
||||||
|
// Defer toast until page transition overlay has fully exited
|
||||||
|
if (typeof window !== 'undefined' && isPageTransitioning) {
|
||||||
|
onPageTransitionEnd(() => addToast(options))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const id = options.id ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
const id = options.id ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
const toast: ToastInternal = {
|
const toast: ToastInternal = {
|
||||||
id,
|
id,
|
||||||
@ -215,8 +224,64 @@ interface ToastItemProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TOAST_ICONS: Record<ToastVariant, React.ReactElement> = {
|
||||||
|
success: (
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
error: (
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
info: (
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
|
||||||
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
warning: (
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
|
||||||
|
<path fillRule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOAST_VARIANT_STYLES: Record<ToastVariant, { card: string; iconWrap: string; title: string; message: string; close: string }> = {
|
||||||
|
success: {
|
||||||
|
card: 'border border-emerald-200 border-l-4 border-l-emerald-400 bg-emerald-50/90 shadow-[0_8px_32px_-8px_rgba(5,150,105,0.18)]',
|
||||||
|
iconWrap: 'bg-emerald-100 text-emerald-600',
|
||||||
|
title: 'text-emerald-800',
|
||||||
|
message: 'text-emerald-900/80',
|
||||||
|
close: 'text-emerald-400 hover:bg-emerald-100 hover:text-emerald-700',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
card: 'border border-red-200 border-l-4 border-l-red-400 bg-red-50/90 shadow-[0_8px_32px_-8px_rgba(220,38,38,0.18)]',
|
||||||
|
iconWrap: 'bg-red-100 text-red-600',
|
||||||
|
title: 'text-red-800',
|
||||||
|
message: 'text-red-900/80',
|
||||||
|
close: 'text-red-300 hover:bg-red-100 hover:text-red-700',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
card: 'border border-sky-200 border-l-4 border-l-sky-400 bg-sky-50/90 shadow-[0_8px_32px_-8px_rgba(14,165,233,0.18)]',
|
||||||
|
iconWrap: 'bg-sky-100 text-sky-600',
|
||||||
|
title: 'text-sky-800',
|
||||||
|
message: 'text-sky-900/80',
|
||||||
|
close: 'text-sky-300 hover:bg-sky-100 hover:text-sky-700',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
card: 'border border-amber-200 border-l-4 border-l-amber-400 bg-amber-50/90 shadow-[0_8px_32px_-8px_rgba(217,119,6,0.18)]',
|
||||||
|
iconWrap: 'bg-amber-100 text-amber-600',
|
||||||
|
title: 'text-amber-800',
|
||||||
|
message: 'text-amber-900/80',
|
||||||
|
close: 'text-amber-300 hover:bg-amber-100 hover:text-amber-700',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
function ToastItem({ toast, onClose }: ToastItemProps) {
|
function ToastItem({ toast, onClose }: ToastItemProps) {
|
||||||
const { title, message, variant } = toast
|
const { title, message, variant } = toast
|
||||||
|
const v = variant ?? 'info'
|
||||||
|
const styles = TOAST_VARIANT_STYLES[v]
|
||||||
|
|
||||||
// local visible state for entry animation
|
// local visible state for entry animation
|
||||||
const [visible, setVisible] = useState(false)
|
const [visible, setVisible] = useState(false)
|
||||||
@ -226,53 +291,37 @@ function ToastItem({ toast, onClose }: ToastItemProps) {
|
|||||||
return () => cancelAnimationFrame(frame)
|
return () => cancelAnimationFrame(frame)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const variantClasses: Record<ToastVariant, string> = {
|
|
||||||
success: 'border-emerald-400/80 bg-emerald-900/90',
|
|
||||||
error: 'border-red-400/80 bg-red-900/90',
|
|
||||||
info: 'border-sky-400/80 bg-slate-900/90',
|
|
||||||
warning: 'border-amber-400/80 bg-amber-900/90'
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconBg: Record<ToastVariant, string> = {
|
|
||||||
success: 'bg-emerald-500/10 text-emerald-300',
|
|
||||||
error: 'bg-red-500/10 text-red-300',
|
|
||||||
info: 'bg-sky-500/10 text-sky-300',
|
|
||||||
warning: 'bg-amber-500/10 text-amber-300'
|
|
||||||
}
|
|
||||||
|
|
||||||
const isClosing = !!toast.closing
|
const isClosing = !!toast.closing
|
||||||
const motionClasses = isClosing
|
const motionClasses = isClosing
|
||||||
? 'opacity-0 translate-y-2'
|
? 'opacity-0 translate-y-2 scale-95'
|
||||||
: visible
|
: visible
|
||||||
? 'opacity-100 translate-y-0'
|
? 'opacity-100 translate-y-0 scale-100'
|
||||||
: 'opacity-0 translate-y-2'
|
: 'opacity-0 translate-y-2 scale-95'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
pointer-events-auto flex w-full items-start gap-3 rounded-xl border-l-4
|
pointer-events-auto flex w-full items-start gap-3 rounded-2xl
|
||||||
px-4 py-3 text-sm text-slate-50 shadow-xl shadow-black/40
|
px-4 py-3.5 backdrop-blur-md transform transition-all duration-400
|
||||||
backdrop-blur-md transform transition-all duration-400
|
|
||||||
${motionClasses}
|
${motionClasses}
|
||||||
${variantClasses[variant ?? 'info']}
|
${styles.card}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className={`mt-0.5 flex h-8 w-8 items-center justify-center rounded-full ${iconBg[variant ?? 'info']}`}>
|
<div className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full ${styles.iconWrap}`}>
|
||||||
{/* Simple dot indicator */}
|
{TOAST_ICONS[v]}
|
||||||
<span className="h-2 w-2 rounded-full bg-current" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
{title && (
|
{title && (
|
||||||
<div className="mb-0.5 text-xs font-semibold uppercase tracking-wide text-slate-200">
|
<div className={`mb-0.5 text-[11px] font-bold uppercase tracking-widest ${styles.title}`}>
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-[13px] leading-snug text-slate-50">{message}</div>
|
<div className={`text-[13px] leading-snug ${styles.message}`}>{message}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="ml-1 mt-0.5 inline-flex h-6 w-6 items-center justify-center rounded-full text-xs text-slate-300 hover:bg-slate-800/70 hover:text-white focus:outline-none focus:ring-2 focus:ring-slate-500"
|
className={`ml-1 mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-lg text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-slate-300 ${styles.close}`}
|
||||||
aria-label="Close notification"
|
aria-label="Close notification"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1109,6 +1109,8 @@ export const en: Translations = {
|
|||||||
"k096f4013": "Manage your company stamps. One active at a time.",
|
"k096f4013": "Manage your company stamps. One active at a time.",
|
||||||
"k0af6c6be": "Create & Activate",
|
"k0af6c6be": "Create & Activate",
|
||||||
"k0affa826": "Shown to users in the shop and checkout.",
|
"k0affa826": "Shown to users in the shop and checkout.",
|
||||||
|
"k0a50d234": " missing keys.",
|
||||||
|
"k0b27fdf8": "Scroll to save changes",
|
||||||
"k0b03e660": "2. Choose coffees & quantities",
|
"k0b03e660": "2. Choose coffees & quantities",
|
||||||
"k0b2445d5": "Generating PDF preview…",
|
"k0b2445d5": "Generating PDF preview…",
|
||||||
"k0bbc633d": "Loading contract preview…",
|
"k0bbc633d": "Loading contract preview…",
|
||||||
@ -1135,7 +1137,8 @@ export const en: Translations = {
|
|||||||
"k41afd863": "Editing:",
|
"k41afd863": "Editing:",
|
||||||
"k4aeb8688": "2. Your selection",
|
"k4aeb8688": "2. Your selection",
|
||||||
"k4be6f631": "Save changes",
|
"k4be6f631": "Save changes",
|
||||||
"k516705dd": "Ort ist erforderlich.",
|
"k5188f06f": "Unsaved changes!",
|
||||||
|
"k516705dd": "City is required.",
|
||||||
"k528eede9": "Same as shipping address",
|
"k528eede9": "Same as shipping address",
|
||||||
"k56717603": "no image",
|
"k56717603": "no image",
|
||||||
"k56a52520": "Skipped files",
|
"k56a52520": "Skipped files",
|
||||||
@ -1148,7 +1151,8 @@ export const en: Translations = {
|
|||||||
"k6a892262": "No keys match your search.",
|
"k6a892262": "No keys match your search.",
|
||||||
"k6ee0a1b6": "Click or drag and drop an image here",
|
"k6ee0a1b6": "Click or drag and drop an image here",
|
||||||
"k73d1d7d7": "Edit Crop",
|
"k73d1d7d7": "Edit Crop",
|
||||||
"k74491338": "Reverse Charge aktiv: gueltige UID und auslaendisches Rechnungsland erkannt.",
|
"k7a515516": "Add language",
|
||||||
|
"k74491338": "Reverse Charge active: valid UID and foreign invoice country detected.",
|
||||||
"k7775eddb": "Your Company Stamps",
|
"k7775eddb": "Your Company Stamps",
|
||||||
"k788633d1": "Profit Planet",
|
"k788633d1": "Profit Planet",
|
||||||
"k7a3a6ea3": "to render invoice line items.",
|
"k7a3a6ea3": "to render invoice line items.",
|
||||||
@ -1165,6 +1169,7 @@ export const en: Translations = {
|
|||||||
"k91052e3f": "Translation calls",
|
"k91052e3f": "Translation calls",
|
||||||
"k92639a9a": "Language code",
|
"k92639a9a": "Language code",
|
||||||
"k926966d0": "Language name",
|
"k926966d0": "Language name",
|
||||||
|
"k889cc3e3": "Scroll to save changes",
|
||||||
"k96839795": "Back to selection",
|
"k96839795": "Back to selection",
|
||||||
"k99bffb65": "Fill all fields to proceed.",
|
"k99bffb65": "Fill all fields to proceed.",
|
||||||
"k9b173204": "Files auto-fixed",
|
"k9b173204": "Files auto-fixed",
|
||||||
@ -1175,6 +1180,7 @@ export const en: Translations = {
|
|||||||
"ka802064d": "Applying i18n auto-fixes to client components and updating translation files...",
|
"ka802064d": "Applying i18n auto-fixes to client components and updating translation files...",
|
||||||
"kaa30f0cd": "Create Coffee",
|
"kaa30f0cd": "Create Coffee",
|
||||||
"kaa8bbc8e": "Company Information",
|
"kaa8bbc8e": "Company Information",
|
||||||
|
"kac6aab53": "Saved",
|
||||||
"kac6cedc7": "Saving…",
|
"kac6cedc7": "Saving…",
|
||||||
"kae63e46a": "Missing translation keys detected in workspace",
|
"kae63e46a": "Missing translation keys detected in workspace",
|
||||||
"kb06fa395": "Edit Coffee",
|
"kb06fa395": "Edit Coffee",
|
||||||
@ -1184,6 +1190,7 @@ export const en: Translations = {
|
|||||||
"kb791958e": "Use these placeholders in your HTML: invoiceNumber, customerName, issuedAt, totalNet, totalTax, totalGross, itemsHtml.",
|
"kb791958e": "Use these placeholders in your HTML: invoiceNumber, customerName, issuedAt, totalNet, totalTax, totalGross, itemsHtml.",
|
||||||
"kb8f33873": "Translation progress",
|
"kb8f33873": "Translation progress",
|
||||||
"kb9e483c4": "Update details of the coffee.",
|
"kb9e483c4": "Update details of the coffee.",
|
||||||
|
"k644d9ea8": "Revert override",
|
||||||
"kba6bd6f3": "or click to browse",
|
"kba6bd6f3": "or click to browse",
|
||||||
"kcc4adbcc": "Navigation shortcuts",
|
"kcc4adbcc": "Navigation shortcuts",
|
||||||
"kce094582": "Invoice address",
|
"kce094582": "Invoice address",
|
||||||
@ -1204,7 +1211,7 @@ export const en: Translations = {
|
|||||||
"ke7f0a9e3": "FREE SHIPPING",
|
"ke7f0a9e3": "FREE SHIPPING",
|
||||||
"kea7cde7a": "Back to Admin",
|
"kea7cde7a": "Back to Admin",
|
||||||
"kec078e54": "No coffees selected yet.",
|
"kec078e54": "No coffees selected yet.",
|
||||||
"kefe5f0dd": "Ohne gueltige UID wird die Rechnung mit normaler MwSt erstellt.",
|
"kefe5f0dd": "Without a valid UID, the invoice will be created with standard VAT.",
|
||||||
"kf1a9384b": "Auto-applied to documents where applicable.",
|
"kf1a9384b": "Auto-applied to documents where applicable.",
|
||||||
"kf4e45236": "Add Language",
|
"kf4e45236": "Add Language",
|
||||||
"kf72d41db": "Add a new coffee.",
|
"kf72d41db": "Add a new coffee.",
|
||||||
@ -1212,7 +1219,7 @@ export const en: Translations = {
|
|||||||
"kfeac3f7e": "Choose file",
|
"kfeac3f7e": "Choose file",
|
||||||
"k0c51fa85": "Activate template now?",
|
"k0c51fa85": "Activate template now?",
|
||||||
"k134e3932": "Active stamp",
|
"k134e3932": "Active stamp",
|
||||||
"k1f0b2c48": "z.B. Wien",
|
"k1f0b2c48": "e.g. Vienna",
|
||||||
"k2fac9ff2": "Template name",
|
"k2fac9ff2": "Template name",
|
||||||
"k3477c83a": "Describe the product",
|
"k3477c83a": "Describe the product",
|
||||||
"k35ac864e": "Search templates…",
|
"k35ac864e": "Search templates…",
|
||||||
@ -1220,7 +1227,7 @@ export const en: Translations = {
|
|||||||
"kaa5e5363": "ABO Contract PDF Preview",
|
"kaa5e5363": "ABO Contract PDF Preview",
|
||||||
"kcb65c692": "e.g., Company Seal 2025",
|
"kcb65c692": "e.g., Company Seal 2025",
|
||||||
"kd9e4bcbd": "Contract Preview",
|
"kd9e4bcbd": "Contract Preview",
|
||||||
"kf1512f8f": "z.B. SI12345678",
|
"kf1512f8f": "e.g. SI12345678",
|
||||||
"k00016501": "🧪 Token Refresh Test",
|
"k00016501": "🧪 Token Refresh Test",
|
||||||
"k002455d8": "Total Gross / Brutto",
|
"k002455d8": "Total Gross / Brutto",
|
||||||
"k00394342": "Welcome back! Log in to continue.",
|
"k00394342": "Welcome back! Log in to continue.",
|
||||||
@ -1244,7 +1251,7 @@ export const en: Translations = {
|
|||||||
"k0c838ec3": "Min €",
|
"k0c838ec3": "Min €",
|
||||||
"k0c87d75d": "Max €",
|
"k0c87d75d": "Max €",
|
||||||
"k0c95a1b4": "Back:",
|
"k0c95a1b4": "Back:",
|
||||||
"k0cc2a3ba": "Versuche andere Suchbegriffe oder Filter",
|
"k0cc2a3ba": "Try different search terms or filters",
|
||||||
"k0cdde8f8": "Name:",
|
"k0cdde8f8": "Name:",
|
||||||
"k0d6626e3": "👤 User Info",
|
"k0d6626e3": "👤 User Info",
|
||||||
"k0d8cb427": "Type:",
|
"k0d8cb427": "Type:",
|
||||||
@ -1256,7 +1263,7 @@ export const en: Translations = {
|
|||||||
"k0efd830c": "Verification Readiness",
|
"k0efd830c": "Verification Readiness",
|
||||||
"k0f0395ca": "Multi-statement SQL and dump files are supported. Use with caution.",
|
"k0f0395ca": "Multi-statement SQL and dump files are supported. Use with caution.",
|
||||||
"k0f1fc266": "All Statuses",
|
"k0f1fc266": "All Statuses",
|
||||||
"k0fbaa1a9": "Jetzt registrieren",
|
"k0fbaa1a9": "Register now",
|
||||||
"k0fe28e0b": "Affiliate Management",
|
"k0fe28e0b": "Affiliate Management",
|
||||||
"k10ccb626": "All Users",
|
"k10ccb626": "All Users",
|
||||||
"k10e2568f": "All Types",
|
"k10e2568f": "All Types",
|
||||||
@ -1273,7 +1280,7 @@ export const en: Translations = {
|
|||||||
"k16b60f69": "View All",
|
"k16b60f69": "View All",
|
||||||
"k17ba59ff": "Community Hands - Profit Planet",
|
"k17ba59ff": "Community Hands - Profit Planet",
|
||||||
"k17f65c37": "Example: /shop or https://example.com",
|
"k17f65c37": "Example: /shop or https://example.com",
|
||||||
"k1882bd75": "Max Mustermann",
|
"k1882bd75": "John Doe",
|
||||||
"k199db5f1": "your.email@example.com",
|
"k199db5f1": "your.email@example.com",
|
||||||
"k19f2c5dc": "No affiliates found",
|
"k19f2c5dc": "No affiliates found",
|
||||||
"k1a1ca621": "e.g. DE89 3704 0044 0532 0130 00",
|
"k1a1ca621": "e.g. DE89 3704 0044 0532 0130 00",
|
||||||
@ -1291,7 +1298,7 @@ export const en: Translations = {
|
|||||||
"k209ba561": "Create New Pool",
|
"k209ba561": "Create New Pool",
|
||||||
"k20ab2fc7": "We'll send a verification code to your email address.",
|
"k20ab2fc7": "We'll send a verification code to your email address.",
|
||||||
"k21440f8a": "Pool Management",
|
"k21440f8a": "Pool Management",
|
||||||
"k21db276a": "Auf Lager",
|
"k21db276a": "In Stock",
|
||||||
"k228929e2": "Profile Information",
|
"k228929e2": "Profile Information",
|
||||||
"k23c9f0ff": "No results yet. Import a SQL dump to see output.",
|
"k23c9f0ff": "No results yet. Import a SQL dump to see output.",
|
||||||
"k258c3515": "892 members",
|
"k258c3515": "892 members",
|
||||||
@ -1300,7 +1307,7 @@ export const en: Translations = {
|
|||||||
"k2786bc5f": "Signing in...",
|
"k2786bc5f": "Signing in...",
|
||||||
"k27e93fd7": "Stay informed with our latest announcements and insights",
|
"k27e93fd7": "Stay informed with our latest announcements and insights",
|
||||||
"k27f56959": "State change will affect add/remove operations.",
|
"k27f56959": "State change will affect add/remove operations.",
|
||||||
"k290e3aab": "tt.mm jjjj",
|
"k290e3aab": "dd.mm.yyyy",
|
||||||
"k2a2fe15a": "Phone Number",
|
"k2a2fe15a": "Phone Number",
|
||||||
"k2a37c394": "Brief description of the affiliate partner...",
|
"k2a37c394": "Brief description of the affiliate partner...",
|
||||||
"k2af2916f": "Your account is fully submitted. Our team will verify your account shortly.",
|
"k2af2916f": "Your account is fully submitted. Our team will verify your account shortly.",
|
||||||
@ -1343,14 +1350,14 @@ export const en: Translations = {
|
|||||||
"k483aa95a": "• Share authentic experiences",
|
"k483aa95a": "• Share authentic experiences",
|
||||||
"k48852b8d": "Customer Email",
|
"k48852b8d": "Customer Email",
|
||||||
"k49568342": "Manage your affiliate partners and tracking links",
|
"k49568342": "Manage your affiliate partners and tracking links",
|
||||||
"k4968eb2a": "Abonement:",
|
"k4968eb2a": "Subscription:",
|
||||||
"k49f254bd": "Current URL:",
|
"k49f254bd": "Current URL:",
|
||||||
"k4a055849": "ID Front",
|
"k4a055849": "ID Front",
|
||||||
"k4a9e1ebe": "Loading user details...",
|
"k4a9e1ebe": "Loading user details...",
|
||||||
"k4b6c7681": "Open subscriptions",
|
"k4b6c7681": "Open subscriptions",
|
||||||
"k4c5e8e87": "Export CSV",
|
"k4c5e8e87": "Export CSV",
|
||||||
"k4c5ecd73": "Export PDF",
|
"k4c5ecd73": "Export PDF",
|
||||||
"k4cb62cff": "Keine Produkte gefunden",
|
"k4cb62cff": "No products found",
|
||||||
"k4db68c96": "SQL Import",
|
"k4db68c96": "SQL Import",
|
||||||
"k4e0c889b": "Not Ready",
|
"k4e0c889b": "Not Ready",
|
||||||
"k4e168c01": "Coffee Abonnements",
|
"k4e168c01": "Coffee Abonnements",
|
||||||
@ -1364,9 +1371,9 @@ export const en: Translations = {
|
|||||||
"k533db977": "Your new password",
|
"k533db977": "Your new password",
|
||||||
"k54c06343": "Refresh Token",
|
"k54c06343": "Refresh Token",
|
||||||
"k54f49724": "No users match your search.",
|
"k54f49724": "No users match your search.",
|
||||||
"k55aba973": "Produkte gefunden",
|
"k55aba973": "Products found",
|
||||||
"k5614c806": "Review and verify all users who need admin approval. Users must complete all steps before verification.",
|
"k5614c806": "Review and verify all users who need admin approval. Users must complete all steps before verification.",
|
||||||
"k56435c9b": "Verfügbarkeit",
|
"k56435c9b": "Availability",
|
||||||
"k5738c039": "Matrix created successfully.",
|
"k5738c039": "Matrix created successfully.",
|
||||||
"k577a012c": "User Type",
|
"k577a012c": "User Type",
|
||||||
"k578dcc0b": "PNG, JPG, WebP, SVG up to 5MB",
|
"k578dcc0b": "PNG, JPG, WebP, SVG up to 5MB",
|
||||||
@ -1379,19 +1386,19 @@ export const en: Translations = {
|
|||||||
"k5c598bc0": "Trending Groups",
|
"k5c598bc0": "Trending Groups",
|
||||||
"k5d4d494e": "Loading members...",
|
"k5d4d494e": "Loading members...",
|
||||||
"k5d85b354": "Driver's License",
|
"k5d85b354": "Driver's License",
|
||||||
"k5e580e3f": "Filter zurücksetzen",
|
"k5e580e3f": "Reset filters",
|
||||||
"k5ef19112": "Join our team",
|
"k5ef19112": "Join our team",
|
||||||
"k5f74c123": "Last 30 days",
|
"k5f74c123": "Last 30 days",
|
||||||
"k5fb70267": "Shop wird geladen...",
|
"k5fb70267": "Loading shop...",
|
||||||
"k5fbf1824": "Masked names for deeper descendants.",
|
"k5fbf1824": "Masked names for deeper descendants.",
|
||||||
"k61c2a732": "Angemeldet bleiben",
|
"k61c2a732": "Stay logged in",
|
||||||
"k61f6cd4e": "Token Preview:",
|
"k61f6cd4e": "Token Preview:",
|
||||||
"k6285753a": "Back to Pool Management",
|
"k6285753a": "Back to Pool Management",
|
||||||
"k62bc3c59": "e.g. Berlin",
|
"k62bc3c59": "e.g. Berlin",
|
||||||
"k62d12fab": "Error loading data",
|
"k62d12fab": "Error loading data",
|
||||||
"k63115bb4": "ID Documents",
|
"k63115bb4": "ID Documents",
|
||||||
"k633438a0": "Discover our trusted partners and earn commissions through affiliate links.",
|
"k633438a0": "Discover our trusted partners and earn commissions through affiliate links.",
|
||||||
"k63458f03": "Produkte durchsuchen...",
|
"k63458f03": "Browse products...",
|
||||||
"k65b67dc3": "Back to matrices",
|
"k65b67dc3": "Back to matrices",
|
||||||
"k65e33378": "Total users fetched",
|
"k65e33378": "Total users fetched",
|
||||||
"k661c032b": "You need admin privileges to access this page.",
|
"k661c032b": "You need admin privileges to access this page.",
|
||||||
@ -1407,7 +1414,7 @@ export const en: Translations = {
|
|||||||
"k6aa2d843": "Read full guidelines",
|
"k6aa2d843": "Read full guidelines",
|
||||||
"k6af9037b": "Open navigation",
|
"k6af9037b": "Open navigation",
|
||||||
"k6b0f4f70": "ID documents or a signed contract are missing for this user. The user’s verification status should be checked.",
|
"k6b0f4f70": "ID documents or a signed contract are missing for this user. The user’s verification status should be checked.",
|
||||||
"k6b76bd0e": "Willkommen bei Profit Planet",
|
"k6b76bd0e": "Welcome to Profit Planet",
|
||||||
"k6c6e5c0f": "Use with caution",
|
"k6c6e5c0f": "Use with caution",
|
||||||
"k6ca85cda": "Trending right now",
|
"k6ca85cda": "Trending right now",
|
||||||
"k6d85810b": "Your password",
|
"k6d85810b": "Your password",
|
||||||
@ -1451,7 +1458,7 @@ export const en: Translations = {
|
|||||||
"k81a1b900": "Loading settings…",
|
"k81a1b900": "Loading settings…",
|
||||||
"k81b056f2": "See our job postings",
|
"k81b056f2": "See our job postings",
|
||||||
"k81c0b74b": "Status:",
|
"k81c0b74b": "Status:",
|
||||||
"k81c7c2f2": "Musterstraße 1",
|
"k81c7c2f2": "Sample Street 1",
|
||||||
"k8323a7d9": "Loading:",
|
"k8323a7d9": "Loading:",
|
||||||
"k832a032b": "Search affiliates...",
|
"k832a032b": "Search affiliates...",
|
||||||
"k8358f1d1": "Loading folder issues...",
|
"k8358f1d1": "Loading folder issues...",
|
||||||
@ -1463,7 +1470,7 @@ export const en: Translations = {
|
|||||||
"k86aa4f9c": "Current Month",
|
"k86aa4f9c": "Current Month",
|
||||||
"k87e4b9a2": "Core Pool",
|
"k87e4b9a2": "Core Pool",
|
||||||
"k883ea8c5": "Loading ghost directories...",
|
"k883ea8c5": "Loading ghost directories...",
|
||||||
"k88d8bb9d": "Passwort vergessen?",
|
"k88d8bb9d": "Forgot password?",
|
||||||
"k890ff52f": "e.g., Coffee Equipment Co.",
|
"k890ff52f": "e.g., Coffee Equipment Co.",
|
||||||
"k8a35cc53": "SQL dumps run immediately and can modify production data.",
|
"k8a35cc53": "SQL dumps run immediately and can modify production data.",
|
||||||
"k8a59b156": "Import SQL",
|
"k8a59b156": "Import SQL",
|
||||||
@ -1486,7 +1493,7 @@ export const en: Translations = {
|
|||||||
"k91eb415a": "ProfitPlanet Logo",
|
"k91eb415a": "ProfitPlanet Logo",
|
||||||
"k91f24187": "Complete Profile",
|
"k91f24187": "Complete Profile",
|
||||||
"k9213db6e": "📋 Testing Instructions",
|
"k9213db6e": "📋 Testing Instructions",
|
||||||
"k93165aea": "12345 Berlin",
|
"k93165aea": "12345 London",
|
||||||
"k93b6dc1b": "Uploaded Documents",
|
"k93b6dc1b": "Uploaded Documents",
|
||||||
"k93f03bca": "Signed Contract Document",
|
"k93f03bca": "Signed Contract Document",
|
||||||
"k941fd092": "Last Folder Structure Action",
|
"k941fd092": "Last Folder Structure Action",
|
||||||
@ -1506,7 +1513,7 @@ export const en: Translations = {
|
|||||||
"k9c3db145": "Start Discussion",
|
"k9c3db145": "Start Discussion",
|
||||||
"k9d0c063d": "Password saved. Redirecting to login...",
|
"k9d0c063d": "Password saved. Redirecting to login...",
|
||||||
"k9e609523": "No missing folders found. Run Refresh to scan again.",
|
"k9e609523": "No missing folders found. Run Refresh to scan again.",
|
||||||
"k9f29dbfb": "Entdecke nachhaltige Produkte und verdiene dabei. Deine Plattform für bewussten Konsum und finanzielle Vorteile.",
|
"k9f29dbfb": "Discover sustainable products and earn rewards. Your platform for conscious consumption and financial benefits.",
|
||||||
"k9f56d4ac": "e.g. +43 676 1234567",
|
"k9f56d4ac": "e.g. +43 676 1234567",
|
||||||
"ka00fc5db": "Manage your account information and preferences",
|
"ka00fc5db": "Manage your account information and preferences",
|
||||||
"ka15f5ec5": "Auth Store State",
|
"ka15f5ec5": "Auth Store State",
|
||||||
@ -1561,7 +1568,7 @@ export const en: Translations = {
|
|||||||
"kbce9fbea": "No platforms configured.",
|
"kbce9fbea": "No platforms configured.",
|
||||||
"kbd8b3364": "Sign Contract",
|
"kbd8b3364": "Sign Contract",
|
||||||
"kbd979e13": "We are a community",
|
"kbd979e13": "We are a community",
|
||||||
"kbdb02e32": "Keine Rechnungen gefunden.",
|
"kbdb02e32": "No invoices found.",
|
||||||
"kbe9355f8": "Business License",
|
"kbe9355f8": "Business License",
|
||||||
"kbf4b7789": "You are already logged in. Redirecting...",
|
"kbf4b7789": "You are already logged in. Redirecting...",
|
||||||
"kbf7bde57": "Select any subscription to view details and included items.",
|
"kbf7bde57": "Select any subscription to view details and included items.",
|
||||||
@ -1588,17 +1595,17 @@ export const en: Translations = {
|
|||||||
"kccc13f16": "← Go back",
|
"kccc13f16": "← Go back",
|
||||||
"kccde6d86": "User Verification Center",
|
"kccde6d86": "User Verification Center",
|
||||||
"kccf7593a": "• Be respectful and kind",
|
"kccf7593a": "• Be respectful and kind",
|
||||||
"kcd7a1625": "deine@email.com",
|
"kcd7a1625": "your@email.com",
|
||||||
"kcd9890e5": "PNG, JPG, WebP up to 5MB",
|
"kcd9890e5": "PNG, JPG, WebP up to 5MB",
|
||||||
"kcdfef775": "Loading subscriptions…",
|
"kcdfef775": "Loading subscriptions…",
|
||||||
"kce0ab46c": "Dein Passwort",
|
"kce0ab46c": "Your password",
|
||||||
"kcf4ba87d": "Crop Affiliate Logo",
|
"kcf4ba87d": "Crop Affiliate Logo",
|
||||||
"kcf61fc9e": "Last Loose Files Action",
|
"kcf61fc9e": "Last Loose Files Action",
|
||||||
"kd00443f2": "Go to Dashboard",
|
"kd00443f2": "Go to Dashboard",
|
||||||
"kd04a7c59": "Matrix Name",
|
"kd04a7c59": "Matrix Name",
|
||||||
"kd058bb7b": "Missing:",
|
"kd058bb7b": "Missing:",
|
||||||
"kd09be3cd": "Matrix Management",
|
"kd09be3cd": "Matrix Management",
|
||||||
"kd1c17b3f": "Alle Marken",
|
"kd1c17b3f": "All Brands",
|
||||||
"kd1f35ccf": "Search & Filter Users",
|
"kd1f35ccf": "Search & Filter Users",
|
||||||
"kd2e35b08": "Rows per page",
|
"kd2e35b08": "Rows per page",
|
||||||
"kd2e5e813": "• Already booked:",
|
"kd2e5e813": "• Already booked:",
|
||||||
@ -1614,7 +1621,7 @@ export const en: Translations = {
|
|||||||
"kd5cca6e9": "? This action cannot be undone.",
|
"kd5cca6e9": "? This action cannot be undone.",
|
||||||
"kd6024811": "PDF File",
|
"kd6024811": "PDF File",
|
||||||
"kd642e230": "Search by name or email. Minimum 3 characters. Existing matrix members are hidden.",
|
"kd642e230": "Search by name or email. Minimum 3 characters. Existing matrix members are hidden.",
|
||||||
"kd68da70d": "Nachhaltige Produkte für deinen Erfolg",
|
"kd68da70d": "Sustainable products for your success",
|
||||||
"kd89474fa": "Back to News",
|
"kd89474fa": "Back to News",
|
||||||
"kda96f5b3": "Matrix Depth",
|
"kda96f5b3": "Matrix Depth",
|
||||||
"kdb27a82d": "‹ Previous",
|
"kdb27a82d": "‹ Previous",
|
||||||
@ -1633,7 +1640,7 @@ export const en: Translations = {
|
|||||||
"ke4c4a858": "Min. 3 characters",
|
"ke4c4a858": "Min. 3 characters",
|
||||||
"ke697b8cb": "Set Active",
|
"ke697b8cb": "Set Active",
|
||||||
"ke8b9f33c": "Total in Pool",
|
"ke8b9f33c": "Total in Pool",
|
||||||
"ke9e71971": "Oder weiter mit",
|
"ke9e71971": "Or continue with",
|
||||||
"kebf33594": "Filter by category:",
|
"kebf33594": "Filter by category:",
|
||||||
"kec5a5357": "Upload Invoice",
|
"kec5a5357": "Upload Invoice",
|
||||||
"keccee79f": "Email address",
|
"keccee79f": "Email address",
|
||||||
@ -1655,7 +1662,7 @@ export const en: Translations = {
|
|||||||
"kf3b81ba3": "Used in the URL. Auto-generated from title unless edited.",
|
"kf3b81ba3": "Used in the URL. Auto-generated from title unless edited.",
|
||||||
"kf4868273": "Click to upload",
|
"kf4868273": "Click to upload",
|
||||||
"kf4f44e2f": "e.g. ATU12345678",
|
"kf4f44e2f": "e.g. ATU12345678",
|
||||||
"kf530c357": "Anmeldung läuft...",
|
"kf530c357": "Signing in...",
|
||||||
"kf663ef67": "Shop with an infinite variety of products",
|
"kf663ef67": "Shop with an infinite variety of products",
|
||||||
"kf69154f8": "• Stay on topic",
|
"kf69154f8": "• Stay on topic",
|
||||||
"kf70b9896": "e.g. 12345",
|
"kf70b9896": "e.g. 12345",
|
||||||
@ -1682,23 +1689,30 @@ export const en: Translations = {
|
|||||||
"k4bfb4f28": "Feature comparison",
|
"k4bfb4f28": "Feature comparison",
|
||||||
"k4c6eb72c": "Select all",
|
"k4c6eb72c": "Select all",
|
||||||
"k4f209a66": "You currently don’t have an active subscription.",
|
"k4f209a66": "You currently don’t have an active subscription.",
|
||||||
|
"k511d7fab": "keys scanned from the English source file.",
|
||||||
"k5b7042c7": "Previous page",
|
"k5b7042c7": "Previous page",
|
||||||
"k60b1e339": "No media or documents found.",
|
"k60b1e339": "No media or documents found.",
|
||||||
"k6569783c": "Use this to include server-style files. Files with server-only Next.js APIs are skipped for safety.",
|
"k6569783c": "Use this to include server-style files. Files with server-only Next.js APIs are skipped for safety.",
|
||||||
"k68c88f41": "Force convert selected files to client components before auto-fix",
|
"k68c88f41": "Force convert selected files to client components before auto-fix",
|
||||||
"k74914369": "Delete Item",
|
"k74914369": "Delete Item",
|
||||||
|
"k7227f13d": "Manage UI translations. All",
|
||||||
"k772cc77b": "Complete your profile to unlock all features",
|
"k772cc77b": "Complete your profile to unlock all features",
|
||||||
"k7fa55432": "My Subscription",
|
"k7fa55432": "My Subscription",
|
||||||
"k86b03343": "Billed annually",
|
"k86b03343": "Billed annually",
|
||||||
"k8953de89": "Finance & Invoices",
|
"k8953de89": "Finance & Invoices",
|
||||||
"k947d8777": "Invoice #",
|
"k947d8777": "Invoice #",
|
||||||
|
"k9863fa5": "Scan & review fixes",
|
||||||
"ka5603827": "Loading invoices…",
|
"ka5603827": "Loading invoices…",
|
||||||
|
"ka6cf3286": "languages",
|
||||||
|
"ka8c928ac": "Admin",
|
||||||
"ka86bdc9b": "Payment frequency",
|
"ka86bdc9b": "Payment frequency",
|
||||||
"kb3243742": "No file",
|
"kb3243742": "No file",
|
||||||
"kc48b877b": "No subscription selected. Invoices will appear once you have an active subscription.",
|
"kc48b877b": "No subscription selected. Invoices will appear once you have an active subscription.",
|
||||||
"kd08b698a": "Profile Completion",
|
"kd08b698a": "Profile Completion",
|
||||||
|
"kdcc78d97": "Fully translated",
|
||||||
"ke3480838": "No fixable hardcoded UI text detected in eligible components.",
|
"ke3480838": "No fixable hardcoded UI text detected in eligible components.",
|
||||||
"ked7d533b": "Media & Documents",
|
"ked7d533b": "Media & Documents",
|
||||||
|
"kf191f6df5": "Scanning…",
|
||||||
"kf5ac16fb": "Pricing that grows with you",
|
"kf5ac16fb": "Pricing that grows with you",
|
||||||
"kf9f94d5e": "Buy this plan",
|
"kf9f94d5e": "Buy this plan",
|
||||||
"kfd632d02": "Export all invoices",
|
"kfd632d02": "Export all invoices",
|
||||||
@ -1706,9 +1720,13 @@ export const en: Translations = {
|
|||||||
"k9dafde30": "Contact Person",
|
"k9dafde30": "Contact Person",
|
||||||
"kada9d61c": "Account Holder",
|
"kada9d61c": "Account Holder",
|
||||||
"kde6d477f": "Email Address",
|
"kde6d477f": "Email Address",
|
||||||
|
"k20eb1f87": "language",
|
||||||
|
"k33f55455": "keys",
|
||||||
|
"k3931709b": "translation keys",
|
||||||
"kfc6b6a29": "Editing disabled",
|
"kfc6b6a29": "Editing disabled",
|
||||||
"k03538639": "e.g. fr, es, zh-TW",
|
"k03538639": "e.g. fr, es, zh-TW",
|
||||||
"k5fcc9b0e": "Delete language",
|
"k5fcc9b0e": "Delete language",
|
||||||
|
"k571ffd91": "missing",
|
||||||
"k9bd0812b": "Shows why a file was changed, skipped, or left untouched after a fix attempt.",
|
"k9bd0812b": "Shows why a file was changed, skipped, or left untouched after a fix attempt.",
|
||||||
"ka019b3c0": "e.g. Français",
|
"ka019b3c0": "e.g. Français",
|
||||||
"kbe30c353": "Coverage by namespace",
|
"kbe30c353": "Coverage by namespace",
|
||||||
@ -1738,6 +1756,7 @@ export const en: Translations = {
|
|||||||
"kc518ff5c": "English reference",
|
"kc518ff5c": "English reference",
|
||||||
"kcd190bdd": "Translation wizard",
|
"kcd190bdd": "Translation wizard",
|
||||||
"kfd1e0089": "Auto-scroll on panel open",
|
"kfd1e0089": "Auto-scroll on panel open",
|
||||||
|
"k23e95df1": "Auto-scroll on save",
|
||||||
"k429d94bf": "Organized by template family",
|
"k429d94bf": "Organized by template family",
|
||||||
"k61d66984": "Grouped library",
|
"k61d66984": "Grouped library",
|
||||||
"k66b39536": "Template overview",
|
"k66b39536": "Template overview",
|
||||||
@ -1747,7 +1766,16 @@ export const en: Translations = {
|
|||||||
"k8351e02f": "Admin workspace",
|
"k8351e02f": "Admin workspace",
|
||||||
"kccff045c": "Faster edit flow",
|
"kccff045c": "Faster edit flow",
|
||||||
"kf962066f": "Contract type",
|
"kf962066f": "Contract type",
|
||||||
"kb1cf599b": "Counts as global key"
|
"kb1cf599b": "Counts as global key",
|
||||||
|
"k1c7ec4f2": "Admin Fetch Log",
|
||||||
|
"k057b3dbd": "visible for slow fetches",
|
||||||
|
"k6d79b1df": "Use English value",
|
||||||
|
"k78e1bf35": "Fetching language data...",
|
||||||
|
"k77d01d6a": "Open ▾",
|
||||||
|
"k835d3cbf": "Waiting for first bootstrap event...",
|
||||||
|
"k8de6d3df": "EN ref",
|
||||||
|
"kb494ddd8": "Scroll to top",
|
||||||
|
"kcfb5fb54": "Click to open"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"loginSuccess": "Login successful",
|
"loginSuccess": "Login successful",
|
||||||
|
|||||||
@ -21,10 +21,10 @@ import { editProfileBasic } from './hooks/editProfile'
|
|||||||
import { authFetch } from '../utils/authFetch'
|
import { authFetch } from '../utils/authFetch'
|
||||||
|
|
||||||
// Helper to display missing fields in subtle gray italic (no yellow highlight)
|
// Helper to display missing fields in subtle gray italic (no yellow highlight)
|
||||||
function HighlightIfMissing({ value, children }: { value: any, children: React.ReactNode }) {
|
function HighlightIfMissing({ value, children, missingLabel }: { value: any, children: React.ReactNode, missingLabel?: React.ReactNode }) {
|
||||||
if (value === null || value === undefined || value === '') {
|
if (value === null || value === undefined || value === '') {
|
||||||
return (
|
return (
|
||||||
<span className="italic text-gray-400">{t('autofix.kf2147f07')}</span>
|
<span className="italic text-gray-400">{missingLabel ?? 'Not provided'}</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
@ -98,6 +98,11 @@ export default function ProfilePage() {
|
|||||||
const [editModalError, setEditModalError] = React.useState<string | null>(null)
|
const [editModalError, setEditModalError] = React.useState<string | null>(null)
|
||||||
const [downloadLoading, setDownloadLoading] = React.useState(false)
|
const [downloadLoading, setDownloadLoading] = React.useState(false)
|
||||||
const [downloadError, setDownloadError] = React.useState<string | null>(null)
|
const [downloadError, setDownloadError] = React.useState<string | null>(null)
|
||||||
|
const HighlightIfMissingWithText: React.FC<{ value: any, children: React.ReactNode }> = ({ value, children }) => (
|
||||||
|
<HighlightIfMissing value={value} missingLabel={t('autofix.kf2147f07')}>
|
||||||
|
{children}
|
||||||
|
</HighlightIfMissing>
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => { setHasHydrated(true) }, [])
|
useEffect(() => { setHasHydrated(true) }, [])
|
||||||
|
|
||||||
@ -347,7 +352,7 @@ export default function ProfilePage() {
|
|||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<BasicInformation
|
<BasicInformation
|
||||||
profileData={profileData}
|
profileData={profileData}
|
||||||
HighlightIfMissing={HighlightIfMissing}
|
HighlightIfMissing={HighlightIfMissingWithText}
|
||||||
// Add edit button handler
|
// Add edit button handler
|
||||||
onEdit={() => openEditModal('basic', {
|
onEdit={() => openEditModal('basic', {
|
||||||
firstName: profileData.firstName,
|
firstName: profileData.firstName,
|
||||||
|
|||||||
@ -62,7 +62,7 @@ const init: CompanyProfileData = {
|
|||||||
|
|
||||||
function ModernSelect({
|
function ModernSelect({
|
||||||
label,
|
label,
|
||||||
placeholder={t('autofix.ka5bf342b')},
|
placeholder,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
options,
|
options,
|
||||||
@ -73,10 +73,12 @@ function ModernSelect({
|
|||||||
onChange: (next: string) => void
|
onChange: (next: string) => void
|
||||||
options: { value: string; label: string }[]
|
options: { value: string; label: string }[]
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const btnRef = useRef<HTMLButtonElement | null>(null)
|
const btnRef = useRef<HTMLButtonElement | null>(null)
|
||||||
const [pos, setPos] = useState({ left: 16, top: 0, width: 320 })
|
const [pos, setPos] = useState({ left: 16, top: 0, width: 320 })
|
||||||
|
const resolvedPlaceholder = placeholder ?? t('autofix.ka5bf342b')
|
||||||
|
|
||||||
const selected = useMemo(() => options.find(o => o.value === value) || null, [options, value])
|
const selected = useMemo(() => options.find(o => o.value === value) || null, [options, value])
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
@ -129,7 +131,7 @@ function ModernSelect({
|
|||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
>
|
>
|
||||||
<span className={selected ? 'text-gray-900' : 'text-gray-500'}>
|
<span className={selected ? 'text-gray-900' : 'text-gray-500'}>
|
||||||
{selected ? selected.label : placeholder}
|
{selected ? selected.label : resolvedPlaceholder}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDownIcon className={`h-5 w-5 text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`} />
|
<ChevronDownIcon className={`h-5 w-5 text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -80,7 +80,7 @@ type SelectOption = { value: string; label: string }
|
|||||||
|
|
||||||
function ModernSelect({
|
function ModernSelect({
|
||||||
label,
|
label,
|
||||||
placeholder={t('autofix.ka5bf342b')},
|
placeholder,
|
||||||
searchPlaceholder = 'Search…',
|
searchPlaceholder = 'Search…',
|
||||||
noResults = 'No results',
|
noResults = 'No results',
|
||||||
value,
|
value,
|
||||||
@ -95,10 +95,12 @@ function ModernSelect({
|
|||||||
onChange: (next: string) => void
|
onChange: (next: string) => void
|
||||||
options: SelectOption[]
|
options: SelectOption[]
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const btnRef = useRef<HTMLButtonElement | null>(null)
|
const btnRef = useRef<HTMLButtonElement | null>(null)
|
||||||
const [pos, setPos] = useState({ left: 16, top: 0, width: 320 })
|
const [pos, setPos] = useState({ left: 16, top: 0, width: 320 })
|
||||||
|
const resolvedPlaceholder = placeholder ?? t('autofix.ka5bf342b')
|
||||||
|
|
||||||
const selected = useMemo(
|
const selected = useMemo(
|
||||||
() => options.find(o => o.value === value) || null,
|
() => options.find(o => o.value === value) || null,
|
||||||
@ -162,7 +164,7 @@ function ModernSelect({
|
|||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
>
|
>
|
||||||
<span className={selected ? 'text-gray-900' : 'text-gray-500'}>
|
<span className={selected ? 'text-gray-900' : 'text-gray-500'}>
|
||||||
{selected ? selected.label : placeholder}
|
{selected ? selected.label : resolvedPlaceholder}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDownIcon className={`h-5 w-5 text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`} />
|
<ChevronDownIcon className={`h-5 w-5 text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -66,7 +66,7 @@ interface AuthStore {
|
|||||||
setUser: (userData: User | null) => void;
|
setUser: (userData: User | null) => void;
|
||||||
clearAuth: () => void;
|
clearAuth: () => void;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
refreshAuthToken: () => Promise<boolean | null>;
|
refreshAuthToken: (forceRefresh?: boolean) => Promise<boolean | null>;
|
||||||
getAuthState: () => AuthStore;
|
getAuthState: () => AuthStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,16 +165,16 @@ const useAuthStore = create<AuthStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshAuthToken: async () => {
|
refreshAuthToken: async (forceRefresh = false) => {
|
||||||
// If there's already a refresh in flight, return that promise
|
// If there's already a refresh in flight, return that promise
|
||||||
if (get().refreshPromise) {
|
if (get().refreshPromise) {
|
||||||
log("🔁 Zustand: refreshAuthToken - returning existing refresh promise");
|
log("🔁 Zustand: refreshAuthToken - returning existing refresh promise");
|
||||||
return get().refreshPromise;
|
return get().refreshPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SHORT-CIRCUIT: if we already have a valid accessToken that's not about to expire, skip refresh
|
// SHORT-CIRCUIT: skip refresh when token is still valid unless caller explicitly forces refresh.
|
||||||
const currentToken = get().accessToken;
|
const currentToken = get().accessToken;
|
||||||
if (currentToken) {
|
if (!forceRefresh && currentToken) {
|
||||||
const expiry = getTokenExpiry(currentToken);
|
const expiry = getTokenExpiry(currentToken);
|
||||||
if (expiry && expiry.getTime() - Date.now() > 60 * 1000) { // more than 60s left
|
if (expiry && expiry.getTime() - Date.now() > 60 * 1000) { // more than 60s left
|
||||||
log("⏸️ Zustand: accessToken present and valid, skipping refresh");
|
log("⏸️ Zustand: accessToken present and valid, skipping refresh");
|
||||||
@ -203,7 +203,14 @@ const useAuthStore = create<AuthStore>((set, get) => ({
|
|||||||
if (res.ok && body && body.accessToken) {
|
if (res.ok && body && body.accessToken) {
|
||||||
log("✅ Zustand: Refresh succeeded, setting in-memory token and user");
|
log("✅ Zustand: Refresh succeeded, setting in-memory token and user");
|
||||||
get().setAccessToken(body.accessToken);
|
get().setAccessToken(body.accessToken);
|
||||||
if (body.user) get().setUser(body.user);
|
if (body.user) {
|
||||||
|
const existingUser = get().user;
|
||||||
|
const mergedUser =
|
||||||
|
existingUser && typeof existingUser === 'object' && body.user && typeof body.user === 'object'
|
||||||
|
? { ...existingUser, ...body.user }
|
||||||
|
: body.user;
|
||||||
|
get().setUser(mergedUser);
|
||||||
|
}
|
||||||
|
|
||||||
// Log token expiry for debugging
|
// Log token expiry for debugging
|
||||||
const newExpiry = getTokenExpiry(body.accessToken);
|
const newExpiry = getTokenExpiry(body.accessToken);
|
||||||
|
|||||||
@ -44,6 +44,38 @@ interface CustomRequestInit extends RequestInit {
|
|||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let logoutInFlight: Promise<void> | null = null;
|
||||||
|
let lastLogoutAt = 0;
|
||||||
|
const LOGOUT_DEBOUNCE_MS = 1500;
|
||||||
|
|
||||||
|
async function runLogoutOnce(): Promise<void> {
|
||||||
|
if (logoutInFlight) {
|
||||||
|
return logoutInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastLogoutAt < LOGOUT_DEBOUNCE_MS) {
|
||||||
|
log("⏭️ authFetch: Skipping duplicate logout attempt in debounce window");
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!logoutInFlight) {
|
||||||
|
lastLogoutAt = now;
|
||||||
|
logoutInFlight = useAuthStore
|
||||||
|
.getState()
|
||||||
|
.logout()
|
||||||
|
.catch((e) => {
|
||||||
|
log("❌ authFetch: logout error:", e);
|
||||||
|
useAuthStore.getState().clearAuth();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
logoutInFlight = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return logoutInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper: safe stringify body for logging
|
// Helper: safe stringify body for logging
|
||||||
function safeBodyPreview(body: any, max = 500): string | null {
|
function safeBodyPreview(body: any, max = 500): string | null {
|
||||||
if (body == null) return null;
|
if (body == null) return null;
|
||||||
@ -97,10 +129,10 @@ export async function authFetch(input: RequestInfo | URL, init: CustomRequestIni
|
|||||||
|
|
||||||
// If unauthorized, try to refresh token and retry once
|
// If unauthorized, try to refresh token and retry once
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
log("🔄 authFetch: 401 Unauthorized received. Attempting store.refreshAuthToken()...");
|
log("🔄 authFetch: 401 Unauthorized received. Attempting forced store.refreshAuthToken()...");
|
||||||
try {
|
try {
|
||||||
// call centralized, deduped refresh in store
|
// call centralized, deduped refresh in store (forced on 401)
|
||||||
const refreshOk = await useAuthStore.getState().refreshAuthToken();
|
const refreshOk = await useAuthStore.getState().refreshAuthToken(true);
|
||||||
log("🔄 authFetch: store.refreshAuthToken() result:", refreshOk);
|
log("🔄 authFetch: store.refreshAuthToken() result:", refreshOk);
|
||||||
|
|
||||||
if (refreshOk) {
|
if (refreshOk) {
|
||||||
@ -117,18 +149,12 @@ export async function authFetch(input: RequestInfo | URL, init: CustomRequestIni
|
|||||||
res = await fetch(url, { ...init, headers: buildHeaders(newToken), credentials: "include" });
|
res = await fetch(url, { ...init, headers: buildHeaders(newToken), credentials: "include" });
|
||||||
log("📡 authFetch: Retry response status:", res.status);
|
log("📡 authFetch: Retry response status:", res.status);
|
||||||
} else {
|
} else {
|
||||||
log("❌ authFetch: Refresh failed. Calling logout to revoke server cookie and clear client state");
|
log("❌ authFetch: Refresh failed. Running deduplicated logout flow");
|
||||||
await useAuthStore.getState().logout().catch((e) => {
|
await runLogoutOnce();
|
||||||
log("❌ authFetch: logout error:", e);
|
|
||||||
useAuthStore.getState().clearAuth();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log("❌ authFetch: Error while refreshing token:", err);
|
log("❌ authFetch: Error while refreshing token:", err);
|
||||||
await useAuthStore.getState().logout().catch((e) => {
|
await runLogoutOnce();
|
||||||
log("❌ authFetch: logout error after refresh exception:", e);
|
|
||||||
useAuthStore.getState().clearAuth();
|
|
||||||
});
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
275
src/tests/authRefresh.smoke.test.ts
Normal file
275
src/tests/authRefresh.smoke.test.ts
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* Smoke tests: token refresh logic in authFetch + authStore
|
||||||
|
*
|
||||||
|
* Scenarios covered:
|
||||||
|
* 1. Valid token present → request goes through, no refresh attempted
|
||||||
|
* 2. No token, first request gets 401, refresh succeeds → retry with new token succeeds
|
||||||
|
* 3. No token, first request gets 401, refresh fails → logout called, 401 returned
|
||||||
|
* 4. Concurrent 401s trigger only ONE refresh call (deduplication)
|
||||||
|
* 5. Short-circuit: valid non-expired token in store → refreshAuthToken returns true without hitting /api/refresh
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
// ── Silence logger so test output stays clean ─────────────────────────────────
|
||||||
|
vi.mock('@/app/utils/logger', () => ({ log: vi.fn() }));
|
||||||
|
|
||||||
|
// ── Build a deterministic JWT with a given exp (seconds from now) ─────────────
|
||||||
|
function makeJwt(secondsFromNow: number): string {
|
||||||
|
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
||||||
|
const payload = btoa(
|
||||||
|
JSON.stringify({ sub: 'test-user', exp: Math.floor(Date.now() / 1000) + secondsFromNow })
|
||||||
|
);
|
||||||
|
return `${header}.${payload}.sig`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FRESH_TOKEN = makeJwt(3600); // 1 hour from now
|
||||||
|
const NEW_TOKEN = makeJwt(3600); // token returned after refresh
|
||||||
|
const STALE_TOKEN = makeJwt(-60); // already expired
|
||||||
|
|
||||||
|
// ── Minimal sessionStorage shim (jsdom already provides one but let's be explicit) ──
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
function makeResponse(status: number, body: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// We re-import authStore fresh per test so Zustand state is reset.
|
||||||
|
async function freshStore() {
|
||||||
|
// Vitest module cache survives between tests in the same file.
|
||||||
|
// Reset by clearing sessionStorage (store reads it on init) and then
|
||||||
|
// re-importing the module with a cache-bust isn't straightforward,
|
||||||
|
// so we directly manipulate state after import.
|
||||||
|
const mod = await import('@/app/store/authStore');
|
||||||
|
const store = mod.default;
|
||||||
|
store.getState().clearAuth();
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe('authStore.refreshAuthToken()', () => {
|
||||||
|
it('short-circuits when a valid non-expired token is already in state', async () => {
|
||||||
|
const store = await freshStore();
|
||||||
|
store.getState().setAccessToken(FRESH_TOKEN);
|
||||||
|
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||||
|
|
||||||
|
const result = await store.getState().refreshAuthToken();
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls /api/refresh and sets new token on success', async () => {
|
||||||
|
const store = await freshStore();
|
||||||
|
// No token in store → refresh should proceed
|
||||||
|
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
||||||
|
makeResponse(200, { accessToken: NEW_TOKEN, user: { email: 'test@example.com' } })
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await store.getState().refreshAuthToken();
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(store.getState().accessToken).toBe(NEW_TOKEN);
|
||||||
|
expect(store.getState().user?.email).toBe('test@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false and clears auth when /api/refresh returns non-ok', async () => {
|
||||||
|
const store = await freshStore();
|
||||||
|
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
||||||
|
makeResponse(401, { message: 'Refresh token expired' })
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await store.getState().refreshAuthToken();
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(store.getState().accessToken).toBeNull();
|
||||||
|
expect(store.getState().user).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates concurrent refresh calls — fetch called exactly once', async () => {
|
||||||
|
const store = await freshStore();
|
||||||
|
|
||||||
|
let resolveRefresh!: (v: Response) => void;
|
||||||
|
const refreshPending = new Promise<Response>((res) => { resolveRefresh = res; });
|
||||||
|
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, 'fetch')
|
||||||
|
.mockReturnValueOnce(refreshPending);
|
||||||
|
|
||||||
|
// Fire three concurrent refreshes
|
||||||
|
const p1 = store.getState().refreshAuthToken();
|
||||||
|
const p2 = store.getState().refreshAuthToken();
|
||||||
|
const p3 = store.getState().refreshAuthToken();
|
||||||
|
|
||||||
|
// Resolve the single in-flight fetch
|
||||||
|
resolveRefresh(makeResponse(200, { accessToken: NEW_TOKEN }));
|
||||||
|
|
||||||
|
const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
|
||||||
|
|
||||||
|
expect(r1).toBe(true);
|
||||||
|
expect(r2).toBe(true);
|
||||||
|
expect(r3).toBe(true);
|
||||||
|
// fetch should only have been called once for /api/refresh
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.getState().accessToken).toBe(NEW_TOKEN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears isRefreshing and refreshPromise after completion', async () => {
|
||||||
|
const store = await freshStore();
|
||||||
|
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
||||||
|
makeResponse(200, { accessToken: NEW_TOKEN })
|
||||||
|
);
|
||||||
|
|
||||||
|
await store.getState().refreshAuthToken();
|
||||||
|
|
||||||
|
expect(store.getState().isRefreshing).toBe(false);
|
||||||
|
expect(store.getState().refreshPromise).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe('authFetch() token refresh flow', () => {
|
||||||
|
it('attaches Bearer token and succeeds on first try when token is fresh', async () => {
|
||||||
|
const store = await freshStore();
|
||||||
|
store.getState().setAccessToken(FRESH_TOKEN);
|
||||||
|
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
||||||
|
makeResponse(200, { ok: true, data: 'hello' })
|
||||||
|
);
|
||||||
|
|
||||||
|
const { authFetch } = await import('@/app/utils/authFetch');
|
||||||
|
const res = await authFetch('/api/some-endpoint');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
const callHeaders = (fetchSpy.mock.calls[0][1] as RequestInit)
|
||||||
|
.headers as Record<string, string>;
|
||||||
|
expect(callHeaders['Authorization']).toBe(`Bearer ${FRESH_TOKEN}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries with new token after 401 + successful refresh', async () => {
|
||||||
|
const store = await freshStore();
|
||||||
|
// No access token → first request will get 401
|
||||||
|
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, 'fetch')
|
||||||
|
// 1st call: the protected endpoint → 401
|
||||||
|
.mockResolvedValueOnce(makeResponse(401, { message: 'Unauthorized' }))
|
||||||
|
// 2nd call: /api/refresh → success
|
||||||
|
.mockResolvedValueOnce(makeResponse(200, { accessToken: NEW_TOKEN }))
|
||||||
|
// 3rd call: retry of protected endpoint with new token → 200
|
||||||
|
.mockResolvedValueOnce(makeResponse(200, { ok: true }));
|
||||||
|
|
||||||
|
const { authFetch } = await import('@/app/utils/authFetch');
|
||||||
|
const res = await authFetch('/api/protected');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// 3 total fetch calls: original + refresh + retry
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
const retryHeaders = (fetchSpy.mock.calls[2][1] as RequestInit)
|
||||||
|
.headers as Record<string, string>;
|
||||||
|
expect(retryHeaders['Authorization']).toBe(`Bearer ${NEW_TOKEN}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the 401 response and calls logout when refresh fails', async () => {
|
||||||
|
const store = await freshStore();
|
||||||
|
|
||||||
|
const logoutSpy = vi.spyOn(store.getState(), 'logout').mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
vi.spyOn(globalThis, 'fetch')
|
||||||
|
// Original request → 401
|
||||||
|
.mockResolvedValueOnce(makeResponse(401, { message: 'Unauthorized' }))
|
||||||
|
// /api/refresh → fails
|
||||||
|
.mockResolvedValueOnce(makeResponse(401, { message: 'Refresh token expired' }));
|
||||||
|
|
||||||
|
const { authFetch } = await import('@/app/utils/authFetch');
|
||||||
|
const res = await authFetch('/api/protected');
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(logoutSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates logout when multiple concurrent requests fail refresh', async () => {
|
||||||
|
const store = await freshStore();
|
||||||
|
|
||||||
|
const logoutSpy = vi.spyOn(store.getState(), 'logout').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
if (url === '/api/refresh') {
|
||||||
|
return makeResponse(401, { message: 'Refresh token expired' });
|
||||||
|
}
|
||||||
|
return makeResponse(401, { message: 'Unauthorized' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const { authFetch } = await import('@/app/utils/authFetch');
|
||||||
|
|
||||||
|
const [res1, res2] = await Promise.all([
|
||||||
|
authFetch('/api/protected-a'),
|
||||||
|
authFetch('/api/protected-b'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(res1.status).toBe(401);
|
||||||
|
expect(res2.status).toBe(401);
|
||||||
|
expect(logoutSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const refreshCalls = fetchSpy.mock.calls.filter(([input]) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
return url === '/api/refresh';
|
||||||
|
});
|
||||||
|
expect(refreshCalls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT call /api/refresh when token is fresh (no 401 response)', async () => {
|
||||||
|
const store = await freshStore();
|
||||||
|
store.getState().setAccessToken(FRESH_TOKEN);
|
||||||
|
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
||||||
|
makeResponse(200, { ok: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
const { authFetch } = await import('@/app/utils/authFetch');
|
||||||
|
await authFetch('/api/something');
|
||||||
|
|
||||||
|
// Only 1 call — to the endpoint, not to /api/refresh
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchSpy.mock.calls[0][0]).toBe('/api/something');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces /api/refresh on 401 even when access token is not expired', async () => {
|
||||||
|
const store = await freshStore();
|
||||||
|
store.getState().setAccessToken(FRESH_TOKEN);
|
||||||
|
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, 'fetch')
|
||||||
|
// 1st call: protected endpoint with stale/invalid server-side token -> 401
|
||||||
|
.mockResolvedValueOnce(makeResponse(401, { message: 'Unauthorized' }))
|
||||||
|
// 2nd call: forced refresh endpoint
|
||||||
|
.mockResolvedValueOnce(makeResponse(200, { accessToken: NEW_TOKEN }))
|
||||||
|
// 3rd call: retry succeeds
|
||||||
|
.mockResolvedValueOnce(makeResponse(200, { ok: true }));
|
||||||
|
|
||||||
|
const { authFetch } = await import('@/app/utils/authFetch');
|
||||||
|
const res = await authFetch('/api/protected');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||||
|
expect(fetchSpy.mock.calls[1][0]).toBe('/api/refresh');
|
||||||
|
});
|
||||||
|
});
|
||||||
1
src/tests/setup.ts
Normal file
1
src/tests/setup.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
15
vitest.config.ts
Normal file
15
vitest.config.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./src/tests/setup.ts'],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user