import { useEffect, useMemo, useRef, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { ApiError, api } from '@/api/client'; import type { TranslationEntry, ValidationWarning } from '@/api/types'; import { composePlural, decomposePlural, isPluralMessage, selectorOrder } from '@/api/icu'; export type SaveState = 'idle' | 'saving' | 'saved' | 'error'; interface Props { keyUuid: string; locale: string; value: string | null; version: number; direction: 'ltr' | 'rtl'; pluralCategories: string[]; maxLength: number | null; autoFocus?: boolean; /** Rendu compact pour la grille, aéré pour le mode Focus. */ dense?: boolean; onSaved?: (entry: TranslationEntry) => void; onRequestNext?: () => void; } /** * Saisie d'une traduction — le composant que le traducteur utilise toute la * journée. * * Trois partis pris : * * 1. **Pas de bouton « Enregistrer ».** L'enregistrement se déclenche à la * perte de focus. Un bouton par ligne sur une grille de trente mille lignes * est une plaisanterie ; un bouton global oblige à se souvenir de cliquer. * * 2. **Le traducteur ne voit jamais d'ICU.** Un message pluriel se présente * comme un champ par forme, avec le nom de la catégorie CLDR en clair. La * recomposition se fait à l'enregistrement. * * 3. **Les erreurs s'affichent sous le champ, pas dans une alerte.** Elles * concernent ce texte-là ; les éloigner du texte oblige à faire le lien * soi-même. */ export function TranslationInput({ keyUuid, locale, value, version, direction, pluralCategories, maxLength, autoFocus = false, dense = false, onSaved, onRequestNext, }: Props) { const queryClient = useQueryClient(); const decomposed = useMemo(() => decomposePlural(value), [value]); const plural = decomposed !== null; const [simple, setSimple] = useState(value ?? ''); const [branches, setBranches] = useState>(decomposed?.branches ?? {}); const [state, setState] = useState('idle'); const [error, setError] = useState(null); const [warnings, setWarnings] = useState([]); const currentVersion = useRef(version); // Le contenu peut changer sous nos pieds — changement de ligne dans la // grille, rafraîchissement au retour d'onglet. On ne resynchronise que si la // valeur serveur diffère de ce qu'on a écrit, sinon la frappe serait écrasée. useEffect(() => { currentVersion.current = version; setSimple(value ?? ''); setBranches(decomposePlural(value)?.branches ?? {}); setState('idle'); setError(null); setWarnings([]); }, [keyUuid, locale, value, version]); const selectors = useMemo( () => selectorOrder(pluralCategories, Object.keys(decomposed?.branches ?? {})), [pluralCategories, decomposed], ); const mutation = useMutation({ mutationFn: (next: string | null) => api.writeTranslation(keyUuid, locale, { value: next, version: currentVersion.current, }), onMutate: () => { setState('saving'); setError(null); }, onSuccess: (entry) => { currentVersion.current = entry.currentVersion; setWarnings(entry.warnings); setState('saved'); onSaved?.(entry); // La grille et les compteurs sont désormais périmés. On invalide // plutôt que de patcher le cache à la main : le calcul d'avancement // dépend de règles serveur qu'on ne veut pas dupliquer ici. void queryClient.invalidateQueries({ queryKey: ['grid'] }); // La vue par clé montre la MÊME traduction sous un autre angle : // l'oublier ici laisserait un onglet afficher une valeur périmée // après une saisie faite dans l'autre. void queryClient.invalidateQueries({ queryKey: ['keys'] }); void queryClient.invalidateQueries({ queryKey: ['stats'] }); void queryClient.invalidateQueries({ queryKey: ['namespaces'] }); window.setTimeout(() => setState('idle'), 1500); }, onError: (caught) => { setState('error'); if (caught instanceof ApiError && caught.isConflict) { setError( 'Modifiée entre-temps par quelqu\'un d\'autre. Rechargez avant de réécrire.', ); return; } setError(caught instanceof Error ? caught.message : 'Enregistrement impossible.'); }, }); function currentValue(): string | null { if (!plural) return simple.trim() === '' ? null : simple; const composed = composePlural( { prefix: decomposed.prefix, suffix: decomposed.suffix, variable: decomposed.variable, branches, }, selectors, ); return composed === '' ? null : composed; } function save() { const next = currentValue(); const unchanged = next === (value ?? null); if (unchanged || mutation.isPending) return; mutation.mutate(next); } function onKeyDown(event: React.KeyboardEvent) { // ⌘↵ enregistre et passe à la suivante : c'est le geste du mode Focus, // et il doit fonctionner à l'identique dans la grille. if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault(); save(); onRequestNext?.(); return; } if (event.key === 'Escape') { event.preventDefault(); setSimple(value ?? ''); setBranches(decomposePlural(value)?.branches ?? {}); (event.target as HTMLElement).blur(); } } const length = plural ? Math.max(0, ...Object.values(branches).map((b) => b.length)) : simple.length; const tooLong = maxLength !== null && length > maxLength; return (
{plural ? (
{selectors.map((selector) => (
setBranches((prev) => ({ ...prev, [selector]: e.target.value })) } onBlur={save} onKeyDown={onKeyDown} className="min-h-8 flex-1 resize-y rounded border border-ink-200 bg-white px-2 py-1 text-sm outline-none focus:border-accent" />
))}
) : (