Commit 89d23791 authored by Kourser's avatar Kourser
Browse files

Console : pleine largeur, colonnes redimensionnables et permutables

Pleine largeur. `.admin-content` bridait tout à 1100 px ; la console d'animation y
perdait à la fois le chronogramme et ses flux. La contrainte est levée sur la
section Exercices seulement — une liste d'utilisateurs étalée sur 2500 px se lit
mal, la centrer reste le bon choix ailleurs.

Colonnes redimensionnables. Une poignée entre les deux panneaux, largeur mémorisée
par exercice et par poste (localStorage : c'est une préférence de l'animateur, pas
une donnée d'exercice partagée). Bornée à 260 px d'un côté et 60 % de la largeur de
l'autre, y compris À LA LECTURE — une valeur aberrante enregistrée par une version
précédente ne doit pas rendre la console inutilisable.

Le glissement écoute sur `window` et non sur la poignée : le pointeur sort très vite
de six pixels de large. L'écriture n'a lieu qu'au relâchement, inutile de marteler le
stockage à chaque pixel.

Permutation gauche/droite par un bouton, pour l'animateur qui pilote depuis un second
écran. Double-clic sur la poignée pour revenir à la largeur par défaut.

Accessible au clavier : la poignée est focalisable, porte `role="separator"`, et les
flèches la déplacent (pas de 20 px, 60 px avec Maj). Redimensionner ne doit pas exiger
une souris.

Sous 900 px les colonnes s'empilent et la poignée disparaît. Le gabarit étant posé en
style en ligne, la règle d'empilement doit porter `!important` — seul moyen de
reprendre la main sur un style d'attribut ; c'est commenté sur place.

Les types d'événement React sont importés sous alias : `PointerEvent` et
`KeyboardEvent` nus désignent les types DOM, dont les écouteurs `window` ont besoin.

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

Co-Authored-By: Claude (RCA)
parent f2baef7e
Loading
Loading
Loading
Loading
+141 −4
Original line number Diff line number Diff line
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react';
import {
  useCallback,
  useEffect,
  useRef,
  useState,
  type FormEvent,
  // Alias : `PointerEvent` et `KeyboardEvent` nus designent les types DOM, utilises
  // par les ecouteurs `window` du redimensionnement.
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import type { ApiCharacter, ApiEventLog, ApiInject, ClockView, MailMessage } from '@cythin/shared';
import { api, ApiError, type InjectInput } from './api';
import { connectSocket, formatClock, RT } from './socket';
@@ -59,6 +69,40 @@ export function injectGroup(i: ApiInject, nowSec: number, started: boolean): Inj
  return i.offsetSeconds - nowSec <= IMMINENT_WINDOW_SEC ? 'IMMINENT' : 'UPCOMING';
}

/** Largeur par defaut de la colonne laterale, et bornes du redimensionnement. */
const SIDE_DEFAULT = 340;
const SIDE_MIN = 260;
/** Part maximale de la largeur totale : la colonne laterale reste laterale. */
const SIDE_MAX_RATIO = 0.6;

interface ConsoleLayout {
  sideWidth: number;
  /** Colonne laterale a gauche : certains animateurs pilotent d'un second ecran. */
  swapped: boolean;
}

const layoutKey = (exerciseId: string): string => `cythin_console_layout_${exerciseId}`;

function loadLayout(exerciseId: string): ConsoleLayout {
  const fallback: ConsoleLayout = { sideWidth: SIDE_DEFAULT, swapped: false };
  try {
    const raw = localStorage.getItem(layoutKey(exerciseId));
    if (!raw) return fallback;
    const parsed = JSON.parse(raw) as Partial<ConsoleLayout>;
    return {
      // Une valeur aberrante enregistree par une version precedente ne doit pas
      // rendre la console inutilisable : on borne a la lecture aussi.
      sideWidth:
        typeof parsed.sideWidth === 'number' && Number.isFinite(parsed.sideWidth)
          ? Math.max(SIDE_MIN, parsed.sideWidth)
          : SIDE_DEFAULT,
      swapped: parsed.swapped === true,
    };
  } catch {
    return fallback;
  }
}

export function Console({
  exerciseId,
  characters,
@@ -84,6 +128,9 @@ export function Console({
  const [showForm, setShowForm] = useState(false);
  const [feedTab, setFeedTab] = useState<'calls' | 'mail' | 'activity'>('mail');
  const [closing, setClosing] = useState(false);
  const [layout, setLayout] = useState<ConsoleLayout>(() => loadLayout(exerciseId));
  const gridRef = useRef<HTMLDivElement>(null);
  const [dragging, setDragging] = useState(false);
  const socketRef = useRef<ReturnType<typeof connectSocket> | null>(null);

  const reload = useCallback(async () => {
@@ -171,6 +218,67 @@ export function Console({
    }
  }

  /** Enregistre la disposition : elle est propre au poste, pas a l'exercice partage. */
  const persistLayout = useCallback(
    (next: ConsoleLayout) => {
      setLayout(next);
      try {
        localStorage.setItem(layoutKey(exerciseId), JSON.stringify(next));
      } catch {
        /* stockage indisponible : la disposition vaut pour la session */
      }
    },
    [exerciseId],
  );

  /**
   * Glisser la poignee. On ecoute sur `window` plutot que sur la poignee : le
   * pointeur sort tres vite de six pixels de large pendant un glissement.
   */
  function startResize(e: ReactPointerEvent<HTMLElement>): void {
    e.preventDefault();
    const grid = gridRef.current;
    if (!grid) return;
    const rect = grid.getBoundingClientRect();
    const max = rect.width * SIDE_MAX_RATIO;
    setDragging(true);

    const move = (ev: PointerEvent): void => {
      const raw = layout.swapped ? ev.clientX - rect.left : rect.right - ev.clientX;
      setLayout((prev) => ({ ...prev, sideWidth: Math.min(max, Math.max(SIDE_MIN, raw)) }));
    };
    const up = (): void => {
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', up);
      setDragging(false);
      // On n'ecrit qu'au relachement : inutile de marteler le stockage.
      setLayout((prev) => {
        try {
          localStorage.setItem(layoutKey(exerciseId), JSON.stringify(prev));
        } catch {
          /* sans effet */
        }
        return prev;
      });
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', up);
  }

  /** Clavier : le redimensionnement ne doit pas exiger une souris. */
  function resizeByKey(e: ReactKeyboardEvent<HTMLElement>): void {
    const step = e.shiftKey ? 60 : 20;
    const dir = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0;
    if (dir === 0) return;
    e.preventDefault();
    const max = (gridRef.current?.getBoundingClientRect().width ?? 1200) * SIDE_MAX_RATIO;
    const delta = layout.swapped ? dir * step : -dir * step;
    persistLayout({
      ...layout,
      sideWidth: Math.min(max, Math.max(SIDE_MIN, layout.sideWidth + delta)),
    });
  }

  const view = clock?.view;
  const running = view?.running ?? false;
  const status = view?.status ?? 'DRAFT';
@@ -274,8 +382,16 @@ export function Console({
        )}
      </div>

      <div className="console-grid">
        <div className="tl-pane">
      <div
        className="console-grid"
        ref={gridRef}
        style={{
          gridTemplateColumns: layout.swapped
            ? `${layout.sideWidth}px 6px minmax(0, 1fr)`
            : `minmax(0, 1fr) 6px ${layout.sideWidth}px`,
        }}
      >
        <div className="tl-pane" style={{ order: layout.swapped ? 3 : 1 }}>
          <div className="tl-head">
            <h4>
              {t('Chronogramme')} <span className="muted">{filtered.length}</span>
@@ -303,6 +419,14 @@ export function Console({
            >
              {showForm ? t('Fermer') : `+ ${t('Ajouter')}`}
            </button>
            <button
              className="icon-btn"
              title={t('Inverser les colonnes')}
              aria-label={t('Inverser les colonnes')}
              onClick={() => persistLayout({ ...layout, swapped: !layout.swapped })}
            >

            </button>
          </div>

          {(showForm || editing) && (
@@ -403,9 +527,22 @@ export function Console({
          </div>
        </div>

        <div
          className={`col-resizer ${dragging ? 'dragging' : ''}`}
          style={{ order: 2 }}
          role="separator"
          aria-orientation="vertical"
          aria-label={t('Redimensionner les colonnes')}
          tabIndex={0}
          title={t('Glisser pour redimensionner · double-clic pour réinitialiser')}
          onPointerDown={startResize}
          onKeyDown={resizeByKey}
          onDoubleClick={() => persistLayout({ ...layout, sideWidth: SIDE_DEFAULT })}
        />

        {/* Les trois flux etaient empiles : le journal, le plus verbeux, chassait
            les appels a passer hors de l'ecran. En onglets, chacun garde sa place. */}
        <div className="side-pane">
        <div className="side-pane" style={{ order: layout.swapped ? 1 : 3 }}>
          <div className="side-tabs">
            <button
              className={`${feedTab === 'calls' ? 'active' : ''} ${calls.length > 0 ? 'urgent' : ''}`}
+6 −1
Original line number Diff line number Diff line
@@ -97,7 +97,12 @@ export function Dashboard({
          </div>
        </header>

        <main className="admin-content">
        {/*
          * Les exercices prennent toute la largeur : la console d'animation y vit,
          * et 1100 px y bridaient le chronogramme comme les flux. Les autres
          * sections restent centrées, une liste d'utilisateurs sur 2500 px se lit mal.
          */}
        <main className={`admin-content ${section === 'exercises' ? 'wide' : ''}`}>
          {section === 'exercises' && <Exercises key={exercisesKey} isAdmin={isAdmin} />}
          {section === 'kits' && (
            <KitsLibrary
+6 −0
Original line number Diff line number Diff line
@@ -619,6 +619,12 @@ const EN: Record<string, string> = {
  'Nom du portail RH': 'HR portal name',
  'Défaut : « intranet ».': 'Default: “intranet”.',
  'Défaut : « portail RH ».': 'Default: “HR portal”.',

  // Disposition de la console d'animation
  'Redimensionner les colonnes': 'Resize columns',
  'Glisser pour redimensionner · double-clic pour réinitialiser':
    'Drag to resize · double-click to reset',
  'Inverser les colonnes': 'Swap columns',
};

interface I18n {
+44 −5
Original line number Diff line number Diff line
@@ -319,6 +319,11 @@ textarea:focus {
  margin: 0 auto;
}

/* Sections larges (exercices) : on rend l'écran entier au pilotage. */
.admin-content.wide {
  max-width: none;
}

/* ---- Onglets (détail d'exercice) ---- */
.detail-head {
  display: flex;
@@ -764,11 +769,16 @@ textarea {
  color: var(--brand);
}

/*
 * Deux colonnes redimensionnables. Le gabarit est posé en style en ligne par la
 * console (largeur mémorisée par exercice) ; on ne fixe ici que le repli.
 * `minmax(0, …)` reste indispensable : sans lui, un titre d'inject long élargit
 * la colonne et déborde.
 */
.console-grid {
  display: grid;
  /* minmax(0, …) : sans lui, un titre d'inject long élargit la colonne et déborde. */
  grid-template-columns: minmax(0, 1fr) 320px;
  gap: 18px;
  grid-template-columns: minmax(0, 1fr) 6px 320px;
  gap: 0 12px;
  align-items: start;
}

@@ -776,9 +786,38 @@ textarea {
  min-width: 0;
}

@media (max-width: 800px) {
/* Poignée de redimensionnement : discrète au repos, franche au survol. */
.col-resizer {
  align-self: stretch;
  min-height: 200px;
  cursor: col-resize;
  border-radius: 3px;
  background: transparent;
  border: none;
  padding: 0;
  transition: background 0.12s;
  touch-action: none;
}
.col-resizer:hover,
.col-resizer.dragging {
  background: var(--brand);
}
.col-resizer:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}

@media (max-width: 900px) {
  /*
   * Empilement : le gabarit en ligne doit céder, d'où `!important` — c'est le seul
   * moyen de reprendre la main sur un style d'attribut.
   */
  .console-grid {
    grid-template-columns: 1fr;
    grid-template-columns: 1fr !important;
    gap: 18px;
  }
  .col-resizer {
    display: none;
  }
}