Add toolbox library page with editable examples
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
This commit is contained in:
parent
8eb622e05d
commit
9b72f4934c
20 changed files with 4774 additions and 31 deletions
|
|
@ -163,6 +163,7 @@ Stockage compact :
|
||||||
- `title` est omis si vide.
|
- `title` est omis si vide.
|
||||||
- `hideWhenComplete` est omis si non défini.
|
- `hideWhenComplete` est omis si non défini.
|
||||||
- `collapsed` est omis si `false`.
|
- `collapsed` est omis si `false`.
|
||||||
|
- les sections sans item sont ignorées.
|
||||||
|
|
||||||
## Outil Images
|
## Outil Images
|
||||||
|
|
||||||
|
|
@ -545,6 +546,7 @@ Stockage compact :
|
||||||
- `rows` est omis si la valeur vaut `10`.
|
- `rows` est omis si la valeur vaut `10`.
|
||||||
- `columns` est omis si la valeur vaut `6`.
|
- `columns` est omis si la valeur vaut `6`.
|
||||||
- `cells` conserve uniquement les cellules non vides et dans les limites du tableau.
|
- `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`.
|
- `rows` est limité entre `1` et `50`.
|
||||||
- `columns` est limité entre `1` et `20`.
|
- `columns` est limité entre `1` et `20`.
|
||||||
- Les valeurs de cellules sont stockées sous forme de texte brut, formules incluses.
|
- Les valeurs de cellules sont stockées sous forme de texte brut, formules incluses.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import assert from "node:assert/strict";
|
||||||
import {
|
import {
|
||||||
validateDiablo4Affixes,
|
validateDiablo4Affixes,
|
||||||
validateEndemicData,
|
validateEndemicData,
|
||||||
|
validateLibraryData,
|
||||||
validateMonsterData,
|
validateMonsterData,
|
||||||
validateSiteContent,
|
validateSiteContent,
|
||||||
validateTranslationKeys
|
validateTranslationKeys
|
||||||
|
|
@ -15,6 +16,11 @@ test("site content json is complete", async () => {
|
||||||
validateSiteContent(site);
|
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 () => {
|
test("mhwilds data and assets are available", async () => {
|
||||||
const site = JSON.parse(await readFile("website/public/data/site.json", "utf8"));
|
const site = JSON.parse(await readFile("website/public/data/site.json", "utf8"));
|
||||||
const games = JSON.parse(await readFile("website/public/data/games.json", "utf8"));
|
const games = JSON.parse(await readFile("website/public/data/games.json", "utf8"));
|
||||||
|
|
|
||||||
|
|
@ -32,18 +32,27 @@ export function validateSiteContent(site) {
|
||||||
"navigation.home",
|
"navigation.home",
|
||||||
"navigation.toolboxes",
|
"navigation.toolboxes",
|
||||||
"navigation.games",
|
"navigation.games",
|
||||||
|
"navigation.library",
|
||||||
"navigation.about",
|
"navigation.about",
|
||||||
|
"navigation.mobileLibrary",
|
||||||
"navigation.mobileGames",
|
"navigation.mobileGames",
|
||||||
"sidebar.badge",
|
"sidebar.badge",
|
||||||
"sidebar.note",
|
"sidebar.note",
|
||||||
"topbar.dashboard",
|
"topbar.dashboard",
|
||||||
"topbar.toolbox",
|
"topbar.toolbox",
|
||||||
|
"topbar.library",
|
||||||
"topbar.games",
|
"topbar.games",
|
||||||
"gamesPage.eyebrow",
|
"gamesPage.eyebrow",
|
||||||
"gamesPage.title",
|
"gamesPage.title",
|
||||||
"gamesPage.description",
|
"gamesPage.description",
|
||||||
"gamesPage.emptyTitle",
|
"gamesPage.emptyTitle",
|
||||||
"gamesPage.emptyText",
|
"gamesPage.emptyText",
|
||||||
|
"library.eyebrow",
|
||||||
|
"library.title",
|
||||||
|
"library.description",
|
||||||
|
"library.summaryLabel",
|
||||||
|
"library.tocLabel",
|
||||||
|
"library.toolsLabel",
|
||||||
"home.hero.eyebrow",
|
"home.hero.eyebrow",
|
||||||
"home.hero.title",
|
"home.hero.title",
|
||||||
"home.hero.description",
|
"home.hero.description",
|
||||||
|
|
@ -65,6 +74,7 @@ export function validateSiteContent(site) {
|
||||||
"about.title",
|
"about.title",
|
||||||
"about.description",
|
"about.description",
|
||||||
"about.toolsTitle",
|
"about.toolsTitle",
|
||||||
|
"about.toolsLibraryLink",
|
||||||
"about.limitsTitle",
|
"about.limitsTitle",
|
||||||
"about.contribute.eyebrow",
|
"about.contribute.eyebrow",
|
||||||
"about.contribute.title",
|
"about.contribute.title",
|
||||||
|
|
@ -76,6 +86,7 @@ export function validateSiteContent(site) {
|
||||||
"toolboxes.eyebrow",
|
"toolboxes.eyebrow",
|
||||||
"toolboxes.title",
|
"toolboxes.title",
|
||||||
"toolboxes.newButton",
|
"toolboxes.newButton",
|
||||||
|
"toolboxes.libraryLink",
|
||||||
"toolboxes.importAll",
|
"toolboxes.importAll",
|
||||||
"toolboxes.exportAll",
|
"toolboxes.exportAll",
|
||||||
"toolboxes.importOne",
|
"toolboxes.importOne",
|
||||||
|
|
@ -340,6 +351,16 @@ export function validateSiteContent(site) {
|
||||||
|
|
||||||
assertStringArray(site, "toolboxes.storageHelp.items", 3);
|
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");
|
const aboutLimits = valueAt(site, "about.limits");
|
||||||
assert.ok(Array.isArray(aboutLimits), "about.limits must be an array");
|
assert.ok(Array.isArray(aboutLimits), "about.limits must be an array");
|
||||||
assert.ok(aboutLimits.length >= 3, "about.limits must contain at least 3 items");
|
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) {
|
function assertSnakeCase(value, path) {
|
||||||
assert.equal(typeof value, "string", `${path} must be a string`);
|
assert.equal(typeof value, "string", `${path} must be a string`);
|
||||||
assert.match(value, SNAKE_CASE_RE, `${path} must be snake_case`);
|
assert.match(value, SNAKE_CASE_RE, `${path} must be snake_case`);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ test("vite entrypoint and app shell are wired", async () => {
|
||||||
const richText = await readFile("website/src/components/RichText.jsx", "utf8");
|
const richText = await readFile("website/src/components/RichText.jsx", "utf8");
|
||||||
const homePage = await readFile("website/src/pages/HomePage.jsx", "utf8");
|
const homePage = await readFile("website/src/pages/HomePage.jsx", "utf8");
|
||||||
const aboutPage = await readFile("website/src/pages/AboutPage.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 hashRouter = await readFile("website/src/router/hashRouter.js", "utf8");
|
||||||
const routeContent = await readFile("website/src/router/RouteContent.jsx", "utf8");
|
const routeContent = await readFile("website/src/router/RouteContent.jsx", "utf8");
|
||||||
const bodyScrollLock = await readFile("website/src/utils/bodyScrollLock.js", "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, /export function RouteContent/);
|
||||||
assert.match(routeContent, /pages\/HomePage\.jsx/);
|
assert.match(routeContent, /pages\/HomePage\.jsx/);
|
||||||
assert.match(routeContent, /pages\/AboutPage\.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(routeContent, /features\/games\/GameRoute\.jsx/);
|
||||||
assert.match(shell, /export function Shell/);
|
assert.match(shell, /export function Shell/);
|
||||||
|
assert.doesNotMatch(shell, /content\.navigation\.library/);
|
||||||
assert.match(shell, /sidebar-about-link/);
|
assert.match(shell, /sidebar-about-link/);
|
||||||
assert.match(shell, /ImportButton/);
|
assert.match(shell, /ImportButton/);
|
||||||
assert.match(overlays, /export function AppOverlays/);
|
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(homePage, /dragon\.png/);
|
||||||
assert.match(aboutPage, /export function AboutPage/);
|
assert.match(aboutPage, /export function AboutPage/);
|
||||||
assert.match(aboutPage, /module-icon-\$\{tool\.icon \|\| "notepad"\}/);
|
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, /modalLocks/);
|
||||||
assert.match(bodyScrollLock, /is-modal-open/);
|
assert.match(bodyScrollLock, /is-modal-open/);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ test("toolbox storage, cards and pages are wired", async () => {
|
||||||
assert.match(importButton, /accept="application\/json"/);
|
assert.match(importButton, /accept="application\/json"/);
|
||||||
assert.match(reorderHook, /export function usePointerReorder/);
|
assert.match(reorderHook, /export function usePointerReorder/);
|
||||||
assert.match(reorderHook, /setPointerCapture/);
|
assert.match(reorderHook, /setPointerCapture/);
|
||||||
|
assert.match(reorderHook, /elementsFromPoint/);
|
||||||
assert.match(reorderHook, /elementFromPoint/);
|
assert.match(reorderHook, /elementFromPoint/);
|
||||||
assert.match(groupedReorderHook, /export function useGroupedReorder/);
|
assert.match(groupedReorderHook, /export function useGroupedReorder/);
|
||||||
assert.match(groupedReorderHook, /usePointerReorder/);
|
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, /useInlineEdit/);
|
||||||
assert.match(combosModule, /useGroupedReorder/);
|
assert.match(combosModule, /useGroupedReorder/);
|
||||||
assert.match(combosModule, /reorderFeatures/);
|
assert.match(combosModule, /reorderFeatures/);
|
||||||
|
assert.match(combosModule, /namespace: `combos-\$\{moduleId\}`/);
|
||||||
assert.match(combosModule, /getGroupBoundaryProps/);
|
assert.match(combosModule, /getGroupBoundaryProps/);
|
||||||
assert.match(combosModule, /combos-category-boundary-drop-zone/);
|
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, /canMoveItem:/);
|
||||||
assert.doesNotMatch(combosModule, /canMoveGroup:/);
|
assert.doesNotMatch(combosModule, /canMoveGroup:/);
|
||||||
assert.match(calculatorModule, /export function CalculatorModule/);
|
assert.match(calculatorModule, /export function CalculatorModule/);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import assert from "node:assert/strict";
|
||||||
import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js";
|
import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js";
|
||||||
import { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js";
|
import { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js";
|
||||||
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.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";
|
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", () => {
|
test("colon text import keeps urls intact after the first separator", () => {
|
||||||
|
|
@ -226,6 +226,26 @@ test("grouped reorder operation moves whole categories", () => {
|
||||||
assert.deepEqual(moved.categoryOrder, ["Farm", "Boss"]);
|
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", () => {
|
test("combos storage normalizes devices, steps and compact export ids", () => {
|
||||||
const longText = "x".repeat(120);
|
const longText = "x".repeat(120);
|
||||||
const normalized = normalizeCombosData({
|
const normalized = normalizeCombosData({
|
||||||
|
|
|
||||||
4123
website/public/data/library.json
Normal file
4123
website/public/data/library.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -7,7 +7,9 @@
|
||||||
"home": "Accueil",
|
"home": "Accueil",
|
||||||
"toolboxes": "Toolboxes",
|
"toolboxes": "Toolboxes",
|
||||||
"games": "Jeux",
|
"games": "Jeux",
|
||||||
|
"library": "Librairie",
|
||||||
"about": "C'est quoi Sokko G ?",
|
"about": "C'est quoi Sokko G ?",
|
||||||
|
"mobileLibrary": "Lib.",
|
||||||
"mobileGames": "Infos"
|
"mobileGames": "Infos"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
|
|
@ -17,6 +19,7 @@
|
||||||
"topbar": {
|
"topbar": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"toolbox": "Toolboxes",
|
"toolbox": "Toolboxes",
|
||||||
|
"library": "Librairie",
|
||||||
"games": "Informations jeu"
|
"games": "Informations jeu"
|
||||||
},
|
},
|
||||||
"gamesPage": {
|
"gamesPage": {
|
||||||
|
|
@ -26,6 +29,31 @@
|
||||||
"emptyTitle": "Aucun jeu disponible",
|
"emptyTitle": "Aucun jeu disponible",
|
||||||
"emptyText": "Ajoutez des entrées dans /data/games.json."
|
"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": {
|
"home": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"eyebrow": "Session gaming efficace",
|
"eyebrow": "Session gaming efficace",
|
||||||
|
|
@ -106,6 +134,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"toolsTitle": "Outils disponibles",
|
"toolsTitle": "Outils disponibles",
|
||||||
|
"toolsLibraryLink": "Voir la librairie d'outils",
|
||||||
"contribute": {
|
"contribute": {
|
||||||
"eyebrow": "Contribuer",
|
"eyebrow": "Contribuer",
|
||||||
"title": "Proposer une liste de jeu",
|
"title": "Proposer une liste de jeu",
|
||||||
|
|
@ -151,10 +180,20 @@
|
||||||
"name": "Calculateur",
|
"name": "Calculateur",
|
||||||
"description": "Un calculateur avec résultats enregistrables et arborescence pour préparer des chaînes de craft ou de ressources."
|
"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",
|
"icon": "clock",
|
||||||
"name": "Timer",
|
"name": "Timer",
|
||||||
"description": "Un espace pour préparer chronomètres à étapes et comptes à rebours multiples pendant une session."
|
"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",
|
"limitsTitle": "À retenir",
|
||||||
|
|
@ -185,6 +224,7 @@
|
||||||
"eyebrow": "Données locales",
|
"eyebrow": "Données locales",
|
||||||
"title": "Toolboxes",
|
"title": "Toolboxes",
|
||||||
"newButton": "Nouvelle toolbox",
|
"newButton": "Nouvelle toolbox",
|
||||||
|
"libraryLink": "Voir la librairie d'outils",
|
||||||
"importAll": "Importer tout",
|
"importAll": "Importer tout",
|
||||||
"exportAll": "Exporter tout",
|
"exportAll": "Exporter tout",
|
||||||
"importOne": "Importer une toolbox",
|
"importOne": "Importer une toolbox",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export function Shell({ route, content, games, toolboxes, links, actions, childr
|
||||||
const gameId = route.split("/")[1] === "games" ? route.split("/")[2] || "" : "";
|
const gameId = route.split("/")[1] === "games" ? route.split("/")[2] || "" : "";
|
||||||
const game = games.find((item) => item.id === gameId);
|
const game = games.find((item) => item.id === gameId);
|
||||||
const linkedToolbox = game ? toolboxes.find((item) => item.id === links[game.id]) : null;
|
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";
|
const toolboxLabel = linkedToolbox ? `Ouvrir la toolbox ${linkedToolbox.name}` : "Associer une toolbox";
|
||||||
|
|
||||||
return (
|
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 === "/" ? "active" : ""} href="#/">{content.navigation.home}</a>
|
||||||
<a className={route.startsWith("/toolboxes") || route.startsWith("/toolbox") ? "active" : ""} href="#/toolboxes">{content.navigation.toolboxes}</a>
|
<a className={route.startsWith("/toolboxes") || route.startsWith("/toolbox") ? "active" : ""} href="#/toolboxes">{content.navigation.toolboxes}</a>
|
||||||
<button className="primary" onClick={() => actions.setCreateModal({})}>+</button>
|
<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>
|
<a className={route.startsWith("/games") ? "active" : ""} href="#/games">{content.navigation.mobileGames}</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ export function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions,
|
||||||
<p className="eyebrow">{content.eyebrow}</p>
|
<p className="eyebrow">{content.eyebrow}</p>
|
||||||
<h1>{content.title}</h1>
|
<h1>{content.title}</h1>
|
||||||
<p>{content.summary}</p>
|
<p>{content.summary}</p>
|
||||||
|
<a className="button hero-library-button" href="#/library">{content.libraryLink}</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="toolbar toolbox-actions-row">
|
<section className="toolbar toolbox-actions-row">
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,7 @@ function getSectionHideWhenComplete(section, globalHide) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function keepChecklistSection(section) {
|
function keepChecklistSection(section) {
|
||||||
return section.items.length || section.title;
|
return section.items.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getChecklistReorderItems(sections) {
|
function getChecklistReorderItems(sections) {
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@ function getComboCategory(combo) {
|
||||||
return String(combo?.category || "").trim();
|
return String(combo?.category || "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setComboCategory(combo, category) {
|
function setComboItemCategory(combo, category) {
|
||||||
const nextCombo = { ...combo };
|
const nextCombo = { ...combo };
|
||||||
if (category) nextCombo.category = category;
|
if (category) nextCombo.category = category;
|
||||||
else delete nextCombo.category;
|
else delete nextCombo.category;
|
||||||
|
|
@ -292,7 +292,7 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
|
||||||
getDropPlacement,
|
getDropPlacement,
|
||||||
shouldShowGroupBoundaries
|
shouldShowGroupBoundaries
|
||||||
} = useGroupedReorder({
|
} = useGroupedReorder({
|
||||||
namespace: "combos",
|
namespace: `combos-${moduleId}`,
|
||||||
items: data.combos,
|
items: data.combos,
|
||||||
getItemId: (combo) => combo.id,
|
getItemId: (combo) => combo.id,
|
||||||
getItemGroup: getComboCategory,
|
getItemGroup: getComboCategory,
|
||||||
|
|
@ -334,7 +334,7 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
|
||||||
groupOrderKey: "categoryOrder",
|
groupOrderKey: "categoryOrder",
|
||||||
collapsedGroupsKey: "collapsedCategories",
|
collapsedGroupsKey: "collapsedCategories",
|
||||||
getItemGroup: getComboCategory,
|
getItemGroup: getComboCategory,
|
||||||
setItemGroup: setComboCategory
|
setItemGroup: setComboItemCategory
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -595,7 +595,7 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
|
||||||
</button>
|
</button>
|
||||||
<h3>{category}</h3>
|
<h3>{category}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="checklist-section-actions">
|
||||||
<span>{group.combos.length}</span>
|
<span>{group.combos.length}</span>
|
||||||
<button
|
<button
|
||||||
className="checklist-section-collapse-button"
|
className="checklist-section-collapse-button"
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ export function CountersModule({ toolboxId, moduleId, context, editing }) {
|
||||||
>
|
>
|
||||||
<Icon name="drag" />
|
<Icon name="drag" />
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div className="counter-value">
|
||||||
<strong>{counter.value}</strong>
|
<strong>{counter.value}</strong>
|
||||||
<span>{counter.label}</span>
|
<span>{counter.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -297,7 +297,7 @@ export function normalizeChecklistData(data) {
|
||||||
const hideCompletedSections = Boolean(data?.hideCompletedSections);
|
const hideCompletedSections = Boolean(data?.hideCompletedSections);
|
||||||
const hideCompletedSectionsFully = Boolean(data?.hideCompletedSectionsFully);
|
const hideCompletedSectionsFully = Boolean(data?.hideCompletedSectionsFully);
|
||||||
const sections = Array.isArray(data?.sections)
|
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);
|
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 };
|
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 rowIndex = 0; rowIndex < rows; rowIndex += 1) {
|
||||||
for (let columnIndex = 0; columnIndex < columns; columnIndex += 1) {
|
for (let columnIndex = 0; columnIndex < columns; columnIndex += 1) {
|
||||||
const address = tableCellAddress(rowIndex, columnIndex);
|
const address = tableCellAddress(rowIndex, columnIndex);
|
||||||
const value = String(sourceCells[address] || "").trim();
|
const value = String(sourceCells[address] || "");
|
||||||
if (value) cells[address] = value.slice(0, 500);
|
if (value.trim()) cells[address] = value.slice(0, 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -704,7 +704,7 @@ export function compactModuleDataForStorage(type, value) {
|
||||||
}
|
}
|
||||||
if (type === "checklist") {
|
if (type === "checklist") {
|
||||||
const normalized = normalizeChecklistData(value);
|
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;
|
if (!sections.length) return null;
|
||||||
const settings = {
|
const settings = {
|
||||||
...(normalized.hideCompletedSections ? { hideCompletedSections: true } : {}),
|
...(normalized.hideCompletedSections ? { hideCompletedSections: true } : {}),
|
||||||
|
|
|
||||||
|
|
@ -28,20 +28,19 @@ export function usePointerReorder({
|
||||||
|
|
||||||
function getDropTarget(event) {
|
function getDropTarget(event) {
|
||||||
const { getTargetId, canDropOn, getPlacement } = optionsRef.current;
|
const { getTargetId, canDropOn, getPlacement } = optionsRef.current;
|
||||||
const element = document.elementFromPoint(event.clientX, event.clientY);
|
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);
|
const target = element?.closest?.(targetSelector);
|
||||||
if (!target || !canDropOn(target, draggingId)) {
|
if (!target || !canDropOn(target, draggingId)) continue;
|
||||||
return { id: "", placement: "before" };
|
|
||||||
}
|
|
||||||
const targetId = getTargetId(target);
|
const targetId = getTargetId(target);
|
||||||
if (targetId === draggingId) {
|
if (targetId === draggingId) continue;
|
||||||
return { id: "", placement: "before" };
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
id: targetId,
|
id: targetId,
|
||||||
placement: getPlacement(event, target)
|
placement: getPlacement(event, target)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
return { id: "", placement: "before" };
|
||||||
|
}
|
||||||
|
|
||||||
function handlePointerMove(event) {
|
function handlePointerMove(event) {
|
||||||
setDropTarget(getDropTarget(event));
|
setDropTarget(getDropTarget(event));
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ export function AboutPage({ siteContent }) {
|
||||||
<p className="eyebrow">Toolbox</p>
|
<p className="eyebrow">Toolbox</p>
|
||||||
<h2>{content.toolsTitle}</h2>
|
<h2>{content.toolsTitle}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<a className="button" href="#/library">{content.toolsLibraryLink}</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="about-tools-list">
|
<div className="about-tools-list">
|
||||||
{content.tools.map((tool) => (
|
{content.tools.map((tool) => (
|
||||||
|
|
|
||||||
257
website/src/pages/LibraryPage.jsx
Normal file
257
website/src/pages/LibraryPage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,11 +5,13 @@ import { GamesPage } from "../features/games/GamesPage.jsx";
|
||||||
import { ToolboxesPage, ToolboxPage } from "../features/toolboxes/ToolboxPages.jsx";
|
import { ToolboxesPage, ToolboxPage } from "../features/toolboxes/ToolboxPages.jsx";
|
||||||
import { AboutPage } from "../pages/AboutPage.jsx";
|
import { AboutPage } from "../pages/AboutPage.jsx";
|
||||||
import { HomePage } from "../pages/HomePage.jsx";
|
import { HomePage } from "../pages/HomePage.jsx";
|
||||||
|
import { LibraryPage } from "../pages/LibraryPage.jsx";
|
||||||
import { navigate } from "./hashRouter.js";
|
import { navigate } from "./hashRouter.js";
|
||||||
|
|
||||||
export function RouteContent(props) {
|
export function RouteContent(props) {
|
||||||
const { route } = props;
|
const { route } = props;
|
||||||
if (route === "/") return <HomePage {...props} />;
|
if (route === "/") return <HomePage {...props} />;
|
||||||
|
if (route === "/library") return <LibraryPage {...props} />;
|
||||||
if (route === "/about") return <AboutPage siteContent={props.siteContent} />;
|
if (route === "/about") return <AboutPage siteContent={props.siteContent} />;
|
||||||
if (route === "/toolboxes") return <ToolboxesPage {...props} />;
|
if (route === "/toolboxes") return <ToolboxesPage {...props} />;
|
||||||
if (route.startsWith("/toolbox/")) return <ToolboxPage {...props} toolboxId={route.split("/")[2]} />;
|
if (route.startsWith("/toolbox/")) return <ToolboxPage {...props} toolboxId={route.split("/")[2]} />;
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,12 @@ span {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-library-button {
|
||||||
|
display: inline-flex;
|
||||||
|
width: fit-content;
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
.hero-panel {
|
.hero-panel {
|
||||||
padding: var(--space-5);
|
padding: var(--space-5);
|
||||||
border: 1px solid rgba(165, 180, 252, 0.14);
|
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));
|
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 {
|
.nebula-panel {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
|
||||||
|
|
@ -1002,6 +1002,7 @@
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: rgba(10, 13, 28, 0.99);
|
background: rgba(10, 13, 28, 0.99);
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notepad-list-menu::before {
|
.notepad-list-menu::before {
|
||||||
|
|
@ -1014,6 +1015,15 @@
|
||||||
border-left: 7px solid transparent;
|
border-left: 7px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notepad-list-menu::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 0;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.notepad-list-option {
|
.notepad-list-option {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 34px;
|
width: 34px;
|
||||||
|
|
@ -2621,7 +2631,7 @@ textarea:focus {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.combos-category-header > div {
|
.combos-category-header .combos-category-title {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2661,6 +2671,8 @@ textarea:focus {
|
||||||
|
|
||||||
.combo-drag-handle {
|
.combo-drag-handle {
|
||||||
align-self: start;
|
align-self: start;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.combo-card-main {
|
.combo-card-main {
|
||||||
|
|
@ -3060,7 +3072,8 @@ button.combo-input-token.combo-input-mouse {
|
||||||
|
|
||||||
.counters-grid {
|
.counters-grid {
|
||||||
display: 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;
|
gap: 10px;
|
||||||
margin: var(--space-4);
|
margin: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
@ -3068,6 +3081,8 @@ button.combo-input-token.combo-input-mouse {
|
||||||
.counter-item {
|
.counter-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
min-height: 106px;
|
||||||
|
align-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px 52px 12px 12px;
|
padding: 12px 52px 12px 12px;
|
||||||
border: 1px solid rgba(165, 180, 252, 0.12);
|
border: 1px solid rgba(165, 180, 252, 0.12);
|
||||||
|
|
@ -3093,7 +3108,7 @@ button.combo-input-token.combo-input-mouse {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.counter-item > div:first-child {
|
.counter-value {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -3117,11 +3132,16 @@ button.combo-input-token.combo-input-mouse {
|
||||||
|
|
||||||
.counter-actions {
|
.counter-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, 34px);
|
||||||
|
justify-content: start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.counter-actions button {
|
.counter-actions button {
|
||||||
|
width: 34px;
|
||||||
|
min-width: 34px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue