import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useParams } from 'react-router-dom'; import { api } from '@/api/client'; import type { AdminEnvironment, AdminPlatform, PlatformList } from '@/api/types'; import { humanMessage } from '@/auth/AuthProvider'; import { ProjectNav } from '@/components/ProjectNav'; import { RequiresAdmin } from '@/components/RequiresAdmin'; const KIND_LABELS: Record = { web: 'Application web', ios: 'iOS', android: 'Android', backend: 'Back-end', email: 'E-mails', other: 'Autre', }; /** * Les surfaces d'un projet, et ses cibles de déploiement. * * Deux notions distinctes réunies sur un écran parce qu'on s'y rend pour la * même raison : rendre un projet livrable. Une **plateforme** dit quelles clés * entrent dans un fichier et dans quelle syntaxe ; un **environnement** dit * quelle version de ce fichier est servie. Il faut les deux, et un projet neuf * n'a que le second. */ export function PlatformsPage() { const { projectUuid = '' } = useParams(); const stats = useQuery({ queryKey: ['stats', projectUuid], queryFn: () => api.stats(projectUuid) }); const platforms = useQuery({ queryKey: ['platforms', projectUuid], queryFn: () => api.platforms(projectUuid), }); const environments = useQuery({ queryKey: ['environments', projectUuid], queryFn: () => api.environments(projectUuid), }); if (stats.isSuccess && !stats.data.viewer.canAdminister) { return ; } const active = platforms.data?.platforms.filter((p) => !p.isArchived) ?? []; const archived = platforms.data?.platforms.filter((p) => p.isArchived) ?? []; return (

Plateformes et environnements

Ce que {stats.data?.projectName} produit, et où cela est servi.

{/* Le cas du projet neuf : il a ses environnements mais aucune plateforme, donc rien à livrer. Le dire avant que l'utilisateur ne découvre l'échec au moment de publier. */} {platforms.isSuccess && active.length === 0 && (

Aucune plateforme active : ce projet ne peut produire aucun fichier de traduction. Déclarez-en une pour rendre la publication possible.

)}

Plateformes

{active.map((platform) => ( ))} {active.length === 0 && platforms.isSuccess && (

Aucune plateforme active.

)}
void platforms.refetch()} />
{archived.length > 0 && (

Archivées

{/* Archivées et non supprimées : les clés leur restent rattachées, et c'est cette trace qui explique pourquoi telle clé existe. */}
{archived.map((platform) => ( ))}
)}

Environnements

{environments.data?.environments.map((environment) => ( ))}
void environments.refetch()} />
); } // ── Plateformes ────────────────────────────────────────────────────────── function PlatformRow({ projectUuid, platform, options, isLastActive, }: { projectUuid: string; platform: AdminPlatform; options: PlatformList | undefined; isLastActive: boolean; }) { const queryClient = useQueryClient(); const [editing, setEditing] = useState(false); const [name, setName] = useState(platform.name); const [format, setFormat] = useState(platform.messageFormat); const [layout, setLayout] = useState(platform.exportLayout); const [maxLength, setMaxLength] = useState(platform.defaultMaxLength?.toString() ?? ''); const [error, setError] = useState(null); const refresh = () => void queryClient.invalidateQueries({ queryKey: ['platforms', projectUuid] }); const save = useMutation({ mutationFn: () => api.updatePlatform(projectUuid, platform.uuid, { name, messageFormat: format, exportLayout: layout, defaultMaxLength: maxLength === '' ? null : Number(maxLength), }), onSuccess: () => { setError(null); setEditing(false); refresh(); }, onError: (caught) => setError(humanMessage(caught)), }); const toggleArchive = useMutation({ mutationFn: () => api.updatePlatform(projectUuid, platform.uuid, { isArchived: !platform.isArchived }), onSuccess: () => { setError(null); refresh(); }, onError: (caught) => setError(humanMessage(caught)), }); if (editing) { return (
{ e.preventDefault(); save.mutate(); }} className="space-y-3 bg-ink-50 px-4 py-4" >
setName(e.target.value)} className="min-w-48 flex-1 rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent" /> {/* Le slug est en lecture seule : il est dans les URL de livraison, dans les tqs.config.json des dépôts clients et dans les scripts d'intégration continue. Le changer casserait tout cela sans qu'aucune erreur ne remonte. */} {platform.slug} setMaxLength(e.target.value)} placeholder="longueur max" className="w-32 rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent" />
{error &&

{error}

}
); } return (
{platform.name} {platform.slug} {KIND_LABELS[platform.kind] ?? platform.kind} {!platform.acceptsPush && !platform.isArchived && ( push impossible )} {!platform.isArchived && ( )}
{platform.messageFormat} · {platform.exportLayout === 'nested' ? 'imbriqué' : 'à plat'} {/* Le chiffre qui rend l'archivage décidable : « 0 clé » se retire sans réfléchir, « 340 clés » demande de vérifier. */} {platform.keyCount} clé{platform.keyCount > 1 ? 's' : ''} {platform.defaultMaxLength !== null && ( longueur max {platform.defaultMaxLength} )} {platform.isArchived && platform.archivedAt !== null && ( archivée le {new Date(platform.archivedAt).toLocaleDateString('fr-FR')} )}
{error &&

{error}

}
); } function CreatePlatformForm({ projectUuid, options, onDone, }: { projectUuid: string; options: PlatformList | undefined; onDone: () => void; }) { const [open, setOpen] = useState(false); const [name, setName] = useState(''); const [slug, setSlug] = useState(''); const [kind, setKind] = useState('web'); const [format, setFormat] = useState('icu'); const [layout, setLayout] = useState('nested'); const [maxLength, setMaxLength] = useState(''); const [error, setError] = useState(null); const create = useMutation({ mutationFn: () => api.createPlatform(projectUuid, { name, slug, kind, messageFormat: format, exportLayout: layout, defaultMaxLength: maxLength === '' ? null : Number(maxLength), }), onSuccess: () => { setError(null); setName(''); setSlug(''); setMaxLength(''); setOpen(false); onDone(); }, onError: (caught) => setError(humanMessage(caught)), }); if (!open) { return ( ); } return (
{ e.preventDefault(); create.mutate(); }} className="mt-3 rounded-lg border border-ink-200 bg-white p-5 shadow-sm" >

Nouvelle plateforme

{ setName(e.target.value); setSlug(slugify(e.target.value)); }} placeholder="Nom de la plateforme" className="min-w-48 flex-1 rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent" /> setSlug(e.target.value)} placeholder="slug" className="w-40 rounded-md border border-ink-300 px-3 py-2 font-mono text-sm outline-none focus:border-accent" /> setMaxLength(e.target.value)} placeholder="longueur max" className="w-32 rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent" />

