Commit dea295bb authored by Kourser's avatar Kourser
Browse files

Exercice : bandeau d'état dans l'en-tête et renommage sur place

Bandeau visible sur tous les onglets d'un exercice : statut, temps de jeu, heure
courante et avancement des injects (envoyés / total). Répond à « où en est-on ? »
sans imposer de revenir sur l'onglet Animation, ce qui obligeait à perdre le
contexte de l'onglet courant.

L'avancement est recompté sur `inject:updated` plutôt que déduit localement : la
liste peut changer autrement qu'en envoyant (ajout à la volée, annulation).

Le nom de l'exercice devient renommable sur place. L'édition passe par un crayon
et non par un clic sur le titre : renommer par accident un exercice en cours
serait difficile à repérer.

L'extrapolation locale de l'horloge existait déjà en double dans Console et
CrisisWall. Plutôt qu'une troisième copie, elle est extraite dans le hook
useExerciseClock. Les deux copies existantes restent en place — les migrer sans
compilateur disponible aurait été un risque gratuit ; c'est un nettoyage à faire.

Non compilé ni exécuté (pas de toolchain Node dans l'environnement utilisé).

Co-Authored-By: Claude (RCA)
parent d2a11173
Loading
Loading
Loading
Loading
+155 −2
Original line number Diff line number Diff line
@@ -8,6 +8,8 @@ import type {
  CharacterKind,
} from '@cythin/shared';
import { api, ApiError } from './api';
import { connectSocket, formatClock, RT } from './socket';
import { useExerciseClock } from './useExerciseClock';
import { Console } from './Console';
import { Chat } from './Chat';
import { SocialConsole } from './Social';
@@ -240,11 +242,16 @@ function ExerciseDetail({ id, onBack }: { id: string; onBack: () => void }): JSX
{t('Exercices')}
        </button>
        {exercise && (
          <>
            <div className="detail-title">
            <h3>{exercise.name}</h3>
            <span className={`status s-${exercise.status}`}>{t(STATUS_LABEL[exercise.status])}</span>
              <ExerciseName exercise={exercise} onRenamed={reload} />
              <span className={`status s-${exercise.status}`}>
                {t(STATUS_LABEL[exercise.status])}
              </span>
              <span className="muted">{exercise.sector ?? t('Secteur non précisé')}</span>
            </div>
            <ExerciseHeaderStats exerciseId={id} />
          </>
        )}
      </div>
      {error && <p className="err">{t(error)}</p>}
@@ -657,6 +664,152 @@ function ExerciseLogo({ exerciseId }: { exerciseId: string }): JSX.Element {
  );
}

/**
 * Bandeau d'état d'un exercice, visible sur tous ses onglets : statut, temps de
 * jeu, heure courante et avancement des injects. Répond à « où en est-on ? »
 * sans imposer de revenir sur l'onglet Animation.
 */
function ExerciseHeaderStats({ exerciseId }: { exerciseId: string }): JSX.Element {
  const { t } = useT();
  const { view, elapsed } = useExerciseClock(exerciseId);
  const [counts, setCounts] = useState<{ sent: number; total: number } | null>(null);
  const [now, setNow] = useState(() => new Date());

  const reloadCounts = useCallback(async () => {
    try {
      const injects = await api.listInjects(exerciseId);
      setCounts({
        sent: injects.filter((i) => i.status === 'SENT').length,
        total: injects.length,
      });
    } catch {
      /* le bandeau reste partiel plutôt que de masquer l'exercice */
    }
  }, [exerciseId]);

  useEffect(() => {
    void reloadCounts();
    const socket = connectSocket();
    socket.emit('join', { exerciseId });
    // Un inject qui part change l'avancement : on recompte plutôt que de deviner.
    socket.on(RT.injectUpdated, () => void reloadCounts());
    return () => {
      socket.disconnect();
    };
  }, [exerciseId, reloadCounts]);

  // Heure courante : une seconde de granularité suffit à un bandeau.
  useEffect(() => {
    const h = setInterval(() => setNow(new Date()), 1000);
    return () => clearInterval(h);
  }, []);

  return (
    <div className="ex-stats">
      <span className="ex-stat">
        <span className="ex-stat-lbl">{t('Temps de jeu')}</span>
        <b className="mono">T+{formatClock(elapsed)}</b>
        {view && view.speed !== 1 && <span className="muted">×{view.speed}</span>}
        {view && !view.running && <span className="ex-paused">{t('en pause')}</span>}
      </span>
      <span className="ex-stat">
        <span className="ex-stat-lbl">{t('Heure')}</span>
        <b className="mono">
          {String(now.getHours()).padStart(2, '0')}:{String(now.getMinutes()).padStart(2, '0')}
        </b>
      </span>
      <span className="ex-stat">
        <span className="ex-stat-lbl">{t('Injects')}</span>
        <b className="mono">{counts ? `${counts.sent}/${counts.total}` : ''}</b>
      </span>
    </div>
  );
}

/**
 * Nom de l'exercice, renommable sur place. L'édition se déclenche au clic sur le
 * crayon plutôt qu'au clic sur le titre : renommer par accident un exercice en
 * cours serait pénible à repérer.
 */
