Commit 2458d1b2 authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Front : console sociale animation, sélecteur destinataires, injects, éditeur de kits

- Réseaux sociaux (animation) : console de gestion compacte + édition de
  l'engagement / suppression (remplace la homepage immersive, inadaptée à la colonne).
- Messagerie joueur : champ destinataires à jetons + autocomplétion.
- Injects en exercice : badge canal, titre complet, Rejouer, Modifier (formulaire pré-rempli).
- Bibliothèque : éditeur de kits (métadonnées, injects, personnages) + nouveau kit / dupliquer / réinitialiser / supprimer.

Co-Authored-By: Claude (RCA)
parent e9a39b6f
Loading
Loading
Loading
Loading
+96 −7
Original line number Diff line number Diff line
@@ -13,6 +13,16 @@ const STATUS_LABEL: Record<string, string> = {
  DRAFT: 'Brouillon',
};

const CHANNEL_LABEL: Record<string, string> = {
  EMAIL: 'Email',
  CHAT: 'Chat',
  SOCIAL: 'Réseau social',
  NEWS: 'Presse',
  CALL: 'Appel',
  DOCUMENT: 'Document',
  DIRECTORY: 'Annuaire',
};

export function Console({
  exerciseId,
  characters,
@@ -30,6 +40,7 @@ export function Console({
    { id: string; title: string; to: string | null; body: string; atExerciseSec: number }[]
  >([]);
  const [error, setError] = useState<string | null>(null);
  const [editing, setEditing] = useState<ApiInject | null>(null);
  const socketRef = useRef<ReturnType<typeof connectSocket> | null>(null);

  const reload = useCallback(async () => {
@@ -164,17 +175,31 @@ export function Console({
                <span className="t mono">
                  {i.offsetSeconds != null ? `T+${formatClock(i.offsetSeconds)}` : t('Manuel')}
                </span>
                <div className="inj-main">
                  <span className={`inj-chan c-${i.channel}`}>{t(CHANNEL_LABEL[i.channel] ?? i.channel)}</span>
                  <span className="inj-title">{i.title}</span>
                </div>
                <span className={`pill s-${i.status}`}>{t(STATUS_LABEL[i.status])}</span>
                <span className="inj-actions">
                  {i.status === 'PENDING' && (
                    <button className="btn ghost go" onClick={async () => void api.triggerInject(i.id)}>
                      {t('Déclencher')}
                    </button>
                  )}
                  {i.status === 'SENT' && (
                    <button
                      className="btn ghost go"
                      className="btn ghost"
                      onClick={async () => {
                        await api.triggerInject(i.id);
                        await api.replayInject(i.id);
                        await reload();
                      }}
                    >
                      {t('Déclencher')}
                      {t('Rejouer')}
                    </button>
                  )}
                  {i.status !== 'SENT' && (
                    <button className="btn ghost" onClick={() => setEditing(i)}>
                      {t('Modifier')}
                    </button>
                  )}
                  {i.status === 'PENDING' && (
@@ -193,7 +218,13 @@ export function Console({
            ))}
            {injects.length === 0 && <p className="muted">{t('Aucun inject planifié.')}</p>}
          </div>
          <CreateInjectForm exerciseId={exerciseId} characters={characters} onCreated={reload} />
          <CreateInjectForm
            exerciseId={exerciseId}
            characters={characters}
            onCreated={reload}
            editing={editing}
            onDoneEditing={() => setEditing(null)}
          />
        </div>

        <div>
@@ -242,10 +273,14 @@ function CreateInjectForm({
  exerciseId,
  characters,
  onCreated,
  editing,
  onDoneEditing,
}: {
  exerciseId: string;
  characters: ApiCharacter[];
  onCreated: () => Promise<void>;
  editing?: ApiInject | null;
  onDoneEditing?: () => void;
}): JSX.Element {
  const { t } = useT();
  const [channel, setChannel] = useState<'EMAIL' | 'CHAT' | 'SOCIAL' | 'CALL' | 'NEWS'>('EMAIL');
@@ -278,6 +313,39 @@ function CreateInjectForm({
    void api.listChannels(exerciseId).then(setChannels).catch(() => setChannels([]));
  }, [exerciseId]);

  // Pre-remplissage en mode edition.
  useEffect(() => {
    if (!editing) return;
    const p = (editing.payload ?? {}) as unknown as Record<string, unknown>;
    const str = (v: unknown): string => (typeof v === 'string' ? v : '');
    const num = (v: unknown): number => (typeof v === 'number' ? v : 0);
    setChannel((['EMAIL', 'CHAT', 'SOCIAL', 'CALL', 'NEWS'].includes(editing.channel) ? editing.channel : 'EMAIL') as typeof channel);
    setTitle(editing.title);
    setTrigger(editing.trigger);
    if (editing.offsetSeconds != null) {
      setSchedHours(Math.floor(editing.offsetSeconds / 3600));
      setSchedMin(Math.floor((editing.offsetSeconds % 3600) / 60));
    }
    setSenderCharId(editing.senderCharId ?? '');
    setSubject(str(p.subject));
    setBody(str(p.body));
    setChannelId(str(p.channelId));
    setNetwork((['X', 'LINKEDIN', 'INSTAGRAM'].includes(str(p.network)) ? str(p.network) : 'X') as typeof network);
    setAuthorName(str(p.authorName));
    setAuthorHandle(str(p.authorHandle));
    setLikes(num(p.likes));
    setOutlet(
      (['GENERIC', 'LEMONDE', 'FIGARO', 'FRANCEINFO', 'OUESTFRANCE'].includes(str(p.outlet))
        ? str(p.outlet)
        : 'GENERIC') as typeof outlet,
    );
    setHeadline(str(p.headline));
    setNewsSource(str(p.source));
    setNewsCategory(str(p.category));
    setCallTo(str(p.to));
    setAttachIds(Array.isArray(p.attachmentDocIds) ? (p.attachmentDocIds as string[]) : []);
  }, [editing]);

  async function submit(e: FormEvent): Promise<void> {
    e.preventDefault();
    setError(null);
@@ -307,7 +375,12 @@ function CreateInjectForm({
      };
    }
    try {
      if (editing) {
        await api.updateInject(editing.id, input);
        onDoneEditing?.();
      } else {
        await api.createInject(exerciseId, input);
      }
      setTitle('');
      setSubject('');
      setBody('');
@@ -324,9 +397,20 @@ function CreateInjectForm({
    }
  }

  function cancelEdit(): void {
    onDoneEditing?.();
    setTitle('');
    setSubject('');
    setBody('');
    setCallTo('');
    setAttachIds([]);
    setHeadline('');
    setNewsCategory('');
  }

  return (
    <form className="create-form" onSubmit={submit}>
      <h4>{t('Nouvel inject')}</h4>
    <form className={`create-form ${editing ? 'editing' : ''}`} onSubmit={submit}>
      <h4>{editing ? t("Modifier l'inject") : t('Nouvel inject')}</h4>
      <div className="row">
        <select value={channel} onChange={(e) => setChannel(e.target.value as typeof channel)}>
          <option value="EMAIL">{t('Email')}</option>
@@ -522,8 +606,13 @@ function CreateInjectForm({

      <div className="row">
        <button className="btn primary" type="submit">
          {t('Ajouter au chronogramme')}
          {editing ? t('Enregistrer') : t('Ajouter au chronogramme')}
        </button>
        {editing && (
          <button className="btn ghost" type="button" onClick={cancelEdit}>
            {t('Annuler')}
          </button>
        )}
      </div>
      {error && <p className="err">{error}</p>}
    </form>
+2 −6
Original line number Diff line number Diff line
@@ -3,7 +3,7 @@ import type { ApiCharacter, ApiExercise, ApiParticipant, CharacterKind } from '@
import { api, ApiError } from './api';
import { Console } from './Console';
import { Chat } from './Chat';
import { Social } from './Social';
import { SocialConsole } from './Social';
import { News } from './News';
import { Docs } from './Docs';
import { MainCourante } from './MainCourante';
@@ -322,11 +322,7 @@ function ExerciseDetail({ id, onBack }: { id: string; onBack: () => void }): JSX
              </div>
              <div>
                <h4>{t('Réseaux sociaux')}</h4>
                <Social
                  load={() => api.listSocial(id)}
                  onPublish={(input) => api.createPost(id, input)}
                  joinExerciseId={id}
                />
                <SocialConsole exerciseId={id} />
              </div>
              <div>
                <h4>{t('Presse')}</h4>
+424 −0
Original line number Diff line number Diff line
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import type { ApiKit, ApiKitCharacter, ApiKitInject, KitInjectChannel } from '@cythin/shared';
import { api, ApiError, type KitCharacterInput, type KitInjectInput } from './api';
import { useT } from './i18n';

const CHANNELS: KitInjectChannel[] = ['EMAIL', 'CHAT', 'SOCIAL', 'NEWS', 'CALL'];
const CHANNEL_LABEL: Record<KitInjectChannel, string> = {
  EMAIL: 'Email',
  CHAT: 'Chat',
  SOCIAL: 'Réseau social',
  NEWS: 'Presse',
  CALL: 'Appel',
};

function fmtOffset(min: number): string {
  return `T+${String(Math.floor(min / 60)).padStart(2, '0')}:${String(min % 60).padStart(2, '0')}`;
}

export function KitEditor({ kitId, onBack }: { kitId: string; onBack: () => void }): JSX.Element {
  const { t } = useT();
  const [kit, setKit] = useState<ApiKit | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [meta, setMeta] = useState({ name: '', sector: '', description: '', objectives: '' });
  const [injForm, setInjForm] = useState<ApiKitInject | 'new' | null>(null);
  const [charForm, setCharForm] = useState<ApiKitCharacter | 'new' | null>(null);

  const reload = useCallback(async () => {
    try {
      const k = await api.getKit(kitId);
      setKit(k);
      setMeta({ name: k.name, sector: k.sector, description: k.description, objectives: k.objectives ?? '' });
    } catch (err) {
      setError(err instanceof ApiError ? err.message : 'Chargement impossible');
    }
  }, [kitId]);
  useEffect(() => {
    void reload();
  }, [reload]);

  if (error) return <p className="err">{error}</p>;
  if (!kit) return <p className="muted">{t('Chargement…')}</p>;

  async function saveMeta(): Promise<void> {
    await api.updateKit(kitId, meta);
    await reload();
  }
  async function removeKit(): Promise<void> {
    await api.deleteKit(kitId);
    onBack();
  }
  async function duplicate(): Promise<void> {
    await api.duplicateKit(kitId);
    onBack();
  }
  async function reset(): Promise<void> {
    await api.resetKit(kitId);
    await reload();
  }

  return (
    <section className="panel kit-editor">
      <div className="detail-head">
        <button className="btn ghost" onClick={onBack}>
{t('Bibliothèque de kits')}
        </button>
        <div className="detail-title">
          <h3>{kit.name}</h3>
          <span className="muted">{kit.sector}</span>
        </div>
        <div className="ke-head-actions">
          <button className="btn ghost" onClick={() => void duplicate()}>
            {t('Dupliquer')}
          </button>
          {kit.builtinId && (
            <button className="btn ghost" onClick={() => void reset()}>
              {t('Réinitialiser')}
            </button>
          )}
          <button className="btn ghost danger" onClick={() => void removeKit()}>
            {t('Supprimer')}
          </button>
        </div>
      </div>

      {/* Metadonnees */}
      <div className="ke-meta">
        <div className="row">
          <input value={meta.name} onChange={(e) => setMeta({ ...meta, name: e.target.value })} placeholder={t('Nom')} />
          <input value={meta.sector} onChange={(e) => setMeta({ ...meta, sector: e.target.value })} placeholder={t('Secteur')} />
        </div>
        <textarea
          value={meta.description}
          onChange={(e) => setMeta({ ...meta, description: e.target.value })}
          placeholder={t('Description')}
          rows={2}
        />
        <textarea
          value={meta.objectives}
          onChange={(e) => setMeta({ ...meta, objectives: e.target.value })}
          placeholder={t('Objectifs')}
          rows={2}
        />
        <div className="row">
          <button className="btn primary" onClick={() => void saveMeta()}>
            {t('Enregistrer')}
          </button>
        </div>
      </div>

      {/* Injects */}
      <div className="ke-section-head">
        <h4>{t('Chronogramme des injects')} ({kit.injects.length})</h4>
        <button className="btn ghost" onClick={() => setInjForm('new')}>
          + {t('Ajouter un inject')}
        </button>
      </div>
      {injForm && (
        <KitInjectForm
          kit={kit}
          inject={injForm === 'new' ? null : injForm}
          onDone={() => setInjForm(null)}
          onSaved={reload}
        />
      )}
      <div className="timeline">
        {kit.injects.map((i) => (
          <div key={i.id} className="inj">
            <span className="t mono">{fmtOffset(i.offsetMinutes)}</span>
            <div className="inj-main">
              <span className={`inj-chan c-${i.channel}`}>{t(CHANNEL_LABEL[i.channel])}</span>
              <span className="inj-title">{i.title}</span>
            </div>
            <span className="inj-actions">
              <button className="btn ghost" onClick={() => setInjForm(i)}>
                {t('Modifier')}
              </button>
              <button
                className="btn ghost danger"
                onClick={async () => {
                  await api.deleteKitInject(kitId, i.id);
                  await reload();
                }}
              >
                {t('Supprimer')}
              </button>
            </span>
          </div>
        ))}
        {kit.injects.length === 0 && <p className="muted">{t('Aucun inject planifié.')}</p>}
      </div>

      {/* Personnages */}
      <div className="ke-section-head">
        <h4>{t('Personnages')} ({kit.characters.length})</h4>
        <button className="btn ghost" onClick={() => setCharForm('new')}>
          + {t('Ajouter un personnage')}
        </button>
      </div>
      {charForm && (
        <KitCharForm
          kitId={kitId}
          character={charForm === 'new' ? null : charForm}
          onDone={() => setCharForm(null)}
          onSaved={reload}
        />
      )}
      <table className="grid">
        <thead>
          <tr>
            <th>{t('Nom')}</th>
            <th>{t('Fonction')}</th>
            <th>{t('Type')}</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {kit.characters.map((c) => (
            <tr key={c.id}>
              <td>{c.name}</td>
              <td className="muted">
                {c.title ?? ''}
                {c.orgUnit ? ` · ${c.orgUnit}` : ''}
              </td>
              <td>{c.kind === 'PLAYER' ? t('Joueur') : t('animé')}</td>
              <td className="actions-cell">
                <button className="btn ghost" onClick={() => setCharForm(c)}>
                  {t('Modifier')}
                </button>
                <button
                  className="btn ghost danger"
                  onClick={async () => {
                    await api.deleteKitCharacter(kitId, c.id);
                    await reload();
                  }}
                >
                  {t('Supprimer')}
                </button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      <p className="muted small" style={{ marginTop: 10 }}>
        {t("L'expéditeur et les destinataires des injects renvoient à ces personnages.")}
      </p>
    </section>
  );
}

function KitInjectForm({
  kit,
  inject,
  onDone,
  onSaved,
}: {
  kit: ApiKit;
  inject: ApiKitInject | null;
  onDone: () => void;
  onSaved: () => Promise<void>;
}): JSX.Element {
  const { t } = useT();
  const [channel, setChannel] = useState<KitInjectChannel>(inject?.channel ?? 'EMAIL');
  const [offsetMinutes, setOffsetMinutes] = useState(inject?.offsetMinutes ?? 0);
  const [title, setTitle] = useState(inject?.title ?? '');
  const [subject, setSubject] = useState(inject?.subject ?? '');
  const [body, setBody] = useState(inject?.body ?? '');
  const [senderRef, setSenderRef] = useState(inject?.senderRef ?? '');
  const [channelRef, setChannelRef] = useState(inject?.channelRef ?? '');
  const [targetRefs, setTargetRefs] = useState<string[]>(inject?.targetRefs ?? []);
  const [network, setNetwork] = useState<'X' | 'LINKEDIN' | 'INSTAGRAM'>(inject?.network ?? 'X');
  const [authorName, setAuthorName] = useState(inject?.authorName ?? '');
  const [authorHandle, setAuthorHandle] = useState(inject?.authorHandle ?? '');
  const [likes, setLikes] = useState(inject?.likes ?? 0);
  const [error, setError] = useState<string | null>(null);

  async function submit(e: FormEvent): Promise<void> {
    e.preventDefault();
    setError(null);
    const input: KitInjectInput = {
      offsetMinutes,
      channel,
      title,
      body,
      senderRef: senderRef || undefined,
      subject: channel === 'EMAIL' ? subject : undefined,
      targetRefs,
      attachmentRefs: inject?.attachmentRefs ?? [],
      channelRef: channel === 'CHAT' ? channelRef || undefined : undefined,
      network: channel === 'SOCIAL' ? network : undefined,
      authorName: channel === 'SOCIAL' ? authorName : undefined,
      authorHandle: channel === 'SOCIAL' ? authorHandle : undefined,
      likes: channel === 'SOCIAL' ? likes : undefined,
    };
    try {
      if (inject) await api.updateKitInject(kit.id, inject.id, input);
      else await api.addKitInject(kit.id, input);
      onDone();
      await onSaved();
    } catch (err) {
      setError(err instanceof ApiError ? err.message : t('Enregistrement impossible'));
    }
  }

  function toggleTarget(ref: string): void {
    setTargetRefs((prev) => (prev.includes(ref) ? prev.filter((x) => x !== ref) : [...prev, ref]));
  }

  return (
    <form className="create-form editing" onSubmit={submit}>
      <h4>{inject ? t("Modifier l'inject") : t('Nouvel inject')}</h4>
      <div className="row">
        <select value={channel} onChange={(e) => setChannel(e.target.value as KitInjectChannel)}>
          {CHANNELS.map((c) => (
            <option key={c} value={c}>
              {t(CHANNEL_LABEL[c])}
            </option>
          ))}
        </select>
        <label className="inline">
          T+
          <input
            type="number"
            min={0}
            value={offsetMinutes}
            onChange={(e) => setOffsetMinutes(Math.max(0, Math.floor(Number(e.target.value))))}
            style={{ width: 80 }}
          />
          min
        </label>
        <select value={senderRef} onChange={(e) => setSenderRef(e.target.value)}>
          <option value="">{t('Expéditeur : Animation')}</option>
          {kit.characters.map((c) => (
            <option key={c.ref} value={c.ref}>
              {c.name}
            </option>
          ))}
        </select>
      </div>
      <div className="row">
        <input placeholder={t('Titre (interne)')} value={title} onChange={(e) => setTitle(e.target.value)} required />
      </div>
      {channel === 'EMAIL' && (
        <div className="row">
          <input placeholder={t("Objet de l'email")} value={subject} onChange={(e) => setSubject(e.target.value)} />
        </div>
      )}
      {channel === 'CHAT' && (
        <div className="row">
          <select value={channelRef} onChange={(e) => setChannelRef(e.target.value)}>
            <option value="">{t('— Canal —')}</option>
            {kit.channels.map((ch) => (
              <option key={ch.ref} value={ch.ref}>
                #{ch.name}
              </option>
            ))}
          </select>
        </div>
      )}
      {channel === 'SOCIAL' && (
        <div className="row">
          <select value={network} onChange={(e) => setNetwork(e.target.value as typeof network)}>
            <option value="X">X</option>
            <option value="LINKEDIN">LinkedIn</option>
            <option value="INSTAGRAM">Instagram</option>
          </select>
          <input placeholder={t('Nom du compte')} value={authorName} onChange={(e) => setAuthorName(e.target.value)} />
          <input placeholder={t('@identifiant')} value={authorHandle} onChange={(e) => setAuthorHandle(e.target.value)} />
          <label className="inline">

            <input type="number" min={0} value={likes} onChange={(e) => setLikes(Number(e.target.value))} style={{ width: 72 }} />
          </label>
        </div>
      )}
      <div className="row">
        <textarea placeholder={t('Corps du message')} value={body} onChange={(e) => setBody(e.target.value)} rows={3} required />
      </div>
      {channel === 'EMAIL' && kit.characters.length > 0 && (
        <div className="recipients">
          <span className="muted">{t('Destinataires (vide = tous) :')}</span>
          {kit.characters.map((c) => (
            <label key={c.ref} className={`chip-check ${targetRefs.includes(c.ref) ? 'on' : ''}`}>
              <input type="checkbox" checked={targetRefs.includes(c.ref)} onChange={() => toggleTarget(c.ref)} />
              {c.name}
            </label>
          ))}
        </div>
      )}
      <div className="row">
        <button className="btn primary" type="submit">
          {t('Enregistrer')}
        </button>
        <button className="btn ghost" type="button" onClick={onDone}>
          {t('Annuler')}
        </button>
      </div>
      {error && <p className="err">{error}</p>}
    </form>
  );
}

function KitCharForm({
  kitId,
  character,
  onDone,
  onSaved,
}: {
  kitId: string;
  character: ApiKitCharacter | null;
  onDone: () => void;
  onSaved: () => Promise<void>;
}): JSX.Element {
  const { t } = useT();
  const [name, setName] = useState(character?.name ?? '');
  const [title, setTitle] = useState(character?.title ?? '');
  const [orgUnit, setOrgUnit] = useState(character?.orgUnit ?? '');
  const [kind, setKind] = useState<'PLAYER' | 'NPC'>(character?.kind ?? 'PLAYER');
  const [simEmail, setSimEmail] = useState(character?.simEmail ?? '');
  const [error, setError] = useState<string | null>(null);

  async function submit(e: FormEvent): Promise<void> {
    e.preventDefault();
    setError(null);
    const input: KitCharacterInput = {
      name,
      title: title || undefined,
      orgUnit: orgUnit || undefined,
      kind,
      simEmail: simEmail || undefined,
    };
    try {
      if (character) await api.updateKitCharacter(kitId, character.id, input);
      else await api.addKitCharacter(kitId, input);
      onDone();
      await onSaved();
    } catch (err) {
      setError(err instanceof ApiError ? err.message : t('Enregistrement impossible'));
    }
  }

  return (
    <form className="create-form editing" onSubmit={submit}>
      <h4>{character ? t('Modifier le personnage') : t('Ajouter un personnage')}</h4>
      <div className="row">
        <input placeholder={t('Nom')} value={name} onChange={(e) => setName(e.target.value)} required />
        <input placeholder={t('Fonction (optionnel)')} value={title} onChange={(e) => setTitle(e.target.value)} />
        <input placeholder={t('Service')} value={orgUnit} onChange={(e) => setOrgUnit(e.target.value)} />
        <select value={kind} onChange={(e) => setKind(e.target.value as 'PLAYER' | 'NPC')}>
          <option value="PLAYER">{t('Joueur')}</option>
          <option value="NPC">{t('Animé (PNJ)')}</option>
        </select>
      </div>
      <div className="row">
        <input placeholder={t('Email simulé')} value={simEmail} onChange={(e) => setSimEmail(e.target.value)} />
        <button className="btn primary" type="submit">
          {t('Enregistrer')}
        </button>
        <button className="btn ghost" type="button" onClick={onDone}>
          {t('Annuler')}
        </button>
      </div>
      {error && <p className="err">{error}</p>}
    </form>
  );
}
+37 −0
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import type { ApiKitSummary } from '@cythin/shared';
import { api, ApiError } from './api';
import { useT } from './i18n';
import { KitEditor } from './KitEditor';

export function KitsLibrary({ onInstalled }: { onInstalled: () => void }): JSX.Element {
  const { t } = useT();
@@ -9,6 +10,7 @@ export function KitsLibrary({ onInstalled }: { onInstalled: () => void }): JSX.E
  const [busy, setBusy] = useState<string | null>(null);
  const [message, setMessage] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [editKitId, setEditKitId] = useState<string | null>(null);

  const reload = useCallback(async () => {
    try {
@@ -21,6 +23,29 @@ export function KitsLibrary({ onInstalled }: { onInstalled: () => void }): JSX.E
    void reload();
  }, [reload]);

  async function createKit(): Promise<void> {
    setError(null);
    try {
      const k = await api.createKit({ name: t('Nouveau kit') });
      await reload();
      setEditKitId(k.id);
    } catch (err) {
      setError(err instanceof ApiError ? err.message : t('Création impossible'));
    }
  }

  if (editKitId) {
    return (
      <KitEditor
        kitId={editKitId}
        onBack={() => {
          setEditKitId(null);
          void reload();
        }}
      />
    );
  }

  async function install(kit: ApiKitSummary): Promise<void> {
    setBusy(kit.id);
    setMessage(null);
@@ -38,7 +63,14 @@ export function KitsLibrary({ onInstalled }: { onInstalled: () => void }): JSX.E

  return (
    <section className="panel">
      <div className="detail-head">
        <div className="detail-title">
          <h3>{t('Bibliothèque de kits')}</h3>
        </div>
        <button className="btn ghost" onClick={() => void createKit()}>
          + {t('Nouveau kit')}
        </button>
      </div>
      <p className="muted">
        {t(
          "Des exercices prêts à l'emploi. « Utiliser » crée un exercice complet (contexte, personnages, chronogramme de stimuli, documents), prêt à dupliquer et à jouer.",
@@ -57,9 +89,14 @@ export function KitsLibrary({ onInstalled }: { onInstalled: () => void }): JSX.E
              {k.characters} {t('rôles')} · {k.injects} {t('stimuli')} · {k.documents} {t('documents')}
            </div>
            <div className="kit-source muted">{k.source}</div>
            <div className="kit-actions">
              <button className="btn primary" disabled={busy === k.id} onClick={() => void install(k)}>
                {busy === k.id ? t('Installation…') : t('Utiliser ce kit')}
              </button>
              <button className="btn ghost" onClick={() => setEditKitId(k.id)}>
                {t('Éditer')}
              </button>
            </div>
          </div>
        ))}
        {kits.length === 0 && <p className="muted">{t('Aucun kit disponible.')}</p>}
+111 −27

File changed.

Preview size limit exceeded, changes collapsed.

Loading