Seuls les formats disposant d'un sérialiseur sont proposés. Le slug n'est plus modifiable ensuite : il apparaît dans les URL de livraison et dans la configuration des dépôts clients.

{error && (

{error}

)}
); } // ── Environnements ─────────────────────────────────────────────────────── function EnvironmentRow({ projectUuid, environment, }: { projectUuid: string; environment: AdminEnvironment; }) { const queryClient = useQueryClient(); const [editing, setEditing] = useState(false); const [name, setName] = useState(environment.name); const [error, setError] = useState(null); const save = useMutation({ mutationFn: () => api.updateEnvironment(projectUuid, environment.uuid, { name }), onSuccess: () => { setError(null); setEditing(false); void queryClient.invalidateQueries({ queryKey: ['environments', projectUuid] }); }, onError: (caught) => setError(humanMessage(caught)), }); return (
{editing ? ( setName(e.target.value)} onBlur={() => save.mutate()} onKeyDown={(e) => { if (e.key === 'Enter') save.mutate(); if (e.key === 'Escape') { setName(environment.name); setEditing(false); } }} className="rounded border border-accent px-2 py-1 text-sm outline-none" /> ) : ( )} {environment.slug} {/* Rien de déployé = LA cause d'un bundle introuvable côté client. La voir ici évite de la chercher ailleurs. */} {environment.currentRelease === null ? ( rien de déployé ) : ( {environment.currentRelease.label} )} {environment.activeKeyCount} clé{environment.activeKeyCount > 1 ? 's' : ''} d'accès {environment.totalKeyCount > environment.activeKeyCount && ` (${environment.totalKeyCount - environment.activeKeyCount} révoquée${ environment.totalKeyCount - environment.activeKeyCount > 1 ? 's' : '' })`}
{environment.currentRelease !== null && (

{environment.currentRelease.keyCount} clés · déployée le{' '} {environment.currentRelease.publishedAt !== null ? new Date(environment.currentRelease.publishedAt).toLocaleDateString('fr-FR') : '—'}

)} {error &&

{error}

}
); } function CreateEnvironmentForm({ projectUuid, onDone, }: { projectUuid: string; onDone: () => void; }) { const [open, setOpen] = useState(false); const [name, setName] = useState(''); const [slug, setSlug] = useState(''); const [error, setError] = useState(null); const create = useMutation({ mutationFn: () => api.createEnvironment(projectUuid, { name, slug }), onSuccess: () => { setError(null); setName(''); setSlug(''); setOpen(false); onDone(); }, onError: (caught) => setError(humanMessage(caught)), }); if (!open) { return (
{/* Dit une fois, ici, plutôt que découvert au moment où l'on cherche le bouton qui n'existe pas. */}

Un environnement ne se supprime pas : les applications qui l'interrogent cesseraient d'être servies, sans qu'aucun signal ne parte d'ici.

); } return (
{ e.preventDefault(); create.mutate(); }} className="mt-3 rounded-lg border border-ink-200 bg-white p-5 shadow-sm" >

Nouvel environnement

{ setName(e.target.value); setSlug(slugify(e.target.value)); }} placeholder="Nom (Recette client, Préproduction…)" className="min-w-56 flex-1 rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent" /> setSlug(e.target.value)} placeholder="slug" className="w-40 rounded-md border border-ink-300 px-3 py-2 font-mono text-sm outline-none focus:border-accent" />

Le slug entre dans l'URL de livraison :{' '} /delivery/v1/{projet}/{slug || 'slug'}/…{' '} — il n'est plus modifiable ensuite.

{error && (

{error}

)}
); } function slugify(value: string): string { return value .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, ''); }