Refactor tool catalog into documentation layout
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-08-09 13:34:08 +02:00
parent d53d4160af
commit a9b5ca423a
12 changed files with 1925 additions and 114 deletions

View file

@ -49,6 +49,14 @@ const MODULE_COMPONENTS = {
imageAnnotation: { label: "Annotation d'images", mode: "Image + marqueurs", icon: "map", Component: ImageAnnotationModule, editable: true }
};
const LIBRARY_CATEGORY_DEFINITIONS = [
{ key: "notesTracking", types: ["notepad", "checklist", "counters"] },
{ key: "references", types: ["images", "imageAnnotation", "links"] },
{ key: "calculationData", types: ["calculator", "table"] },
{ key: "timeRoutines", types: ["timer", "taskPlanner"] },
{ key: "buildsCommands", types: ["combos", "equipmentPlanner"] }
];
function cloneData(value) {
if (value === undefined) return {};
if (typeof structuredClone === "function") return structuredClone(value);
@ -75,6 +83,10 @@ function getLibraryAnchorId(module) {
return `library-tool-${String(module?.id || module?.type || "outil").replace(/[^a-z0-9_-]+/gi, "-").toLowerCase()}`;
}
function getLibraryCategoryAnchorId(category) {
return `library-category-${category.key}`;
}
function getCurrentLibraryAnchor() {
const match = location.hash.match(/^#\/library#(.+)$/);
return match ? decodeURIComponent(match[1]) : "";
@ -88,16 +100,36 @@ function scrollToLibraryTool(anchorId, replace = false) {
window.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
}
function findCategoryForAnchor(categories, anchorId) {
return categories.find((category) => (
getLibraryCategoryAnchorId(category) === anchorId ||
category.modules.some((module) => getLibraryAnchorId(module) === anchorId)
)) || null;
}
function getToolDescription(siteContent, definition) {
const tools = siteContent.about?.tools || [];
return tools.find((tool) => tool.name === definition.label)?.description || "";
}
function getLibraryCategories(modules, content) {
const modulesByType = new Map((modules || []).map((module) => [module.type, module]));
return LIBRARY_CATEGORY_DEFINITIONS.map((category) => ({
...category,
title: content.categories?.[category.key] || category.key,
description: content.categoryDescriptions?.[category.key] || "",
modules: category.types.map((type) => modulesByType.get(type)).filter(Boolean)
})).filter((category) => category.modules.length);
}
export function LibraryPage({ siteContent, actions }) {
const content = siteContent.library;
const [libraryPayload, setLibraryPayload] = useState(null);
const [libraryError, setLibraryError] = useState("");
const [moduleData, setModuleData] = useState({});
const [activeCategoryKey, setActiveCategoryKey] = useState("");
const libraryCategories = useMemo(() => getLibraryCategories(libraryPayload?.toolbox?.modules || [], content), [content, libraryPayload]);
const activeCategory = libraryCategories.find((category) => category.key === activeCategoryKey) || libraryCategories[0] || null;
useEffect(() => {
let cancelled = false;
@ -118,11 +150,13 @@ export function LibraryPage({ siteContent, actions }) {
}, []);
useEffect(() => {
if (!libraryPayload) return;
if (!libraryPayload || !libraryCategories.length) return;
const anchorId = getCurrentLibraryAnchor();
const category = findCategoryForAnchor(libraryCategories, anchorId) || libraryCategories[0];
setActiveCategoryKey(category.key);
if (!anchorId) return;
requestAnimationFrame(() => scrollToLibraryTool(anchorId));
}, [libraryPayload]);
}, [libraryPayload, libraryCategories]);
const moduleContext = useMemo(() => ({
getModuleData: (toolboxId, moduleId, fallback) => moduleData[`${toolboxId}:${moduleId}`] || fallback,
@ -203,52 +237,94 @@ export function LibraryPage({ siteContent, actions }) {
{!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"}>
<div className="library-toc-heading">
<h2>{content.tocTitle || "Sommaire"}</h2>
<p>{content.tocDescription || "Accédez directement à un exemple doutil dans la page."}</p>
</div>
<div className="library-toc-links">
{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>
);
})}
</div>
</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 className="library-doc-layout">
<LibraryDocNav
content={content}
categories={libraryCategories}
activeCategoryKey={activeCategory?.key || ""}
onSelectCategory={(category, anchorId = getLibraryCategoryAnchorId(category)) => {
setActiveCategoryKey(category.key);
requestAnimationFrame(() => scrollToLibraryTool(anchorId, true));
}}
/>
<section className="library-tools" aria-label={content.toolsLabel}>
{activeCategory && (
<section className="library-tool-category" key={activeCategory.key} aria-labelledby={getLibraryCategoryAnchorId(activeCategory)}>
<div className="library-category-hero">
<div>
<p className="eyebrow">{content.categoryLabel || "Catégorie"}</p>
<h2 id={getLibraryCategoryAnchorId(activeCategory)}>{activeCategory.title}</h2>
{activeCategory.description && <p>{activeCategory.description}</p>}
</div>
</div>
{activeCategory.modules.map((module) => (
<LibraryToolExample
key={module.id}
module={module}
sourceData={libraryPayload.modules?.[module.id] || {}}
description={getToolDescription(siteContent, MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad)}
categoryTitle={activeCategory.title}
docs={content.toolDocs?.[module.type]}
featureHeading={content.featureHeading}
controlLegendHeading={content.controlLegendHeading}
context={moduleContext}
/>
))}
</section>
)}
</section>
</div>
</>
)}
</div>
);
}
function LibraryToolExample({ module, sourceData, description, context }) {
function LibraryDocNav({ content, categories, activeCategoryKey, onSelectCategory }) {
return (
<nav className="library-doc-nav nebula-panel" aria-label={content.tocLabel || "Sommaire des outils"}>
<div className="library-toc-heading">
<h2>{content.docTocTitle || content.tocTitle || "Sommaire"}</h2>
<p>{content.tocDescription || "Accédez directement à un exemple doutil dans la page."}</p>
</div>
<div className="library-doc-nav-groups">
{categories.map((category) => (
<div className={`library-doc-nav-group ${category.key === activeCategoryKey ? "active" : ""}`} key={category.key}>
<a href={`#/library#${getLibraryCategoryAnchorId(category)}`} onClick={(event) => {
event.preventDefault();
onSelectCategory(category);
}}>{category.title}</a>
<ul hidden={category.key !== activeCategoryKey}>
{category.modules.map((module) => {
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
const title = module.title || definition.label || "Outil";
const anchorId = getLibraryAnchorId(module);
return (
<li key={module.id}>
<a
href={`#/library#${anchorId}`}
onClick={(event) => {
event.preventDefault();
onSelectCategory(category, anchorId);
}}
>
<span className="module-icon" aria-hidden="true">
<span className={`module-icon-svg module-icon-${definition.icon}`} />
</span>
<span>{title}</span>
</a>
</li>
);
})}
</ul>
</div>
))}
</div>
</nav>
);
}
function LibraryToolExample({ module, sourceData, description, categoryTitle, docs, featureHeading, controlLegendHeading, context }) {
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
const Component = definition.Component;
const [scrollable, setScrollable] = useState(false);
@ -259,55 +335,220 @@ function LibraryToolExample({ module, sourceData, description, context }) {
<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>
<div className="library-tool-title-row">
<span className="module-icon" aria-hidden="true">
<span className={`module-icon-svg module-icon-${definition.icon}`} />
</span>
<h2>{title}</h2>
</div>
<span className="library-tool-badge">{categoryTitle}</span>
{description && <p>{description}</p>}
</div>
</div>
<div className={`library-example-layout ${Array.isArray(docs?.controls) && docs.controls.length ? "has-legend" : ""}`}>
<ToolControlLegend items={docs?.controls} heading={controlLegendHeading || "Repères"} />
<article className={`module library-tool-example ${scrollable ? "is-scrollable" : ""}`}>
<header>
<div>
<h2>{definition.label}</h2>
<p>{definition.mode}</p>
</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" />
<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>
)}
{definition.editable && (
<button
className={`module-edit-button ${editing ? "active" : ""}`}
type="button"
onClick={() => setEditing((value) => !value)}
aria-label={`${editing ? "Masquer" : "Afficher"} l'ajout de ${title}`}
aria-pressed={editing}
title={editing ? "Masquer l'ajout" : "Ajouter"}
>
<Icon name="add" />
</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>
)}
{definition.editable && (
<button
className={`module-edit-button ${editing ? "active" : ""}`}
type="button"
onClick={() => setEditing((value) => !value)}
aria-label={`${editing ? "Masquer" : "Afficher"} l'ajout de ${title}`}
aria-pressed={editing}
title={editing ? "Masquer l'ajout" : "Ajouter"}
>
<Icon name="add" />
</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={editing} />
</div>
</header>
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
<Component toolboxId={LIBRARY_TOOLBOX_ID} moduleId={module.id} context={context} editing={editing} />
</div>
</article>
</article>
</div>
<ToolFeatureList docs={docs} heading={featureHeading || "Fonctionnalités"} />
</section>
);
}
function ToolControlLegend({ items, heading }) {
const legendItems = Array.isArray(items) ? items : [];
if (!legendItems.length) return null;
const groups = legendItems.reduce((accumulator, item) => {
const groupTitle = item.group || "Contrôles";
const existingGroup = accumulator.find((group) => group.title === groupTitle);
if (existingGroup) existingGroup.items.push(item);
else accumulator.push({ title: groupTitle, items: [item] });
return accumulator;
}, []);
return (
<aside className="library-control-legend" aria-label={heading}>
<h3>{heading}</h3>
{groups.map((group) => {
const genericSingleGroup = groups.length === 1 && group.title === "Contrôles";
return (
<section className={`library-control-legend-group ${genericSingleGroup ? "is-generic-single" : ""}`} key={group.title}>
{!genericSingleGroup && <h4>{group.title}</h4>}
<div className="library-control-legend-items">
{group.items.map((item) => (
<div className="library-control-legend-item" key={item.title}>
<ControlPreview item={item} />
<div>
<strong>{item.title}</strong>
{item.text && <p>{item.text}</p>}
</div>
</div>
))}
</div>
</section>
);
})}
</aside>
);
}
function ControlPreview({ item }) {
const preview = item.preview || {};
const kind = preview.kind || "iconButton";
if (kind === "textFormats") {
return (
<span className="library-control-preview notepad-toolbar-group" aria-hidden="true">
<span className="notepad-toolbar-button">B</span>
<span className="notepad-toolbar-button"><em>I</em></span>
<span className="notepad-toolbar-button"><u>U</u></span>
<span className="notepad-toolbar-button"><s>S</s></span>
</span>
);
}
if (kind === "textButton") {
return (
<span className="library-control-preview" aria-hidden="true">
<span className={`notepad-toolbar-button ${preview.active ? "active" : ""}`}>{preview.label || item.title.slice(0, 1)}</span>
</span>
);
}
if (kind === "listDropdown") {
return (
<span className="library-control-preview" aria-hidden="true">
<span className="notepad-toolbar-button active"><span className="notepad-list-icon" /></span>
<span className="notepad-toolbar-button"></span>
<span className="notepad-toolbar-button">1.</span>
</span>
);
}
if (kind === "color") {
return (
<span className="library-control-preview" aria-hidden="true">
<span className="notepad-color-toggle is-rainbow active" />
</span>
);
}
if (kind === "drawingWidth") {
return (
<span className="library-control-preview" aria-hidden="true">
<span className="drawing-width-toggle active"><span style={{ "--line-width": `${preview.width || 4}px` }} /></span>
</span>
);
}
if (kind === "switch") {
return (
<span className="library-control-preview" aria-hidden="true">
<span className={`module-scroll-button drawing-storage-switch ${preview.active ? "active" : ""}`}>
<Icon name={preview.icon || item.icon || "settings"} />
<i />
</span>
</span>
);
}
if (kind === "buttonGroup") {
const buttons = Array.isArray(preview.buttons) ? preview.buttons : [];
return (
<span className="library-control-preview" aria-hidden="true">
{buttons.map((button, index) => (
<span className={`notepad-toolbar-button ${button.label && String(button.label).length > 2 ? "is-wide" : ""} ${button.active ? "active" : ""} ${button.danger ? "danger" : ""}`} key={`${button.label || button.icon || index}-${index}`}>
{button.icon ? <Icon name={button.icon} /> : button.label}
</span>
))}
</span>
);
}
return (
<span className="library-control-preview" aria-hidden="true">
<span className={`notepad-toolbar-button ${preview.active ? "active" : ""} ${preview.danger ? "danger" : ""}`}>
<Icon name={preview.icon || item.icon || "settings"} />
</span>
</span>
);
}
function ToolFeatureList({ docs, heading }) {
const features = Array.isArray(docs?.features) ? docs.features : [];
if (!features.length && !docs?.importFormat) return null;
return (
<section className="library-tool-docs">
<details className="library-tool-feature-list">
<summary>
<h3>{heading}</h3>
<Icon name="chevron-down" />
</summary>
<div className="library-tool-feature-content">
<dl>
{features.map((feature) => (
<div key={feature.title}>
<dt>{feature.title}</dt>
<dd>
<p>{feature.text}</p>
{Array.isArray(feature.details) && feature.details.length > 0 && (
<ul>
{feature.details.map((detail) => <li key={detail}>{detail}</li>)}
</ul>
)}
</dd>
</div>
))}
</dl>
{docs?.importFormat && (
<div className="library-tool-import-format">
<h3>{docs.importFormat.title}</h3>
<p>{docs.importFormat.text}</p>
<pre><code>{docs.importFormat.example}</code></pre>
</div>
)}
</div>
</details>
</section>
);
}