Improve toolbox checklists, image browsing, and guide integration
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s
This commit is contained in:
parent
00e73f0062
commit
386212041f
47 changed files with 744 additions and 112 deletions
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") })) };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue