import { useCallback, useEffect, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { api } from '@/api/client'; import type { KeyRow, LocaleStats, TranslationStatus } from '@/api/types'; import { SourceText } from '@/components/SourceText'; import { StatusDot } from '@/components/Status'; import { TranslationInput } from '@/editor/TranslationInput'; interface Props { projectUuid: string; /** Langues cibles du projet, avec l'habilitation d'écriture de l'utilisateur. */ locales: LocaleStats[]; platform?: string; namespace?: string; onClose: () => void; } const ACTIONABLE: TranslationStatus[] = ['untranslated', 'draft', 'needs_review']; /** * Mode Focus toutes langues confondues. * * Le pendant du mode Focus par langue, et non son remplaçant. Celui-ci enchaîne * les clés d'UNE langue ; celui-là enchaîne les clés en présentant d'un coup * **toutes les langues sur lesquelles l'utilisateur a quelque chose à faire**. * * Le gain est précis, et c'est le seul qui justifie l'écran : la source et son * contexte se lisent UNE fois pour plusieurs langues. María, habilitée en * espagnol et en portugais, rencontrait jusqu'ici deux fois la même clé dans * deux files séparées, et relisait deux fois « Annuler la séance » avant * d'écrire deux phrases voisines. Ici elle la lit une fois. * * Trois conséquences de conception : * * 1. **La file ne contient que du faisable.** Une clé qui ne manque qu'en * allemand n'entre pas dans la file d'une traductrice espagnole : elle la * traverserait sans rien pouvoir y faire. Le filtrage est serveur, à partir * de l'identité courante. * 2. **Les langues déjà faites restent visibles, en lecture.** Une traduction * voisine déjà écrite est la meilleure matière première qui soit — souvent * meilleure que la source elle-même pour trancher un registre. * 3. **⌘↵ descend d'un champ, puis passe à la clé suivante.** Le geste reste * celui du mode Focus par langue ; il traverse simplement plusieurs champs * avant de changer de clé. */ export function KeyFocusMode({ projectUuid, locales, platform, namespace, onClose }: Props) { const [index, setIndex] = useState(0); const [done, setDone] = useState>(new Set()); const fieldRefs = useRef<(HTMLDivElement | null)[]>([]); // La file est constituée UNE fois à l'ouverture. La rafraîchir à chaque // enregistrement ferait disparaître la clé courante sous les doigts — le // pire défaut possible pour un mode de saisie en rafale. const queue = useQuery({ queryKey: ['key-focus-queue', projectUuid, platform, namespace], queryFn: () => api.keys({ projectUuid, platform, namespace, focus: true, itemsPerPage: 200, }), staleTime: Infinity, refetchOnWindowFocus: false, }); const rows = queue.data?.items ?? []; const current: KeyRow | 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]); const next = useCallback(() => { fieldRefs.current = []; setIndex((value) => Math.min(value + 1, rows.length)); }, [rows.length]); function previous() { fieldRefs.current = []; setIndex((value) => Math.max(value - 1, 0)); } // Les langues à traiter sur CETTE clé : écrivables et pas encore faites. const writable = locales.filter((locale) => locale.canWrite); const byCode = new Map((current?.targets ?? []).map((target) => [target.locale, target])); const todo = writable.filter((locale) => { const status = byCode.get(locale.code)?.status ?? 'untranslated'; return ACTIONABLE.includes(status); }); const reference = (current?.targets ?? []).filter( (target) => !todo.some((locale) => locale.code === target.locale) && target.value !== null, ); /** ⌘↵ : champ suivant, ou clé suivante si c'était le dernier. */ function advanceFrom(position: number) { const nextField = fieldRefs.current[position + 1]; if (nextField != null) { nextField.querySelector('textarea')?.focus(); return; } next(); } return (
{Math.min(index + 1, rows.length)} / {rows.length}
{done.size} traitée{done.size > 1 ? 's' : ''} {writable.map((l) => l.code).join(' · ')}
{queue.isLoading &&

Constitution de la file…

} {!queue.isLoading && writable.length === 0 && (

Aucune langue à traiter.

Vous n'êtes habilité à écrire dans aucune langue de ce projet. Le mode Focus n'a rien à vous proposer ; la consultation reste ouverte.

)} {!queue.isLoading && writable.length > 0 && current === undefined && (

File terminée.

{done.size > 0 ? `${done.size} clé${done.size > 1 ? 's' : ''} traitée${done.size > 1 ? 's' : ''}.` : 'Aucune clé ne restait à traiter dans vos langues.'}

)} {current !== undefined && writable.length > 0 && (

{current.keyPath}

{current.platforms.map((slug) => ( {slug} ))} {current.completion}%
{current.description !== null && current.description !== '' && (

{current.description}

)}

{current.sourceValue !== null && }

{/* Un champ par langue restant à faire. La clé de React inclut l'identifiant de la clé de traduction : sans cela, passer à la clé suivante réutiliserait les composants et garderait le texte précédent à l'écran. */}
{todo.map((locale, position) => { const target = byCode.get(locale.code); return (
{ fieldRefs.current[position] = element; }} >
{locale.code} {locale.nativeName} {target?.isStale === true && ( source modifiée )}
setDone((prev) => new Set(prev).add(current.id)) } onRequestNext={() => advanceFrom(position)} />
); })}
{/* Les langues déjà faites, en lecture. Une traduction voisine tranche souvent mieux un registre que la source elle-même. */} {reference.length > 0 && (

Déjà traduites

    {reference.map((target) => (
  • {target.locale} {target.value}
  • ))}
)}

⌘↵{' '} enregistrer et descendre Échap{' '} quitter

)}
); }