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

@ -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>
);
}