Commit 8e27181e authored by Kourser's avatar Kourser
Browse files

Le studio, et une seule entrée de rail pour une seule activité

La salle de presse en occupait trois — le direct, les questions, la page
publique. « Vers l'extérieur » était repassé de deux à cinq entrées, défaisant
en partie le dégonflement du rail. Et surtout, le découpage cassait le geste
réel : dans une conférence de presse, quelqu'un lit au porte-parole les
questions retenues PENDANT qu'il parle. Il fallait changer d'écran, donc
quitter le direct des yeux. Elles sont maintenant à côté de la scène.

Le panneau visio cesse de flotter. Le mode flottant existe pour la CONTINUITÉ
— on continue de parler pendant qu'on ouvre un document, qu'on écrit en main
courante. Un point presse est l'inverse : un outil d'ATTENTION, où l'on ne fait
que ça. Réutiliser la fenêtre flottante pour cet usage était réemployer un
composant conçu pour le contraire. En studio il remplit la scène et perd les
gestes qui n'ont plus de sens.

Le témoin « EN DIRECT » est gros, rouge, et il bat : c'est la seule information
qu'on doit voir sans la chercher quand on parle à la presse. Hors antenne, il
le dit — pas de silence ambigu. Les destinations sont en pastilles avec leur
état et le motif rendu par la plateforme : « clé rejetée » et « ingestion
saturée » ne se réparent pas de la même façon.

Depuis le studio, on convoque la presse. L'annuaire et les passerelles
existaient, et rien ne s'en servait : on collait l'adresse à la main dans un
client de messagerie pendant que Kastell tenait le carnet deux écrans plus
loin. Le texte est fabriqué par le serveur parce qu'il connaît l'heure et
l'adresse publique — les deux choses qu'on oublie d'écrire le jour J. Sans
adresse, il ne l'invente pas.

La page publique se voit avant d'être publiée. On publiait, puis on
téléchargeait, puis on ouvrait pour regarder : à l'envers, et une page qu'on
découvre après coup est une page qu'on corrige en public. L'aperçu passe par le
même rendu que la publication — les faire diverger donnerait un aperçu qui ment.

