sokko-g/website/src/pages/LibraryPage.jsx
Shinuwa a9b5ca423a
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
Refactor tool catalog into documentation layout
2026-08-09 13:34:08 +02:00

554 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 { EquipmentPlannerModule } from "../features/toolboxes/modules/EquipmentPlannerModule.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,
normalizeEquipmentPlannerData,
normalizeImageAnnotationData,
normalizeLinksData,
normalizeNotepadData,
normalizeTableData,
normalizeTaskPlannerData,
normalizeTimerData,
summarizeEquipmentPlannerData,
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, editable: true, scrollable: true },
images: { label: "Images", mode: "Média", icon: "picture", Component: ImagesModule, editable: true, scrollable: true },
links: { label: "Liens", mode: "Liste simple", icon: "link", Component: LinksModule, editable: true, scrollable: true },
counters: { label: "Compteurs", mode: "Valeurs", icon: "abacus", Component: CountersModule, editable: true, scrollable: true },
combos: { label: "Combos", mode: "Catégories", icon: "controller", Component: CombosModule, editable: true, 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, editable: true, scrollable: true },
equipmentPlanner: { label: "Planificateur d'équipements", mode: "Build + sertissages", icon: "chest-armor", Component: EquipmentPlannerModule, editable: true, scrollable: true },
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);
return JSON.parse(JSON.stringify(value));
}
function fileToDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => resolve(String(reader.result || "")));
reader.addEventListener("error", () => reject(reader.error || new Error("Image illisible.")));
reader.readAsDataURL(file);
});
}
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 getLibraryCategoryAnchorId(category) {
return `library-category-${category.key}`;
}
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 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;
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 || !libraryCategories.length) return;
const anchorId = getCurrentLibraryAnchor();
const category = findCategoryForAnchor(libraryCategories, anchorId) || libraryCategories[0];
setActiveCategoryKey(category.key);
if (!anchorId) return;
requestAnimationFrame(() => scrollToLibraryTool(anchorId));
}, [libraryPayload, libraryCategories]);
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,
normalizeEquipmentPlannerData,
normalizeCalculatorData,
normalizeImageAnnotationData,
normalizeNotepadData,
normalizeTableData,
normalizeTimerData,
normalizeTaskPlannerData,
summarizeEquipmentPlannerData,
normalizeUrl,
hostnameFromUrl,
clampQty,
uid,
copyText: async (value) => {
await navigator.clipboard.writeText(value);
return true;
},
notify: actions.notify,
compressImageFile: fileToDataUrl,
addImageFiles: async (toolboxId, moduleId, files) => {
const imageFiles = [...files].filter((file) => file?.type?.startsWith("image/"));
if (!imageFiles.length) return false;
const images = await Promise.all(imageFiles.map(async (file) => ({
id: uid("image"),
label: file.name?.replace(/\.[^.]+$/, "").trim() || "",
dataUrl: await fileToDataUrl(file)
})));
setModuleData((current) => {
const key = `${toolboxId}:${moduleId}`;
const data = current[key] || { images: [] };
return { ...current, [key]: { ...data, images: [...(data.images || []), ...images] } };
});
return true;
},
setImage: actions.setImage,
createImageAnnotationModule: (dataUrl) => {
const annotationModule = libraryPayload?.toolbox?.modules?.find((module) => module.type === "imageAnnotation");
if (!annotationModule || !dataUrl) return;
setModuleData((current) => ({
...current,
[`${LIBRARY_TOOLBOX_ID}:${annotationModule.id}`]: { image: dataUrl, markers: [], drawings: { strokes: [] } }
}));
requestAnimationFrame(() => scrollToLibraryTool(getLibraryAnchorId(annotationModule), true));
}
}), [actions, libraryPayload, 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 && (
<>
<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 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);
const [editing, setEditing] = useState(false);
const title = module.title || definition.label || "Outil";
return (
<section id={getLibraryAnchorId(module)} className="library-tool-section">
<div className="library-tool-intro">
<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 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>
</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>
</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>
);
}