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>
186 lines
8.4 KiB
TypeScript
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>
|
|
);
|
|
}
|