function ExerciseName({
  exercise,
  onRenamed,
}: {
  exercise: ApiExercise;
  onRenamed: () => void;
}): JSX.Element {
  const { t } = useT();
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState(exercise.name);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function save(): Promise<void> {
    const name = draft.trim();
    if (name.length === 0) {
      setError('Le nom ne peut pas être vide.');
      return;
    }
    if (name === exercise.name) {
      setEditing(false);
      return;
    }
    setBusy(true);
    setError(null);
    try {
      await api.updateExercise(exercise.id, { name });
      setEditing(false);
      onRenamed();
    } catch (err) {
      setError(err instanceof ApiError ? err.message : 'Renommage impossible');
    } finally {
      setBusy(false);
    }
  }

  if (!editing) {
    return (
      <>
        <h3>{exercise.name}</h3>
        <button
          className="icon-btn"
          title={t("Renommer l'exercice")}
          aria-label={t("Renommer l'exercice")}
          onClick={() => {
            setDraft(exercise.name);
            setError(null);
            setEditing(true);
          }}
        >

        </button>
      </>
    );
  }

  return (
    <span className="ex-rename">
      <input
        value={draft}
        maxLength={200}
        autoFocus
        onChange={(e) => setDraft(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === 'Enter') void save();
          if (e.key === 'Escape') setEditing(false);
        }}
      />
      <button className="btn primary" disabled={busy} onClick={() => void save()}>
        {t('Enregistrer')}
      </button>
      <button className="btn ghost" onClick={() => setEditing(false)}>
        {t('Annuler')}
      </button>
      {error && <span className="err">{t(error)}</span>}
    </span>
  );
}

function CharacterRow({
  character: c,
  participant: p,
+7 −0
Original line number Diff line number Diff line
@@ -540,6 +540,13 @@ const EN: Record<string, string> = {
    'Organization logo. Upload one for this exercise only.',
  'Aucun logo : un avatar générique est utilisé.': 'No logo: a generic avatar is used.',
  'Revenir au logo de l’organisation': 'Revert to the organization logo',

  // Bandeau d'état d'un exercice et renommage
  'Temps de jeu': 'Play time',
  Injects: 'Injects',
  "Renommer l'exercice": 'Rename the exercise',
  'Le nom ne peut pas être vide.': 'The name cannot be empty.',
  'Renommage impossible': 'Rename failed',
};

interface I18n {
+48 −0
Original line number Diff line number Diff line
@@ -4900,3 +4900,51 @@ textarea {
.chan .badge {
  margin-left: auto;
}

/* ---- Bandeau d'état d'un exercice (en-tête, tous onglets) ---- */
.ex-stats {
  display: flex;
  align-items: center;
  gap: 18px;
  flex-wrap: wrap;
  margin-left: auto;
  padding: 6px 12px;
  border: 1px solid var(--border);
  border-radius: 9px;
  background: var(--panel-2);
  font-size: 14px;
}
.ex-stat {
  display: flex;
  align-items: baseline;
  gap: 6px;
}
.ex-stat-lbl {
  font-size: 11px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}
.ex-paused {
  font-size: 11px;
  font-weight: 650;
  color: var(--warn);
}
.ex-rename {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}
.ex-rename input {
  min-width: 240px;
  font-size: 16px;
  font-weight: 650;
}

@media (max-width: 800px) {
  .ex-stats {
    margin-left: 0;
    width: 100%;
  }
}
+59 −0
Original line number Diff line number Diff line
import { useCallback, useEffect, useState } from 'react';
import type { ClockView } from '@cythin/shared';
import { api } from './api';
import { connectSocket, RT } from './socket';

/**
 * Horloge d'un exercice, suivie en direct.
 *
 * Le serveur ne diffuse qu'aux changements d'état ; entre deux, le temps écoulé
 * est extrapolé localement à partir du dernier instantané et de la vitesse. La
 * même logique existe encore en copie dans Console et CrisisWall : ce hook est
 * là pour que le prochain appelant ne la réécrive pas une quatrième fois.
 */
export function useExerciseClock(exerciseId: string): {
  view: ClockView | null;
  /** Secondes écoulées d'exercice, rafraîchies deux fois par seconde. */
  elapsed: number;
  /** À appeler avec la réponse d'une commande d'horloge, pour éviter un aller-retour. */
  apply: (view: ClockView) => void;
} {
  const [snapshot, setSnapshot] = useState<{ view: ClockView; at: number } | null>(null);
  const [elapsed, setElapsed] = useState(0);

  const apply = useCallback((view: ClockView) => setSnapshot({ view, at: Date.now() }), []);

  useEffect(() => {
    let cancelled = false;
    api
      .getClock(exerciseId)
      .then((v) => {
        if (!cancelled) apply(v);
      })
      .catch(() => {
        /* l'appelant affiche l'exercice même sans horloge */
      });

    const socket = connectSocket();
    socket.emit('join', { exerciseId });
    socket.on(RT.clockUpdate, (v: ClockView) => apply(v));
    return () => {
      cancelled = true;
      socket.disconnect();
    };
  }, [exerciseId, apply]);

  useEffect(() => {
    const tick = (): void => {
      if (!snapshot) return;
      const { view, at } = snapshot;
      const extra = view.running ? ((Date.now() - at) / 1000) * view.speed : 0;
      setElapsed(view.exerciseSeconds + extra);
    };
    tick();
    const h = setInterval(tick, 500);
    return () => clearInterval(h);
  }, [snapshot]);

  return { view: snapshot?.view ?? null, elapsed, apply };
}