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>
258 lines
9.5 KiB
TypeScript
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 };
|