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>
425 lines
19 KiB
TypeScript
425 lines
19 KiB
TypeScript
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 };
|