Add toolbox library page with editable examples
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-08-03 11:37:59 +02:00
parent 8eb622e05d
commit 9b72f4934c
20 changed files with 4774 additions and 31 deletions

View file

@ -163,6 +163,7 @@ Stockage compact :
- `title` est omis si vide.
- `hideWhenComplete` est omis si non défini.
- `collapsed` est omis si `false`.
- les sections sans item sont ignorées.
## Outil Images
@ -545,6 +546,7 @@ Stockage compact :
- `rows` est omis si la valeur vaut `10`.
- `columns` est omis si la valeur vaut `6`.
- `cells` conserve uniquement les cellules non vides et dans les limites du tableau.
- les espaces saisis dans une cellule non vide sont conservés.
- `rows` est limité entre `1` et `50`.
- `columns` est limité entre `1` et `20`.
- Les valeurs de cellules sont stockées sous forme de texte brut, formules incluses.

View file

@ -5,6 +5,7 @@ import assert from "node:assert/strict";
import {
validateDiablo4Affixes,
validateEndemicData,
validateLibraryData,
validateMonsterData,
validateSiteContent,
validateTranslationKeys
@ -15,6 +16,11 @@ test("site content json is complete", async () => {
validateSiteContent(site);
});
test("library examples are available", async () => {
const library = JSON.parse(await readFile("website/public/data/library.json", "utf8"));
validateLibraryData(library);
});
test("mhwilds data and assets are available", async () => {
const site = JSON.parse(await readFile("website/public/data/site.json", "utf8"));
const games = JSON.parse(await readFile("website/public/data/games.json", "utf8"));

View file

@ -32,18 +32,27 @@ export function validateSiteContent(site) {
"navigation.home",
"navigation.toolboxes",
"navigation.games",
"navigation.library",
"navigation.about",
"navigation.mobileLibrary",
"navigation.mobileGames",
"sidebar.badge",
"sidebar.note",
"topbar.dashboard",
"topbar.toolbox",
"topbar.library",
"topbar.games",
"gamesPage.eyebrow",
"gamesPage.title",
"gamesPage.description",
"gamesPage.emptyTitle",
"gamesPage.emptyText",
"library.eyebrow",
"library.title",
"library.description",
"library.summaryLabel",
"library.tocLabel",
"library.toolsLabel",
"home.hero.eyebrow",
"home.hero.title",
"home.hero.description",
@ -65,6 +74,7 @@ export function validateSiteContent(site) {
"about.title",
"about.description",
"about.toolsTitle",
"about.toolsLibraryLink",
"about.limitsTitle",
"about.contribute.eyebrow",
"about.contribute.title",
@ -76,6 +86,7 @@ export function validateSiteContent(site) {
"toolboxes.eyebrow",
"toolboxes.title",
"toolboxes.newButton",
"toolboxes.libraryLink",
"toolboxes.importAll",
"toolboxes.exportAll",
"toolboxes.importOne",
@ -340,6 +351,16 @@ export function validateSiteContent(site) {
assertStringArray(site, "toolboxes.storageHelp.items", 3);
const librarySummaryItems = valueAt(site, "library.summaryItems");
assert.ok(Array.isArray(librarySummaryItems), "library.summaryItems must be an array");
assert.ok(librarySummaryItems.length >= 3, "library.summaryItems must contain at least 3 items");
librarySummaryItems.forEach((item, index) => {
assertNonEmptyString({ item }, `item.icon`);
assertNonEmptyString({ item }, `item.title`);
assertNonEmptyString({ item }, `item.text`);
assert.equal(typeof item, "object", `library.summaryItems[${index}] must be an object`);
});
const aboutLimits = valueAt(site, "about.limits");
assert.ok(Array.isArray(aboutLimits), "about.limits must be an array");
assert.ok(aboutLimits.length >= 3, "about.limits must contain at least 3 items");
@ -381,6 +402,43 @@ export function validateSiteContent(site) {
});
}
export function validateLibraryData(payload) {
assert.equal(typeof payload?.toolbox, "object", "library.toolbox must be an object");
assert.equal(typeof payload?.modules, "object", "library.modules must be an object");
assertNonEmptyString(payload, "toolbox.name");
assert.ok(Array.isArray(payload.toolbox.modules), "library.toolbox.modules must be an array");
assert.ok(payload.toolbox.modules.length >= 1, "library.toolbox.modules must not be empty");
const moduleIds = new Set();
payload.toolbox.modules.forEach((module, index) => {
assertNonEmptyString({ module }, "module.id");
assertNonEmptyString({ module }, "module.type");
assert.ok(!moduleIds.has(module.id), `library.toolbox.modules[${index}].id must be unique`);
moduleIds.add(module.id);
assert.equal(typeof payload.modules[module.id], "object", `library.modules.${module.id} must be an object`);
});
["notepad", "checklist", "images", "links", "counters", "combos", "calculator", "table", "timer", "taskPlanner", "imageAnnotation"].forEach((type) => {
assert.ok(payload.toolbox.modules.some((module) => module.type === type), `library must include a ${type} example`);
});
const imagesModule = payload.toolbox.modules.find((module) => module.type === "images");
const imagesData = payload.modules[imagesModule.id];
assert.ok(Array.isArray(imagesData.images), "library images example must contain images");
assert.ok(imagesData.images.length >= 1, "library images example must not be empty");
imagesData.images.forEach((image, index) => {
assertNonEmptyString({ image }, "image.id");
assertNonEmptyString({ image }, "image.dataUrl");
assert.match(image.dataUrl, /^data:image\//, `library images[${index}].dataUrl must be an inline image`);
});
const taskModule = payload.toolbox.modules.find((module) => module.type === "taskPlanner");
const taskData = payload.modules[taskModule.id];
assert.ok(Array.isArray(taskData.tasks), "library task planner example must contain tasks");
assert.ok(Array.isArray(taskData.relations), "library task planner example must contain relations");
assert.ok(taskData.relations.length >= 1, "library task planner example must include a parent/child relation");
}
function assertSnakeCase(value, path) {
assert.equal(typeof value, "string", `${path} must be a string`);
assert.match(value, SNAKE_CASE_RE, `${path} must be snake_case`);

View file

@ -15,6 +15,7 @@ test("vite entrypoint and app shell are wired", async () => {
const richText = await readFile("website/src/components/RichText.jsx", "utf8");
const homePage = await readFile("website/src/pages/HomePage.jsx", "utf8");
const aboutPage = await readFile("website/src/pages/AboutPage.jsx", "utf8");
const libraryPage = await readFile("website/src/pages/LibraryPage.jsx", "utf8");
const hashRouter = await readFile("website/src/router/hashRouter.js", "utf8");
const routeContent = await readFile("website/src/router/RouteContent.jsx", "utf8");
const bodyScrollLock = await readFile("website/src/utils/bodyScrollLock.js", "utf8");
@ -38,8 +39,11 @@ test("vite entrypoint and app shell are wired", async () => {
assert.match(routeContent, /export function RouteContent/);
assert.match(routeContent, /pages\/HomePage\.jsx/);
assert.match(routeContent, /pages\/AboutPage\.jsx/);
assert.match(routeContent, /pages\/LibraryPage\.jsx/);
assert.match(routeContent, /route === "\/library"/);
assert.match(routeContent, /features\/games\/GameRoute\.jsx/);
assert.match(shell, /export function Shell/);
assert.doesNotMatch(shell, /content\.navigation\.library/);
assert.match(shell, /sidebar-about-link/);
assert.match(shell, /ImportButton/);
assert.match(overlays, /export function AppOverlays/);
@ -59,6 +63,9 @@ test("vite entrypoint and app shell are wired", async () => {
assert.match(homePage, /dragon\.png/);
assert.match(aboutPage, /export function AboutPage/);
assert.match(aboutPage, /module-icon-\$\{tool\.icon \|\| "notepad"\}/);
assert.match(libraryPage, /export function LibraryPage/);
assert.match(libraryPage, /LIBRARY_TOOLBOX_ID/);
assert.match(libraryPage, /TaskPlannerModule/);
assert.match(bodyScrollLock, /modalLocks/);
assert.match(bodyScrollLock, /is-modal-open/);
});

View file

@ -79,6 +79,7 @@ test("toolbox storage, cards and pages are wired", async () => {
assert.match(importButton, /accept="application\/json"/);
assert.match(reorderHook, /export function usePointerReorder/);
assert.match(reorderHook, /setPointerCapture/);
assert.match(reorderHook, /elementsFromPoint/);
assert.match(reorderHook, /elementFromPoint/);
assert.match(groupedReorderHook, /export function useGroupedReorder/);
assert.match(groupedReorderHook, /usePointerReorder/);
@ -161,8 +162,11 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(combosModule, /useInlineEdit/);
assert.match(combosModule, /useGroupedReorder/);
assert.match(combosModule, /reorderFeatures/);
assert.match(combosModule, /namespace: `combos-\$\{moduleId\}`/);
assert.match(combosModule, /getGroupBoundaryProps/);
assert.match(combosModule, /combos-category-boundary-drop-zone/);
assert.match(combosModule, /function setComboItemCategory/);
assert.match(combosModule, /setItemGroup: setComboItemCategory/);
assert.doesNotMatch(combosModule, /canMoveItem:/);
assert.doesNotMatch(combosModule, /canMoveGroup:/);
assert.match(calculatorModule, /export function CalculatorModule/);

View file

@ -4,7 +4,7 @@ import assert from "node:assert/strict";
import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js";
import { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js";
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
import { compactModuleDataForStorage, createToolboxExportPayload, normalizeCombosData, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
import { compactModuleDataForStorage, createToolboxExportPayload, normalizeChecklistData, normalizeCombosData, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js";
test("colon text import keeps urls intact after the first separator", () => {
@ -60,7 +60,7 @@ test("table storage clamps dimensions and compacts non-empty cells", () => {
rows: 200,
columns: 40,
cells: {
A1: "Boss",
A1: "Boss ",
T50: "=A1+1",
U51: "Ignored",
B1: " "
@ -79,14 +79,14 @@ test("table storage clamps dimensions and compacts non-empty cells", () => {
assert.equal(normalized.rows, 50);
assert.equal(normalized.columns, 20);
assert.deepEqual(normalized.cells, { A1: "Boss", T50: "=A1+1" });
assert.deepEqual(normalized.cells, { A1: "Boss ", T50: "=A1+1" });
assert.deepEqual(normalized.rowLabels, { 0: "Boss final " });
assert.deepEqual(normalized.columnLabels, { 0: "Item final " });
const compact = compactModuleDataForStorage("table", normalized);
assert.equal(compact.rows, 50);
assert.equal(compact.columns, 20);
assert.deepEqual(compact.cells, { A1: "Boss", T50: "=A1+1" });
assert.deepEqual(compact.cells, { A1: "Boss ", T50: "=A1+1" });
assert.deepEqual(compact.rowLabels, { 0: "Boss final " });
assert.deepEqual(compact.columnLabels, { 0: "Item final " });
assert.deepEqual(compactModuleDataForStorage("table", { rows: 10, columns: 6, cells: {}, columnLabels: { 0: "Item " } }), {
@ -226,6 +226,26 @@ test("grouped reorder operation moves whole categories", () => {
assert.deepEqual(moved.categoryOrder, ["Farm", "Boss"]);
});
test("checklist data drops empty titled sections", () => {
const normalized = normalizeChecklistData({
sections: [
{ id: "empty", title: "Empty", items: [] },
{ id: "filled", title: "Filled", items: [{ id: "item1", label: "Potion" }] }
]
});
assert.deepEqual(normalized.sections.map((section) => section.id), ["filled"]);
const compact = compactModuleDataForStorage("checklist", {
sections: [
{ id: "empty", title: "Empty", items: [] },
{ id: "filled", title: "Filled", items: [{ id: "item1", label: "Potion" }] }
]
});
assert.deepEqual(compact.sections.map((section) => section.id), ["filled"]);
});
test("combos storage normalizes devices, steps and compact export ids", () => {
const longText = "x".repeat(120);
const normalized = normalizeCombosData({

File diff suppressed because one or more lines are too long

View file

@ -7,7 +7,9 @@
"home": "Accueil",
"toolboxes": "Toolboxes",
"games": "Jeux",
"library": "Librairie",
"about": "C'est quoi Sokko G ?",
"mobileLibrary": "Lib.",
"mobileGames": "Infos"
},
"sidebar": {
@ -17,6 +19,7 @@
"topbar": {
"dashboard": "Dashboard",
"toolbox": "Toolboxes",
"library": "Librairie",
"games": "Informations jeu"
},
"gamesPage": {
@ -26,6 +29,31 @@
"emptyTitle": "Aucun jeu disponible",
"emptyText": "Ajoutez des entrées dans /data/games.json."
},
"library": {
"eyebrow": "Catalogue des outils",
"title": "Librairie",
"description": "Parcourez tous les outils disponibles avec des exemples basiques couvrant les listes simples, les catégories, les contenus sans catégorie, les médias, les tableaux et les suivis de session.",
"summaryLabel": "Repères de la librairie",
"tocLabel": "Sommaire des outils",
"toolsLabel": "Exemples d'outils disponibles",
"summaryItems": [
{
"icon": "toolbox",
"title": "Tous les outils",
"text": "Chaque exemple utilise le vrai composant de toolbox pour refléter le comportement réel."
},
{
"icon": "checklist",
"title": "Formats variés",
"text": "Les exemples couvrent les catégories, les entrées libres, les relations parent/enfant et les vues en grille."
},
{
"icon": "notepad",
"title": "Démo locale",
"text": "Les modifications restent limitées à cette page et ne changent pas vos toolboxes enregistrées."
}
]
},
"home": {
"hero": {
"eyebrow": "Session gaming efficace",
@ -106,6 +134,7 @@
}
],
"toolsTitle": "Outils disponibles",
"toolsLibraryLink": "Voir la librairie d'outils",
"contribute": {
"eyebrow": "Contribuer",
"title": "Proposer une liste de jeu",
@ -151,10 +180,20 @@
"name": "Calculateur",
"description": "Un calculateur avec résultats enregistrables et arborescence pour préparer des chaînes de craft ou de ressources."
},
{
"icon": "table",
"name": "Tableau",
"description": "Une grille compacte avec formules et copie TSV pour comparer ressources, stocks, besoins ou valeurs."
},
{
"icon": "clock",
"name": "Timer",
"description": "Un espace pour préparer chronomètres à étapes et comptes à rebours multiples pendant une session."
},
{
"icon": "tasklist",
"name": "Planificateur de tâches",
"description": "Un suivi de tâches ponctuelles, quotidiennes ou hebdomadaires avec catégories et dépendances."
}
],
"limitsTitle": "À retenir",
@ -185,6 +224,7 @@
"eyebrow": "Données locales",
"title": "Toolboxes",
"newButton": "Nouvelle toolbox",
"libraryLink": "Voir la librairie d'outils",
"importAll": "Importer tout",
"exportAll": "Exporter tout",
"importOne": "Importer une toolbox",

View file

@ -7,7 +7,7 @@ export function Shell({ route, content, games, toolboxes, links, actions, childr
const gameId = route.split("/")[1] === "games" ? route.split("/")[2] || "" : "";
const game = games.find((item) => item.id === gameId);
const linkedToolbox = game ? toolboxes.find((item) => item.id === links[game.id]) : null;
const topbarLabel = route.startsWith("/toolbox") ? content.topbar.toolbox : route.startsWith("/games") ? content.topbar.games : content.topbar.dashboard;
const topbarLabel = route.startsWith("/toolbox") ? content.topbar.toolbox : route.startsWith("/games") ? content.topbar.games : route === "/library" ? content.topbar.library : content.topbar.dashboard;
const toolboxLabel = linkedToolbox ? `Ouvrir la toolbox ${linkedToolbox.name}` : "Associer une toolbox";
return (
@ -49,6 +49,7 @@ export function Shell({ route, content, games, toolboxes, links, actions, childr
<a className={route === "/" ? "active" : ""} href="#/">{content.navigation.home}</a>
<a className={route.startsWith("/toolboxes") || route.startsWith("/toolbox") ? "active" : ""} href="#/toolboxes">{content.navigation.toolboxes}</a>
<button className="primary" onClick={() => actions.setCreateModal({})}>+</button>
<a className={route === "/library" ? "active" : ""} href="#/library">{content.navigation.mobileLibrary}</a>
<a className={route.startsWith("/games") ? "active" : ""} href="#/games">{content.navigation.mobileGames}</a>
</nav>
</div>

View file

@ -70,6 +70,7 @@ export function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions,
<p className="eyebrow">{content.eyebrow}</p>
<h1>{content.title}</h1>
<p>{content.summary}</p>
<a className="button hero-library-button" href="#/library">{content.libraryLink}</a>
</div>
</section>
<section className="toolbar toolbox-actions-row">

View file

@ -246,7 +246,7 @@ function getSectionHideWhenComplete(section, globalHide) {
}
function keepChecklistSection(section) {
return section.items.length || section.title;
return section.items.length;
}
function getChecklistReorderItems(sections) {

View file

@ -143,7 +143,7 @@ function getComboCategory(combo) {
return String(combo?.category || "").trim();
}
function setComboCategory(combo, category) {
function setComboItemCategory(combo, category) {
const nextCombo = { ...combo };
if (category) nextCombo.category = category;
else delete nextCombo.category;
@ -292,7 +292,7 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
getDropPlacement,
shouldShowGroupBoundaries
} = useGroupedReorder({
namespace: "combos",
namespace: `combos-${moduleId}`,
items: data.combos,
getItemId: (combo) => combo.id,
getItemGroup: getComboCategory,
@ -334,7 +334,7 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
groupOrderKey: "categoryOrder",
collapsedGroupsKey: "collapsedCategories",
getItemGroup: getComboCategory,
setItemGroup: setComboCategory
setItemGroup: setComboItemCategory
}));
}
@ -595,7 +595,7 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
</button>
<h3>{category}</h3>
</div>
<div>
<div className="checklist-section-actions">
<span>{group.combos.length}</span>
<button
className="checklist-section-collapse-button"

View file

@ -69,7 +69,7 @@ export function CountersModule({ toolboxId, moduleId, context, editing }) {
>
<Icon name="drag" />
</button>
<div>
<div className="counter-value">
<strong>{counter.value}</strong>
<span>{counter.label}</span>
</div>

View file

@ -297,7 +297,7 @@ 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)
? data.sections.map((section) => normalizeChecklistSection(section)).filter((section) => section.items.length)
: [];
const simpleItems = (data?.items || []).map(normalizeChecklistItem).filter((item) => item.label);
if (!sections.length && simpleItems.length) return { hideCompletedSections, hideCompletedSectionsFully, sections: [{ id: uid("section"), title: "", items: simpleItems }], items: simpleItems };
@ -505,8 +505,8 @@ export function normalizeTableData(data) {
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
for (let columnIndex = 0; columnIndex < columns; columnIndex += 1) {
const address = tableCellAddress(rowIndex, columnIndex);
const value = String(sourceCells[address] || "").trim();
if (value) cells[address] = value.slice(0, 500);
const value = String(sourceCells[address] || "");
if (value.trim()) cells[address] = value.slice(0, 500);
}
}
@ -704,7 +704,7 @@ export function compactModuleDataForStorage(type, value) {
}
if (type === "checklist") {
const normalized = normalizeChecklistData(value);
const sections = normalized.sections.map(compactChecklistSectionForStorage).filter((section) => section.items.length || section.title);
const sections = normalized.sections.map(compactChecklistSectionForStorage).filter((section) => section.items.length);
if (!sections.length) return null;
const settings = {
...(normalized.hideCompletedSections ? { hideCompletedSections: true } : {}),

View file

@ -28,19 +28,18 @@ export function usePointerReorder({
function getDropTarget(event) {
const { getTargetId, canDropOn, getPlacement } = optionsRef.current;
const element = document.elementFromPoint(event.clientX, event.clientY);
const target = element?.closest?.(targetSelector);
if (!target || !canDropOn(target, draggingId)) {
return { id: "", placement: "before" };
const elements = document.elementsFromPoint?.(event.clientX, event.clientY) || [document.elementFromPoint(event.clientX, event.clientY)].filter(Boolean);
for (const element of elements) {
const target = element?.closest?.(targetSelector);
if (!target || !canDropOn(target, draggingId)) continue;
const targetId = getTargetId(target);
if (targetId === draggingId) continue;
return {
id: targetId,
placement: getPlacement(event, target)
};
}
const targetId = getTargetId(target);
if (targetId === draggingId) {
return { id: "", placement: "before" };
}
return {
id: targetId,
placement: getPlacement(event, target)
};
return { id: "", placement: "before" };
}
function handlePointerMove(event) {

View file

@ -65,6 +65,7 @@ export function AboutPage({ siteContent }) {
<p className="eyebrow">Toolbox</p>
<h2>{content.toolsTitle}</h2>
</div>
<a className="button" href="#/library">{content.toolsLibraryLink}</a>
</div>
<div className="about-tools-list">
{content.tools.map((tool) => (

View file

@ -0,0 +1,257 @@
// Rôle : affiche la librairie des outils avec des exemples locaux non persistés.
import React, { useEffect, useMemo, useState } from "react";
import { Icon } from "../components/Icon.jsx";
import { CalculatorModule } from "../features/toolboxes/modules/CalculatorModule.jsx";
import { ChecklistModule } from "../features/toolboxes/modules/ChecklistModule.jsx";
import { CombosModule } from "../features/toolboxes/modules/CombosModule.jsx";
import { CountersModule } from "../features/toolboxes/modules/CountersModule.jsx";
import { ImageAnnotationModule } from "../features/toolboxes/modules/ImageAnnotationModule.jsx";
import { ImagesModule } from "../features/toolboxes/modules/ImagesModule.jsx";
import { LinksModule } from "../features/toolboxes/modules/LinksModule.jsx";
import { NotepadModule } from "../features/toolboxes/modules/NotepadModule.jsx";
import { TableModule } from "../features/toolboxes/modules/TableModule.jsx";
import { TaskPlannerModule } from "../features/toolboxes/modules/TaskPlannerModule.jsx";
import { TimerModule } from "../features/toolboxes/modules/TimerModule.jsx";
import {
clampQty,
hostnameFromUrl,
normalizeCalculatorData,
normalizeChecklistData,
normalizeCombosData,
normalizeCountersData,
normalizeImageAnnotationData,
normalizeLinksData,
normalizeNotepadData,
normalizeTableData,
normalizeTaskPlannerData,
normalizeTimerData,
normalizeUrl,
uid
} from "../features/toolboxes/storage/toolboxStorage.js";
const LIBRARY_TOOLBOX_ID = "library";
const MODULE_COMPONENTS = {
notepad: { label: "Bloc notes", mode: "Texte riche", icon: "notepad", Component: NotepadModule },
checklist: { label: "Checklist", mode: "Catégories + sans catégorie", icon: "checklist", Component: ChecklistModule, scrollable: true },
images: { label: "Images", mode: "Média", icon: "picture", Component: ImagesModule, scrollable: true },
links: { label: "Liens", mode: "Liste simple", icon: "link", Component: LinksModule, scrollable: true },
counters: { label: "Compteurs", mode: "Valeurs", icon: "abacus", Component: CountersModule, scrollable: true },
combos: { label: "Combos", mode: "Catégories", icon: "controller", Component: CombosModule, scrollable: true },
calculator: { label: "Calculateur", mode: "Arborescence", icon: "calculator", Component: CalculatorModule },
table: { label: "Tableau", mode: "Grille + formules", icon: "table", Component: TableModule, scrollable: true },
timer: { label: "Timer", mode: "Chrono + comptes à rebours", icon: "clock", Component: TimerModule },
taskPlanner: { label: "Planificateur de tâches", mode: "Catégories + parent/enfant", icon: "tasklist", Component: TaskPlannerModule, scrollable: true },
imageAnnotation: { label: "Annotation d'images", mode: "Image + marqueurs", icon: "map", Component: ImageAnnotationModule }
};
function cloneData(value) {
if (value === undefined) return {};
if (typeof structuredClone === "function") return structuredClone(value);
return JSON.parse(JSON.stringify(value));
}
function createModuleDataFromPayload(payload) {
return Object.fromEntries((payload?.toolbox?.modules || []).map((module) => [
`${LIBRARY_TOOLBOX_ID}:${module.id}`,
cloneData(payload?.modules?.[module.id] || {})
]));
}
function getLibraryAnchorId(module) {
return `library-tool-${String(module?.id || module?.type || "outil").replace(/[^a-z0-9_-]+/gi, "-").toLowerCase()}`;
}
function getCurrentLibraryAnchor() {
const match = location.hash.match(/^#\/library#(.+)$/);
return match ? decodeURIComponent(match[1]) : "";
}
function scrollToLibraryTool(anchorId, replace = false) {
const element = document.getElementById(anchorId);
if (!element) return;
if (replace) history.replaceState(null, "", `#/library#${encodeURIComponent(anchorId)}`);
const top = element.getBoundingClientRect().top + window.scrollY - 24;
window.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
}
function getToolDescription(siteContent, definition) {
const tools = siteContent.about?.tools || [];
return tools.find((tool) => tool.name === definition.label)?.description || "";
}
export function LibraryPage({ siteContent, actions }) {
const content = siteContent.library;
const [libraryPayload, setLibraryPayload] = useState(null);
const [libraryError, setLibraryError] = useState("");
const [moduleData, setModuleData] = useState({});
useEffect(() => {
let cancelled = false;
fetch("/data/library.json")
.then((response) => {
if (!response.ok) throw new Error("Impossible de charger les exemples de librairie.");
return response.json();
})
.then((payload) => {
if (cancelled) return;
setLibraryPayload(payload);
setModuleData(createModuleDataFromPayload(payload));
})
.catch((error) => {
if (!cancelled) setLibraryError(error.message);
});
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (!libraryPayload) return;
const anchorId = getCurrentLibraryAnchor();
if (!anchorId) return;
requestAnimationFrame(() => scrollToLibraryTool(anchorId));
}, [libraryPayload]);
const moduleContext = useMemo(() => ({
getModuleData: (toolboxId, moduleId, fallback) => moduleData[`${toolboxId}:${moduleId}`] || fallback,
setModuleData: (toolboxId, moduleId, data) => setModuleData((current) => ({ ...current, [`${toolboxId}:${moduleId}`]: data })),
moduleText: siteContent.toolboxes.modules,
normalizeChecklistData,
normalizeCombosData,
normalizeLinksData,
normalizeCountersData,
normalizeCalculatorData,
normalizeImageAnnotationData,
normalizeNotepadData,
normalizeTableData,
normalizeTimerData,
normalizeTaskPlannerData,
normalizeUrl,
hostnameFromUrl,
clampQty,
uid,
copyText: async (value) => {
await navigator.clipboard.writeText(value);
return true;
},
notify: actions.notify,
compressImageFile: async () => "",
addImageFiles: async () => false,
setImage: actions.setImage,
createImageAnnotationModule: () => {}
}), [actions, moduleData, siteContent.toolboxes.modules]);
return (
<div className="library-page">
<section className="page-hero library-hero">
<div>
<p className="eyebrow">{content.eyebrow}</p>
<h1>{content.title}</h1>
<p>{content.description}</p>
</div>
</section>
<section className="library-summary nebula-panel" aria-label={content.summaryLabel}>
{content.summaryItems.map((item) => (
<article key={item.title}>
<span className="library-summary-icon" aria-hidden="true">
<span className={`module-icon-svg module-icon-${item.icon || "toolbox"}`} />
</span>
<div>
<h2>{item.title}</h2>
<p>{item.text}</p>
</div>
</article>
))}
</section>
{libraryError && <section className="empty"><h2>Librairie indisponible</h2><p>{libraryError}</p></section>}
{!libraryError && !libraryPayload && <section className="empty"><p>Chargement des exemples...</p></section>}
{libraryPayload && (
<>
<nav className="library-toc nebula-panel" aria-label={content.tocLabel || "Sommaire des outils"}>
{libraryPayload.toolbox.modules.map((module) => {
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
const title = module.title || definition.label || "Outil";
const anchorId = getLibraryAnchorId(module);
return (
<a
key={module.id}
href={`#/library#${anchorId}`}
onClick={(event) => {
event.preventDefault();
scrollToLibraryTool(anchorId, true);
}}
>
<span className="module-icon" aria-hidden="true">
<span className={`module-icon-svg module-icon-${definition.icon}`} />
</span>
<span>{title}</span>
</a>
);
})}
</nav>
<section className="library-tools" aria-label={content.toolsLabel}>
{libraryPayload.toolbox.modules.map((module) => (
<LibraryToolExample
key={module.id}
module={module}
sourceData={libraryPayload.modules?.[module.id] || {}}
description={getToolDescription(siteContent, MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad)}
context={moduleContext}
/>
))}
</section>
</>
)}
</div>
);
}
function LibraryToolExample({ module, sourceData, description, context }) {
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
const Component = definition.Component;
const [scrollable, setScrollable] = useState(false);
const title = module.title || definition.label || "Outil";
return (
<section id={getLibraryAnchorId(module)} className="library-tool-section">
<div className="library-tool-intro">
<div>
<h2>{title}</h2>
{description && <p>{description}</p>}
</div>
</div>
<article className={`module library-tool-example ${scrollable ? "is-scrollable" : ""}`}>
<header>
<div>
<span className="module-icon" aria-hidden="true">
<span className={`module-icon-svg module-icon-${definition.icon}`} />
</span>
<div>
<h2>{definition.label}</h2>
</div>
</div>
<div className="library-example-actions">
{definition.scrollable && (
<button
className={`module-scroll-button ${scrollable ? "active" : ""}`}
type="button"
onClick={() => setScrollable((value) => !value)}
aria-label={`${scrollable ? "Désactiver" : "Activer"} le scroll de ${title}`}
aria-pressed={scrollable}
title={scrollable ? "Désactiver le scroll" : "Activer le scroll"}
>
<Icon name="scrollable" />
<i aria-hidden="true" />
</button>
)}
<button className="library-reset-button" type="button" onClick={() => context.setModuleData(LIBRARY_TOOLBOX_ID, module.id, cloneData(sourceData))} aria-label={`Réinitialiser ${title}`} title="Réinitialiser l'exemple">
<Icon name="refresh" />
</button>
</div>
</header>
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
<Component toolboxId={LIBRARY_TOOLBOX_ID} moduleId={module.id} context={context} editing={false} />
</div>
</article>
</section>
);
}

View file

@ -5,11 +5,13 @@ import { GamesPage } from "../features/games/GamesPage.jsx";
import { ToolboxesPage, ToolboxPage } from "../features/toolboxes/ToolboxPages.jsx";
import { AboutPage } from "../pages/AboutPage.jsx";
import { HomePage } from "../pages/HomePage.jsx";
import { LibraryPage } from "../pages/LibraryPage.jsx";
import { navigate } from "./hashRouter.js";
export function RouteContent(props) {
const { route } = props;
if (route === "/") return <HomePage {...props} />;
if (route === "/library") return <LibraryPage {...props} />;
if (route === "/about") return <AboutPage siteContent={props.siteContent} />;
if (route === "/toolboxes") return <ToolboxesPage {...props} />;
if (route.startsWith("/toolbox/")) return <ToolboxPage {...props} toolboxId={route.split("/")[2]} />;

View file

@ -149,6 +149,12 @@ span {
gap: 10px;
}
.hero-library-button {
display: inline-flex;
width: fit-content;
margin-top: var(--space-4);
}
.hero-panel {
padding: var(--space-5);
border: 1px solid rgba(165, 180, 252, 0.14);
@ -187,6 +193,202 @@ span {
filter: drop-shadow(0 22px 36px rgba(0, 0, 0, 0.34));
}
.library-page {
display: grid;
gap: var(--space-6);
}
.library-hero {
min-height: 220px;
}
.library-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
padding: var(--space-5);
}
.library-summary article {
display: flex;
min-width: 0;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid rgba(150, 165, 205, 0.12);
border-radius: var(--radius-md);
background: rgba(5, 7, 17, 0.28);
}
.library-summary h2,
.library-summary p {
margin: 0;
}
.library-summary h2 {
margin-bottom: 5px;
font-size: var(--font-size-md);
}
.library-summary-icon {
display: grid;
width: 34px;
min-width: 34px;
height: 34px;
place-items: center;
border: 1px solid rgba(196, 181, 253, 0.24);
border-radius: var(--radius-md);
background:
linear-gradient(135deg, rgba(139, 92, 246, 0.92), rgba(99, 102, 241, 0.84));
box-shadow: var(--shadow-primary);
color: white;
}
.library-summary-icon .module-icon-svg {
width: 18px;
height: 18px;
background: currentColor;
}
.library-toc {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: var(--space-4);
scroll-margin-top: var(--space-5);
}
.library-toc a {
display: inline-flex;
min-width: 0;
min-height: 40px;
align-items: center;
gap: 8px;
padding: 5px 10px 5px 5px;
border: 1px solid rgba(150, 165, 205, 0.14);
border-radius: var(--radius-md);
background: rgba(5, 7, 17, 0.28);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-weight: 800;
text-decoration: none;
}
.library-toc a:hover,
.library-toc a:focus-visible {
border-color: rgba(246, 196, 83, 0.42);
background: rgba(246, 196, 83, 0.08);
color: var(--color-text-primary);
}
.library-toc .module-icon {
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
}
.library-toc .module-icon-svg {
width: 16px;
height: 16px;
}
.library-tools {
display: grid;
gap: var(--space-5);
justify-items: stretch;
}
.library-tool-section {
position: relative;
display: grid;
width: 100%;
min-width: 0;
gap: var(--space-4);
overflow: hidden;
padding: var(--space-5);
border: 1px solid transparent;
border-radius: var(--radius-lg);
background: rgba(16, 20, 38, 0.42);
box-shadow: var(--shadow-sm);
scroll-margin-top: var(--space-8);
@include mixins.gold-panel-frame;
}
.library-tool-section > * {
position: relative;
z-index: 1;
}
.library-tool-intro {
min-width: 0;
}
.library-tool-intro h2,
.library-tool-intro p {
margin: 0;
}
.library-tool-intro h2 {
margin-bottom: 6px;
font-size: var(--font-size-xl);
}
.library-tool-intro p {
max-width: 860px;
}
.library-tool-example {
width: min(100%, 860px);
min-width: 0;
justify-self: center;
}
.library-tool-example header > div {
min-width: 0;
}
.library-tool-example header p {
margin: 3px 0 0;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 700;
text-transform: uppercase;
}
.library-example-actions {
display: flex;
flex: 0 0 auto;
gap: 8px;
}
.library-reset-button {
display: inline-grid;
width: 40px;
min-width: 40px;
min-height: 40px;
flex: 0 0 40px;
place-items: center;
padding: 0;
}
.library-reset-button .ui-icon {
width: 16px;
height: 16px;
}
.library-tool-example .module-content {
padding: var(--space-4);
}
.library-tool-example .module-add-panel {
display: none;
}
.library-tool-example .image-annotation-stage,
.library-tool-example .images-grid img {
max-height: 260px;
}
.nebula-panel {
position: relative;
overflow: hidden;

View file

@ -1002,6 +1002,7 @@
border-radius: var(--radius-md);
background: rgba(10, 13, 28, 0.99);
box-shadow: var(--shadow-lg);
transform: translateX(-50%);
}
.notepad-list-menu::before {
@ -1014,6 +1015,15 @@
border-left: 7px solid transparent;
}
.notepad-list-menu::after {
content: "";
position: absolute;
right: 0;
bottom: 100%;
left: 0;
height: 10px;
}
.notepad-list-option {
justify-content: center;
width: 34px;
@ -2621,7 +2631,7 @@ textarea:focus {
white-space: nowrap;
}
.combos-category-header > div {
.combos-category-header .combos-category-title {
gap: 6px;
}
@ -2661,6 +2671,8 @@ textarea:focus {
.combo-drag-handle {
align-self: start;
touch-action: none;
user-select: none;
}
.combo-card-main {
@ -3060,7 +3072,8 @@ button.combo-input-token.combo-input-mouse {
.counters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(100%, 180px), 220px));
justify-content: start;
gap: 10px;
margin: var(--space-4);
}
@ -3068,6 +3081,8 @@ button.combo-input-token.combo-input-mouse {
.counter-item {
position: relative;
display: grid;
min-height: 106px;
align-content: space-between;
gap: 12px;
padding: 12px 52px 12px 12px;
border: 1px solid rgba(165, 180, 252, 0.12);
@ -3093,7 +3108,7 @@ button.combo-input-token.combo-input-mouse {
user-select: none;
}
.counter-item > div:first-child {
.counter-value {
display: grid;
gap: 3px;
min-width: 0;
@ -3117,11 +3132,16 @@ button.combo-input-token.combo-input-mouse {
.counter-actions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(4, 34px);
justify-content: start;
gap: 8px;
}
.counter-actions button {
width: 34px;
min-width: 34px;
min-height: 34px;
padding: 0;
font-weight: 900;
}