structure refacto & file documentation
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-07-25 17:51:53 +02:00
parent d7871736c8
commit 895fec2b40
84 changed files with 2629 additions and 2313 deletions

View file

@ -1,3 +1,4 @@
// Rôle : copie des données filtrées au format compatible avec l'import checklist.
import { useState } from "react";
import { Icon } from "../../components/Icon.jsx";

View file

@ -1,3 +1,4 @@
// Rôle : affiche le fil d'Ariane compact commun aux pages de jeu.
import { Icon } from "../../components/Icon.jsx";
export function GameBreadcrumb({ game }) {

View file

@ -1,3 +1,4 @@
// Rôle : fournit le panneau de filtres générique des pages de données de jeu.
import { Icon } from "../../components/Icon.jsx";
export function GameFiltersPanel({

View file

@ -1,3 +1,4 @@
// Rôle : affiche les listes de jeu transformables en checklist.
import { Icon } from "../../components/Icon.jsx";
import { CopyChecklistItemsButton } from "./CopyChecklistItemsButton.jsx";
import { GameBreadcrumb } from "./GameBreadcrumb.jsx";

View file

@ -1,3 +1,4 @@
// Rôle : route vers les pages de jeux disponibles et leurs sous-pages.
import { GamesPage } from "./GamesPage.jsx";
import { Diablo4Page } from "./diablo4/Diablo4Page.jsx";
import { MhwildsPage } from "./mhwilds/MhwildsPage.jsx";

View file

@ -1,3 +1,4 @@
// Rôle : liste les jeux déclarés dans le contenu éditable.
export function GamesPage({ siteContent, games, gamesError }) {
const content = siteContent.gamesPage;

View file

@ -1,3 +1,4 @@
// Rôle : affiche une carte d'affixe Diablo IV et ses catégories.
import { getCategoryIconStyle, getCategoryLabel } from "./utils.js";
export function Diablo4AffixCard({ affix, categoryMap }) {

View file

@ -1,3 +1,4 @@
// Rôle : configure les filtres applicables aux affixes Diablo IV.
import { GameFiltersPanel } from "../GameFiltersPanel.jsx";
import { getCategoryIconStyle, getCategoryId, getCategoryLabel } from "./utils.js";

View file

@ -1,3 +1,4 @@
// Rôle : affiche et filtre la liste des affixes Diablo IV.
import { useMemo } from "react";
import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx";
import { GameBreadcrumb } from "../GameBreadcrumb.jsx";

View file

@ -1,3 +1,4 @@
// Rôle : affiche les raccourcis de la page d'accueil Diablo IV.
export function Diablo4Overview({ game }) {
const heroStyle = game?.images?.heroBg ? { "--game-hero-image": `url("${game.images.heroBg}")` } : undefined;

View file

@ -1,3 +1,4 @@
// Rôle : assemble le hero et les sous-pages Diablo IV.
import { Diablo4Listing } from "./Diablo4Listing.jsx";
import { Diablo4Overview } from "./Diablo4Overview.jsx";

View file

@ -1,3 +1,4 @@
// Rôle : regroupe les helpers d'affichage et de filtrage Diablo IV.
export function normalizeText(value) {
return String(value || "").trim().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/\s+/g, " ");
}

View file

@ -1,3 +1,4 @@
// Rôle : charge et normalise les données publiques des jeux.
export const INITIAL_MHWILDS_STATE = {
loaded: false,
loading: false,

View file

@ -1,3 +1,4 @@
// Rôle : configure les filtres Monster Hunter Wilds pour monstres et faune.
import { GameFiltersPanel } from "../GameFiltersPanel.jsx";
import { assetPath } from "./utils.js";

View file

@ -1,3 +1,4 @@
// Rôle : affiche les listes filtrables de monstres et de faune Monster Hunter Wilds.
import { useMemo } from "react";
import { GameBreadcrumb } from "../GameBreadcrumb.jsx";
import { EndemicCard } from "./cards/EndemicCard.jsx";

View file

@ -1,3 +1,4 @@
// Rôle : affiche les listes Monster Hunter Wilds exportables en checklist.
import { useMemo, useState } from "react";
import { GameListsPage } from "../GameListsPage.jsx";

View file

@ -1,3 +1,4 @@
// Rôle : affiche les raccourcis de la page d'accueil Monster Hunter Wilds.
import { assetPath } from "./utils.js";
export function MhwildsOverview({ game }) {

View file

@ -1,3 +1,4 @@
// Rôle : assemble le hero et les sous-pages Monster Hunter Wilds.
import { MhwildsListing } from "./MhwildsListing.jsx";
import { MhwildsLists } from "./MhwildsLists.jsx";
import { MhwildsOverview } from "./MhwildsOverview.jsx";

View file

@ -1,3 +1,4 @@
// Rôle : affiche les hitzones d'un monstre sous forme de tableau compact.
import { assetPath } from "../utils.js";
const PHYSICAL_COLUMNS = ["cut", "blunt", "ammo"];

View file

@ -1,3 +1,4 @@
// Rôle : affiche une carte de faune endémique Monster Hunter Wilds.
import { IconRow } from "./IconRow.jsx";
import { assetPath, getConditionValues, normalizeText } from "../utils.js";

View file

@ -1,3 +1,4 @@
// Rôle : rend une ligne d'icônes localisées pour les cartes Monster Hunter Wilds.
import { assetPath } from "../utils.js";
export function IconRow({ label, values, t }) {

View file

@ -1,3 +1,4 @@
// Rôle : affiche une carte monstre retournable avec faiblesses et hitzones.
import { useState } from "react";
import { DamageTable } from "./DamageTable.jsx";
import { IconRow } from "./IconRow.jsx";

View file

@ -1,3 +1,4 @@
// Rôle : regroupe les helpers d'assets, traduction et filtres Monster Hunter Wilds.
const MHWILDS_IMG_PATH = "/static/img/mhwilds";
export function assetPath(name) {

View file

@ -0,0 +1,144 @@
// Rôle : affiche une toolbox dans la liste, avec actions rapides et icône personnalisable.
import React, { useEffect, useRef, useState } from "react";
import { Icon } from "../../components/Icon.jsx";
import {
DEFAULT_TOOLBOX_ICON,
normalizeToolboxIcon,
TOOLBOX_ICONS
} from "./storage/toolboxStorage.js";
export function getGameCardCover(game) {
return game?.images?.cardCover || game?.image || "";
}
export function getGameCardBackground(game) {
return game?.cover || "var(--gradient-nebula)";
}
export function formatDate(value) {
return new Intl.DateTimeFormat("fr-FR", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}
export function ToolboxCard({ toolbox, game, actions, draggingToolboxId, dropTarget, onDragStart }) {
const gameCoverImage = getGameCardCover(game);
const coverImage = gameCoverImage || toolbox.icon || DEFAULT_TOOLBOX_ICON;
const isDragging = draggingToolboxId === toolbox.id;
const isDropTarget = dropTarget.id === toolbox.id;
const className = [
"card",
"toolbox-card",
isDragging ? "is-dragging" : "",
isDropTarget ? "is-drop-target" : "",
isDropTarget && dropTarget.placement === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
return (
<article className={className} data-toolbox-id={toolbox.id}>
<button
className="toolbox-card-drag-handle"
type="button"
onPointerDown={(event) => onDragStart(event, toolbox.id)}
aria-label={`Déplacer ${toolbox.name}`}
title="Déplacer"
>
<Icon name="drag" />
</button>
<a
className={`card-cover toolbox-card-cover-link ${gameCoverImage ? "toolbox-card-cover" : "toolbox-icon-cover"}`}
href={`#/toolbox/${toolbox.id}`}
aria-label={`Ouvrir ${toolbox.name}`}
title={`Ouvrir ${toolbox.name}`}
style={{ "--game-cover": getGameCardBackground(game), background: gameCoverImage ? "" : "var(--gradient-nebula)" }}
>
<img src={coverImage} alt={game ? game.title : `Icône de ${toolbox.name}`} loading="lazy" />
</a>
<div className="card-body">
<p className="eyebrow">{game ? game.title : "Toolbox libre"}</p>
<h2>{toolbox.name}</h2>
<small>Modifiée le {formatDate(toolbox.updatedAt)}</small>
<div className="card-actions">
<a className="button card-icon-button" href={`#/toolbox/${toolbox.id}`} aria-label={`Ouvrir ${toolbox.name}`} title="Ouvrir"><Icon name="open" /></a>
<button className="card-icon-button" onClick={() => actions.exportToolbox(toolbox.id)} aria-label={`Exporter ${toolbox.name}`} title="Exporter"><Icon name="export" /></button>
<button className="card-icon-button danger" onClick={() => actions.setConfirmModal({
title: "Supprimer la toolbox",
message: `Supprimer "${toolbox.name}" et ses données locales ?`,
confirmLabel: "Supprimer",
danger: true,
onResolve: (confirmed) => confirmed && actions.deleteToolbox(toolbox.id)
})} aria-label={`Supprimer ${toolbox.name}`} title="Supprimer"><Icon name="trash" /></button>
</div>
</div>
</article>
);
}
export function ToolboxIconPicker({ toolbox, onChange }) {
const [open, setOpen] = useState(false);
const pickerRef = useRef(null);
const icon = normalizeToolboxIcon(toolbox.icon);
useEffect(() => {
if (!open) return undefined;
function handlePointerDown(event) {
if (!pickerRef.current?.contains(event.target)) setOpen(false);
}
function handleKeyDown(event) {
if (event.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
function selectIcon(nextIcon) {
onChange(nextIcon);
setOpen(false);
}
return (
<div className="toolbox-icon-picker" ref={pickerRef}>
<button
className="toolbox-icon-picker-button"
type="button"
onClick={() => setOpen((value) => !value)}
aria-label="Changer l'icône de la toolbox"
aria-expanded={open}
title="Changer l'icône"
>
<img src={icon} alt="" aria-hidden="true" />
</button>
{open && (
<div className="toolbox-icon-picker-menu" aria-label="Icônes de toolbox">
{TOOLBOX_ICONS.map((path) => (
<button
key={path}
className={path === icon ? "active" : ""}
type="button"
onClick={() => selectIcon(path)}
aria-label={`Utiliser l'icône ${path.split("/").pop().replace(".png", "")}`}
aria-pressed={path === icon}
title={path.split("/").pop().replace(".png", "")}
>
<img src={path} alt="" aria-hidden="true" loading="lazy" />
</button>
))}
</div>
)}
</div>
);
}
export function ToolboxGameIcon({ game }) {
const gameCoverImage = getGameCardCover(game);
return (
<div className="toolbox-game-icon" title={game.title} aria-label={`Jeu associé : ${game.title}`}>
<img src={gameCoverImage} alt="" aria-hidden="true" />
</div>
);
}

View file

@ -0,0 +1,326 @@
// Rôle : affiche les pages toolbox, le panneau latéral et leurs contrôles.
import React, { useEffect, useRef, useState } from "react";
import { Icon } from "../../components/Icon.jsx";
import { ImportButton } from "../../components/ImportButton.jsx";
import { StorageQuota } from "../../components/StorageQuota.jsx";
import { usePointerReorder } from "../../hooks/usePointerReorder.js";
import { compressImage } from "../../utils/imageCompression.js";
import { getSetting as dbGetSetting, setSetting as dbSetSetting } from "../../utils/indexedDbStorage.js";
import { getGameCardCover, ToolboxCard, ToolboxGameIcon, ToolboxIconPicker } from "./ToolboxCard.jsx";
import { AddToolControls, ToolboxModules } from "./modules/index.jsx";
import {
clampQty,
hostnameFromUrl,
normalizeCalculatorData,
normalizeChecklistData,
normalizeCountersData,
normalizeImageAnnotationData,
normalizeLinksData,
normalizeUrl,
uid
} from "./storage/toolboxStorage.js";
const DRAWER_WIDTH_SETTING = "drawerWidth";
async function copyText(value) {
try {
await navigator.clipboard.writeText(value);
return true;
} catch {
return false;
}
}
export function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions, storageUsage }) {
const content = siteContent.toolboxes;
const {
draggingId: draggingToolboxId,
dropTarget,
startDrag: startToolboxDrag
} = usePointerReorder({
targetSelector: ".toolbox-card",
getTargetId: (target) => target.dataset.toolboxId,
getPlacement: (event, target) => {
const rect = target.getBoundingClientRect();
return event.clientY > rect.top + rect.height / 2 || event.clientX > rect.left + rect.width / 2 ? "after" : "before";
},
onMove: (draggingId, targetId, placement) => {
const nextIds = toolboxes.map((toolbox) => toolbox.id).filter((id) => id !== draggingId);
const targetIndex = nextIds.indexOf(targetId);
nextIds.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, draggingId);
actions.updateToolboxOrder(nextIds);
}
});
return (
<div className="toolbox-page">
<section className="page-hero">
<div>
<p className="eyebrow">{content.eyebrow}</p>
<h1>{content.title}</h1>
<p>{content.summary}</p>
</div>
</section>
<section className="toolbar toolbox-actions-row">
<div>
<button className="primary" onClick={() => actions.setCreateModal({})}>{content.newButton}</button>
<ImportButton className="import-button" label={content.importOne} onFile={actions.importToolbox} />
</div>
<div>
<button className="primary" onClick={actions.exportAllToolboxes}>{content.exportAll}</button>
<ImportButton className="import-button" label={content.importAll} onFile={actions.importAllToolboxes} />
</div>
</section>
<section className="cards">
<StorageHelpCard help={content.storageHelp} />
{toolboxes.length ? toolboxes.map((toolbox) => (
<ToolboxCard
key={toolbox.id}
toolbox={toolbox}
game={getToolboxGame(toolbox)}
actions={actions}
draggingToolboxId={draggingToolboxId}
dropTarget={dropTarget}
onDragStart={startToolboxDrag}
/>
)) : (
<div className="empty"><h2>{content.emptyTitle}</h2><p>{content.emptyText}</p></div>
)}
</section>
<StorageQuota usage={storageUsage} />
</div>
);
}
function StorageHelpCard({ help }) {
return (
<article className="card storage-help-card" aria-labelledby="storage-help-title">
<div className="card-body">
<div className="storage-help-heading">
<span className="storage-help-mark" aria-hidden="true" />
<div><p className="eyebrow">IndexedDB</p><h2 id="storage-help-title">{help.title}</h2></div>
</div>
<p className="storage-help-intro">{help.text}</p>
<ul className="storage-help-list">
{help.items.map((item, index) => <li key={item}><span>{index + 1}</span><p>{item}</p></li>)}
</ul>
</div>
</article>
);
}
export function ToolboxPage(props) {
const { toolboxId, toolboxes, getToolboxGame } = props;
const toolbox = toolboxes.find((item) => item.id === toolboxId);
if (!toolbox) return <div className="empty"><h1>Toolbox introuvable</h1><a className="button" href="#/toolboxes">Retour</a></div>;
return <ToolboxView {...props} toolbox={toolbox} toolboxGame={getToolboxGame(toolbox)} embedded={false} />;
}
function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, getModuleData, updateToolbox, updateModuleData, addImageFiles }) {
const moduleColumns = Number(toolbox.moduleColumns) === 1 ? 1 : 2;
const toolboxGameCover = getGameCardCover(toolboxGame);
const moduleText = siteContent.toolboxes.modules;
const moduleContext = {
getModuleData,
moduleText,
normalizeChecklistData,
normalizeLinksData,
normalizeCountersData,
normalizeCalculatorData,
normalizeImageAnnotationData,
normalizeUrl,
hostnameFromUrl,
copyText,
notify: actions.notify,
compressImageFile: compressImage,
clampQty,
uid,
setModuleData: updateModuleData,
addImageFiles,
createImageAnnotationModule,
setImage: actions.setImage,
refresh: () => {}
};
function addModule(type) {
if (!type) return;
updateToolbox({ ...toolbox, modules: [...toolbox.modules, { id: uid("mod"), type }] });
}
function createImageAnnotationModule(dataUrl) {
if (!dataUrl) return;
const moduleId = uid("mod");
updateToolbox({ ...toolbox, modules: [...toolbox.modules, { id: moduleId, type: "imageAnnotation" }] });
updateModuleData(toolbox.id, moduleId, { image: dataUrl, markers: [] }, "imageAnnotation");
}
function moveModule(fromModuleId, toModuleId, placement = "before") {
if (!fromModuleId || !toModuleId || fromModuleId === toModuleId) return;
const modules = [...toolbox.modules];
const fromIndex = modules.findIndex((module) => module.id === fromModuleId);
const toIndex = modules.findIndex((module) => module.id === toModuleId);
if (fromIndex < 0 || toIndex < 0) return;
const [moved] = modules.splice(fromIndex, 1);
const targetIndex = modules.findIndex((module) => module.id === toModuleId);
modules.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, moved);
updateToolbox({ ...toolbox, modules });
}
return (
<div className={embedded ? "toolbox-embedded" : "toolbox-page"}>
<section className={embedded ? "toolbox-head page-hero toolbox-head-embedded" : "toolbox-head page-hero"}>
<div>
<p className="eyebrow">Toolbox</p>
<div className="toolbox-title-row">
{!embedded && !toolboxGame && <ToolboxIconPicker toolbox={toolbox} onChange={(icon) => updateToolbox({ ...toolbox, icon })} />}
{!embedded && toolboxGameCover && <ToolboxGameIcon game={toolboxGame} />}
{embedded ? <h1>{toolbox.name}</h1> : (
<EditableTitle className="toolbox-title" value={toolbox.name} fallback="Nouvelle toolbox" onSave={(name) => name !== toolbox.name && updateToolbox({ ...toolbox, name })} />
)}
</div>
{toolboxGame && (
<a
className="button toolbox-hero-link"
href={embedded ? `#/toolbox/${toolbox.id}` : `#/games/${toolboxGame.id}`}
onClick={embedded ? () => actions.setDrawerGameId("") : undefined}
>
<Icon name="enter" />
<span>{embedded ? "Vers la toolbox complète" : "Vers la page de jeu"}</span>
</a>
)}
</div>
<div className="actions">
<AddToolControls onAdd={addModule} />
</div>
</section>
{!embedded && (
<section className="modules-toolbar" aria-label="Options d'affichage des outils">
<div className="layout-switch" role="group" aria-label="Mode d'affichage des outils">
<button className={moduleColumns === 1 ? "active" : ""} onClick={() => updateToolbox({ ...toolbox, moduleColumns: 1 })} aria-pressed={moduleColumns === 1} aria-label="Afficher en lignes" title="Afficher en lignes"><Icon name="rows" /></button>
<button className={moduleColumns === 2 ? "active" : ""} onClick={() => updateToolbox({ ...toolbox, moduleColumns: 2 })} aria-pressed={moduleColumns === 2} aria-label="Afficher en colonnes" title="Afficher en colonnes"><Icon name="columns" /></button>
</div>
</section>
)}
<ToolboxModules
toolbox={toolbox}
moduleColumns={moduleColumns}
context={moduleContext}
onRename={(moduleId, title) => updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? { ...module, title } : module) })}
onUpdateModule={(moduleId, updater) => updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? updater(module) : module) })}
onDelete={(moduleId) => actions.setConfirmModal({
title: "Retirer l'outil",
message: "Retirer cet outil de la toolbox ?",
confirmLabel: "Retirer",
danger: true,
onResolve: (confirmed) => {
if (!confirmed) return;
actions.removeModuleData(toolbox.id, moduleId);
updateToolbox({ ...toolbox, modules: toolbox.modules.filter((module) => module.id !== moduleId) });
}
})}
onMove={moveModule}
/>
{!embedded && <StorageQuota usage={storageUsage} />}
</div>
);
}
export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, storageUsage, getModuleData, updateToolbox, updateModuleData, addImageFiles }) {
const selectedId = links[gameId] || "";
const toolbox = toolboxes.find((item) => item.id === selectedId);
const [width, setWidth] = useState("");
const panelRef = useRef(null);
useEffect(() => {
let cancelled = false;
dbGetSetting(DRAWER_WIDTH_SETTING, "")
.then((storedWidth) => {
if (cancelled) return;
const value = Number(storedWidth);
if (Number.isFinite(value) && value > 0) setWidth(Math.min(Math.max(value, 360), Math.floor(window.innerWidth * 0.94)));
})
.catch(() => {});
return () => { cancelled = true; };
}, []);
function startResize(event) {
const panel = panelRef.current;
if (!panel) return;
event.preventDefault();
const startX = event.clientX;
const startWidth = panel.getBoundingClientRect().width;
const minWidth = 360;
const maxWidth = Math.floor(window.innerWidth * 0.94);
document.body.classList.add("is-resizing-drawer");
const onMove = (moveEvent) => {
const nextWidth = Math.min(Math.max(startWidth + startX - moveEvent.clientX, minWidth), maxWidth);
setWidth(nextWidth);
dbSetSetting(DRAWER_WIDTH_SETTING, Math.round(nextWidth)).catch(() => {});
};
const stop = () => {
document.body.classList.remove("is-resizing-drawer");
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", stop);
window.addEventListener("pointercancel", stop);
}
return (
<aside className="drawer" id="toolbox-drawer" aria-hidden="false" style={width ? { "--drawer-panel-width": `${width}px` } : undefined}>
<div className="drawer-backdrop" onClick={() => actions.setDrawerGameId("")} />
<span className="drawer-resize-handle" aria-hidden="true" onPointerDown={startResize} />
<section className="drawer-panel legacy-scrollbar" ref={panelRef} style={width ? { width } : undefined}>
<div className="drawer-content">
<header>
<div>
<p className="eyebrow">Toolbox liée</p>
<div className="drawer-toolbox-actions" aria-label="Actions toolbox liée">
<button className="drawer-action-button" onClick={() => actions.setLinkModalGameId(gameId)} aria-label="Lier une toolbox" title="Lier"><Icon name="link" /></button>
<ImportButton className="drawer-action-button import-icon-button" ariaLabel="Importer une toolbox" title="Importer" icon="import" onFile={(file) => actions.importToolbox(file, gameId)} />
<button className="drawer-action-button" onClick={() => actions.exportToolbox(selectedId)} disabled={!selectedId} aria-label="Exporter la toolbox liée" title="Exporter"><Icon name="export" /></button>
</div>
</div>
<button className="drawer-close-button" onClick={() => actions.setDrawerGameId("")} aria-label="Fermer" title="Fermer"><Icon name="close" /></button>
</header>
{toolbox ? <ToolboxView siteContent={siteContent} toolbox={toolbox} toolboxGame={game} embedded actions={actions} storageUsage={storageUsage} getModuleData={getModuleData} updateToolbox={updateToolbox} updateModuleData={updateModuleData} addImageFiles={addImageFiles} /> : (
<div className="empty"><p>Aucune toolbox associée à cette page jeu.</p><button className="primary" onClick={() => actions.setCreateModal({ gameId })}>Créer et associer</button></div>
)}
<StorageQuota usage={storageUsage} />
</div>
</section>
</aside>
);
}
function EditableTitle({ value, fallback, onSave, className = "module-title" }) {
const ref = useRef(null);
useEffect(() => {
if (ref.current && document.activeElement !== ref.current) ref.current.textContent = value;
}, [value]);
return (
<h1
ref={ref}
className={className}
contentEditable
suppressContentEditableWarning
spellCheck="false"
title="Cliquer pour renommer"
onFocus={(event) => { event.currentTarget.dataset.previousTitle = event.currentTarget.textContent.trim(); }}
onBlur={(event) => onSave(event.currentTarget.textContent.trim() || fallback)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
if (event.key === "Escape") {
event.preventDefault();
event.currentTarget.textContent = event.currentTarget.dataset.previousTitle || value;
event.currentTarget.blur();
}
}}
>{value}</h1>
);
}

View file

@ -1,3 +1,4 @@
// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence.
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";

View file

@ -1,3 +1,4 @@
// Rôle : fournit l'outil checklist avec quantités, catégories et imports texte.
import { useCallback, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { TextImportModal } from "./TextImportModal.jsx";

View file

@ -1,3 +1,4 @@
// Rôle : fournit l'outil compteurs personnalisables.
import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx";

View file

@ -1,3 +1,4 @@
// Rôle : fournit l'outil annotation d'images avec marqueurs éditables.
import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
@ -84,7 +85,7 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
<button
className="calculator-action-button"
type="button"
onClick={() => context.setScreenshot({
onClick={() => context.setImage({
dataUrl: data.image,
markers,
alt: textContent.imageAlt || "Image annotée",

View file

@ -1,21 +1,22 @@
// Rôle : fournit l'outil images avec import, collage, libellés et annotation rapide.
import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
export function ScreenshotsModule({ toolboxId, moduleId, context, editing }) {
const data = context.getModuleData(toolboxId, moduleId, { shots: [] });
const textContent = context.moduleText?.screenshots || {};
export function ImagesModule({ toolboxId, moduleId, context, editing }) {
const data = context.getModuleData(toolboxId, moduleId, { images: [] });
const textContent = context.moduleText?.images || {};
const [dragOver, setDragOver] = useState(false);
const pastePlaceholder = textContent.pastePlaceholder || "Coller une image ici";
async function addFiles(files) {
if (await context.addScreenshotFiles(toolboxId, moduleId, files)) {
if (await context.addImageFiles(toolboxId, moduleId, files)) {
setDragOver(false);
}
}
function updateShot(shotId, patch) {
function updateImage(imageId, patch) {
context.setModuleData(toolboxId, moduleId, {
shots: data.shots.map((shot) => shot.id === shotId ? { ...shot, ...patch } : shot)
images: data.images.map((image) => image.id === imageId ? { ...image, ...patch } : image)
});
}
@ -66,35 +67,35 @@ export function ScreenshotsModule({ toolboxId, moduleId, context, editing }) {
</div>
</div>
)}
<div className="shots">
{data.shots.map((shot) => (
<figure key={shot.id}>
<button className="shot-preview" onClick={() => context.setScreenshot(shot)} aria-label={textContent.previewAriaLabel || "Agrandir l'image"}>
<img src={shot.dataUrl} alt={textContent.imageAlt || "Image"} />
<div className="images">
{data.images.map((image) => (
<figure key={image.id}>
<button className="image-preview" onClick={() => context.setImage(image)} aria-label={textContent.previewAriaLabel || "Agrandir l'image"}>
<img src={image.dataUrl} alt={textContent.imageAlt || "Image"} />
</button>
{editing ? (
<input
className="shot-label-input"
value={shot.label || ""}
className="image-label-input"
value={image.label || ""}
placeholder={textContent.labelPlaceholder || "Libellé de l'image"}
onChange={(event) => updateShot(shot.id, { label: event.target.value })}
onChange={(event) => updateImage(image.id, { label: event.target.value })}
aria-label={textContent.labelAriaLabel || "Libellé de l'image"}
/>
) : shot.label ? (
<figcaption className="shot-label">{shot.label}</figcaption>
) : image.label ? (
<figcaption className="image-label">{image.label}</figcaption>
) : null}
<div className="shot-actions">
<div className="image-actions">
<button
className="shot-annotate-button"
className="image-annotate-button"
type="button"
onClick={() => context.createImageAnnotationModule?.(shot.dataUrl)}
onClick={() => context.createImageAnnotationModule?.(image.dataUrl)}
aria-label={textContent.annotateAriaLabel || "Annoter l'image"}
title={textContent.annotateTitle || "Annoter"}
>
<Icon name="map" />
</button>
<button className="shot-delete-button danger" type="button" onClick={() => {
context.setModuleData(toolboxId, moduleId, { shots: data.shots.filter((item) => item.id !== shot.id) });
<button className="image-delete-button danger" type="button" onClick={() => {
context.setModuleData(toolboxId, moduleId, { images: data.images.filter((item) => item.id !== image.id) });
}} aria-label={textContent.deleteAriaLabel || "Supprimer l'image"} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>

View file

@ -1,3 +1,4 @@
// Rôle : fournit l'outil liens avec ajout manuel et import texte.
import { useCallback, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { TextImportModal } from "./TextImportModal.jsx";

View file

@ -1,3 +1,4 @@
// Rôle : fournit l'outil bloc-notes libre.
import { useState } from "react";
export function NotepadModule({ toolboxId, moduleId, context }) {

View file

@ -1,3 +1,4 @@
// Rôle : affiche la modale d'import texte partagée par les outils compatibles.
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { Icon } from "../../../components/Icon.jsx";

View file

@ -1,3 +1,4 @@
// Rôle : registre des outils toolbox, rendu commun et contrôles d'ajout/réorganisation.
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "../../../components/Icon.jsx";
@ -9,12 +10,12 @@ import { CountersModule } from "./CountersModule.jsx";
import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
import { LinksModule } from "./LinksModule.jsx";
import { NotepadModule } from "./NotepadModule.jsx";
import { ScreenshotsModule } from "./ScreenshotsModule.jsx";
import { ImagesModule } from "./ImagesModule.jsx";
const MODULE_COMPONENTS = {
notepad: { label: "Bloc notes", icon: "notepad", Component: NotepadModule, editable: false },
checklist: { label: "Checklist", icon: "checklist", Component: ChecklistModule, editable: true, scrollable: true },
screenshots: { label: "Images", icon: "picture", Component: ScreenshotsModule, editable: true, scrollable: true },
images: { label: "Images", icon: "picture", Component: ImagesModule, editable: true, scrollable: true },
links: { label: "Liens", icon: "link", Component: LinksModule, editable: true, scrollable: true },
counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true, scrollable: true },
calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: false },

View file

@ -1,3 +1,4 @@
// Rôle : parse les imports texte en listes exploitables par les outils.
export function parseColonImportLines(text) {
return String(text || "")
.split(/\r?\n/)

View file

@ -0,0 +1,446 @@
// Rôle : normalise, compacte et sérialise les données toolbox persistées.
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", image: "s", link: "l", counter: "c", calc: "r", marker: "k" };
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/";
const TOOLBOX_ICON_FILES = [
"toolbox.png",
"ball.png",
"capture.png",
"card.png",
"city.png",
"compass.png",
"crash.png",
"detective.png",
"fire.png",
"horror.png",
"jump.png",
"mining.png",
"parachute.png",
"puzzle.png",
"shield.png",
"sword.png",
"target.png",
"tower.png",
"wheel.png"
];
const DEFAULT_MODULE_TITLES = {
notepad: "Bloc notes",
checklist: "Checklist",
images: "Images",
links: "Liens",
counters: "Compteurs",
calculator: "Calculateur",
imageAnnotation: "Annotation d'images"
};
export const TOOLBOX_ICONS = TOOLBOX_ICON_FILES.map((file) => `${TOOLBOX_ICON_BASE}${file}`);
export const DEFAULT_TOOLBOX_ICON = `${TOOLBOX_ICON_BASE}toolbox.png`;
function randomToken(length = 6) {
const bytes = new Uint8Array(length);
if (globalThis.crypto?.getRandomValues) {
globalThis.crypto.getRandomValues(bytes);
return [...bytes].map((byte) => ID_ALPHABET[byte % ID_ALPHABET.length]).join("");
}
return Math.random().toString(36).slice(2, 2 + length).padEnd(length, "0");
}
export function uid(prefix) {
return `${ID_PREFIXES[prefix] || "x"}${randomToken()}`;
}
function getDefaultModuleTitle(type) {
return DEFAULT_MODULE_TITLES[type] || "Outil";
}
export function normalizeToolboxIcon(icon) {
const value = String(icon || "").trim();
const file = value.replace(TOOLBOX_ICON_BASE, "").split("/").pop();
return TOOLBOX_ICON_FILES.includes(file) ? `${TOOLBOX_ICON_BASE}${file}` : DEFAULT_TOOLBOX_ICON;
}
function normalizeToolboxModule(module) {
if (!module || typeof module !== "object" || !module.type) return null;
const title = String(module.title || "").trim();
const normalized = { id: module.id || uid("mod"), type: module.type };
if (title && title !== getDefaultModuleTitle(module.type)) normalized.title = title;
if (module.scrollable === true) normalized.scrollable = true;
return normalized;
}
export function normalizeToolbox(toolbox) {
if (!toolbox || typeof toolbox !== "object") return null;
return {
id: toolbox.id || uid("tbx"),
name: String(toolbox.name || "Nouvelle toolbox").trim() || "Nouvelle toolbox",
icon: normalizeToolboxIcon(toolbox.icon),
moduleColumns: Number(toolbox.moduleColumns) === 1 ? 1 : 2,
modules: (Array.isArray(toolbox.modules) ? toolbox.modules : []).map(normalizeToolboxModule).filter(Boolean),
updatedAt: toolbox.updatedAt || new Date().toISOString()
};
}
export function compactToolboxForStorage(toolbox) {
const normalized = normalizeToolbox(toolbox);
if (!normalized) return null;
const compact = {
id: normalized.id,
name: normalized.name,
modules: normalized.modules,
updatedAt: normalized.updatedAt
};
if (normalized.moduleColumns === 1) compact.moduleColumns = 1;
if (normalized.icon !== DEFAULT_TOOLBOX_ICON) compact.icon = normalized.icon;
return compact;
}
export function compactToolboxesForStorage(toolboxes) {
return (Array.isArray(toolboxes) ? toolboxes : []).map(compactToolboxForStorage).filter(Boolean);
}
export function moduleStorageKey(toolboxId, moduleId) {
return `${toolboxId}:${moduleId}`;
}
export function globalModuleKey(toolboxId, moduleId) {
return `${toolboxId}:${moduleId}`;
}
function parsePositiveInt(value, fallback = 1) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export function clampQty(value, target) {
const parsed = Number.parseInt(value, 10);
const safeValue = Number.isFinite(parsed) ? parsed : 0;
return Math.min(safeValue, Math.max(1, target));
}
function normalizeChecklistItem(item) {
const qtyTarget = Math.max(1, parsePositiveInt(item?.qtyTarget, 1));
const qtyCurrent = clampQty(item?.qtyCurrent, qtyTarget);
return {
id: item?.id || uid("item"),
label: String(item?.label || "").trim(),
qtyTarget,
qtyCurrent
};
}
function normalizeChecklistSection(section, fallbackTitle = "") {
const normalized = {
id: section?.id || uid("section"),
title: String(section?.title ?? fallbackTitle).trim(),
items: (section?.items || []).map(normalizeChecklistItem).filter((item) => item.label)
};
if (typeof section?.hideWhenComplete === "boolean") normalized.hideWhenComplete = section.hideWhenComplete;
if (section?.collapsed === true) normalized.collapsed = true;
return normalized;
}
export function normalizeChecklistData(data) {
const hideCompletedSections = Boolean(data?.hideCompletedSections);
const hideCompletedSectionsFully = Boolean(data?.hideCompletedSectionsFully);
const sections = Array.isArray(data?.sections)
? data.sections.map((section) => normalizeChecklistSection(section)).filter((section) => section.items.length || section.title)
: [];
const legacyItems = (data?.items || []).map(normalizeChecklistItem).filter((item) => item.label);
if (!sections.length && legacyItems.length) return { hideCompletedSections, hideCompletedSectionsFully, sections: [{ id: uid("section"), title: "", items: legacyItems }], items: legacyItems };
const items = sections.flatMap((section) => section.items);
return { hideCompletedSections, hideCompletedSectionsFully, sections, items };
}
export function normalizeUrl(value) {
const cleanValue = String(value || "").trim();
if (!cleanValue) return "";
try {
const url = new URL(cleanValue.includes("://") ? cleanValue : `https://${cleanValue}`);
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
return url.href;
} catch {
return "";
}
}
export function hostnameFromUrl(value) {
try {
return new URL(value).hostname.replace(/^www\./, "");
} catch {
return value;
}
}
export function normalizeLinksData(data) {
return {
links: (data?.links || [])
.map((link) => {
const url = normalizeUrl(link?.url);
if (!url) return null;
return {
id: link?.id || uid("link"),
title: String(link?.title || "").trim(),
url
};
})
.filter(Boolean)
};
}
function normalizeCounterValue(value) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
export function normalizeCountersData(data) {
return {
counters: (data?.counters || [])
.map((counter) => ({
id: counter?.id || uid("counter"),
label: String(counter?.label || "").trim(),
value: normalizeCounterValue(counter?.value)
}))
.filter((counter) => counter.label)
};
}
function normalizeCalculatorValue(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
export function labelFromFileName(name) {
if (!name || name === "image.png") return "";
return name.replace(/\.[^.]+$/, "").trim();
}
export function normalizeCalculatorData(data) {
const entries = (data?.entries || [])
.map((entry) => ({
id: entry?.id || uid("calc"),
parentId: String(entry?.parentId || ""),
label: String(entry?.label || "").trim(),
value: normalizeCalculatorValue(entry?.value)
}));
const entryIds = new Set(entries.map((entry) => entry.id));
return {
scrollResults: data?.scrollResults === true,
entries: entries.map((entry) => entryIds.has(entry.parentId) ? entry : { ...entry, parentId: "" })
};
}
function clampPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 0;
return Math.min(100, Math.max(0, parsed));
}
export function normalizeImageAnnotationData(data) {
const image = String(data?.image || data?.dataUrl || "");
const markers = (data?.markers || [])
.map((marker) => ({
id: marker?.id || uid("marker"),
x: clampPercent(marker?.x),
y: clampPercent(marker?.y),
label: String(marker?.label || "").trim()
}));
return { image, markers };
}
function compactChecklistItemForStorage(item) {
const normalized = normalizeChecklistItem(item);
const compact = { id: normalized.id, label: normalized.label };
if (normalized.qtyTarget !== 1) compact.qtyTarget = normalized.qtyTarget;
if (normalized.qtyCurrent !== 0) compact.qtyCurrent = normalized.qtyCurrent;
return compact;
}
function compactChecklistSectionForStorage(section) {
const normalized = normalizeChecklistSection(section);
const compact = {
id: normalized.id,
items: normalized.items.map(compactChecklistItemForStorage)
};
if (normalized.title) compact.title = normalized.title;
if (typeof normalized.hideWhenComplete === "boolean") compact.hideWhenComplete = normalized.hideWhenComplete;
if (normalized.collapsed === true) compact.collapsed = true;
return compact;
}
export function compactModuleDataForStorage(type, value) {
if (type === "notepad") {
const text = String(value?.text || "");
return text ? { text } : null;
}
if (type === "checklist") {
const normalized = normalizeChecklistData(value);
const sections = normalized.sections.map(compactChecklistSectionForStorage).filter((section) => section.items.length || section.title);
if (!sections.length) return null;
const settings = {
...(normalized.hideCompletedSections ? { hideCompletedSections: true } : {}),
...(normalized.hideCompletedSectionsFully ? { hideCompletedSectionsFully: true } : {})
};
if (sections.length === 1 && !sections[0].title && typeof sections[0].hideWhenComplete !== "boolean") return { ...settings, items: sections[0].items };
return { ...settings, sections };
}
if (type === "images") {
const images = (Array.isArray(value?.images) ? value.images : [])
.filter((image) => image?.dataUrl)
.map((image) => {
const compact = { id: image.id || uid("image"), dataUrl: image.dataUrl };
if (image.label) compact.label = image.label;
return compact;
});
return images.length ? { images } : null;
}
if (type === "imageAnnotation") {
const normalized = normalizeImageAnnotationData(value);
if (!normalized.image) return null;
return {
image: normalized.image,
markers: normalized.markers.map((marker) => {
const compact = { id: marker.id, x: marker.x, y: marker.y };
if (marker.label) compact.label = marker.label;
return compact;
})
};
}
if (type === "links") {
const links = normalizeLinksData(value).links.map((link) => {
const compact = {
id: link.id,
url: link.url
};
if (link.title) compact.title = link.title;
return compact;
});
return links.length ? { links } : null;
}
if (type === "counters") {
const counters = normalizeCountersData(value).counters.map((counter) => ({
id: counter.id,
label: counter.label,
value: counter.value
}));
return counters.length ? { counters } : null;
}
if (type === "calculator") {
const normalized = normalizeCalculatorData(value);
const entries = normalized.entries.map((entry) => {
const compact = {
id: entry.id,
label: entry.label,
value: entry.value
};
if (entry.parentId) compact.parentId = entry.parentId;
return compact;
});
if (!entries.length && !normalized.scrollResults) return null;
return normalized.scrollResults ? { entries, scrollResults: true } : { entries };
}
return value;
}
export function prepareModuleDataForStorage(toolboxes, toolboxId, moduleId, value, moduleType = "") {
const module = toolboxes.find((toolbox) => toolbox.id === toolboxId)?.modules.find((item) => item.id === moduleId);
return compactModuleDataForStorage(moduleType || module?.type, value);
}
function createExportIdFactory() {
const counts = {};
return (prefix) => {
counts[prefix] = (counts[prefix] || 0) + 1;
return `${ID_PREFIXES[prefix] || "x"}${counts[prefix].toString(36)}`;
};
}
function remapModuleDataForExport(type, data, nextId) {
const compact = compactModuleDataForStorage(type, data);
if (!compact) return null;
if (type === "checklist") {
if (compact.sections) {
return {
sections: compact.sections.map((section) => ({
...section,
id: nextId("section"),
items: section.items.map((item) => ({ ...item, id: nextId("item") }))
}))
};
}
return { items: compact.items.map((item) => ({ ...item, id: nextId("item") })) };
}
if (type === "images") {
return { images: compact.images.map((image) => ({ ...image, id: nextId("image") })) };
}
if (type === "imageAnnotation") {
return {
...compact,
markers: compact.markers.map((marker) => ({ ...marker, id: nextId("marker") }))
};
}
if (type === "links") {
return { links: compact.links.map((link) => ({ ...link, id: nextId("link") })) };
}
if (type === "counters") {
return { counters: compact.counters.map((counter) => ({ ...counter, id: nextId("counter") })) };
}
if (type === "calculator") {
const entryIdMap = new Map();
compact.entries.forEach((entry) => entryIdMap.set(entry.id, nextId("calc")));
const remapped = {
entries: compact.entries.map((entry) => {
const remapped = { ...entry, id: entryIdMap.get(entry.id) };
if (entry.parentId) remapped.parentId = entryIdMap.get(entry.parentId) || "";
return remapped;
})
};
if (compact.scrollResults) remapped.scrollResults = true;
return remapped;
}
return compact;
}
export function createToolboxExportPayload(toolbox, moduleData) {
const source = normalizeToolbox(toolbox);
const nextId = createExportIdFactory();
const moduleIdMap = new Map();
const exportedToolbox = compactToolboxForStorage({
...source,
id: nextId("tbx"),
modules: source.modules.map((module) => {
const id = nextId("mod");
moduleIdMap.set(module.id, id);
return { ...module, id };
})
});
const modules = Object.fromEntries(source.modules
.map((module) => [
moduleIdMap.get(module.id),
remapModuleDataForExport(module.type, moduleData[moduleStorageKey(source.id, module.id)] || null, nextId)
])
.filter(([, data]) => data != null));
return { toolbox: exportedToolbox, modules };
}
export function createGlobalExportPayload(toolboxes, links, moduleData) {
const modules = {};
toolboxes.forEach((toolbox) => {
toolbox.modules.forEach((module) => {
const data = compactModuleDataForStorage(module.type, moduleData[moduleStorageKey(toolbox.id, module.id)] || null);
if (data) modules[globalModuleKey(toolbox.id, module.id)] = data;
});
});
return {
version: 1,
exportedAt: new Date().toISOString(),
toolboxes: compactToolboxesForStorage(toolboxes),
modules,
links
};
}

View file

@ -0,0 +1,135 @@
// Rôle : synchronise les toolboxes, données d'outils, liens de jeux et quota via IndexedDB.
import { useEffect, useState } from "react";
import {
getAllModuleData as dbGetAllModuleData,
getLinks as dbGetLinks,
getStorageEstimate,
getToolboxes as dbGetToolboxes,
removeModuleData as dbRemoveModuleData,
removeModuleDataKeys as dbRemoveModuleDataKeys,
setLinks as dbSetLinks,
setModuleData as dbSetModuleData,
setToolboxes as dbSetToolboxes
} from "../../../utils/indexedDbStorage.js";
import {
compactToolboxesForStorage,
moduleStorageKey,
normalizeToolbox,
prepareModuleDataForStorage
} from "./toolboxStorage.js";
export function useIndexedToolboxes(onError) {
const [ready, setReady] = useState(false);
const [toolboxes, setToolboxesState] = useState([]);
const [links, setLinksState] = useState({});
const [moduleData, setModuleDataState] = useState({});
const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 });
async function refreshQuota() {
try {
const estimate = await getStorageEstimate();
const used = estimate.usage || 0;
const limit = estimate.quota || 0;
setStorageUsage({ used, limit, ratio: limit ? Math.min(1, used / limit) : 0 });
} catch {
setStorageUsage({ used: 0, limit: 0, ratio: 0 });
}
}
useEffect(() => {
let cancelled = false;
async function loadStore() {
try {
const [storedToolboxes, storedLinks, storedModules] = await Promise.all([
dbGetToolboxes(),
dbGetLinks(),
dbGetAllModuleData()
]);
if (cancelled) return;
setToolboxesState((Array.isArray(storedToolboxes) ? storedToolboxes : []).map(normalizeToolbox).filter(Boolean));
setLinksState(storedLinks && typeof storedLinks === "object" ? storedLinks : {});
setModuleDataState(Object.fromEntries((storedModules || []).map((entry) => [entry.key, entry.data])));
setReady(true);
refreshQuota();
} catch (error) {
if (!cancelled) {
setReady(true);
onError(error.message || "Stockage IndexedDB indisponible.");
}
}
}
loadStore();
return () => { cancelled = true; };
}, []);
function persistToolboxes(nextToolboxes) {
const normalized = compactToolboxesForStorage(nextToolboxes).map(normalizeToolbox).filter(Boolean);
setToolboxesState(normalized);
dbSetToolboxes(compactToolboxesForStorage(normalized))
.then(refreshQuota)
.catch((error) => onError(error.message));
return true;
}
function persistLinks(nextLinks) {
setLinksState(nextLinks);
dbSetLinks(nextLinks)
.then(refreshQuota)
.catch((error) => onError(error.message));
return true;
}
function getModuleData(toolboxId, moduleId, fallback) {
const value = moduleData[moduleStorageKey(toolboxId, moduleId)];
return value == null ? fallback : value;
}
function updateModuleData(toolboxId, moduleId, value, moduleType = "") {
const compact = prepareModuleDataForStorage(toolboxes, toolboxId, moduleId, value, moduleType);
const key = moduleStorageKey(toolboxId, moduleId);
setModuleDataState((state) => {
const nextState = { ...state };
if (compact == null) delete nextState[key];
else nextState[key] = compact;
return nextState;
});
const operation = compact == null ? dbRemoveModuleData(key) : dbSetModuleData(key, compact);
operation.then(refreshQuota).catch((error) => onError(error.message));
return true;
}
function removeModuleData(toolboxId, moduleId) {
const key = moduleStorageKey(toolboxId, moduleId);
setModuleDataState((state) => {
const nextState = { ...state };
delete nextState[key];
return nextState;
});
dbRemoveModuleData(key).then(refreshQuota).catch((error) => onError(error.message));
}
function removeToolboxModuleData(toolbox) {
const keys = (toolbox?.modules || []).map((module) => moduleStorageKey(toolbox.id, module.id));
setModuleDataState((state) => {
const nextState = { ...state };
keys.forEach((key) => delete nextState[key]);
return nextState;
});
dbRemoveModuleDataKeys(keys).then(refreshQuota).catch((error) => onError(error.message));
}
return {
ready,
toolboxes,
links,
moduleData,
storageUsage,
setToolboxes: persistToolboxes,
setLinks: persistLinks,
getModuleData,
updateModuleData,
removeModuleData,
removeToolboxModuleData,
refreshQuota
};
}

View file

@ -0,0 +1,234 @@
// Rôle : regroupe les actions métier de création, import/export, lien et édition toolbox.
import {
compactModuleDataForStorage,
createGlobalExportPayload,
createToolboxExportPayload,
globalModuleKey,
labelFromFileName,
normalizeToolbox,
uid
} from "./storage/toolboxStorage.js";
import { compressImage } from "../../utils/imageCompression.js";
function downloadJson(payload, filename) {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}
export function useToolboxActions({ store, notify, setConfirmModal, setCreateModal, setLinkModalGameId, setDrawerGameId, setImage }) {
function createToolbox(name, gameId = "") {
const now = new Date().toISOString();
const toolbox = {
id: uid("tbx"),
name: name.trim() || "Nouvelle toolbox",
updatedAt: now,
modules: [
{ id: uid("mod"), type: "notepad", title: "Notes rapides" },
{ id: uid("mod"), type: "checklist" }
]
};
if (!store.setToolboxes([toolbox, ...store.toolboxes])) return null;
if (gameId) store.setLinks({ ...store.links, [gameId]: toolbox.id });
return toolbox;
}
function updateToolbox(nextToolbox) {
store.setToolboxes(store.toolboxes.map((toolbox) => toolbox.id === nextToolbox.id
? { ...nextToolbox, updatedAt: new Date().toISOString() }
: toolbox));
}
function deleteToolbox(id) {
const toolbox = store.toolboxes.find((item) => item.id === id);
store.removeToolboxModuleData(toolbox);
store.setToolboxes(store.toolboxes.filter((item) => item.id !== id));
const nextLinks = { ...store.links };
Object.entries(nextLinks).forEach(([gameId, toolboxId]) => {
if (toolboxId === id) delete nextLinks[gameId];
});
store.setLinks(nextLinks);
}
function linkToolboxToGame(gameId, toolboxId) {
const previousToolboxId = store.links[gameId] || "";
const nextLinks = { ...store.links };
if (toolboxId) nextLinks[gameId] = toolboxId;
else delete nextLinks[gameId];
store.setLinks(nextLinks);
const touched = new Set([previousToolboxId, toolboxId].filter(Boolean));
store.setToolboxes(store.toolboxes.map((toolbox) => touched.has(toolbox.id)
? { ...toolbox, updatedAt: new Date().toISOString() }
: toolbox));
}
async function addImageFiles(toolboxId, moduleId, files) {
const imageFiles = [...files].filter((file) => file?.type?.startsWith("image/"));
if (!imageFiles.length) return false;
const data = store.getModuleData(toolboxId, moduleId, { images: [] });
for (const file of imageFiles) {
const image = { id: uid("image"), dataUrl: await compressImage(file) };
const label = labelFromFileName(file.name);
if (label) image.label = label;
data.images.unshift(image);
}
return store.updateModuleData(toolboxId, moduleId, data);
}
function createChecklistFromList(gameId, list) {
const toolboxId = store.links[gameId] || "";
const toolbox = store.toolboxes.find((item) => item.id === toolboxId);
if (!toolbox || !list) return false;
const moduleId = uid("mod");
const sections = (list.categories || []).map((category) => ({
id: uid("section"),
title: category.title || "",
items: (category.items || []).map((item) => ({
id: uid("item"),
label: item.name,
qtyTarget: Math.max(1, Number.parseInt(item.quantity, 10) || 1),
qtyCurrent: 0
})).filter((item) => item.label)
})).filter((section) => section.items.length);
if (!sections.length) return false;
setConfirmModal({
title: "Créer une checklist",
message: `Créer la checklist "${list.title || "Checklist"}" dans la toolbox "${toolbox.name}" ?`,
confirmLabel: "Créer",
onResolve: (confirmed) => {
if (!confirmed) return;
store.setToolboxes(store.toolboxes.map((item) => item.id === toolboxId
? { ...item, modules: [...item.modules, { id: moduleId, type: "checklist", title: list.title || "Checklist" }], updatedAt: new Date().toISOString() }
: item));
store.updateModuleData(toolboxId, moduleId, { sections }, "checklist");
notify("Checklist créée dans la toolbox associée.");
}
});
return true;
}
async function importToolboxPayload(file, gameId = "") {
const payload = JSON.parse(await file.text());
if (!payload.toolbox?.modules) throw new Error("Format d'outils invalide");
const imported = normalizeToolbox({ ...payload.toolbox, id: uid("tbx"), updatedAt: new Date().toISOString() });
const moduleIdMap = new Map();
imported.modules = imported.modules.map((module) => {
const nextId = uid("mod");
moduleIdMap.set(module.id, nextId);
return { ...module, id: nextId };
});
if (!store.setToolboxes([imported, ...store.toolboxes])) return null;
Object.entries(payload.modules || {}).forEach(([oldId, data]) => {
const nextId = moduleIdMap.get(oldId);
const module = imported.modules.find((item) => item.id === nextId);
if (nextId) store.updateModuleData(imported.id, nextId, data, module?.type);
});
if (gameId) linkToolboxToGame(gameId, imported.id);
return imported;
}
async function importAllToolboxesPayload(file) {
const payload = JSON.parse(await file.text());
if (!Array.isArray(payload.toolboxes) || !payload.modules || typeof payload.modules !== "object") {
throw new Error("Format d'import global invalide");
}
const toolboxIdMap = new Map();
const moduleIdMap = new Map();
const importedToolboxes = payload.toolboxes.map((toolbox) => {
const nextToolboxId = uid("tbx");
toolboxIdMap.set(toolbox.id, nextToolboxId);
const modules = (toolbox.modules || []).map((module) => {
const nextModuleId = uid("mod");
moduleIdMap.set(globalModuleKey(toolbox.id, module.id), nextModuleId);
return { ...module, id: nextModuleId };
});
return normalizeToolbox({ ...toolbox, id: nextToolboxId, name: `${toolbox.name || "Toolbox"} (import)`, modules, updatedAt: new Date().toISOString() });
});
const nextLinks = { ...store.links };
Object.entries(payload.links || {}).forEach(([gameId, oldToolboxId]) => {
const nextToolboxId = toolboxIdMap.get(oldToolboxId);
if (nextToolboxId) nextLinks[gameId] = nextToolboxId;
});
Object.entries(payload.modules).forEach(([key, data]) => {
const [oldToolboxId] = key.split(":");
const nextToolboxId = toolboxIdMap.get(oldToolboxId);
const nextModuleId = moduleIdMap.get(key);
const toolbox = importedToolboxes.find((item) => item.id === nextToolboxId);
const module = toolbox?.modules.find((item) => item.id === nextModuleId);
const compact = compactModuleDataForStorage(module?.type, data);
if (nextToolboxId && nextModuleId && compact) store.updateModuleData(nextToolboxId, nextModuleId, compact, module?.type);
});
store.setToolboxes([...importedToolboxes, ...store.toolboxes]);
store.setLinks(nextLinks);
store.refreshQuota();
}
return {
createToolbox,
updateToolbox,
deleteToolbox,
linkToolboxToGame,
setCreateModal,
setConfirmModal,
setLinkModalGameId,
setDrawerGameId,
setImage,
createChecklistFromList,
addImageFiles,
notify,
removeModuleData: store.removeModuleData,
updateModuleData: store.updateModuleData,
updateToolboxOrder: (orderedIds) => {
const order = new Map(orderedIds.map((id, index) => [id, index]));
store.setToolboxes([...store.toolboxes].sort((a, b) => (order.get(a.id) ?? 9999) - (order.get(b.id) ?? 9999)));
},
importToolbox: async (file, gameId = "") => {
try {
const toolbox = await importToolboxPayload(file, gameId);
if (toolbox) notify("Toolbox importée.");
return toolbox;
} catch (error) {
setConfirmModal({
title: "Import impossible",
message: error.message,
confirmLabel: "Compris",
cancelLabel: "Fermer",
danger: true
});
return null;
}
},
importAllToolboxes: async (file) => {
try {
await importAllToolboxesPayload(file);
notify("Import global terminé.");
return true;
} catch (error) {
setConfirmModal({
title: "Import global impossible",
message: error.message,
confirmLabel: "Compris",
cancelLabel: "Fermer",
danger: true
});
return false;
}
},
exportToolbox: (id) => {
const toolbox = normalizeToolbox(store.toolboxes.find((item) => item.id === id));
if (!toolbox) return;
downloadJson(createToolboxExportPayload(toolbox, store.moduleData), `${toolbox.name.toLowerCase().replace(/[^a-z0-9]+/gi, "-")}.sokkog.json`);
notify("Export de la toolbox lancé.");
},
exportAllToolboxes: () => {
downloadJson(createGlobalExportPayload(store.toolboxes, store.links, store.moduleData), `sokkog-global-${new Date().toISOString().slice(0, 10)}.json`);
notify("Export global lancé.");
}
};
}