Improve toolbox checklists, image browsing, and guide integration
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-27 22:26:39 +02:00
parent 00e73f0062
commit 386212041f
47 changed files with 744 additions and 112 deletions

View file

@ -190,6 +190,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
const toolboxGameCover = getGameCardCover(toolboxGame);
const moduleText = siteContent.toolboxes.modules;
const [showTutorial, setShowTutorial] = useState(() => shouldShowTutorial(toolbox, pendingTutorialToolboxId));
const [pendingModuleId, setPendingModuleId] = useState("");
const tutorialRequested = shouldShowTutorial(toolbox, pendingTutorialToolboxId);
const [previousTutorial, setPreviousTutorial] = useState({ toolboxId, requested: tutorialRequested });
// Consommer la demande de lancement ne doit pas interrompre la visite en cours.
@ -245,6 +246,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
modules,
moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, module.id)
});
setPendingModuleId(module.id);
if (initialData) updateModuleData(toolbox.id, module.id, initialData, type);
}
@ -257,6 +259,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
modules,
moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, moduleId)
});
setPendingModuleId(moduleId);
updateModuleData(toolbox.id, moduleId, { image: dataUrl, markers: [] }, "imageAnnotation");
}
@ -320,6 +323,8 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
toolbox={toolbox}
moduleColumns={moduleColumns}
context={moduleContext}
scrollToModuleId={pendingModuleId}
onModuleScrolled={() => setPendingModuleId("")}
onRename={(moduleId, title) => updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? { ...module, title } : module) })}
onUpdateModule={(moduleId, updater) => updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? updater(module) : module) })}
onModuleOrderChange={(moduleOrder) => updateToolbox({ ...toolbox, moduleOrder })}

View file

