sokko-g/website/src/pages/LibraryPage.jsx
Shinuwa 75d4d99a87
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s
Fix library anchor navigation and scroll cleanup
2026-09-12 08:54:09 +02:00

613 lines
24 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 { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../components/Icon.jsx";
import { LIBRARY_CATEGORY_DEFINITIONS, getToolboxModuleDefinition } from "../features/toolboxes/toolboxModuleRegistry.js";
import { MODULE_COMPONENTS } from "../features/toolboxes/modules/toolboxModuleComponents.jsx";
import {
clampQty,
hostnameFromUrl,
normalizeCalculatorData,
normalizeCalendarData,
normalizeChecklistData,
normalizeCombosData,
normalizeCountersData,
normalizeEquipmentPlannerData,
normalizeGoalTreeData,
normalizeImageAnnotationData,
normalizeLinksData,
normalizeMemosData,
normalizeNotepadData,
normalizeRandomizerData,
normalizeRngSimulatorData,
normalizeTableData,
normalizeTaskPlannerData,
normalizeTimerData,
summarizeEquipmentPlannerData,
normalizeUrl,
uid
} from "../features/toolboxes/storage/toolboxStorage.js";
const LIBRARY_TOOLBOX_ID = "library";
const LIBRARY_ANCHOR_RETRY_DELAYS = [0, 80, 240, 600];
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#(.+)$/);
try {
return match ? decodeURIComponent(match[1]) : "";
} catch {
return "";
}
}
function getLibraryAnchorOffset() {
const topbar = document.querySelector(".topbar");
const topbarHeight = topbar?.getBoundingClientRect?.().height || 0;
return topbarHeight + 16;
}
function scrollToLibraryTool(anchorId, behavior) {
let timers = [];
const frame = requestAnimationFrame(() => {
timers = LIBRARY_ANCHOR_RETRY_DELAYS.map((delay, index) => window.setTimeout(() => {
const element = document.getElementById(anchorId);
if (!element) return;
const top = element.getBoundingClientRect().top + window.scrollY - getLibraryAnchorOffset();
window.scrollTo({ top: Math.max(0, top), behavior: index === 0 ? behavior : "auto" });
}, delay));
});
return () => {
cancelAnimationFrame(frame);
timers.forEach((timer) => window.clearTimeout(timer));
};
}
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 || []).forEach((module) => {
modulesByType.set(module.type, [...(modulesByType.get(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.flatMap((type) => modulesByType.get(type) || [])
})).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 [navigation, setNavigation] = useState(() => ({ anchorId: getCurrentLibraryAnchor(), behavior: "auto" }));
const libraryCategories = useMemo(() => getLibraryCategories(libraryPayload?.toolbox?.modules || [], content), [content, libraryPayload]);
const activeCategory = findCategoryForAnchor(libraryCategories, navigation.anchorId) || libraryCategories[0] || null;
const selectAnchor = useCallback((anchorId) => {
history.replaceState(null, "", `#/library#${encodeURIComponent(anchorId)}`);
setNavigation({ anchorId, behavior: "smooth" });
}, []);
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(() => {
const onHashChange = () => setNavigation({ anchorId: getCurrentLibraryAnchor(), behavior: "auto" });
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, []);
useEffect(() => {
if (!activeCategory?.key || !navigation.anchorId) return undefined;
return scrollToLibraryTool(navigation.anchorId, navigation.behavior);
}, [activeCategory?.key, navigation]);
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,
normalizeMemosData,
normalizeCountersData,
normalizeEquipmentPlannerData,
normalizeGoalTreeData,
normalizeCalculatorData,
normalizeCalendarData,
normalizeImageAnnotationData,
normalizeNotepadData,
normalizeRandomizerData,
normalizeRngSimulatorData,
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: [] } }
}));
selectAnchor(getLibraryAnchorId(annotationModule));
}
}), [actions, libraryPayload, moduleData, selectAnchor, siteContent.toolboxes.modules]);
return (
<div className="library-page">
<section className="page-hero nds-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 nds-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 nds-empty-state"><h2>Librairie indisponible</h2><p>{libraryError}</p></section>}
{!libraryError && !libraryPayload && <section className="empty nds-empty-state"><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)) => {
selectAnchor(anchorId);
}}
/>
<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, getToolboxModuleDefinition(module.type))}
categoryTitle={activeCategory.title}
docs={{
...(content.toolDocs?.[module.type] || {}),
...(module.controls ? { controls: module.controls } : {}),
...(module.importFormat ? { importFormat: module.importFormat } : {})
}}
featureHeading={content.featureHeading}
advancedHeading={content.advancedHeading}
advancedCopyLabel={content.copyExampleLabel}
advancedCopySuccess={content.copyExampleSuccess}
controlLegendHeading={content.controlLegendHeading}
context={moduleContext}
/>
))}
</section>
)}
</section>
</div>
</>
)}
</div>
);
}
function LibraryDocNav({ content, categories, activeCategoryKey, onSelectCategory }) {
return (
<nav className="library-doc-nav nebula-panel nds-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 d’outil 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 = getToolboxModuleDefinition(module.type);
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, advancedHeading, advancedCopyLabel, advancedCopySuccess, controlLegendHeading, context }) {
const definition = getToolboxModuleDefinition(module.type);
const { Component } = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
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 nds-panel">
<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 nds-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 nds-tool-panel library-tool-example ${scrollable ? "is-scrollable" : ""}`} data-module-type={module.type}>
<header>
<div>
<h2>{definition.label}</h2>
<p>{definition.mode}</p>
</div>
<div className="library-example-actions">
{definition.scrollable && (
<button
className={`module-scroll-button nds-switch ${scrollable ? "active" : ""}`}
data-size="md"
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 && definition.addable !== false && (
<button
className={`module-edit-button nds-icon-button ${editing ? "active" : ""}`}
data-size="sm"
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 nds-icon-button" data-size="sm" 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" : ""}`}>
<Suspense fallback={<div className="empty nds-empty-state"><p>Chargement de l'outil...</p></div>}>
<Component
toolboxId={LIBRARY_TOOLBOX_ID}
moduleId={module.id}
context={context}
editing={editing}
setEditing={setEditing}
openEditor={() => setEditing(true)}
/>
</Suspense>
</div>
</article>
</div>
<ToolFeatureList docs={docs} heading={featureHeading || "Fonctionnalités"} />
<ToolAdvancedFeatureList docs={docs} heading={advancedHeading || "Import par texte"} copyLabel={advancedCopyLabel} copySuccess={advancedCopySuccess} context={context} />
</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 nds-icon-button ${button.label && String(button.label).length > 2 ? "is-wide" : ""} ${button.active ? "active" : ""}`.trim()}
data-size="sm"
data-variant={button.danger ? "danger" : undefined}
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 nds-icon-button ${preview.active ? "active" : ""}`.trim()} data-size="sm" data-variant={preview.danger ? "danger" : undefined}>
<Icon name={preview.icon || item.icon || "settings"} />
</span>
</span>
);
}
function ToolFeatureList({ docs, heading }) {
const features = Array.isArray(docs?.features) ? docs.features : [];
if (!features.length) 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>
</div>
</details>
</section>
);
}
function ToolAdvancedFeatureList({ docs, heading, copyLabel, copySuccess, context }) {
const [copied, setCopied] = useState(false);
const copiedTimeoutRef = useRef(0);
useEffect(() => () => window.clearTimeout(copiedTimeoutRef.current), []);
if (!docs?.importFormat) return null;
async function copyExample() {
if (!await context.copyText(docs.importFormat.example)) return;
setCopied(true);
context.notify?.(copySuccess || "Exemple copié.");
window.clearTimeout(copiedTimeoutRef.current);
copiedTimeoutRef.current = window.setTimeout(() => setCopied(false), 1400);
}
return (
<section className="library-tool-docs">
<details className="library-tool-feature-list library-tool-advanced-list">
<summary>
<h3>{heading}</h3>
<Icon name="chevron-down" />
</summary>
<div className="library-tool-feature-content">
<div className="library-tool-import-format">
<p>{docs.importFormat.text}</p>
<div className="library-tool-import-example">
<pre><code>{docs.importFormat.example}</code></pre>
<button className="nds-button" data-size="sm" type="button" onClick={copyExample} title={copyLabel || "Copier l'exemple"}>
<Icon name="copy" />
<span>{copied ? copySuccess || "Exemple copié." : copyLabel || "Copier l'exemple"}</span>
</button>
</div>
</div>
</div>
</details>
</section>
);
}