import { useEffect, useRef, useState } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import type { KeyRow, KeyRowTarget, LocaleStats } from '@/api/types'; import { SourceText } from '@/components/SourceText'; import { STATUS_META, StatusDot } from '@/components/Status'; import { TranslationInput } from '@/editor/TranslationInput'; interface Props { rows: KeyRow[]; total: number; loading: boolean; /** Langues cibles du projet, avec l'habilitation d'écriture de l'utilisateur. */ locales: LocaleStats[]; sourceCode: string; selectedId: string | null; onSelect: (id: string) => void; } /** * La vue par clé : une clé, toutes ses langues. * * Elle ne remplace pas la grille par langue, elle répond à une autre question. * La grille sert la traductrice qui vide sa file dans UNE langue ; celle-ci * sert qui doit juger d'une clé — « ce libellé est-il prêt partout ? » — avant * une mise en production, ou juste après avoir ajouté une clé. * * **Les langues sont repliées par défaut.** Sept champs de saisie ouverts par * clé produiraient un mur illisible et une page de vingt mille pixels ; la * bande de statuts donne la réponse d'un coup d'œil, et l'on ouvre la seule * langue sur laquelle on veut agir. C'est aussi ce qui garde la virtualisation * honnête : les hauteurs restent proches de l'estimation. */ export function KeyGrid({ rows, total, loading, locales, sourceCode, selectedId, onSelect, }: Props) { const parentRef = useRef(null); // Même règle que dans la grille par langue : un panneau de contexte vide au // moment où l'utilisateur découvre l'écran est une colonne perdue. const first = rows[0]?.id; useEffect(() => { if (selectedId === null && first !== undefined) onSelect(first); }, [first, selectedId, onSelect]); const virtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => parentRef.current, estimateSize: () => 120, overscan: 6, getItemKey: (index) => rows[index]?.id ?? index, }); if (loading && rows.length === 0) { return (
Chargement des clés…
); } if (rows.length === 0) { return (

Aucune clé ne correspond.

En vue par clé, le filtre de statut retient les clés dont{' '} au moins une langue est dans cet état.

); } return (
{rows.length < total ? `${rows.length} sur ${total}` : `${total}`} clé {total > 1 ? 's' : ''} {locales.length} langue{locales.length > 1 ? 's' : ''} cible {locales.length > 1 ? 's' : ''}
{virtualizer.getVirtualItems().map((item) => { const row = rows[item.index]; if (!row) return null; return (
onSelect(row.id)} />
); })}
); } function KeyCard({ row, locales, sourceCode, selected, onSelect, }: { row: KeyRow; locales: LocaleStats[]; sourceCode: string; selected: boolean; onSelect: () => void; }) { const [openLocale, setOpenLocale] = useState(null); const byCode = new Map(row.targets.map((target) => [target.locale, target])); return (
{row.keyPath} {row.platforms.map((slug) => ( {slug} ))} {row.platforms.length === 0 && ( non assignée )} {row.completion}%

{sourceCode} {row.sourceValue === null ? ( source absente ) : ( )}

{/* La bande de langues : une ligne par langue, refermée. Elle répond à « où en est cette clé ? » sans rien ouvrir. */}
{locales.map((locale) => { const target = byCode.get(locale.code); const open = openLocale === locale.code; return ( setOpenLocale(open ? null : locale.code)} /> ); })}
); } function LocaleLine({ keyUuid, locale, target, maxLength, open, onToggle, }: { keyUuid: string; locale: LocaleStats; target: KeyRowTarget | undefined; maxLength: number | null; open: boolean; onToggle: () => void; }) { const status = target?.status ?? 'untranslated'; const value = target?.value ?? null; const meta = STATUS_META[status]; return (
{open && (
{locale.canWrite ? ( ) : (

{value ?? '—'}

)} {target?.updatedBy != null && (

{target.updatedBy} {target.updatedAt !== null && ` · ${new Date(target.updatedAt).toLocaleDateString('fr-FR')}`} {target.isMachineTranslated && ' · traduction automatique'}

)}
)}
); }