@ -4,6 +4,7 @@ import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompl
import { Icon } from "../../../components/Icon.jsx";
import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js";
import { ChecklistSection } from "./ChecklistParts.jsx";
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import {
appendChecklistItems,
@ -127,8 +128,20 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc
<TextExchangeActions type="checklist" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "checklist")} />
</div>
)}
{namedSections.length > 0 && (
<div className="checklist-toolbar">
<div className="checklist-toolbar">
<button
className={`checklist-hide-completed-toggle nds-switch ${data.hideCompletedItems ? "active" : ""}`}
data-size="lg"
type="button"
onClick={() => save(data.sections, { hideCompletedItems: !data.hideCompletedItems })}
aria-pressed={data.hideCompletedItems}
aria-label={textContent.hideCompletedItemsTitle || "Masquer les éléments terminés"}
title={textContent.hideCompletedItemsTitle || "Masquer les éléments terminés"}
>
<Icon name={data.hideCompletedItems ? "eye-closed" : "eye-open"} />
<i aria-hidden="true" />
</button>
{namedSections.length > 0 && (
<div className="checklist-complete-mode-group nds-radio-button-group" data-size="md" role="radiogroup" aria-label={textContent.completedSectionsModeTitle || "Affichage des catégories terminées"}>
{[
{ mode: "visible", label: textContent.completedSectionsVisibleLabel || "Visible", icon: "eye-open" },
@ -149,8 +162,9 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc
</button>
))}
</div>
</div>
)}
)}
</div>
{!data.sections.length && <ModuleEmptyMessage>{textContent.emptyItems || "Aucun élément dans cette checklist."}</ModuleEmptyMessage>}
<ul className="checklist">
{visibleSections.map((section, index) => {
const isLastSection = index === visibleSections.length - 1;

View file

@ -15,6 +15,9 @@ export function ChecklistSection({ section, context, data, reorder, onRenameSect
const hideWhenComplete = getSectionHideWhenComplete(section, data.hideCompletedSections);
const isAutoCollapsed = sectionComplete && hideWhenComplete;
const isCollapsed = Boolean(section.collapsed) || isAutoCollapsed;
const visibleItems = data.hideCompletedItems
? section.items.filter((item) => context.clampQty(item.qtyCurrent, item.qtyTarget) < item.qtyTarget)
: section.items;
const textContent = context.moduleText?.checklist || {};
const className = [
"checklist-section",
@ -106,7 +109,7 @@ export function ChecklistSection({ section, context, data, reorder, onRenameSect
)}
{!isCollapsed && (
<ul className="checklist-section-items">
{section.items.map((item) => (
{visibleItems.map((item) => (
<ChecklistItem
key={item.id}
item={item}

View file

@ -3,6 +3,7 @@ import { Icon } from "../../../components/Icon.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
export function CountersModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.normalizeCountersData(context.getModuleData(toolboxId, moduleId, { counters: [] }));
@ -57,6 +58,7 @@ export function CountersModule({ toolboxId, moduleId, context, editing, textExch
<TextExchangeActions type="counters" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "counters")} />
</div>
)}
{!data.counters.length && <ModuleEmptyMessage>{textContent.emptyCounters || "Aucun compteur."}</ModuleEmptyMessage>}
<div className="counters-grid">
{data.counters.map((counter) => {
const className = [

View file

@ -1,7 +1,8 @@
// Rôle : fournit l'outil images avec import fichier/texte, collage, libellés et annotation rapide.
// Rôle : fournit l'outil images avec import, galerie, libellés et annotation rapide.
import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
export function ImagesModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
@ -41,6 +42,16 @@ export function ImagesModule({ toolboxId, moduleId, context, editing, textExchan
});
}
function openCollection(initialView, activeIndex = 0) {
context.setImage({
kind: "collection",
images: data.images,
activeIndex,
initialView,
textContent
});
}
return (
<>
{editing && (
@ -93,8 +104,17 @@ export function ImagesModule({ toolboxId, moduleId, context, editing, textExchan
<TextExchangeActions type="images" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "images")} />
</div>
)}
{data.images.length > 0 && (
<div className="images-toolbar">
<button className="nds-button" data-size="sm" type="button" onClick={() => openCollection("gallery")}>
<Icon name="picture" />
<span>{textContent.galleryButton || "Galerie"}</span>
</button>
</div>
)}
{!data.images.length && <ModuleEmptyMessage>{textContent.emptyImages || "Aucune image."}</ModuleEmptyMessage>}
<div className="images">
{data.images.map((image) => {
{data.images.map((image, index) => {
const className = [
isItemDragging(image.id) ? "is-dragging" : "",
isItemDropTarget(image.id) ? "is-drop-target" : "",
@ -114,7 +134,7 @@ export function ImagesModule({ toolboxId, moduleId, context, editing, textExchan
>
<Icon name="drag" />
</button>
<button className="image-preview" onClick={() => context.setImage(image)} aria-label={textContent.previewAriaLabel || "Agrandir l'image"}>
<button className="image-preview" onClick={() => openCollection("detail", index)} aria-label={textContent.previewAriaLabel || "Agrandir l'image"}>
<img src={image.dataUrl} alt={textContent.imageAlt || "Image"} />
</button>
{editing ? (

View file

@ -6,6 +6,7 @@ import { Icon } from "../../../components/Icon.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
function getLinkCategory(link) {
return String(link?.category || "").trim();
@ -173,6 +174,7 @@ export function LinksModule({ toolboxId, moduleId, context, editing, textExchang
<TextExchangeActions type="links" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "links")} />
</div>
)}
{!data.links.length && <ModuleEmptyMessage>{textContent.emptyLinks || "Aucun lien."}</ModuleEmptyMessage>}
<div className="links-category-list">
{getLinkEntries(data).map((entry) => {
if (entry.type === "group") {

View file

@ -0,0 +1,4 @@
// Rôle : rend le message compact commun aux outils sans contenu.
export function ModuleEmptyMessage({ children }) {
return <p className="toolbox-module-empty">{children}</p>;
}

View file

@ -1,5 +1,5 @@
// Rôle : rend l'en-tête, les contrôles et le contenu repliable d'un outil toolbox.
import { Suspense, useState } from "react";
import { Suspense, useEffect, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { GuideChecklistControls, GuideChecklistToggle } from "../../games/GuideChecklistControls.jsx";
@ -8,12 +8,13 @@ import { ModuleDataGate, ModuleLoadingFallback } from "./ModuleDataGate.jsx";
import { supportsTextExchange } from "./textImport.js";
import { MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";
export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRename, onUpdateModule, onDelete }) {
export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", scrollIntoView = false, onScrolledIntoView, onRename, onUpdateModule, onDelete }) {
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
const Component = definition.Component;
const label = definition.label || module.type;
const [editing, setEditing] = useState(false);
const [textExchangeOpen, setTextExchangeOpen] = useState(false);
const moduleRef = useRef(null);
const guidePanel = useGuideChecklistPanel(context.guideChecklist, module);
const hasAddPanel = definition.editable && definition.addable !== false;
const hasTextExchange = supportsTextExchange(module.type);
@ -40,6 +41,15 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
textExchangeOpen
};
useEffect(() => {
if (!scrollIntoView) return undefined;
const frame = window.requestAnimationFrame(() => {
moduleRef.current?.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
onScrolledIntoView?.();
});
return () => window.cancelAnimationFrame(frame);
}, [onScrolledIntoView, scrollIntoView]);
function toggleCollapsed() {
if (!collapsed) {
setEditing(false);
@ -58,8 +68,10 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
return (
<article
ref={moduleRef}
className={className}
data-module-type={module.type}
data-module-id={module.id}
{...reorder.getItemProps({ itemId: module.id, groupId: itemGroup, parentId: toolbox.id })}
>
<header>

View file

@ -53,7 +53,7 @@ function moveModuleInTwoColumnOrder(order, operation) {
return { ...order, two: columns };
}
export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onUpdateModule, onDelete, onModuleOrderChange }) {
export function ToolboxModules({ toolbox, moduleColumns, context, scrollToModuleId = "", onModuleScrolled, onRename, onUpdateModule, onDelete, onModuleOrderChange }) {
const twoColumnOrder = useMemo(() => toolbox.moduleOrder?.two || [[], []], [toolbox.moduleOrder?.two]);
const orderedModules = useMemo(() => getModulesByIds(toolbox.modules, toolbox.moduleOrder?.one || []), [toolbox.modules, toolbox.moduleOrder?.one]);
const columns = useMemo(() => [
@ -101,6 +101,8 @@ export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onUp
context={context}
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
itemGroup=""
scrollIntoView={module.id === scrollToModuleId}
onScrolledIntoView={onModuleScrolled}
onRename={onRename}
onUpdateModule={onUpdateModule}
onDelete={onDelete}
@ -128,6 +130,8 @@ export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onUp
context={context}
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
itemGroup={MODULE_COLUMN_IDS[index]}
scrollIntoView={module.id === scrollToModuleId}
onScrolledIntoView={onModuleScrolled}
onRename={onRename}
onUpdateModule={onUpdateModule}
onDelete={onDelete}

View file

@ -30,15 +30,16 @@ function normalizeChecklistSection(section, fallbackTitle = "") {
}
export function normalizeChecklistData(data) {
const hideCompletedItems = Boolean(data?.hideCompletedItems);
const hideCompletedSections = Boolean(data?.hideCompletedSections);
const hideCompletedSectionsFully = Boolean(data?.hideCompletedSectionsFully);
const sections = Array.isArray(data?.sections)
? data.sections.map((section) => normalizeChecklistSection(section)).filter((section) => section.items.length)
: [];
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 { hideCompletedItems, hideCompletedSections, hideCompletedSectionsFully, sections: [{ id: uid("section"), title: "", items: simpleItems }], items: simpleItems };
const items = sections.flatMap((section) => section.items);
return { hideCompletedSections, hideCompletedSectionsFully, sections, items };
return { hideCompletedItems, hideCompletedSections, hideCompletedSectionsFully, sections, items };
}
function compactChecklistItemForStorage(item) {
@ -64,11 +65,12 @@ export function compactChecklistSectionForStorage(section) {
export function compactChecklistDataForStorage(value) {
const normalized = normalizeChecklistData(value);
const sections = normalized.sections.map(compactChecklistSectionForStorage).filter((section) => section.items.length);
if (!sections.length) return null;
const settings = {
...(normalized.hideCompletedItems ? { hideCompletedItems: true } : {}),
...(normalized.hideCompletedSections ? { hideCompletedSections: true } : {}),
...(normalized.hideCompletedSectionsFully ? { hideCompletedSectionsFully: true } : {})
};
if (!sections.length) return Object.keys(settings).length ? settings : null;
if (sections.length === 1 && !sections[0].title && typeof sections[0].hideWhenComplete !== "boolean") return { ...settings, items: sections[0].items };
return { ...settings, sections };
}

View file

@ -191,16 +191,19 @@ function remapModuleDataForExport(type, data, nextId) {
const compact = compactModuleDataForStorage(type, data);
if (!compact) return null;
if (type === "checklist") {
const { sections, items, ...settings } = compact;
if (compact.sections) {
return {
sections: compact.sections.map((section) => ({
...settings,
sections: sections.map((section) => ({
...section,
id: nextId("section"),
items: section.items.map((item) => ({ ...item, id: nextId("item") }))
}))
};
}
return { items: compact.items.map((item) => ({ ...item, id: nextId("item") })) };
if (items) return { ...settings, items: items.map((item) => ({ ...item, id: nextId("item") })) };
return settings;
}
if (type === "images") {
return { images: compact.images.map((image) => ({ ...image, id: nextId("image") })) };