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

304 lines
11 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { KeyRow, KeyRowTarget, LocaleStats } from '@/api/types';
import { SourceText } from '@/components/SourceText';
import { STATUS_META, StatusDot } from '@/components/Status';
import { TranslationInput } from '@/editor/TranslationInput';
interface Props {
rows: KeyRow[];
total: number;
loading: boolean;
/** Langues cibles du projet, avec l'habilitation d'écriture de l'utilisateur. */
locales: LocaleStats[];
sourceCode: string;
selectedId: string | null;
onSelect: (id: string) => void;
}
/**
* La vue par clé : une clé, toutes ses langues.
*
* Elle ne remplace pas la grille par langue, elle répond à une autre question.
* La grille sert la traductrice qui vide sa file dans UNE langue ; celle-ci
* sert qui doit juger d'une clé — « ce libellé est-il prêt partout ? » — avant
* une mise en production, ou juste après avoir ajouté une clé.
*
* **Les langues sont repliées par défaut.** Sept champs de saisie ouverts par
* clé produiraient un mur illisible et une page de vingt mille pixels ; la
* bande de statuts donne la réponse d'un coup d'œil, et l'on ouvre la seule
* langue sur laquelle on veut agir. C'est aussi ce qui garde la virtualisation
* honnête : les hauteurs restent proches de l'estimation.
*/
export function KeyGrid({
rows,
total,
loading,
locales,
sourceCode,
selectedId,
onSelect,
}: Props) {
const parentRef = useRef<HTMLDivElement>(null);
// Même règle que dans la grille par langue : un panneau de contexte vide au
// moment où l'utilisateur découvre l'écran est une colonne perdue.
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,
estimateSize: () => 120,
overscan: 6,
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">
En vue par clé, le filtre de statut retient les clés dont{' '}
<strong>au moins une</strong> langue est dans cet état.
</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="text-[11px] text-ink-400">
{locales.length} langue{locales.length > 1 ? 's' : ''} cible
{locales.length > 1 ? 's' : ''}
</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;
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)` }}
>
<KeyCard
row={row}
locales={locales}
sourceCode={sourceCode}
selected={row.id === selectedId}
onSelect={() => onSelect(row.id)}
/>
</div>
);
})}
</div>
</div>
</div>
);
}
function KeyCard({
row,
locales,
sourceCode,
selected,
onSelect,
}: {
row: KeyRow;
locales: LocaleStats[];
sourceCode: string;
selected: boolean;
onSelect: () => void;
}) {
const [openLocale, setOpenLocale] = useState<string | null>(null);
const byCode = new Map(row.targets.map((target) => [target.locale, target]));
return (
<div
onClick={onSelect}
className={`border-b border-ink-100 px-4 py-3 transition-colors ${
selected ? 'bg-accent-soft/30' : 'bg-white'
}`}
>
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs text-ink-700">{row.keyPath}</span>
<span className="ml-auto flex shrink-0 items-center 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
className={`tabular ml-1 rounded px-1.5 py-0.5 text-[11px] font-medium ${
row.completion === 100
? 'bg-emerald-100 text-emerald-800'
: 'bg-ink-100 text-ink-600'
}`}
title="Part des langues cibles dans un état publiable."
>
{row.completion}%
</span>
</span>
</div>
<p className="mt-1.5 flex gap-2 text-sm leading-snug">
<span className="mt-0.5 shrink-0 font-mono text-[11px] text-ink-400">
{sourceCode}
</span>
<span className="whitespace-pre-wrap text-ink-600">
{row.sourceValue === null ? (
<em className="text-ink-300">source absente</em>
) : (
<SourceText value={row.sourceValue} />
)}
</span>
</p>
{/* La bande de langues : une ligne par langue, refermée. Elle répond
à « où en est cette clé ? » sans rien ouvrir. */}
<div className="mt-2 divide-y divide-ink-50 rounded border border-ink-100">
{locales.map((locale) => {
const target = byCode.get(locale.code);
const open = openLocale === locale.code;
return (
<LocaleLine
key={locale.code}
keyUuid={row.id}
locale={locale}
target={target}
maxLength={row.maxLength}
open={open}
onToggle={() => setOpenLocale(open ? null : locale.code)}
/>
);
})}
</div>
</div>
);
}
function LocaleLine({
keyUuid,
locale,
target,
maxLength,
open,
onToggle,
}: {
keyUuid: string;
locale: LocaleStats;
target: KeyRowTarget | undefined;
maxLength: number | null;
open: boolean;
onToggle: () => void;
}) {
const status = target?.status ?? 'untranslated';
const value = target?.value ?? null;
const meta = STATUS_META[status];
return (
<div className={open ? 'bg-ink-50' : ''}>
<button
type="button"
onClick={onToggle}
className="flex w-full items-center gap-2 px-2 py-1 text-left transition-colors hover:bg-ink-50"
>
<span className="w-12 shrink-0 font-mono text-[11px] text-ink-500">
{locale.code}
</span>
<StatusDot status={status} />
<span
className={`min-w-0 flex-1 truncate text-sm ${
value === null ? 'text-ink-300' : 'text-ink-800'
}`}
dir={locale.direction}
>
{value ?? meta.label}
</span>
{target?.isStale === true && (
<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>
)}
{/* Dit AVANT d'ouvrir le champ. Découvrir qu'on n'a pas le droit
d'écrire après avoir tapé sa traduction est le pire moment. */}
{!locale.canWrite && (
<span className="shrink-0 text-[10px] text-ink-400">consultation</span>
)}
<span className="shrink-0 text-[10px] text-ink-300">{open ? '▾' : '▸'}</span>
</button>
{open && (
<div className="px-2 pb-2 pl-16">
{locale.canWrite ? (
<TranslationInput
keyUuid={keyUuid}
locale={locale.code}
value={value}
version={target?.version ?? 0}
direction={locale.direction}
pluralCategories={locale.pluralCategories}
maxLength={maxLength}
autoFocus
dense
/>
) : (
<p className="rounded border border-dashed border-ink-200 px-2 py-1 text-sm text-ink-400">
{value ?? '—'}
</p>
)}
{target?.updatedBy != null && (
<p className="mt-1 text-[11px] text-ink-400">
{target.updatedBy}
{target.updatedAt !== null &&
` · ${new Date(target.updatedAt).toLocaleDateString('fr-FR')}`}
{target.isMachineTranslated && ' · traduction automatique'}
</p>
)}
</div>
)}
</div>
);
}