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(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) => { 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 (
TQ-Slator
{/* La bascule précède le sélecteur de langue : elle décide de ce que les contrôles suivants veulent dire. */}
{( [ ['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]) => ( ))}
{/* 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' ? (
{sourceMeta?.code ?? '…'}
) : ( {sourceMeta?.code ?? '…'} {targetLocales.length} langue{targetLocales.length > 1 ? 's' : ''} )} 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 && (
{localeMeta.needsReview > 0 && ( ⚠ {localeMeta.needsReview} obsolète {localeMeta.needsReview > 1 ? 's' : ''} )} {localeMeta.completion}% traduit
)} {/* 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' ? ( ) : ( )}
{view === 'locale' && readOnly && (
Vous n'êtes pas habilité à écrire en {localeMeta?.nativeName}. Consultation uniquement.
)}
update({ namespace: path })} onSelectFilter={({ status: next, unassigned: on }) => update({ status: next || null, unassigned: on ? '1' : null }) } /> {view === 'locale' ? ( ) : ( )}
{focusOpen && (view === 'locale' ? ( setFocusOpen(false)} /> ) : ( setFocusOpen(false)} /> ))}
); } export type { GridRow };