TQ-Slator/assets/editor/TranslationGrid.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

186 lines
8.4 KiB
TypeScript

import { useEffect, useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { GridRow } from '@/api/types';
import { SourceText } from '@/components/SourceText';
import { StatusDot } from '@/components/Status';
import { TranslationInput } from '@/editor/TranslationInput';
interface Props {
rows: GridRow[];
total: number;
loading: boolean;
locale: string;
direction: 'ltr' | 'rtl';
pluralCategories: string[];
selectedId: string | null;
onSelect: (id: string) => void;
readOnly: boolean;
gridKey: unknown;
}
/**
* La grille de traduction.
*
* **Virtualisée** : un projet mûr compte des dizaines de milliers de clés, et
* une liste non virtualisée effondre le navigateur bien avant. Seules les lignes
* visibles existent dans le DOM.
*
* **Source et cible empilées, pas côte à côte.** Les colonnes obligent à couper
* le texte long ; l'empilement laisse respirer les deux valeurs et fonctionne
* sur un écran d'ordinateur portable, qui est la réalité d'une traductrice en
* déplacement.
*/
export function TranslationGrid({
rows,
total,
loading,
locale,
direction,
pluralCategories,
selectedId,
onSelect,
readOnly,
}: Props) {
const parentRef = useRef<HTMLDivElement>(null);
// Le panneau de contexte est inutile tant que rien n'est sélectionné, et
// demander un clic pour l'activer, c'est afficher une colonne vide au
// moment précis où l'utilisateur découvre l'écran.
const first = rows[0]?.id;
useEffect(() => {
if (selectedId === null && first !== undefined) onSelect(first);
}, [first, selectedId, onSelect]);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
// Estimation volontairement basse : `measureElement` corrige au rendu.
// Surestimer produirait des sauts de défilement bien plus gênants.
estimateSize: () => 132,
overscan: 8,
getItemKey: (index) => rows[index]?.id ?? index,
});
if (loading && rows.length === 0) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-ink-400">
Chargement des clés
</div>
);
}
if (rows.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-1 px-6 text-center">
<p className="text-sm font-medium text-ink-700">Aucune clé ne correspond.</p>
<p className="text-xs text-ink-400">
Élargissez les filtres, ou vérifiez que la plateforme sélectionnée porte bien
des clés.
</p>
</div>
);
}
return (
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex shrink-0 items-baseline justify-between border-b border-ink-200 bg-white px-4 py-1.5">
<span className="tabular text-xs text-ink-500">
{rows.length < total ? `${rows.length} sur ${total}` : `${total}`} clé
{total > 1 ? 's' : ''}
</span>
<span className="font-mono text-[11px] text-ink-400">{locale}</span>
</div>
<div ref={parentRef} className="min-h-0 flex-1 overflow-y-auto">
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((item) => {
const row = rows[item.index];
if (!row) return null;
const selected = row.id === selectedId;
return (
<div
key={item.key}
ref={virtualizer.measureElement}
data-index={item.index}
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${item.start}px)` }}
>
<div
onFocusCapture={() => onSelect(row.id)}
onClick={() => onSelect(row.id)}
className={`border-b border-ink-100 px-4 py-3 transition-colors ${
selected ? 'bg-accent-soft/40' : 'bg-white hover:bg-ink-50'
}`}
>
<div className="flex items-center gap-2">
<StatusDot status={row.status} />
<span className="truncate font-mono text-xs text-ink-700">
{row.keyPath}
</span>
{/* La source modifiée est LE signal qui compte :
il désigne un texte peut-être faux en production. */}
{row.isStale && (
<span className="shrink-0 rounded bg-red-50 px-1.5 py-0.5 text-[10px] font-medium text-red-700">
source modifiée
</span>
)}
<span className="ml-auto flex shrink-0 gap-1">
{row.platforms.map((slug) => (
<span
key={slug}
className="rounded bg-ink-100 px-1 py-0.5 font-mono text-[10px] text-ink-500"
>
{slug}
</span>
))}
{row.platforms.length === 0 && (
<span
className="rounded bg-amber-100 px-1 py-0.5 text-[10px] text-amber-800"
title="Rattachée à aucune plateforme : n'entre dans aucun fichier livré."
>
non assignée
</span>
)}
</span>
</div>
<p className="mt-1.5 whitespace-pre-wrap text-sm leading-snug text-ink-500">
{row.sourceValue === null ? (
<em className="text-ink-300">source absente</em>
) : (
<SourceText value={row.sourceValue} />
)}
</p>
<div className="mt-2">
{readOnly ? (
<p className="rounded border border-dashed border-ink-200 px-2 py-1 text-sm text-ink-400">
{row.targetValue ?? '—'}
</p>
) : (
<TranslationInput
keyUuid={row.id}
locale={locale}
value={row.targetValue}
version={row.version}
direction={direction}
pluralCategories={pluralCategories}
maxLength={row.maxLength}
dense
/>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}