TQ-Slator/assets/pages/ProjectsPage.tsx
Stephan Morand 9025c64c0b Socle complet de TQ-Slator : éditeur, API, CLI, administration
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>
2026-08-21 08:16:05 +02:00

230 lines
11 KiB
TypeScript

import { useQueries, useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { api } from '@/api/client';
import type { LocaleStats } from '@/api/types';
import { useAuth } from '@/auth/AuthProvider';
import { ProgressBar } from '@/components/Status';
/**
* Point d'entrée : les projets, et où en est chaque langue.
*
* Ce n'est délibérément PAS un tableau de bord. Un tableau de bord générique
* affiche des chiffres que personne n'a demandés ; cet écran répond à la seule
* question qu'on se pose en arrivant — « où reste-t-il du travail, et pour quelle
* langue ? » — puis s'efface.
*/
export function ProjectsPage() {
const { user, logout } = useAuth();
const projects = useQuery({ queryKey: ['projects'], queryFn: api.projects });
const stats = useQueries({
queries: (projects.data ?? []).map((project) => ({
queryKey: ['stats', project.uuid],
queryFn: () => api.stats(project.uuid),
})),
});
return (
<div className="mx-auto max-w-5xl px-6 py-10">
<header className="mb-10 flex items-baseline justify-between">
<div>
<h1 className="text-xl font-semibold tracking-tight text-ink-900">Projets</h1>
<p className="mt-0.5 text-sm text-ink-500">
{user?.name}
{user?.isExternal && (
<span className="ml-2 rounded bg-ink-200 px-1.5 py-0.5 text-[11px] font-medium text-ink-600">
prestataire externe
</span>
)}
</p>
</div>
<div className="flex items-center gap-4">
{/* Absente pour tous les autres, pas grisée : un menu plein
d'options inaccessibles apprend à l'utilisateur que
l'interface ne le concerne pas. */}
{user?.isSuperAdmin && (
<Link
to="/admin"
className="text-sm text-ink-500 underline-offset-4 hover:text-ink-800 hover:underline"
>
Administration
</Link>
)}
<button
onClick={() => void logout()}
className="text-sm text-ink-500 underline-offset-4 hover:text-ink-800 hover:underline"
>
Se déconnecter
</button>
</div>
</header>
{projects.isLoading && <p className="text-sm text-ink-400">Chargement</p>}
{projects.data?.length === 0 && (
<div className="rounded-lg border border-dashed border-ink-300 p-10 text-center">
<p className="text-sm text-ink-600">Aucun projet ne vous est accessible.</p>
<p className="mt-1 text-xs text-ink-400">
L'accès à un projet s'obtient auprès de son administrateur.
</p>
</div>
)}
<div className="space-y-4">
{projects.data?.map((project, index) => {
const projectStats = stats[index]?.data;
const targets = (projectStats?.locales ?? []).filter((l) => !l.isSource);
return (
<article
key={project.uuid}
className="rounded-lg border border-ink-200 bg-white p-5 shadow-sm"
>
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<h2 className="font-semibold text-ink-900">{project.name}</h2>
{project.description !== null && (
<p className="mt-1 text-sm text-ink-500">
{project.description}
</p>
)}
<p className="mt-2 flex flex-wrap gap-1.5">
{project.platforms.map((platform) => (
<span
key={platform.uuid}
className="rounded bg-ink-100 px-1.5 py-0.5 font-mono text-[11px] text-ink-600"
title={`Format ${platform.messageFormat}`}
>
{platform.slug}
</span>
))}
</p>
</div>
<span className="shrink-0 rounded bg-ink-100 px-2 py-1 font-mono text-[11px] text-ink-500">
source&nbsp;: {project.sourceLocale.code}
</span>
</div>
{/* Les langues de l'utilisateur d'abord. Présenter les
sept langues d'un projet à une traductrice habilitée
sur deux, c'est lui faire trier son propre travail. */}
{(() => {
const mine = targets.filter((l) => l.canWrite);
const others = targets.filter((l) => !l.canWrite);
const split = mine.length > 0 && others.length > 0;
return (
<>
{split && (
<p className="mt-5 text-[11px] font-semibold uppercase tracking-wide text-ink-400">
Vos langues
</p>
)}
<div
className={`grid gap-x-8 gap-y-3 sm:grid-cols-2 ${split ? 'mt-2' : 'mt-5'}`}
>
{(split ? mine : targets).map((locale) => (
<LocaleRow
key={locale.code}
locale={locale}
projectUuid={project.uuid}
/>
))}
</div>
{split && (
<>
<p className="mt-6 text-[11px] font-semibold uppercase tracking-wide text-ink-400">
Autres langues du projet · consultation
</p>
<div className="mt-2 grid gap-x-8 gap-y-3 opacity-60 sm:grid-cols-2">
{others.map((locale) => (
<LocaleRow
key={locale.code}
locale={locale}
projectUuid={project.uuid}
/>
))}
</div>
</>
)}
</>
);
})()}
{targets.length === 0 && stats[index]?.isSuccess && (
<p className="mt-4 text-xs text-ink-400">
Aucune langue cible activée sur ce projet.
</p>
)}
</article>
);
})}
</div>
</div>
);
}
/**
* Une langue, son avancement, et le raccourci vers le travail restant.
*
* Le lien mène directement à la grille filtrée sur ce qui reste à faire. C'est
* la différence entre « voici l'état des lieux » et « voici votre travail » :
* un clic de moins, mais surtout aucune décision à prendre en arrivant.
*/
function LocaleRow({ locale, projectUuid }: { locale: LocaleStats; projectUuid: string }) {
const actionable = locale.untranslated + locale.draft + locale.needsReview;
const readOnly = !locale.canWrite;
return (
<Link
to={`/p/${projectUuid}/editor?locale=${locale.code}${actionable > 0 ? '&status=untranslated' : ''}`}
className="group block rounded-md px-2 py-1.5 -mx-2 transition-colors hover:bg-ink-50"
>
<div className="flex items-baseline justify-between gap-3">
<span className="truncate text-sm font-medium text-ink-800">
{locale.nativeName}
{locale.direction === 'rtl' && (
<span className="ml-1.5 text-[10px] font-normal text-ink-400">RTL</span>
)}
</span>
<span className="tabular shrink-0 text-sm text-ink-500">{locale.completion}%</span>
</div>
<div className="mt-1.5">
<ProgressBar
reviewed={locale.reviewed}
translated={locale.translated}
needsReview={locale.needsReview}
draft={locale.draft}
total={locale.total}
/>
</div>
<div className="mt-1.5 flex items-center gap-3 text-[11px] text-ink-400">
{readOnly ? (
<span className="text-ink-400">consultation</span>
) : actionable > 0 ? (
<span className="tabular font-medium text-ink-600">
{actionable} à traiter
</span>
) : (
<span className="text-emerald-600">Rien à faire</span>
)}
{/* Les traductions obsolètes sont sorties du lot : c'est le seul
chiffre qui signale un texte potentiellement faux en production. */}
{locale.needsReview > 0 && (
<span className="tabular font-medium text-red-600">
{locale.needsReview} obsolète{locale.needsReview > 1 ? 's' : ''}
</span>
)}
</div>
</Link>
);
}