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>
230 lines
11 KiB
TypeScript
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 : {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>
|
|
);
|
|
}
|