Commit 2250f91c authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Front : skins immersifs, homepages réseaux sociaux, presse, annuaire liste, main courante

- Skins d'exercice : Outlook/Teams (Microsoft) et Gmail/Google Chat (Google) ;
  sélecteur "Habillage de l'espace joueur" dans l'admin.
- Réseaux sociaux : homepages fidèles X / LinkedIn / Instagram (3 colonnes centrées),
  vue "Tout" en 3 colonnes parallèles.
- Presse : cartes par média (Le Monde, Le Figaro, franceinfo, Ouest-France) +
  "site web" immersif de l'article.
- Annuaire : liste dense avec recherche, filtre par type et tri par colonne.
- Main courante : n° d'ordre, heure réelle, recherche, alertes en avant,
  suivi éditable des décisions/actions, impression/PDF.
- Thème clair/sombre joueur ; boutons Message/Chat dans l'annuaire ; correctif d'envoi.
- i18n FR/EN étendue à l'ensemble.

Co-Authored-By: Claude (RCA)
parent 1c77d0f8
Loading
Loading
Loading
Loading
+5 −3
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@ export function Chat({
  onCreateDm,
  loadContacts,
  joinExerciseId,
  initialChannelId,
}: {
  loadChannels: () => Promise<ChannelItem[]>;
  loadMessages: (channelId: string) => Promise<ApiChatMessage[]>;
@@ -26,10 +27,11 @@ export function Chat({
  onCreateDm?: (participantId: string) => Promise<{ id: string }>;
  loadContacts?: () => Promise<MailContact[]>;
  joinExerciseId?: string;
  initialChannelId?: string;
}): JSX.Element {
  const { t } = useT();
  const [channels, setChannels] = useState<ChannelItem[]>([]);
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [selectedId, setSelectedId] = useState<string | null>(initialChannelId ?? null);
  const [messages, setMessages] = useState<ApiChatMessage[]>([]);
  const [input, setInput] = useState('');
  const [newChannel, setNewChannel] = useState('');
@@ -123,9 +125,9 @@ export function Chat({
          >
            <option value="">{t('+ Message direct…')}</option>
            {contacts
              .filter((c) => c.kind === 'PARTICIPANT' && c.participantId)
              .filter((c) => c.kind === 'CHARACTER' && c.participantId)
              .map((c) => (
                <option key={c.participantId} value={c.participantId ?? ''}>
                <option key={c.characterId} value={c.participantId ?? ''}>
                  {c.label}
                </option>
              ))}
+1 −1
Original line number Diff line number Diff line
@@ -216,7 +216,7 @@ export function Console({
            {mail.map((m) => (
              <div key={m.id} className="fitem">
                <div className="ft mono">T+{formatClock(m.sentAtExerciseSec)}</div>
                <b>{m.from}</b>{m.subject}
                <b>{m.from}</b> {m.to.join(', ')} {m.subject}
              </div>
            ))}
            {mail.length === 0 && <p className="muted">{t('Aucun message reçu.')}</p>}
+131 −17
Original line number Diff line number Diff line
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { DirectoryEntry } from '@cythin/shared';
import { api, ApiError } from './api';
import { useT } from './i18n';

export function Directory(): JSX.Element {
type SortKey = 'name' | 'title' | 'orgUnit' | 'kind';

export function Directory({
  selfParticipantId,
  onMessage,
  onChat,
}: {
  selfParticipantId?: string;
  onMessage?: (characterId: string) => void;
  onChat?: (participantId: string) => void;
} = {}): JSX.Element {
  const { t } = useT();
  const [entries, setEntries] = useState<DirectoryEntry[]>([]);
  const [error, setError] = useState<string | null>(null);
  const [q, setQ] = useState('');
  const [typeF, setTypeF] = useState<'ALL' | 'PLAYER' | 'NPC'>('ALL');
  const [sort, setSort] = useState<{ key: SortKey; dir: 1 | -1 }>({ key: 'name', dir: 1 });

  useEffect(() => {
    api
@@ -15,27 +28,128 @@ export function Directory(): JSX.Element {
      .catch((err: unknown) => setError(err instanceof ApiError ? err.message : 'Erreur'));
  }, []);

  const norm = (s: string | null): string => (s ?? '').toLowerCase();

  const rows = useMemo(() => {
    const needle = q.trim().toLowerCase();
    return entries
      .filter((e) => typeF === 'ALL' || e.kind === typeF)
      .filter((e) => {
        if (!needle) return true;
        return [e.name, e.title, e.orgUnit, e.simEmail].map(norm).join(' ').includes(needle);
      })
      .sort((a, b) => {
        const av = norm(a[sort.key]);
        const bv = norm(b[sort.key]);
        if (av < bv) return -sort.dir as number;
        if (av > bv) return sort.dir as number;
        return norm(a.name) < norm(b.name) ? -1 : 1;
      });
  }, [entries, q, typeF, sort]);

  function toggleSort(key: SortKey): void {
    setSort((s) => (s.key === key ? { key, dir: (s.dir === 1 ? -1 : 1) as 1 | -1 } : { key, dir: 1 }));
  }

  const arrow = (key: SortKey): string => (sort.key === key ? (sort.dir === 1 ? '' : '') : '');
  const canAct = Boolean(onMessage || onChat);

  return (
    <div className="directory">
      <div className="dir-head">
        <h3>{t('Annuaire')}</h3>
      <p className="muted">{t("Contacts de l'exercice (données fictives).")}</p>
      {error && <p className="err">{error}</p>}
      <div className="dir-grid">
        {entries.map((c) => (
          <div key={c.id} className="card dir-card">
            <div className="cav" style={{ background: c.avatarColor ?? '#64707f' }}>
              {c.name.slice(0, 1)}
        <span className="muted">{t("Contacts de l'exercice (données fictives).")}</span>
      </div>
            <div className="cn">{c.name}</div>
            <div className="cr muted">
              {c.title ?? ''}
              {c.orgUnit ? ` · ${c.orgUnit}` : ''}
      {error && <p className="err">{error}</p>}

      <div className="dir-toolbar">
        <input
          className="dir-search"
          placeholder={t('Rechercher un contact…')}
          value={q}
          onChange={(e) => setQ(e.target.value)}
        />
        <div className="dir-filters">
          {(['ALL', 'PLAYER', 'NPC'] as const).map((k) => (
            <button key={k} className={typeF === k ? 'active' : ''} onClick={() => setTypeF(k)}>
              {k === 'ALL' ? t('Tous') : k === 'PLAYER' ? t('Joueurs') : t('PNJ')}
            </button>
          ))}
        </div>
            {c.simEmail && <div className="cc mono">{c.simEmail}</div>}
            {c.kind === 'NPC' && <div className="npc-tag">{t('animé')}</div>}
        <span className="dir-count muted">
          {rows.length} {t('contact(s)')}
        </span>
      </div>
        ))}
        {entries.length === 0 && <p className="muted">{t('Aucun contact.')}</p>}

      <div className="dir-table-wrap">
        <table className="dir-table">
          <thead>
            <tr>
              <th onClick={() => toggleSort('name')}>
                {t('Nom')}
                {arrow('name')}
              </th>
              <th onClick={() => toggleSort('title')}>
                {t('Fonction')}
                {arrow('title')}
              </th>
              <th onClick={() => toggleSort('orgUnit')}>
                {t('Service')}
                {arrow('orgUnit')}
              </th>
              <th onClick={() => toggleSort('kind')}>
                {t('Type')}
                {arrow('kind')}
              </th>
              <th>{t('Email')}</th>
              {canAct && <th></th>}
            </tr>
          </thead>
          <tbody>
            {rows.map((c) => {
              const isSelf = Boolean(selfParticipantId && c.participantId === selfParticipantId);
              return (
                <tr key={c.id}>
                  <td className="dir-name">
                    <span className="dir-av" style={{ background: c.avatarColor ?? '#64707f' }}>
                      {c.name.slice(0, 1)}
                    </span>
                    {c.name}
                  </td>
                  <td>{c.title ?? ''}</td>
                  <td>{c.orgUnit ?? ''}</td>
                  <td>
                    <span className={`dir-badge ${c.kind === 'NPC' ? 'is-npc' : 'is-player'}`}>
                      {c.kind === 'NPC' ? t('animé') : t('Joueur')}
                    </span>
                  </td>
                  <td className="mono">{c.simEmail ?? ''}</td>
                  {canAct && (
                    <td className="dir-row-actions">
                      {!isSelf && onMessage && (
                        <button className="btn ghost" onClick={() => onMessage(c.id)}>
{t('Message')}
                        </button>
                      )}
                      {!isSelf && onChat && c.participantId && (
                        <button className="btn ghost" onClick={() => onChat(c.participantId!)}>
                          💬 {t('Chat')}
                        </button>
                      )}
                    </td>
                  )}
                </tr>
              );
            })}
            {rows.length === 0 && (
              <tr>
                <td colSpan={canAct ? 6 : 5} className="muted" style={{ textAlign: 'center', padding: 24 }}>
                  {entries.length === 0 ? t('Aucun contact.') : t('Aucun résultat.')}
                </td>
              </tr>
            )}
          </tbody>
        </table>
      </div>
    </div>
  );
+26 −1
Original line number Diff line number Diff line
@@ -240,6 +240,30 @@ function ExerciseDetail({ id, onBack }: { id: string; onBack: () => void }): JSX
          {tab === 'prep' && (
            <div className="tab-panel">
              {exercise.objectives && <p className="muted">{exercise.objectives}</p>}
              <div className="settings-card">
                <div className="settings-head">
                  <h4>⚙️ {t("Paramètres de l'exercice")}</h4>
                </div>
                <div className="skin-picker">
                  <div className="skin-picker-label">
                    <b>{t("Habillage de l'espace joueur")}</b>
                    <span className="muted">
                      {t("S'applique à tout l'exercice : messagerie, chat et réseaux sociaux des joueurs.")}
                    </span>
                  </div>
                  <select
                    value={exercise.playerSkin}
                    onChange={async (e) => {
                      await api.updateExercise(id, { playerSkin: e.target.value as ApiExercise['playerSkin'] });
                      void reload();
                    }}
                  >
                    <option value="DEFAULT">{t('Cythin (par défaut)')}</option>
                    <option value="MICROSOFT">Microsoft (Outlook + Teams)</option>
                    <option value="GOOGLE">Google (Gmail + Google Chat)</option>
                  </select>
                </div>
              </div>
              <h4>{t('Personnages & accès joueurs')}</h4>
              <table className="grid">
            <thead>
@@ -329,7 +353,8 @@ function ExerciseDetail({ id, onBack }: { id: string; onBack: () => void }): JSX
            <div className="tab-panel">
              <MainCourante
                load={() => api.listLog(id)}
                add={(category, message) => api.addLog(id, { category, message })}
                add={(input) => api.addLog(id, input)}
                update={(entryId, patch) => api.updateLog(id, entryId, patch)}
                csvUrl={`/api/exercises/${id}/log/export.csv`}
                joinExerciseId={id}
              />
+178 −20
Original line number Diff line number Diff line
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react';
import type { ApiLogEntry, LogCategory } from '@cythin/shared';
import { ApiError } from './api';
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import type { ApiLogEntry, LogCategory, LogStatus } from '@cythin/shared';
import { ApiError, type LogInput, type LogUpdate } from './api';
import { connectSocket, formatClock, RT } from './socket';
import { useT } from './i18n';

@@ -11,23 +11,43 @@ const CAT_LABEL: Record<LogCategory, string> = {
  ACTION: 'Action',
  ALERTE: 'Alerte',
};
const STATUSES: LogStatus[] = ['TODO', 'IN_PROGRESS', 'DONE'];
const STATUS_LABEL: Record<LogStatus, string> = {
  TODO: 'À faire',
  IN_PROGRESS: 'En traitement',
  DONE: 'Terminé',
};

/** Heure réelle (horloge murale) au format HH:MM:SS. */
function wallTime(iso: string): string {
  const d = new Date(iso);
  return [d.getHours(), d.getMinutes(), d.getSeconds()]
    .map((n) => String(n).padStart(2, '0'))
    .join(':');
}

export function MainCourante({
  load,
  add,
  update,
  csvUrl,
  joinExerciseId,
}: {
  load: () => Promise<ApiLogEntry[]>;
  add: (category: LogCategory, message: string) => Promise<unknown>;
  add: (input: LogInput) => Promise<unknown>;
  update?: (id: string, patch: LogUpdate) => Promise<unknown>;
  csvUrl?: string;
  joinExerciseId?: string;
}): JSX.Element {
  const { t } = useT();
  const [entries, setEntries] = useState<ApiLogEntry[]>([]);
  const [filter, setFilter] = useState<LogCategory | 'ALL'>('ALL');
  const [q, setQ] = useState('');
  const [category, setCategory] = useState<LogCategory>('FAIT');
  const [message, setMessage] = useState('');
  const [source, setSource] = useState('');
  const [assignee, setAssignee] = useState('');
  const [dueLabel, setDueLabel] = useState('');
  const [error, setError] = useState<string | null>(null);
  const socketRef = useRef<ReturnType<typeof connectSocket> | null>(null);

@@ -47,28 +67,67 @@ export function MainCourante({
    socket.on(RT.logNew, (e: ApiLogEntry) =>
      setEntries((prev) => (prev.some((x) => x.id === e.id) ? prev : [e, ...prev])),
    );
    socket.on(RT.logUpdate, (e: ApiLogEntry) =>
      setEntries((prev) => prev.map((x) => (x.id === e.id ? e : x))),
    );
    return () => {
      socket.disconnect();
    };
  }, [reload, joinExerciseId]);

  const trackable = category === 'DECISION' || category === 'ACTION';

  async function submit(e: FormEvent): Promise<void> {
    e.preventDefault();
    setError(null);
    try {
      await add(category, message);
      await add({
        category,
        message,
        source: source || undefined,
        assignee: trackable && assignee ? assignee : undefined,
        dueLabel: trackable && dueLabel ? dueLabel : undefined,
      });
      setMessage('');
      setSource('');
      setAssignee('');
      setDueLabel('');
      // l'entree revient via log:new ; on ne recharge pas pour eviter le doublon.
    } catch (err) {
      setError(err instanceof ApiError ? err.message : 'Ajout impossible');
    }
  }

  const shown = filter === 'ALL' ? entries : entries.filter((e) => e.category === filter);
  // N° d'ordre chronologique global (independant du filtre/recherche).
  const orderNo = useMemo(() => {
    const asc = [...entries].sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1));
    const m = new Map<string, number>();
    asc.forEach((e, i) => m.set(e.id, i + 1));
    return m;
  }, [entries]);

  const needle = q.trim().toLowerCase();
  const shown = entries
    .filter((e) => filter === 'ALL' || e.category === filter)
    .filter((e) => {
      if (!needle) return true;
      return [e.message, e.authorLabel, e.source, e.assignee]
        .map((s) => (s ?? '').toLowerCase())
        .join(' ')
        .includes(needle);
    });

  function patch(id: string, p: LogUpdate): void {
    if (!update) return;
    void update(id, p).catch(() => {
      /* non bloquant ; l'etat serveur reste la reference */
    });
  }

  return (
    <div className="maincourante">
      <form className="log-add" onSubmit={submit}>
        <div className="log-add-main">
          <select value={category} onChange={(e) => setCategory(e.target.value as LogCategory)}>
            {CATEGORIES.map((c) => (
              <option key={c} value={c}>
@@ -77,6 +136,7 @@ export function MainCourante({
            ))}
          </select>
          <input
            className="log-msg"
            placeholder={t('Nouvelle entrée…')}
            value={message}
            onChange={(e) => setMessage(e.target.value)}
@@ -85,9 +145,38 @@ export function MainCourante({
          <button className="btn primary" type="submit">
            {t('Consigner')}
          </button>
        </div>
        <div className="log-add-extra">
          <input
            placeholder={t('Source / canal (tél., mail…)')}
            value={source}
            onChange={(e) => setSource(e.target.value)}
          />
          {trackable && (
            <>
              <input
                placeholder={t('Responsable')}
                value={assignee}
                onChange={(e) => setAssignee(e.target.value)}
              />
              <input
                placeholder={t('Échéance')}
                value={dueLabel}
                onChange={(e) => setDueLabel(e.target.value)}
              />
            </>
          )}
        </div>
      </form>
      {error && <p className="err">{error}</p>}

      <div className="log-toolbar">
        <input
          className="log-search"
          placeholder={t('Rechercher…')}
          value={q}
          onChange={(e) => setQ(e.target.value)}
        />
        <div className="log-filters">
          <button className={filter === 'ALL' ? 'active' : ''} onClick={() => setFilter('ALL')}>
            {t('Tout')}
@@ -97,42 +186,111 @@ export function MainCourante({
              {t(CAT_LABEL[c])}
            </button>
          ))}
        </div>
        <div className="log-toolbar-right">
          <button className="btn ghost" type="button" onClick={() => window.print()}>
            {t('Imprimer / PDF')}
          </button>
          {csvUrl && (
          <a className="btn ghost csv" href={csvUrl}>
            <a className="btn ghost" href={csvUrl}>
              {t('Exporter CSV')}
            </a>
          )}
        </div>
      </div>

      <table className="grid log-table">
      <div className="log-table-wrap">
        <table className="log-table">
          <thead>
            <tr>
            <th style={{ width: 70 }}>T+</th>
            <th style={{ width: 90 }}>{t('Type')}</th>
              <th className="c-no"></th>
              <th className="c-time">{t('Heure')}</th>
              <th className="c-type">{t('Type')}</th>
              <th>{t('Événement')}</th>
            <th style={{ width: 130 }}>{t('Auteur')}</th>
              <th className="c-track">{t('Suivi')}</th>
              <th className="c-author">{t('Auteur')}</th>
            </tr>
          </thead>
          <tbody>
          {shown.map((e) => (
            <tr key={e.id}>
              <td className="mono">{formatClock(e.atExerciseSec)}</td>
              <td>
            {shown.map((e) => {
              const isTrack = e.category === 'DECISION' || e.category === 'ACTION';
              return (
                <tr key={e.id} className={`log-row cat-${e.category}`}>
                  <td className="c-no mono muted">{orderNo.get(e.id)}</td>
                  <td className="c-time mono">
                    <div className="wall">{wallTime(e.createdAt)}</div>
                    <div className="muted small">T+{formatClock(e.atExerciseSec)}</div>
                  </td>
                  <td className="c-type">
                    <span className={`tag t-${e.category}`}>{t(CAT_LABEL[e.category])}</span>
                  </td>
              <td>{e.message}</td>
              <td className="muted">{e.authorLabel}</td>
            </tr>
                  <td className="log-event">
                    <div>{e.message}</div>
                    {e.source && <div className="muted small">📎 {e.source}</div>}
                  </td>
                  <td className="c-track">
                    {isTrack ? (
                      <div className="track">
                        {update ? (
                          <select
                            className={`status-sel st-${e.status ?? 'TODO'}`}
                            value={e.status ?? 'TODO'}
                            onChange={(ev) => patch(e.id, { status: ev.target.value as LogStatus })}
                          >
                            {STATUSES.map((s) => (
                              <option key={s} value={s}>
                                {t(STATUS_LABEL[s])}
                              </option>
                            ))}
                          </select>
                        ) : (
                          <span className={`status-badge st-${e.status ?? 'TODO'}`}>
                            {t(STATUS_LABEL[e.status ?? 'TODO'])}
                          </span>
                        )}
                        {update ? (
                          <input
                            className="track-in"
                            defaultValue={e.assignee ?? ''}
                            placeholder={t('Responsable')}
                            onBlur={(ev) => {
                              if (ev.target.value !== (e.assignee ?? '')) patch(e.id, { assignee: ev.target.value });
                            }}
                          />
                        ) : (
                          e.assignee && <span className="track-txt">👤 {e.assignee}</span>
                        )}
                        {update ? (
                          <input
                            className="track-in due"
                            defaultValue={e.dueLabel ?? ''}
                            placeholder={t('Échéance')}
                            onBlur={(ev) => {
                              if (ev.target.value !== (e.dueLabel ?? '')) patch(e.id, { dueLabel: ev.target.value });
                            }}
                          />
                        ) : (
                          e.dueLabel && <span className="track-txt">{e.dueLabel}</span>
                        )}
                      </div>
                    ) : (
                      <span className="muted"></span>
                    )}
                  </td>
                  <td className="c-author muted">{e.authorLabel}</td>
                </tr>
              );
            })}
            {shown.length === 0 && (
              <tr>
              <td colSpan={4} className="muted">
                {t('Aucune entrée.')}
                <td colSpan={6} className="muted" style={{ textAlign: 'center', padding: 24 }}>
                  {entries.length === 0 ? t('Aucune entrée.') : t('Aucun résultat.')}
                </td>
              </tr>
            )}
          </tbody>
        </table>
      </div>
    </div>
  );
}
Loading