TQ-Slator/assets/editor/TranslationInput.tsx
Stephan Morand 9025c64c0b Socle complet de TQ-Slator : éditeur, API, CLI, administration
Système de gestion de traductions pensé en CMS headless : un back-office
pour ceux qui traduisent, une API pour ce qui consomme.

Architecture
- Symfony 7.4 / API Platform 4.3 / MariaDB 11.4, SPA React 19 servie en
  même origine — ce qui rend viable le cookie de session plutôt qu'un
  jeton en localStorage.
- Deux APIs séparées : Management (session ou clé) et Delivery
  (stateless, clé seule). Les fusionner ferait porter à chaque lecture de
  bundle le coût de la session.
- Stockage canonique en ICU MessageFormat, sérialisation par plateforme.
  Le format d'une plateforme ne contamine pas la base.
- Publication par releases immuables ; le déploiement est un déplacement
  de pointeur, donc le rollback aussi.
- Isolation multi-organisation par filtre Doctrine, avec un test
  d'architecture qui casse la CI si une entité échappe à l'invariant.

Éditeur, deux vues
- Par langue : source et cible, jamais douze colonnes. Grille virtualisée,
  saisie sans bouton « Enregistrer », panneau de contexte permanent.
- Par clé : une clé, toutes ses langues empilées et repliées. Répond à
  « ce libellé est-il prêt partout ? ».
- Mode Focus dans les deux : une file à vider, ⌘↵ pour enchaîner.
- Le traducteur ne voit jamais d'ICU : pastilles de variables, un champ
  par catégorie CLDR de la langue cible.

Administration
- Deux niveaux : projet (membres, clés API, plateformes) et organisation
  (annuaire des comptes, création de projets).
- Invitations par e-mail, jeton 256 bits stocké haché.
- Désactiver un compte coupe les sessions en cours, pas seulement les
  connexions suivantes.
- Les plateformes s'archivent ; ni elles ni les environnements ne se
  suppriment — la trace explique pourquoi telle clé existe.

CLI tqs
- PHAR autonome de 3 Mo, autoloader généré : le dépôt client ne dépend
  ni de Composer ni de la disponibilité de TQ-Slator.
- init / push / pull / status ; le sync est non destructif par défaut et
  son prune est scopé plateforme.

126 tests, PHPStan niveau 8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 08:16:05 +02:00

258 lines
9.5 KiB
TypeScript

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<Record<string, string>>(decomposed?.branches ?? {});
const [state, setState] = useState<SaveState>('idle');
const [error, setError] = useState<string | null>(null);
const [warnings, setWarnings] = useState<ValidationWarning[]>([]);
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 (
<div className="w-full">
{plural ? (
<div className="space-y-1">
{selectors.map((selector) => (
<div key={selector} className="flex items-start gap-2">
<span
className="mt-1.5 w-12 shrink-0 text-right font-mono text-[11px] text-ink-400"
title={
selector.startsWith('=')
? `Valeur exacte ${selector.slice(1)}`
: `Forme plurielle « ${selector} »`
}
>
{selector}
</span>
<textarea
rows={1}
dir={direction}
value={branches[selector] ?? ''}
onChange={(e) =>
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"
/>
</div>
))}
</div>
) : (
<textarea
rows={dense ? 1 : 3}
dir={direction}
autoFocus={autoFocus}
value={simple}
onChange={(e) => setSimple(e.target.value)}
onBlur={save}
onKeyDown={onKeyDown}
placeholder="Saisir la traduction…"
className={`w-full resize-y rounded border bg-white px-2 py-1 outline-none focus:border-accent ${
dense ? 'min-h-8 text-sm' : 'min-h-24 text-base'
} ${error !== null ? 'border-red-400' : 'border-ink-200'}`}
/>
)}
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]">
<SaveIndicator state={state} />
{maxLength !== null && (
<span className={`tabular ${tooLong ? 'font-medium text-amber-700' : 'text-ink-400'}`}>
{length} / {maxLength}
</span>
)}
{error !== null && (
<span role="alert" className="font-medium text-red-700">
{error}
</span>
)}
{warnings.map((warning) => (
<span key={warning.code} className="text-amber-700">
{warning.message}
</span>
))}
</div>
</div>
);
}
function SaveIndicator({ state }: { state: SaveState }) {
if (state === 'saving') return <span className="text-ink-400"> enregistrement</span>;
if (state === 'saved') return <span className="text-emerald-600"> enregistré</span>;
if (state === 'error') return <span className="text-red-600"> non enregistré</span>;
return null;
}
export { isPluralMessage };