Deux écrans se rafraîchissent seuls et le disent : en crise, on doit savoir si
ce qu'on regarde est vivant ou figé. Et la hauteur du studio n'est plus un
nombre magique qui cassait dès qu'un bandeau apparaissait.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent f15cbe77
Loading
Loading
Loading
Loading
Loading
+16 −0
Changes for apps/web/src/coque/Coque.tsx: 16 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -25,6 +25,7 @@ import { Tableaux } from "../ecrans/Tableaux.js";
import { Mobilisation } from "../ecrans/Mobilisation.js";
import { Cloture } from "../ecrans/Cloture.js";
import { Enregistrements } from "../ecrans/Enregistrements.js";
import { SallePresse } from "../ecrans/SallePresse.js";
import { AgentsCrise } from "../ecrans/AgentsCrise.js";
import { Propositions } from "../ecrans/Propositions.js";
import { BandeauAgents } from "./BandeauAgents.js";
@@ -626,6 +627,17 @@ export function Coque({ criseId }: { criseId: string }) {
              onClick={() => aller(`/crises/${criseId}/veille`)}>
              {t("Veille externe")}
            </button>
            {/* Une seule entrée pour une seule activité. Elle en occupait
                trois — direct, questions, page publique — et le rail « Vers
                l'extérieur » était repassé de deux à cinq, défaisant en partie
                le dégonflement. Surtout, le découpage cassait le geste : on ne
                lit pas les questions retenues au porte-parole depuis un autre
                écran que celui où il parle. */}
            <button className={`item${module === "salle-presse" ? " actif" : ""}`}
              aria-current={module === "salle-presse" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/salle-presse`)}>
              {t("Salle de presse")}
            </button>
          </div>

          <div role="group" aria-label={t("Agents")} className="groupe">
@@ -683,6 +695,10 @@ export function Coque({ criseId }: { criseId: string }) {
          <Conduite criseId={criseId} cellules={cockpit.data?.cellules ?? []} />
        ) : module === "tableaux" ? (
          <Tableaux criseId={criseId} rembobinage={rembobinage} />
        ) : module === "salle-presse" ? (
          <SallePresse criseId={criseId}
            organisationId={crise?.organisation_id ?? null}
            lectureSeule={rembobinage !== null} />
        ) : module === "enregistrements" ? (
          <Enregistrements criseId={criseId} lectureSeule={rembobinage !== null} />
        ) : module === "agents" ? (
+20 −4
Changes for apps/web/src/coque/PanneauVisio.tsx: 20 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -51,8 +51,24 @@ interface Voix {
type Taille = "reduit" | "flottant" | "agrandi";

function PanneauVisio({
  criseId, appel, raccrocher,
}: { criseId: string; appel: Appel; raccrocher: () => void }) {
  criseId, appel, raccrocher, studio = false,
}: {
  criseId: string; appel: Appel; raccrocher: () => void;
  /**
   * Le mode studio (EF-808).
   *
   * Le panneau flottant existe pour la CONTINUITÉ : on continue de parler
   * pendant qu'on ouvre un document, qu'on écrit en main courante. Un point
   * presse est l'inverse — un outil d'ATTENTION. On ne fait que ça, on regarde
   * la caméra, on veut savoir qu'on est en direct et vers où.
   *
   * Réutiliser la fenêtre flottante pour ça, c'était réemployer un composant
   * conçu pour l'usage exactement contraire. En studio, le panneau cesse de
   * flotter : il remplit ce qu'on lui donne, et perd les gestes qui n'ont plus
   * de sens — réduire, agrandir, se déplacer.
   */
  studio?: boolean;
}) {
  const t = useT();
  const capacites = useCapacites();

@@ -420,7 +436,7 @@ function PanneauVisio({
    appareils.filter((a) => a.kind === genre && a.deviceId);

  return (
    <div className={`visio ${taille}`}>
    <div className={`visio ${studio ? "studio" : taille}`}>
      {/* Les voix des autres.
          Hors de tout affichage : une voix n'a pas de vignette. Jamais
          « muted » — un élément muet rattaché à une piste vivante est
@@ -454,7 +470,7 @@ function PanneauVisio({
        </div>
      )}

      <div className="tete">
      <div className="tete" hidden={studio}>
        <span>{appel.libelle}</span>
        <span className="pousse ligne" style={{ gap: 4 }}>
          {taille !== "reduit" && (
+8 −0
Changes for apps/web/src/ecrans/EspaceOrganisation.tsx: 8 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -9,6 +9,7 @@ import { Administration } from "./administration/Administration.js";
import { Securite } from "./Securite.js";
import { Canaux } from "./Canaux.js";
import { Destinations } from "./Destinations.js";
import { SiteModele } from "./SiteModele.js";
import { Membres } from "./Membres.js";
import { Preferences } from "./Preferences.js";
import { MenuCompte } from "../coque/MenuCompte.js";
@@ -111,6 +112,11 @@ export function EspaceOrganisation() {
              onClick={() => aller("/preparation")}>{t("Dossier de préparation")}</button>
            <button className={`item${module === "veille-externe" ? " actif" : ""}`}
              onClick={() => aller("/veille-externe")}>{t("Veille externe")}</button>
            {/* Avec le dossier et les modèles de communiqués, pas avec les
                passerelles : c'est du contenu qu'on relit à froid, pas un
                secret. */}
            <button className={`item${module === "page-publique" ? " actif" : ""}`}
              onClick={() => aller("/page-publique")}>{t("Page publique de crise")}</button>
            {estProprietaire && (
              <button className={`item${module === "diffusion" ? " actif" : ""}`}
                onClick={() => aller("/diffusion")}>{t("Écrire à l’annuaire")}</button>
@@ -163,6 +169,8 @@ export function EspaceOrganisation() {
          : module === "canaux" && organisationId ? <Canaux organisationId={organisationId} />
          : module === "point-presse" && organisationId
            ? <Destinations organisationId={organisationId} />
          : module === "page-publique" && organisationId
            ? <SiteModele organisationId={organisationId} />
          : module === "diffusion" && organisationId
            ? <EnvoiMasse organisationId={organisationId} />
          // « /veille » est déjà le module par défaut, celui de « / ». Monter la
+291 −0
Changes for apps/web/src/ecrans/PagePublique.tsx: 291 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, ErreurApi } from "../api.js";
import { dateHeure } from "../etat.js";
import { useT, traduisible } from "../langues/index.js";

type Etat = "en_cours" | "stabilise" | "termine";

interface Publication {
  id: string; rang: number; etat: Etat; titre: string; message: string;
  publie_at: string; retire_at: string | null;
}
interface Lien { jeton: string; plafond: number; ouvert_at: string }
interface Vue {
  modele: { nom_public: string } | null;
  lien: Lien | null;
  urlPublique: string;
  derniere: Publication | null;
  historique: Publication[];
  questions: { recue: number; publiee: number; repondue: number; ecartee: number };
  abonnes: number;
}

const ETATS: Record<Etat, string> = {
  en_cours: traduisible("Situation en cours"),
  stabilise: traduisible("Situation stabilisée"),
  termine: traduisible("Situation terminée"),
};

/**
 * Publier la page publique de crise (EF-809).
 *
 * Deux champs et un état : c'est tout ce qu'on écrit le jour J. Le reste vient
 * du modèle préparé à froid — et si ce modèle manque, l'écran le dit avant
 * qu'on ait perdu dix minutes à chercher pourquoi rien ne part.
 *
 * Le fichier se télécharge et se dépose où l'exploitant veut. C'est un geste
 * manuel, et c'est assumé pour l'instant : mieux vaut un dépôt qu'on fait
 * soi-même qu'un dépôt automatique vers une cible qu'on n'a jamais éprouvée.
 */
export function PagePublique({ criseId, lectureSeule, nu = false }: {
  criseId: string; lectureSeule: boolean;
  /** Rendu sans coque : un onglet porte déjà le titre et le cadre. */
  nu?: boolean;
}) {
  const t = useT();
  const client = useQueryClient();
  const [etat, definirEtat] = useState<Etat>("en_cours");
  const [titre, definirTitre] = useState("");
  const [message, definirMessage] = useState("");
  const [prochain, definirProchain] = useState("");
  const [apercu, definirApercu] = useState<string | null>(null);
  const [erreur, definirErreur] = useState<string | null>(null);
  const [note, definirNote] = useState<string | null>(null);

  const vue = useQuery({
    queryKey: ["site", criseId],
    queryFn: () => api.lire<Vue>(`/api/crises/${criseId}/site`),
  });
  const rafraichir = () => client.invalidateQueries({ queryKey: ["site", criseId] });
  const attraper = (e: unknown) => definirErreur(
    e instanceof ErreurApi ? e.message : t("L’opération a échoué."));

  const derniere = vue.data?.derniere ?? null;

  return (
    <>
      {!nu && (
      <header>
        <div className="etiq">{t("Vers l'extérieur")}</div>
        <h1>{t("Page publique")}</h1>
      </header>

      )}
      <div className={nu ? "" : "contenu"}>
        {erreur && <div className="erreur">{erreur}</div>}
        {note && <div className="succes">{note}</div>}

        {vue.data && !vue.data.modele && (
          <div className="carte alerte" style={{ marginBottom: 18 }}>
            <span className="etiq">{t("Aucun modèle préparé")}</span>
            <div style={{ fontSize: 13.5, marginTop: 4 }}>
              {t("Rien ne peut être publié tant que le nom public, les consignes et "
                + "les contacts n’ont pas été posés dans la préparation de "
                + "l’organisation. C’est volontaire : on ne les improvise pas ici.")}
            </div>
          </div>
        )}

        {/* ── Ce que le public voit en ce moment ───────────────────────────── */}
        <div className="carte" style={{ marginBottom: 18 }}>
          <span className="etiq">{t("En ligne en ce moment")}</span>
          {derniere ? (
            <>
              <div className="ligne" style={{ marginTop: 6 }}>
                <span className="pastille crit">{t(ETATS[derniere.etat])}</span>
                <strong style={{ marginLeft: 8 }}>{derniere.titre}</strong>
                <span className="discret pousse" style={{ fontSize: 12 }}>
                  {t("Version {n} — {quand}", {
                    n: String(derniere.rang), quand: dateHeure(derniere.publie_at),
                  })}
                </span>
              </div>
              <div className="ligne" style={{ gap: 6, marginTop: 10 }}>
                <a className="bouton petit"
                  href={`/api/crises/${criseId}/site/publications/${derniere.id}/fichier`}>
                  {t("Télécharger la page")}
                </a>
                {!lectureSeule && (
                  <button className="danger petit" onClick={() => {
                    definirErreur(null);
                    void api.poster(`/api/crises/${criseId}/site/publications/`
                      + `${derniere.id}/retrait`).then(rafraichir).catch(attraper);
                  }}>{t("Retirer — retour au site habituel")}</button>
                )}
              </div>
            </>
          ) : (
            <p className="discret" style={{ fontSize: 13.5, margin: "6px 0 0" }}>
              {t("Rien n’est publié. Le public voit le site habituel de l’organisation.")}
            </p>
          )}
        </div>

        {/* ── Publier ──────────────────────────────────────────────────────── */}
        {!lectureSeule && vue.data?.modele && (
          <div className="carte" style={{ marginBottom: 18 }}>
            <span className="etiq">
              {derniere ? t("Mettre à jour la page") : t("Publier la page")}
            </span>
            <div className="champ" style={{ marginTop: 8 }}>
              <label htmlFor="pub-etat">{t("État de la situation")}</label>
              <select id="pub-etat" value={etat}
                onChange={(e) => definirEtat(e.target.value as Etat)}>
                {(Object.keys(ETATS) as Etat[]).map((k) => (
                  <option key={k} value={k}>{t(ETATS[k])}</option>
                ))}
              </select>
              <div className="aide">
                {t("Un public qui ne sait pas si c’est fini revient toutes les dix minutes.")}
              </div>
            </div>
            <div className="champ" style={{ marginTop: 10 }}>
              <label htmlFor="pub-titre">{t("Titre")}</label>
              <input id="pub-titre" value={titre} maxLength={160}
                onChange={(e) => definirTitre(e.target.value)} />
            </div>
            <div className="champ" style={{ marginTop: 10 }}>
              <label htmlFor="pub-suite">{t("Prochain point d’information")}</label>
              <input id="pub-suite" type="datetime-local" value={prochain}
                onChange={(e) => definirProchain(e.target.value)} />
              <div className="aide">
                {t("« Quand saurons-nous la suite ? » est ce qui ramène un public toutes "
                  + "les dix minutes. Laissez vide si vous ne vous engagez pas.")}
              </div>
            </div>
            <div className="champ" style={{ marginTop: 10 }}>
              <label htmlFor="pub-message">{t("Message")}</label>
              <textarea id="pub-message" rows={6} value={message} maxLength={8000}
                onChange={(e) => definirMessage(e.target.value)} />
              <div className="aide">
                {t("Une ligne vide sépare deux paragraphes. Cette mise à jour ouvre le "
                  + "fil ; les précédentes restent dessous, dans leur ordre.")}
              </div>
            </div>
            <div className="ligne" style={{ gap: 6, marginTop: 12 }}>
            {/* Voir avant de publier. On publiait, puis on téléchargeait, puis
                on ouvrait pour regarder : à l'envers. Une page publique qu'on
                découvre après coup est une page qu'on corrige en public. */}
            <button className="petit" disabled={!titre.trim() || !message.trim()}
              onClick={() => {
                definirErreur(null);
                void api.poster<string>(`/api/crises/${criseId}/site/apercu`, {
                  etat, titre, message,
                  prochainPoint: prochain ? new Date(prochain).toISOString() : null,
                }).then(definirApercu).catch(attraper);
              }}>{t("Voir la page")}</button>
            <button className="accent"
              disabled={!titre.trim() || !message.trim()}
              onClick={() => {
                definirErreur(null); definirNote(null);
                void api.poster<{ rang: number; octets: number; prevenus: number }>(
                  `/api/crises/${criseId}/site/publications`, {
                    etat, titre, message,
                    prochainPoint: prochain ? new Date(prochain).toISOString() : null,
                  })
                  .then((r) => {
                    definirTitre(""); definirMessage("");
                    definirNote(r.prevenus > 0
                      ? t("Version {n} publiée, {p} abonné(s) prévenu(s).",
                        { n: String(r.rang), p: String(r.prevenus) })
                      : t("Version {n} publiée.", { n: String(r.rang) }));
                    return rafraichir();
                  }).catch(attraper);
              }}>{t("Publier")}</button>
            </div>
            {apercu !== null && (
              <div style={{ marginTop: 12 }}>
                <div className="ligne" style={{ marginBottom: 4 }}>
                  <span className="etiq">{t("Aperçu")}</span>
                  <button className="discret petit pousse"
                    onClick={() => definirApercu(null)}>{t("Fermer l’aperçu")}</button>
                </div>
                {/* En bac à sable : la page est faite du texte de la cellule et
                    des questions du public, et rien de tout cela n'a à
                    s'exécuter dans la session de qui la relit. */}
                <iframe title={t("Aperçu de la page publique")} srcDoc={apercu}
                  sandbox="" style={{ width: "100%", height: 460, border: "1px solid var(--ligne)",
                    borderRadius: 3, background: "#fff" }} />
              </div>
            )}
          </div>
        )}

        {/* ── Le lien qui reçoit les questions ─────────────────────────────── */}
        {!lectureSeule && (
          <div className="carte" style={{ marginBottom: 18 }}>
            <span className="etiq">{t("Questions du public")}</span>
            <div style={{ fontSize: 13.5, marginTop: 4 }}>
              {t("La page porte un formulaire tant qu’un lien est ouvert. Les questions "
                + "arrivent dans une file que vous seuls voyez ; rien ne s’affiche sans "
                + "que quelqu’un l’ait décidé. Ouvrez le lien AVANT de publier : le "
                + "formulaire n’entre dans la page qu’à la publication suivante.")}
            </div>
            {/* Une adresse, pas un secret : elle finit en clair dans la page
                publiée et dans la barre d'adresse de chaque visiteur. On
                l'affiche donc en permanence — la cacher la rendait surtout
                inutilisable. */}
            {vue.data?.lien && (
              <div className="carte" style={{ marginTop: 10 }}>
                <span className="etiq">{t("Adresse publique de la page")}</span>
                <div style={{ fontFamily: "JetBrains Mono, monospace", fontSize: 12,
                  wordBreak: "break-all", marginTop: 4 }}>
                  {`${vue.data.urlPublique}/p/${vue.data.lien.jeton}`}
                </div>
                <div className="aide">
                  {t("C’est là que le public lit la page et pose ses questions. Servie "
                    + "par cette instance : si vous attendez une affluence nationale, "
                    + "téléchargez le fichier et déposez-le derrière un CDN.")}
                </div>
              </div>
            )}
            {vue.data && (
              <div className="discret" style={{ fontSize: 12, marginTop: 8 }}>
                {t("{a} en attente, {b} publiées, {c} écartées", {
                  a: String(vue.data.questions.recue),
                  b: String(vue.data.questions.publiee + vue.data.questions.repondue),
                  c: String(vue.data.questions.ecartee),
                })}
                {" · "}
                {t("{n} abonné(s) prévenu(s) à chaque publication",
                  { n: String(vue.data.abonnes) })}
              </div>
            )}
          </div>
        )}

        {/* ── Ce qu'on a dit, version par version ──────────────────────────── */}
        {(vue.data?.historique ?? []).length > 0 && (
          <section>
            <h2>{t("Ce qui a été publié")}</h2>
            <table className="tableau">
              <thead>
                <tr>
                  <th>{t("Version")}</th><th>{t("Titre")}</th>
                  <th>{t("État")}</th><th>{t("Publiée")}</th><th />
                </tr>
              </thead>
              <tbody>
                {(vue.data?.historique ?? []).map((p) => (
                  <tr key={p.id} style={p.retire_at ? { opacity: 0.55 } : undefined}>
                    <td>{p.rang}</td>
                    <td>{p.titre}</td>
                    <td>{t(ETATS[p.etat])}</td>
                    <td>{dateHeure(p.publie_at)}</td>
                    <td>
                      <a className="bouton petit"
                        href={`/api/crises/${criseId}/site/publications/${p.id}/fichier`}>
                        {t("Fichier")}
                      </a>
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </section>
        )}
      </div>
    </>
  );
}
+189 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading