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>
200 lines
8.1 KiB
TypeScript
200 lines
8.1 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { api } from '@/api/client';
|
|
import type { GridRow } from '@/api/types';
|
|
import { SourceText } from '@/components/SourceText';
|
|
import { TranslationInput } from '@/editor/TranslationInput';
|
|
|
|
interface Props {
|
|
projectUuid: string;
|
|
locale: string;
|
|
direction: 'ltr' | 'rtl';
|
|
pluralCategories: string[];
|
|
platform?: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
/**
|
|
* Mode Focus — la fonctionnalité qui différencie l'outil.
|
|
*
|
|
* Une traductrice n'explore pas un catalogue : elle **vide une file**. Lui
|
|
* proposer une grille et la laisser choisir par où commencer, c'est lui
|
|
* transférer une décision qu'elle n'a pas envie de prendre quarante-sept fois.
|
|
*
|
|
* Ici : une clé, tout son contexte, et deux raccourcis. Pas de menu, pas de
|
|
* barre latérale, pas de souris. C'est ce qui fait passer « traduire 47 clés »
|
|
* de quarante-cinq minutes à douze.
|
|
*/
|
|
export function FocusMode({
|
|
projectUuid,
|
|
locale,
|
|
direction,
|
|
pluralCategories,
|
|
platform,
|
|
onClose,
|
|
}: Props) {
|
|
const [index, setIndex] = useState(0);
|
|
const [done, setDone] = useState<Set<string>>(new Set());
|
|
|
|
// La file est chargée UNE fois à l'ouverture. La rafraîchir à chaque
|
|
// enregistrement ferait disparaître la ligne courante sous les doigts —
|
|
// le pire défaut possible pour un mode de saisie en rafale.
|
|
const queue = useQuery({
|
|
queryKey: ['focus-queue', projectUuid, locale, platform],
|
|
queryFn: () =>
|
|
api.grid({
|
|
projectUuid,
|
|
locale,
|
|
platform,
|
|
status: 'untranslated',
|
|
itemsPerPage: 200,
|
|
}),
|
|
staleTime: Infinity,
|
|
refetchOnWindowFocus: false,
|
|
});
|
|
|
|
const rows = queue.data?.items ?? [];
|
|
const current: GridRow | undefined = rows[index];
|
|
|
|
useEffect(() => {
|
|
function onKey(event: KeyboardEvent) {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
onClose();
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', onKey);
|
|
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [onClose]);
|
|
|
|
function next() {
|
|
setIndex((value) => Math.min(value + 1, rows.length));
|
|
}
|
|
|
|
function previous() {
|
|
setIndex((value) => Math.max(value - 1, 0));
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex flex-col bg-white">
|
|
<header className="flex shrink-0 items-center gap-4 border-b border-ink-200 px-5 py-3">
|
|
<span className="tabular text-sm font-medium text-ink-700">
|
|
{Math.min(index + 1, rows.length)} / {rows.length}
|
|
</span>
|
|
|
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-ink-200">
|
|
<div
|
|
className="h-full bg-accent transition-[width] duration-200"
|
|
style={{
|
|
width: rows.length === 0 ? '0%' : `${(done.size / rows.length) * 100}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<span className="tabular text-xs text-ink-400">{done.size} traitée{done.size > 1 ? 's' : ''}</span>
|
|
|
|
<button
|
|
onClick={onClose}
|
|
className="rounded px-2 py-1 text-sm text-ink-500 hover:bg-ink-100"
|
|
>
|
|
Quitter <kbd className="ml-1 font-mono text-[11px]">Échap</kbd>
|
|
</button>
|
|
</header>
|
|
|
|
<div className="flex min-h-0 flex-1 items-start justify-center overflow-y-auto px-6 py-10">
|
|
{queue.isLoading && <p className="text-sm text-ink-400">Constitution de la file…</p>}
|
|
|
|
{!queue.isLoading && current === undefined && (
|
|
<div className="max-w-md text-center">
|
|
<p className="text-lg font-medium text-ink-800">File terminée.</p>
|
|
<p className="mt-2 text-sm text-ink-500">
|
|
{done.size > 0
|
|
? `${done.size} traduction${done.size > 1 ? 's' : ''} enregistrée${done.size > 1 ? 's' : ''}.`
|
|
: 'Aucune clé ne restait à traiter.'}
|
|
</p>
|
|
<button
|
|
onClick={onClose}
|
|
className="mt-6 rounded-md bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
|
|
>
|
|
Revenir à la grille
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{current !== undefined && (
|
|
<div className="w-full max-w-2xl">
|
|
<div className="flex items-center gap-2">
|
|
<p className="break-all font-mono text-xs text-ink-500">
|
|
{current.keyPath}
|
|
</p>
|
|
{current.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>
|
|
))}
|
|
</div>
|
|
|
|
{current.description !== null && current.description !== '' && (
|
|
<p className="mt-4 rounded-md bg-ink-50 px-3 py-2 text-sm leading-relaxed text-ink-600">
|
|
{current.description}
|
|
</p>
|
|
)}
|
|
|
|
<p className="mt-6 whitespace-pre-wrap text-lg leading-relaxed text-ink-900">
|
|
{current.sourceValue !== null && <SourceText value={current.sourceValue} />}
|
|
</p>
|
|
|
|
<div className="mt-6">
|
|
<TranslationInput
|
|
key={current.id}
|
|
keyUuid={current.id}
|
|
locale={locale}
|
|
value={current.targetValue}
|
|
version={current.version}
|
|
direction={direction}
|
|
pluralCategories={pluralCategories}
|
|
maxLength={current.maxLength}
|
|
autoFocus
|
|
onSaved={() => setDone((prev) => new Set(prev).add(current.id))}
|
|
onRequestNext={next}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-8 flex items-center justify-between text-xs text-ink-400">
|
|
<button
|
|
onClick={previous}
|
|
disabled={index === 0}
|
|
className="rounded px-2 py-1 hover:bg-ink-100 disabled:opacity-40"
|
|
>
|
|
← Précédente
|
|
</button>
|
|
|
|
<p className="flex items-center gap-4">
|
|
<span>
|
|
<kbd className="rounded bg-ink-100 px-1.5 py-0.5 font-mono">⌘↵</kbd>{' '}
|
|
enregistrer et suivante
|
|
</span>
|
|
<span>
|
|
<kbd className="rounded bg-ink-100 px-1.5 py-0.5 font-mono">Échap</kbd>{' '}
|
|
quitter
|
|
</span>
|
|
</p>
|
|
|
|
<button
|
|
onClick={next}
|
|
className="rounded px-2 py-1 hover:bg-ink-100"
|
|
>
|
|
Passer →
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|