TQ-Slator/assets/pages/EditorPage.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

425 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useParams, useSearchParams } from 'react-router-dom';
import { api } from '@/api/client';
import type { GridRow, TranslationStatus } from '@/api/types';
import { NamespaceTree } from '@/editor/NamespaceTree';
import { TranslationGrid } from '@/editor/TranslationGrid';
import { KeyGrid } from '@/editor/KeyGrid';
import { ContextPanel } from '@/editor/ContextPanel';
import { FocusMode } from '@/editor/FocusMode';
import { KeyFocusMode } from '@/editor/KeyFocusMode';
const PAGE_SIZE = 200;
// Plus court en vue par clé : chaque ligne y porte autant d'entrées qu'il y a
// de langues. Charger 200 clés × 7 langues pour un écran qui en montre dix
// serait payer sept fois le prix d'un défilement qu'on ne fera pas.
const KEY_PAGE_SIZE = 50;
/**
* L'écran, et ses deux façons de regarder le même contenu.
*
* **Par langue** (par défaut) — deux langues à l'écran, source et cible, jamais
* douze. Personne ne traduit vers douze langues simultanément, et une grille à
* douze colonnes est illisible. Le sélecteur « source → cible » est ici l'objet
* de navigation principal.
*
* **Par clé** — une clé, toutes ses langues, repliées. La question n'est plus
* « où en est mon travail en espagnol ? » mais « ce libellé est-il prêt
* partout ? ». Elle se pose avant une mise en production, et juste après avoir
* ajouté une clé.
*
* Le principe des douze colonnes n'est pas abandonné, il est respecté
* autrement : la vue par clé empile les langues au lieu de les juxtaposer, et
* les garde repliées. Sept lignes de vingt pixels se lisent ; sept colonnes ne
* se lisent pas.
*
* Tout l'état de filtrage vit dans l'URL, y compris le choix de vue. Ce n'est
* pas une commodité technique : c'est ce qui permet à une responsable de
* localisation d'envoyer un lien dans Slack et à la traductrice d'atterrir
* exactement sur le travail concerné, dans la vue qui convient.
*/
export function EditorPage() {
const { projectUuid = '' } = useParams();
const [params, setParams] = useSearchParams();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [focusOpen, setFocusOpen] = useState(false);
const locale = params.get('locale') ?? '';
const platform = params.get('platform') ?? '';
const status = (params.get('status') ?? '') as TranslationStatus | '';
const namespace = params.get('namespace') ?? '';
const search = params.get('q') ?? '';
const unassigned = params.get('unassigned') === '1';
// Deux façons de regarder le même contenu, et deux publics :
//
// - `locale` (par défaut) — deux langues à l'écran, source et cible. La
// vue de la traductrice qui vide sa file dans UNE langue.
// - `key` — une clé, toutes ses langues. La vue de qui doit juger d'un
// libellé avant une mise en production, ou du développeur qui vient
// d'ajouter une clé et veut savoir où elle en est.
//
// Le choix vit dans l'URL comme le reste des filtres : un lien partagé
// ouvre la bonne vue sur le bon travail.
const view = params.get('view') === 'key' ? 'key' : 'locale';
const stats = useQuery({
queryKey: ['stats', projectUuid],
queryFn: () => api.stats(projectUuid),
});
// Sans langue explicite, on prend la première cible : arriver sur un écran
// vide en demandant de choisir est une étape de plus pour rien.
const effectiveLocale =
locale || stats.data?.locales.find((l) => !l.isSource)?.code || stats.data?.sourceLocale || '';
const localeMeta = stats.data?.locales.find((l) => l.code === effectiveLocale);
const sourceMeta = stats.data?.locales.find((l) => l.isSource);
const targetLocales = useMemo(
() => (stats.data?.locales ?? []).filter((l) => !l.isSource),
[stats.data],
);
// Les langues que l'utilisateur peut réellement écrire. Le serveur le dit
// dans /stats ; on ne le redéduit pas ici du rôle, ce serait une seconde
// implémentation de la même règle d'autorisation.
const writableLocales = useMemo(
() => targetLocales.filter((l) => l.canWrite),
[targetLocales],
);
// « * » = toutes langues. Sans cela l'arbre compterait le travail restant en
// espagnol au-dessus d'une liste qui montre les sept langues : deux chiffres
// qui ne parlent pas de la même chose, côte à côte.
const countingLocale = view === 'key' ? '*' : effectiveLocale;
const namespaces = useQuery({
queryKey: ['namespaces', projectUuid, countingLocale],
queryFn: () => api.namespaces(projectUuid, countingLocale),
enabled: countingLocale !== '',
});
const gridQuery = useMemo(
() => ({
projectUuid,
locale: effectiveLocale,
platform: platform || undefined,
status: status || undefined,
namespace: namespace || undefined,
q: search || undefined,
unassigned: unassigned || undefined,
itemsPerPage: PAGE_SIZE,
}),
[projectUuid, effectiveLocale, platform, status, namespace, search, unassigned],
);
const grid = useQuery({
queryKey: ['grid', gridQuery],
queryFn: () => api.grid(gridQuery),
enabled: view === 'locale' && effectiveLocale !== '',
placeholderData: (previous) => previous,
});
const keyQuery = useMemo(
() => ({
projectUuid,
platform: platform || undefined,
status: status || undefined,
namespace: namespace || undefined,
q: search || undefined,
unassigned: unassigned || undefined,
itemsPerPage: KEY_PAGE_SIZE,
}),
[projectUuid, platform, status, namespace, search, unassigned],
);
const keyRows = useQuery({
queryKey: ['keys', keyQuery],
queryFn: () => api.keys(keyQuery),
enabled: view === 'key',
placeholderData: (previous) => previous,
});
/**
* Modification atomique de l'URL.
*
* Prend un LOT de paramètres, et non un seul, parce que deux appels
* successifs dans le même gestionnaire d'événement s'écrasent : la fonction
* de mise à jour reçoit les paramètres du rendu courant, pas ceux de la
* navigation encore en attente. Le second appel repart donc de l'état
* d'avant le premier et gagne.
*
* C'est ce qui rendait muets tous les filtres de la colonne de gauche : ils
* touchent `status` et `unassigned` ensemble.
*/
const update = useCallback(
(patch: Record<string, string | null>) => {
setParams(
(current) => {
const next = new URLSearchParams(current);
for (const [key, value] of Object.entries(patch)) {
if (value === null || value === '') next.delete(key);
else next.set(key, value);
}
return next;
},
{ replace: true },
);
},
[setParams],
);
const rows = grid.data?.items ?? [];
const selected = rows.find((row) => row.id === selectedId) ?? null;
const selectedKey = keyRows.data?.items.find((row) => row.id === selectedId) ?? null;
const total = (view === 'locale' ? grid.data?.total : keyRows.data?.total) ?? 0;
// Le panneau de contexte ne parle que de la CLÉ ; les deux vues lui
// fournissent donc la même chose, chacune extraite de son propre DTO.
const context = view === 'locale' ? selected : selectedKey;
const actionable = localeMeta
? localeMeta.untranslated + localeMeta.draft + localeMeta.needsReview
: 0;
// Lecture seule : la langue existe et se consulte, mais l'utilisateur n'y a
// pas d'habilitation d'écriture. Le dire AVANT la saisie évite le pire
// enchaînement possible — traduire, puis se voir refuser l'enregistrement.
const readOnly = localeMeta !== undefined && !localeMeta.canWrite;
return (
<div className="flex h-full flex-col bg-ink-100">
<header className="flex shrink-0 items-center gap-4 border-b border-ink-200 bg-white px-4 py-2.5">
<Link
to="/projects"
className="shrink-0 text-sm font-semibold text-ink-900 hover:text-accent"
>
TQ-Slator
</Link>
<div className="h-5 w-px bg-ink-200" />
{/* La bascule précède le sélecteur de langue : elle décide de ce
que les contrôles suivants veulent dire. */}
<div className="flex shrink-0 rounded-md bg-ink-100 p-0.5">
{(
[
['locale', 'Par langue', 'Deux langues à l\'écran : la source et une cible.'],
['key', 'Par clé', 'Une clé, toutes ses langues.'],
] as const
).map(([value, label, hint]) => (
<button
key={value}
onClick={() => update({ view: value === 'locale' ? null : value })}
title={hint}
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${
view === value
? 'bg-white text-ink-900 shadow-sm'
: 'text-ink-500 hover:text-ink-800'
}`}
>
{label}
</button>
))}
</div>
{/* Le sélecteur de langue est l'objet de navigation principal de
la vue par langue — et n'a aucun sens dans l'autre, où toutes
les langues sont là. Le laisser affiché grisé suggérerait
qu'il reste quelque chose à y choisir. */}
{view === 'locale' ? (
<div className="flex items-center gap-2 text-sm">
<span className="rounded bg-ink-100 px-2 py-1 font-mono text-xs text-ink-500">
{sourceMeta?.code ?? '…'}
</span>
<span className="text-ink-300"></span>
<select
value={effectiveLocale}
onChange={(e) => update({ locale: e.target.value })}
className="rounded border border-ink-300 bg-white px-2 py-1 text-sm font-medium outline-none focus:border-accent"
>
{stats.data?.locales
.filter((l) => !l.isSource)
.map((l) => (
<option key={l.code} value={l.code}>
{l.nativeName}
{!l.canWrite ? ' — lecture seule' : ''}
</option>
))}
</select>
</div>
) : (
<span className="shrink-0 text-sm text-ink-500">
<span className="rounded bg-ink-100 px-2 py-1 font-mono text-xs">
{sourceMeta?.code ?? '…'}
</span>
<span className="ml-2 text-ink-300"></span>
<span className="ml-2 text-xs">
{targetLocales.length} langue{targetLocales.length > 1 ? 's' : ''}
</span>
</span>
)}
<select
value={platform}
onChange={(e) => update({ platform: e.target.value })}
className="rounded border border-ink-300 bg-white px-2 py-1 text-sm outline-none focus:border-accent"
>
<option value="">Toutes plateformes</option>
{stats.data?.platforms.map((p) => (
<option key={p.slug} value={p.slug}>
{p.name}
</option>
))}
</select>
<input
type="search"
value={search}
onChange={(e) => update({ q: e.target.value })}
placeholder="Rechercher une clé ou un texte…"
className="min-w-0 flex-1 rounded border border-ink-300 px-2.5 py-1 text-sm outline-none focus:border-accent"
/>
{view === 'locale' && localeMeta && (
<div className="flex shrink-0 items-center gap-3 text-xs">
{localeMeta.needsReview > 0 && (
<span className="tabular font-medium text-red-600">
{localeMeta.needsReview} obsolète
{localeMeta.needsReview > 1 ? 's' : ''}
</span>
)}
<span className="tabular text-ink-500">
{localeMeta.completion}% traduit
</span>
</div>
)}
{/* Le bouton principal n'est pas « enregistrer » mais « traiter la
file » : un traducteur ne vient pas explorer, il vient vider
une liste. Chaque vue a la sienne — celle d'une langue, ou
celle de toutes les langues qu'on a le droit d'écrire. */}
{view === 'locale' ? (
<button
onClick={() => setFocusOpen(true)}
disabled={actionable === 0 || readOnly}
className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700 disabled:bg-ink-300"
>
{readOnly
? 'Lecture seule'
: actionable > 0
? `Traiter les ${actionable}`
: 'Rien à traiter'}
</button>
) : (
<button
onClick={() => setFocusOpen(true)}
disabled={writableLocales.length === 0}
title={
writableLocales.length === 0
? 'Vous n\'êtes habilité à écrire dans aucune langue de ce projet.'
: `Enchaîner les clés à traiter en ${writableLocales.map((l) => l.code).join(', ')}.`
}
className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700 disabled:bg-ink-300"
>
{writableLocales.length === 0
? 'Lecture seule'
: `Traiter (${writableLocales.length} langue${writableLocales.length > 1 ? 's' : ''})`}
</button>
)}
</header>
{view === 'locale' && readOnly && (
<div className="shrink-0 border-b border-amber-200 bg-amber-50 px-4 py-1.5 text-xs text-amber-900">
Vous n'êtes pas habilité à écrire en {localeMeta?.nativeName}. Consultation
uniquement.
</div>
)}
<div className="flex min-h-0 flex-1">
<NamespaceTree
tree={namespaces.data}
selected={namespace}
status={status}
unassigned={unassigned}
total={total}
onSelectNamespace={(path) => update({ namespace: path })}
onSelectFilter={({ status: next, unassigned: on }) =>
update({ status: next || null, unassigned: on ? '1' : null })
}
/>
{view === 'locale' ? (
<TranslationGrid
rows={rows}
total={total}
loading={grid.isLoading}
locale={effectiveLocale}
direction={localeMeta?.direction ?? 'ltr'}
pluralCategories={localeMeta?.pluralCategories ?? ['other']}
selectedId={selectedId}
onSelect={setSelectedId}
readOnly={readOnly}
gridKey={gridQuery}
/>
) : (
<KeyGrid
rows={keyRows.data?.items ?? []}
total={total}
loading={keyRows.isLoading}
locales={targetLocales}
sourceCode={sourceMeta?.code ?? ''}
selectedId={selectedId}
onSelect={setSelectedId}
/>
)}
<ContextPanel
row={context}
sourceCode={sourceMeta?.code ?? ''}
target={
view === 'locale' && selected !== null
? {
localeCode: effectiveLocale,
status: selected.status,
isStale: selected.isStale,
isMachineTranslated: selected.isMachineTranslated,
updatedAt: selected.updatedAt,
updatedBy: selected.updatedBy,
}
: null
}
targets={view === 'key' ? (selectedKey?.targets ?? null) : null}
/>
</div>
{focusOpen &&
(view === 'locale' ? (
<FocusMode
projectUuid={projectUuid}
locale={effectiveLocale}
direction={localeMeta?.direction ?? 'ltr'}
pluralCategories={localeMeta?.pluralCategories ?? ['other']}
platform={platform || undefined}
onClose={() => setFocusOpen(false)}
/>
) : (
<KeyFocusMode
projectUuid={projectUuid}
locales={targetLocales}
platform={platform || undefined}
namespace={namespace || undefined}
onClose={() => setFocusOpen(false)}
/>
))}
</div>
);
}
export type { GridRow };