update checklist component
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-07-24 13:38:17 +02:00
parent 772b9951de
commit d9d22683dc
13 changed files with 709 additions and 65 deletions

View file

@ -76,10 +76,16 @@ export function validateSiteContent(site) {
"toolboxes.storageHelp.text",
"toolboxes.modules.notepad.placeholder",
"toolboxes.modules.checklist.itemPlaceholder",
"toolboxes.modules.checklist.sectionPlaceholder",
"toolboxes.modules.checklist.quantityLabel",
"toolboxes.modules.checklist.addButton",
"toolboxes.modules.checklist.importPlaceholder",
"toolboxes.modules.checklist.importOpenButton",
"toolboxes.modules.checklist.importModalTitle",
"toolboxes.modules.checklist.importButton",
"toolboxes.modules.checklist.hideCompletedSectionsTitle",
"toolboxes.modules.checklist.showCompletedSectionTitle",
"toolboxes.modules.checklist.hideCompletedSectionTitle",
"toolboxes.modules.checklist.decrementLabel",
"toolboxes.modules.checklist.incrementLabel",
"toolboxes.modules.checklist.currentQuantityLabel",
@ -97,6 +103,8 @@ export function validateSiteContent(site) {
"toolboxes.modules.links.urlPlaceholder",
"toolboxes.modules.links.addButton",
"toolboxes.modules.links.importPlaceholder",
"toolboxes.modules.links.importOpenButton",
"toolboxes.modules.links.importModalTitle",
"toolboxes.modules.links.importButton",
"toolboxes.modules.links.copyTitle",
"toolboxes.modules.links.copiedTitle",

View file

@ -51,6 +51,7 @@ test("react application defines the expected local toolbox primitives", async ()
const screenshotsModule = await readFile("website/src/features/toolboxes/modules/ScreenshotsModule.jsx", "utf8");
const imageAnnotationModule = await readFile("website/src/features/toolboxes/modules/ImageAnnotationModule.jsx", "utf8");
const linksModule = await readFile("website/src/features/toolboxes/modules/LinksModule.jsx", "utf8");
const textImportModal = await readFile("website/src/features/toolboxes/modules/TextImportModal.jsx", "utf8");
const countersModule = await readFile("website/src/features/toolboxes/modules/CountersModule.jsx", "utf8");
const textImport = await readFile("website/src/features/toolboxes/modules/textImport.js", "utf8");
const reorderHook = await readFile("website/src/hooks/usePointerReorder.js", "utf8");
@ -90,12 +91,15 @@ test("react application defines the expected local toolbox primitives", async ()
assert.match(styleToolboxes, /\.toolbox-game-icon/);
assert.match(styleToolboxes, /\.toolbox-hero-link/);
assert.match(styleToolboxes, /\.toolbox-actions-row/);
assert.match(styleToolboxes, /\.text-import-form/);
assert.match(styleToolboxes, /\.text-import-actions/);
assert.match(styleIcons, /ui-icon-drag/);
assert.match(styleIcons, /ui-icon-dropdown/);
assert.match(styleIcons, /ui-icon-enter/);
assert.match(styleIcons, /ui-icon-calculator/);
assert.match(styleIcons, /ui-icon-scrollable/);
assert.match(styleIcons, /ui-icon-hide/);
assert.match(styleIcons, /ui-icon-chevron-down/);
assert.match(styleIcons, /ui-icon-chevron-up/);
assert.match(styleIcons, /ui-icon-flip/);
assert.match(styleIcons, /ui-icon-add/);
assert.match(styleMhwilds, /\.mhwilds-card/);
@ -180,6 +184,13 @@ test("react application defines the expected local toolbox primitives", async ()
assert.match(calculatorModule, /--calculator-scroll-height/);
assert.match(calculatorModule, /CalculatorEntries/);
assert.match(checklistModule, /export function ChecklistModule/);
assert.match(checklistModule, /TextImportModal/);
assert.match(textImportModal, /createPortal/);
assert.match(textImportModal, /lockBodyScroll/);
assert.match(checklistModule, /Icon name="import"/);
assert.match(styleToolboxes, /\.text-import-modal-dialog/);
assert.match(linksModule, /TextImportModal/);
assert.match(linksModule, /Icon name="import"/);
assert.match(screenshotsModule, /export function ScreenshotsModule/);
assert.match(imageAnnotationModule, /export function ImageAnnotationModule/);
assert.match(linksModule, /export function LinksModule/);
@ -241,11 +252,18 @@ test("react application defines the expected local toolbox primitives", async ()
assert.match(moduleRegistry, /tool-add-card/);
assert.match(moduleRegistry, /tool-quick-add-button/);
assert.match(moduleRegistry, /tool-add-quick-toggle/);
assert.match(moduleRegistry, /scrollable: true/);
assert.match(moduleRegistry, /module-content/);
assert.match(moduleRegistry, /module-scroll-button/);
assert.match(moduleRegistry, /Icon name="dropdown"/);
assert.match(moduleRegistry, /aria-expanded=\{quickOpen\}/);
assert.doesNotMatch(source, /<select value="" onChange=\{\(event\) => addModule/);
assert.match(source, /qtyTarget/);
assert.match(source, /qtyCurrent/);
assert.match(source, /hideCompletedSections/);
assert.match(source, /hideWhenComplete/);
assert.match(source, /collapsed/);
assert.match(source, /module\.scrollable/);
assert.match(source, /normalizeLinksData/);
assert.match(source, /normalizeCountersData/);
assert.match(source, /normalizeCalculatorData/);
@ -253,7 +271,12 @@ test("react application defines the expected local toolbox primitives", async ()
assert.match(source, /compactModuleDataForStorage\(type, value\)/);
assert.match(source, /scrollResults/);
assert.match(checklistModule, /ChecklistItem/);
assert.match(checklistModule, /ChecklistSection/);
assert.match(checklistModule, /parseChecklistImport/);
assert.match(checklistModule, /parseColonImportLines/);
assert.match(checklistModule, /startsWith\("#"\)/);
assert.match(checklistModule, /chevron-down/);
assert.match(checklistModule, /chevron-up/);
assert.match(checklistModule, /Number\.parseInt/);
assert.match(checklistModule, /checklist-qty-current/);
assert.match(checklistModule, /checklist-delete-button danger/);
@ -317,6 +340,9 @@ test("legacy svg icons are available for reuse", async () => {
await readFile("website/public/static/icons/map.svg", "utf8");
await readFile("website/public/static/icons/enter.svg", "utf8");
await readFile("website/public/static/icons/export.svg", "utf8");
await readFile("website/public/static/icons/hide.svg", "utf8");
await readFile("website/public/static/icons/chevron-down.svg", "utf8");
await readFile("website/public/static/icons/chevron-up.svg", "utf8");
await readFile("website/public/static/icons/scrollable.svg", "utf8");
await readFile("website/public/static/icons/notepad.svg", "utf8");
await readFile("website/public/static/icons/picture.svg", "utf8");

View file

@ -190,10 +190,16 @@
},
"checklist": {
"itemPlaceholder": "Nouvel item",
"sectionPlaceholder": "Catégorie",
"quantityLabel": "Quantité cible",
"addButton": "Ajouter",
"importPlaceholder": "Importer plusieurs items...\nPotion:10\nMéga potion:5",
"importPlaceholder": "Importer plusieurs items...\n# Potions\nPotion:10\nMéga potion:5\n\n# Armures\nCasque Rey Dau:1",
"importOpenButton": "Importer du texte",
"importModalTitle": "Importer une checklist",
"importButton": "Importer le texte",
"hideCompletedSectionsTitle": "Masquer les catégories terminées",
"showCompletedSectionTitle": "Afficher cette catégorie terminée",
"hideCompletedSectionTitle": "Masquer cette catégorie terminée",
"decrementLabel": "Retirer une quantité",
"incrementLabel": "Ajouter une quantité",
"currentQuantityLabel": "Quantité actuelle",
@ -215,6 +221,8 @@
"urlPlaceholder": "https://...",
"addButton": "Ajouter",
"importPlaceholder": "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map",
"importOpenButton": "Importer du texte",
"importModalTitle": "Importer des liens",
"importButton": "Importer le texte",
"copyTitle": "Copier",
"copiedTitle": "Copié",

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor" d="M12 15.5a1 1 0 0 1-.7-.29l-5-5a1 1 0 1 1 1.4-1.42L12 13.08l4.3-4.29a1 1 0 1 1 1.4 1.42l-5 5a1 1 0 0 1-.7.29Z"/>
</svg>

After

Width:  |  Height:  |  Size: 230 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor" d="M17 15.5a1 1 0 0 1-.7-.29L12 10.92l-4.3 4.29a1 1 0 0 1-1.4-1.42l5-5a1 1 0 0 1 1.4 0l5 5a1 1 0 0 1-.7 1.71Z"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_extend "http://ns.adobe.com/Extensibility/1.0/">
<!ENTITY ns_ai "http://ns.adobe.com/AdobeIllustrator/10.0/">
<!ENTITY ns_graphs "http://ns.adobe.com/Graphs/1.0/">
<!ENTITY ns_vars "http://ns.adobe.com/Variables/1.0/">
<!ENTITY ns_imrep "http://ns.adobe.com/ImageReplacement/1.0/">
<!ENTITY ns_sfw "http://ns.adobe.com/SaveForWeb/1.0/">
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
]>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" version="1.1" id="Layer_1" xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800px" height="800px"
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
<metadata>
<sfw xmlns="&ns_sfw;">
<slices>
</slices>
<sliceSourceBounds width="505" height="984" bottomLeftOrigin="true" x="0" y="-120">
</sliceSourceBounds>
</sfw>
</metadata>
<g>
<g>
<g>
<path d="M20,24H4c-2.2,0-4-1.8-4-4V4c0-2.2,1.8-4,4-4h16c2.2,0,4,1.8,4,4v16C24,22.2,22.2,24,20,24z M4,2C2.9,2,2,2.9,2,4v16
c0,1.1,0.9,2,2,2h16c1.1,0,2-0.9,2-2V4c0-1.1-0.9-2-2-2H4z"/>
</g>
</g>
<g>
<g>
<path d="M23,9H1C0.4,9,0,8.6,0,8s0.4-1,1-1h22c0.6,0,1,0.4,1,1S23.6,9,23,9z"/>
</g>
</g>
<g>
<g>
<path d="M15,18c-0.3,0-0.5-0.1-0.7-0.3l-3-3c-0.4-0.4-0.4-1,0-1.4s1-0.4,1.4,0l3,3c0.4,0.4,0.4,1,0,1.4C15.5,17.9,15.3,18,15,18z
"/>
</g>
</g>
<g>
<g>
<path d="M9,18c-0.3,0-0.5-0.1-0.7-0.3c-0.4-0.4-0.4-1,0-1.4l3-3c0.4-0.4,1-0.4,1.4,0s0.4,1,0,1.4l-3,3C9.5,17.9,9.3,18,9,18z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -1,38 +1,55 @@
import { useState } from "react";
import { useCallback, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { TextImportModal } from "./TextImportModal.jsx";
import { parseColonImportLines } from "./textImport.js";
export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeChecklistData(context.getModuleData(toolboxId, moduleId, { items: [] }));
const textContent = context.moduleText?.checklist || {};
const [label, setLabel] = useState("");
const [sectionTitle, setSectionTitle] = useState("");
const [qty, setQty] = useState(1);
const [textImport, setTextImport] = useState("");
const [importOpen, setImportOpen] = useState(false);
const closeImportModal = useCallback(() => setImportOpen(false), []);
const completedNamedSections = data.sections.filter((section) => section.title && isSectionComplete(section, context));
const allCompletedSectionsHidden = completedNamedSections.length > 0 && completedNamedSections.every((section) => getSectionHideWhenComplete(section, data.hideCompletedSections));
function save(items) {
context.setModuleData(toolboxId, moduleId, { items });
function save(sections, settings = {}) {
context.setModuleData(toolboxId, moduleId, { ...data, ...settings, sections });
}
function appendItems(sections, title, items) {
const cleanTitle = title.trim();
const existingIndex = sections.findIndex((section) => section.title.toLowerCase() === cleanTitle.toLowerCase());
if (existingIndex >= 0) {
return sections.map((section, index) => index === existingIndex ? { ...section, items: [...section.items, ...items] } : section);
}
return [...sections, { id: context.uid("section"), title: cleanTitle, items }];
}
function addItem(event) {
event.preventDefault();
const cleanLabel = label.trim();
if (!cleanLabel) return;
save([...data.items, { id: context.uid("item"), label: cleanLabel, qtyTarget: Math.max(1, Number(qty) || 1), qtyCurrent: 0 }]);
const nextItem = { id: context.uid("item"), label: cleanLabel, qtyTarget: Math.max(1, Number(qty) || 1), qtyCurrent: 0 };
save(appendItems(data.sections, sectionTitle, [nextItem]));
setLabel("");
setQty(1);
}
function importItems(event) {
event.preventDefault();
const imported = parseColonImportLines(textImport).map((entry) => ({
id: context.uid("item"),
label: entry.label,
qtyTarget: Math.max(1, Number.parseInt(entry.value, 10) || 1),
qtyCurrent: 0
}));
if (!imported.length) return;
save([...data.items, ...imported]);
const importedSections = parseChecklistImport(textImport, context);
if (!importedSections.length) return;
const nextSections = importedSections.reduce((sections, section) => appendItems(sections, section.title, section.items), data.sections);
save(nextSections);
setTextImport("");
setImportOpen(false);
}
function setHideCompletedSections(hideCompletedSections) {
save(data.sections.map((section) => ({ ...section, hideWhenComplete: undefined })), { hideCompletedSections });
}
return (
@ -40,30 +57,156 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
{editing && (
<div className="module-add-panel">
<form className="inline-form checklist-add-form" onSubmit={addItem}>
<input name="section" placeholder={textContent.sectionPlaceholder || "Catégorie"} value={sectionTitle} onChange={(event) => setSectionTitle(event.target.value)} />
<input name="label" placeholder={textContent.itemPlaceholder || "Nouvel item"} value={label} onChange={(event) => setLabel(event.target.value)} />
<input className="checklist-qty-input" name="qty" type="number" min="1" value={qty} onChange={(event) => setQty(event.target.value)} aria-label={textContent.quantityLabel || "Quantité cible"} />
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
<form className="text-import-form" onSubmit={importItems}>
<textarea
value={textImport}
onChange={(event) => setTextImport(event.target.value)}
placeholder={textContent.importPlaceholder || "Importer plusieurs items...\nPotion:10\nMéga potion:5"}
rows={3}
/>
<button type="submit">{textContent.importButton || "Importer le texte"}</button>
</form>
<div className="text-import-actions">
<button type="button" onClick={() => setImportOpen(true)}>
<Icon name="import" />
<span>{textContent.importOpenButton || "Importer du texte"}</span>
</button>
</div>
</div>
)}
{importOpen && (
<TextImportModal
title={textContent.importModalTitle || "Importer une checklist"}
value={textImport}
onChange={setTextImport}
placeholder={textContent.importPlaceholder || "Importer plusieurs items...\nPotion:10\nMéga potion:5"}
submitLabel={textContent.importButton || "Importer le texte"}
onSubmit={importItems}
onClose={closeImportModal}
/>
)}
{completedNamedSections.length > 0 && (
<div className="checklist-toolbar">
<button
className={`checklist-hide-toggle ${allCompletedSectionsHidden ? "active" : ""}`}
type="button"
onClick={() => setHideCompletedSections(!allCompletedSectionsHidden)}
aria-pressed={allCompletedSectionsHidden}
title={textContent.hideCompletedSectionsTitle || "Masquer les catégories terminées"}
aria-label={textContent.hideCompletedSectionsTitle || "Masquer les catégories terminées"}
>
<Icon name="hide" />
<i aria-hidden="true" />
</button>
</div>
)}
<ul className="checklist">
{data.items.map((item) => (
<ChecklistItem key={item.id} item={item} toolboxId={toolboxId} moduleId={moduleId} context={context} items={data.items} save={save} />
{data.sections.map((section) => (
<ChecklistSection key={section.id} section={section} sections={data.sections} data={data} context={context} save={save} />
))}
</ul>
</>
);
}
function parseChecklistImport(text, context) {
const sections = [];
let currentSection = { id: context.uid("section"), title: "", items: [] };
String(text || "").split(/\r?\n/).forEach((line) => {
const cleanLine = line.trim();
if (!cleanLine) return;
if (cleanLine.startsWith("#")) {
if (currentSection.items.length || currentSection.title) sections.push(currentSection);
currentSection = { id: context.uid("section"), title: cleanLine.replace(/^#+/, "").trim(), items: [] };
return;
}
parseColonImportLines(cleanLine).forEach((entry) => {
currentSection.items.push({
id: context.uid("item"),
label: entry.label,
qtyTarget: Math.max(1, Number.parseInt(entry.value, 10) || 1),
qtyCurrent: 0
});
});
});
if (currentSection.items.length || currentSection.title) sections.push(currentSection);
return sections.filter((section) => section.items.length);
}
function isSectionComplete(section, context) {
return section.items.length > 0 && section.items.every((item) => context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget);
}
function getSectionHideWhenComplete(section, globalHide) {
return typeof section.hideWhenComplete === "boolean" ? section.hideWhenComplete : globalHide;
}
function ChecklistSection({ section, context, sections, data, save }) {
const showTitle = Boolean(section.title);
const sectionComplete = showTitle && isSectionComplete(section, context);
const hideWhenComplete = getSectionHideWhenComplete(section, data.hideCompletedSections);
const isAutoCollapsed = sectionComplete && hideWhenComplete;
const isCollapsed = Boolean(section.collapsed) || isAutoCollapsed;
function saveItems(items) {
save(sections.map((entry) => entry.id === section.id ? { ...entry, items } : entry).filter((entry) => entry.items.length || entry.title));
}
function toggleSectionCollapsed() {
const nextCollapsed = !isCollapsed;
const nextSections = sections.map((entry) => {
if (entry.id === section.id) {
const nextSection = { ...entry };
if (nextCollapsed) {
if (sectionComplete) {
nextSection.hideWhenComplete = true;
delete nextSection.collapsed;
} else {
nextSection.collapsed = true;
}
} else {
delete nextSection.collapsed;
if (sectionComplete) nextSection.hideWhenComplete = false;
}
return nextSection;
}
if (data.hideCompletedSections && !nextCollapsed && entry.title && isSectionComplete(entry, context)) return { ...entry, hideWhenComplete: true };
return entry;
});
const nextCompletedSections = nextSections.filter((entry) => entry.title && isSectionComplete(entry, context));
const allHidden = nextCompletedSections.length > 0 && nextCompletedSections.every((entry) => getSectionHideWhenComplete(entry, false));
save(nextSections, { hideCompletedSections: allHidden });
}
return (
<li className={`checklist-section ${showTitle ? "is-grouped" : ""} ${isCollapsed ? "is-collapsed" : ""}`}>
{showTitle && (
<div className="checklist-section-header">
<h3>{section.title || "Sans catégorie"}</h3>
<div>
<span>{section.items.filter((item) => context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget).length} / {section.items.length}</span>
<button
className="checklist-section-collapse-button"
type="button"
onClick={toggleSectionCollapsed}
aria-expanded={!isCollapsed}
title={isCollapsed ? context.moduleText?.checklist?.showCompletedSectionTitle || "Afficher cette catégorie" : context.moduleText?.checklist?.hideCompletedSectionTitle || "Masquer cette catégorie"}
aria-label={isCollapsed ? context.moduleText?.checklist?.showCompletedSectionTitle || "Afficher cette catégorie" : context.moduleText?.checklist?.hideCompletedSectionTitle || "Masquer cette catégorie"}
>
<Icon name={isCollapsed ? "chevron-down" : "chevron-up"} />
</button>
</div>
</div>
)}
{!isCollapsed && (
<ul className="checklist-section-items">
{section.items.map((item) => (
<ChecklistItem key={item.id} item={item} context={context} items={section.items} save={saveItems} />
))}
</ul>
)}
</li>
);
}
function ChecklistItem({ item, context, items, save }) {
const done = context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget;

View file

@ -1,5 +1,6 @@
import { useState } from "react";
import { useCallback, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { TextImportModal } from "./TextImportModal.jsx";
import { parseColonImportLines } from "./textImport.js";
export function LinksModule({ toolboxId, moduleId, context, editing }) {
@ -8,7 +9,9 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) {
const [title, setTitle] = useState("");
const [url, setUrl] = useState("");
const [textImport, setTextImport] = useState("");
const [importOpen, setImportOpen] = useState(false);
const [copiedId, setCopiedId] = useState("");
const closeImportModal = useCallback(() => setImportOpen(false), []);
function save(links) {
context.setModuleData(toolboxId, moduleId, { links });
@ -43,6 +46,7 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) {
if (!imported.length) return;
save([...data.links, ...imported]);
setTextImport("");
setImportOpen(false);
}
async function copyUrl(link) {
@ -61,17 +65,25 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) {
<input name="url" placeholder={textContent.urlPlaceholder || "https://..."} value={url} onChange={(event) => setUrl(event.target.value)} />
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
<form className="text-import-form" onSubmit={importLinks}>
<textarea
value={textImport}
onChange={(event) => setTextImport(event.target.value)}
placeholder={textContent.importPlaceholder || "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map"}
rows={3}
/>
<button type="submit">{textContent.importButton || "Importer le texte"}</button>
</form>
<div className="text-import-actions">
<button type="button" onClick={() => setImportOpen(true)}>
<Icon name="import" />
<span>{textContent.importOpenButton || "Importer du texte"}</span>
</button>
</div>
</div>
)}
{importOpen && (
<TextImportModal
title={textContent.importModalTitle || "Importer des liens"}
value={textImport}
onChange={setTextImport}
placeholder={textContent.importPlaceholder || "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map"}
submitLabel={textContent.importButton || "Importer le texte"}
onSubmit={importLinks}
onClose={closeImportModal}
/>
)}
<ul className="links-list">
{data.links.map((link) => (
<li className="link-item" key={link.id}>

View file

@ -0,0 +1,47 @@
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { Icon } from "../../../components/Icon.jsx";
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
export function TextImportModal({ title, value, placeholder, submitLabel, onChange, onSubmit, onClose }) {
useEffect(() => {
function handleKeyDown(event) {
if (event.key === "Escape") onClose();
}
const unlock = lockBodyScroll();
document.addEventListener("keydown", handleKeyDown);
return () => {
unlock();
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose]);
return createPortal(
<div className="confirm-modal-root text-import-modal-root">
<div className="confirm-backdrop" onClick={onClose} />
<section className="confirm-modal text-import-modal-dialog" role="dialog" aria-modal="true" aria-labelledby="text-import-title">
<header>
<h2 id="text-import-title">{title}</h2>
<button className="drawer-close-button" type="button" onClick={onClose} aria-label="Fermer" title="Fermer">
<Icon name="close" />
</button>
</header>
<form className="text-import-modal-form" onSubmit={onSubmit}>
<textarea
autoFocus
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
rows={9}
/>
<footer>
<button type="button" onClick={onClose}>Annuler</button>
<button className="primary" type="submit">{submitLabel}</button>
</footer>
</form>
</section>
</div>,
document.body
);
}

View file

@ -13,10 +13,10 @@ import { ScreenshotsModule } from "./ScreenshotsModule.jsx";
const MODULE_COMPONENTS = {
notepad: { label: "Bloc notes", icon: "notepad", Component: NotepadModule, editable: false },
checklist: { label: "Checklist", icon: "checklist", Component: ChecklistModule, editable: true },
screenshots: { label: "Images", icon: "picture", Component: ScreenshotsModule, editable: true },
links: { label: "Liens", icon: "link", Component: LinksModule, editable: true },
counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true },
checklist: { label: "Checklist", icon: "checklist", Component: ChecklistModule, editable: true, scrollable: true },
screenshots: { label: "Images", icon: "picture", Component: ScreenshotsModule, editable: true, scrollable: true },
links: { label: "Liens", icon: "link", Component: LinksModule, editable: true, scrollable: true },
counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true, scrollable: true },
calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: false },
imageAnnotation: { label: "Annotation d'images", icon: "map", Component: ImageAnnotationModule, editable: true }
};
@ -184,7 +184,7 @@ export function AddToolControls({ onAdd }) {
);
}
export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onDelete, onMove }) {
export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onUpdateModule, onDelete, onMove }) {
const [measuredSplitIndex, setMeasuredSplitIndex] = useState(() => Math.ceil(toolbox.modules.length / 2));
const moduleElementsRef = useRef(new Map());
const moduleIdSignature = useMemo(() => toolbox.modules.map((module) => module.id).join("|"), [toolbox.modules]);
@ -259,6 +259,7 @@ export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onDe
dropTarget={dropTarget}
onDragStart={startModuleDrag}
onRename={onRename}
onUpdateModule={onUpdateModule}
onDelete={onDelete}
onMove={onMove}
registerModuleElement={registerModuleElement}
@ -285,6 +286,7 @@ export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onDe
dropTarget={dropTarget}
onDragStart={startModuleDrag}
onRename={onRename}
onUpdateModule={onUpdateModule}
onDelete={onDelete}
onMove={onMove}
registerModuleElement={registerModuleElement}
@ -296,18 +298,20 @@ export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onDe
);
}
function ModuleShell({ toolbox, module, context, draggingModuleId, dropTarget, onDragStart, onRename, onDelete, registerModuleElement }) {
function ModuleShell({ toolbox, module, context, draggingModuleId, dropTarget, onDragStart, onRename, onUpdateModule, onDelete, registerModuleElement }) {
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 scrollable = definition.scrollable && module.scrollable === true;
const isDragging = draggingModuleId === module.id;
const isDropTarget = dropTarget.id === module.id;
const className = [
"module",
isDragging ? "is-dragging" : "",
isDropTarget ? "is-drop-target" : "",
isDropTarget && dropTarget.placement === "after" ? "drop-after" : ""
isDropTarget && dropTarget.placement === "after" ? "drop-after" : "",
scrollable ? "is-scrollable" : ""
].filter(Boolean).join(" ");
return (
@ -334,6 +338,26 @@ function ModuleShell({ toolbox, module, context, draggingModuleId, dropTarget, o
<EditableModuleTitle value={module.title || label} fallback={label} onSave={(title) => onRename(module.id, title)} />
</div>
<div>
{definition.scrollable && (
<button
className={`module-scroll-button ${scrollable ? "active" : ""}`}
onClick={() => onUpdateModule(module.id, (currentModule) => {
const nextModule = { ...currentModule };
if (scrollable) {
delete nextModule.scrollable;
} else {
nextModule.scrollable = true;
}
return nextModule;
})}
aria-label={`${scrollable ? "Désactiver" : "Activer"} le scroll de ${module.title || label}`}
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" : ""}`}
@ -350,7 +374,9 @@ function ModuleShell({ toolbox, module, context, draggingModuleId, dropTarget, o
</button>
</div>
</header>
<Component toolboxId={toolbox.id} moduleId={module.id} context={context} editing={editing} />
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
<Component toolboxId={toolbox.id} moduleId={module.id} context={context} editing={editing} />
</div>
</article>
);
}

View file

@ -25,7 +25,7 @@ import {
const APP_STORAGE_WARNING_RATIO = 0.85;
const APP_STORAGE_SOFT_LIMIT_BYTES = 250 * 1024 * 1024;
const DRAWER_WIDTH_SETTING = "drawerWidth";
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", shot: "s", link: "l", counter: "c", calc: "r", marker: "k" };
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", shot: "s", link: "l", counter: "c", calc: "r", marker: "k" };
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/";
const TOOLBOX_ICON_FILES = [
@ -127,10 +127,16 @@ const DEFAULT_SITE_CONTENT = {
notepad: { placeholder: "Notes rapides..." },
checklist: {
itemPlaceholder: "Nouvel item",
sectionPlaceholder: "Catégorie",
quantityLabel: "Quantité cible",
addButton: "Ajouter",
importPlaceholder: "Importer plusieurs items...\nPotion:10\nMéga potion:5",
importPlaceholder: "Importer plusieurs items...\n# Potions\nPotion:10\nMéga potion:5",
importOpenButton: "Importer du texte",
importModalTitle: "Importer une checklist",
importButton: "Importer le texte",
hideCompletedSectionsTitle: "Masquer les catégories terminées",
showCompletedSectionTitle: "Afficher cette catégorie terminée",
hideCompletedSectionTitle: "Masquer cette catégorie terminée",
decrementLabel: "Retirer une quantité",
incrementLabel: "Ajouter une quantité",
currentQuantityLabel: "Quantité actuelle",
@ -152,6 +158,8 @@ const DEFAULT_SITE_CONTENT = {
urlPlaceholder: "https://...",
addButton: "Ajouter",
importPlaceholder: "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map",
importOpenButton: "Importer du texte",
importModalTitle: "Importer des liens",
importButton: "Importer le texte",
copyTitle: "Copier",
copiedTitle: "Copié",
@ -251,6 +259,7 @@ function normalizeToolboxModule(module) {
const title = String(module.title || "").trim();
const normalized = { id: module.id || uid("mod"), type: module.type };
if (title && title !== getDefaultModuleTitle(module.type)) normalized.title = title;
if (module.scrollable === true) normalized.scrollable = true;
return normalized;
}
@ -314,10 +323,26 @@ function normalizeChecklistItem(item) {
};
}
function normalizeChecklistData(data) {
return {
items: (data?.items || []).map(normalizeChecklistItem).filter((item) => item.label)
function normalizeChecklistSection(section, fallbackTitle = "") {
const normalized = {
id: section?.id || uid("section"),
title: String(section?.title ?? fallbackTitle).trim(),
items: (section?.items || []).map(normalizeChecklistItem).filter((item) => item.label)
};
if (typeof section?.hideWhenComplete === "boolean") normalized.hideWhenComplete = section.hideWhenComplete;
if (section?.collapsed === true) normalized.collapsed = true;
return normalized;
}
function normalizeChecklistData(data) {
const hideCompletedSections = Boolean(data?.hideCompletedSections);
const sections = Array.isArray(data?.sections)
? data.sections.map((section) => normalizeChecklistSection(section)).filter((section) => section.items.length || section.title)
: [];
const legacyItems = (data?.items || []).map(normalizeChecklistItem).filter((item) => item.label);
if (!sections.length && legacyItems.length) return { hideCompletedSections, sections: [{ id: uid("section"), title: "", items: legacyItems }], items: legacyItems };
const items = sections.flatMap((section) => section.items);
return { hideCompletedSections, sections, items };
}
function normalizeUrl(value) {
@ -420,14 +445,30 @@ function compactChecklistItemForStorage(item) {
return compact;
}
function compactChecklistSectionForStorage(section) {
const normalized = normalizeChecklistSection(section);
const compact = {
id: normalized.id,
items: normalized.items.map(compactChecklistItemForStorage)
};
if (normalized.title) compact.title = normalized.title;
if (typeof normalized.hideWhenComplete === "boolean") compact.hideWhenComplete = normalized.hideWhenComplete;
if (normalized.collapsed === true) compact.collapsed = true;
return compact;
}
function compactModuleDataForStorage(type, value) {
if (type === "notepad") {
const text = String(value?.text || "");
return text ? { text } : null;
}
if (type === "checklist") {
const items = normalizeChecklistData(value).items.map(compactChecklistItemForStorage);
return items.length ? { items } : null;
const normalized = normalizeChecklistData(value);
const sections = normalized.sections.map(compactChecklistSectionForStorage).filter((section) => section.items.length || section.title);
if (!sections.length) return null;
const settings = normalized.hideCompletedSections ? { hideCompletedSections: true } : {};
if (sections.length === 1 && !sections[0].title && typeof sections[0].hideWhenComplete !== "boolean") return { ...settings, items: sections[0].items };
return { ...settings, sections };
}
if (type === "screenshots") {
const shots = (Array.isArray(value?.shots) ? value.shots : [])
@ -500,6 +541,15 @@ function remapModuleDataForExport(type, data, nextId) {
const compact = compactModuleDataForStorage(type, data);
if (!compact) return null;
if (type === "checklist") {
if (compact.sections) {
return {
sections: compact.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 (type === "screenshots") {
@ -1568,6 +1618,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
moduleColumns={moduleColumns}
context={moduleContext}
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) })}
onDelete={(moduleId) => actions.setConfirmModal({
title: "Retirer l'outil",
message: "Retirer cet outil de la toolbox ?",

View file

@ -76,6 +76,21 @@
-webkit-mask-image: url("/static/icons/scrollable.svg");
}
.ui-icon-hide {
mask-image: url("/static/icons/hide.svg");
-webkit-mask-image: url("/static/icons/hide.svg");
}
.ui-icon-chevron-down {
mask-image: url("/static/icons/chevron-down.svg");
-webkit-mask-image: url("/static/icons/chevron-down.svg");
}
.ui-icon-chevron-up {
mask-image: url("/static/icons/chevron-up.svg");
-webkit-mask-image: url("/static/icons/chevron-up.svg");
}
.ui-icon-drag {
mask-image: url("/static/icons/drag.svg");
-webkit-mask-image: url("/static/icons/drag.svg");

View file

@ -389,6 +389,16 @@
font-size: 17px;
}
.module-content {
min-width: 0;
}
.module-content.is-scrollable {
max-height: 450px;
overflow-y: auto;
overscroll-behavior: contain;
}
.layout-switch {
display: inline-grid;
grid-template-columns: repeat(2, 38px);
@ -479,6 +489,52 @@
color: var(--color-text-secondary);
}
.module-scroll-button {
display: inline-grid;
grid-template-columns: 16px 30px;
width: fit-content;
min-height: 40px;
flex: 0 0 auto;
align-items: center;
gap: 7px;
padding: 0 7px 0 10px;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-pill);
background: rgba(5, 7, 17, 0.24);
color: var(--color-text-secondary);
}
.module-scroll-button .ui-icon {
width: 16px;
height: 16px;
}
.module-scroll-button i {
position: relative;
display: block;
width: 30px;
height: 16px;
border: 1px solid rgba(165, 180, 252, 0.18);
border-radius: var(--radius-pill);
background: rgba(7, 10, 24, 0.72);
}
.module-scroll-button i::before {
content: "";
position: absolute;
top: 50%;
left: 3px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--color-text-muted);
transform: translateY(-50%);
transition:
left var(--duration-fast) var(--ease-standard),
background-color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
}
.module-edit-button.active {
border-color: var(--color-primary-border);
background: var(--color-primary-soft);
@ -577,7 +633,7 @@
}
.checklist-add-form {
grid-template-columns: minmax(0, 1fr) 76px auto;
grid-template-columns: minmax(120px, 0.45fr) minmax(0, 1fr) 76px auto;
}
.module-add-panel {
@ -590,20 +646,57 @@
padding: 0;
}
.text-import-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
padding: 0;
.text-import-actions {
display: flex;
justify-content: flex-end;
}
.text-import-form textarea {
min-height: 86px;
.text-import-actions button {
display: inline-flex;
min-height: 42px;
align-items: center;
gap: 8px;
}
.text-import-actions .ui-icon {
width: 17px;
height: 17px;
}
.text-import-modal-dialog {
width: min(640px, calc(100vw - 32px));
}
.text-import-modal-root {
z-index: 330;
}
.text-import-modal-dialog header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.text-import-modal-dialog h2 {
margin: 0;
}
.text-import-modal-form {
display: grid;
gap: var(--space-4);
}
.text-import-modal-form textarea {
min-height: 260px;
resize: vertical;
}
.text-import-form button {
align-self: end;
.text-import-modal-form footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.checklist-qty-input {
@ -638,12 +731,147 @@ textarea:focus {
.checklist {
display: grid;
gap: 8px;
gap: var(--space-3);
margin: var(--space-4) 0 0;
padding: 0 var(--space-4) var(--space-4);
list-style: none;
}
.checklist-toolbar {
display: flex;
justify-content: flex-end;
padding: var(--space-4) var(--space-4) 0;
}
.checklist-section {
display: grid;
gap: 8px;
}
.checklist-section.is-grouped {
padding: 10px;
border: 1px solid rgba(246, 196, 83, 0.16);
border-radius: var(--radius-md);
background:
linear-gradient(135deg, rgba(246, 196, 83, 0.04), transparent 42%),
rgba(7, 10, 24, 0.18);
}
.checklist-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: 0 2px;
}
.checklist-section-header > div {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.checklist-section-header h3 {
margin: 0;
color: var(--color-accent-gold);
font-size: var(--font-size-sm);
}
.checklist-section.is-collapsed .checklist-section-header h3 {
color: var(--color-text-muted);
text-decoration: line-through;
}
.checklist-section-header span {
flex: 0 0 auto;
padding: 3px 9px;
border: 1px solid rgba(246, 196, 83, 0.22);
border-radius: var(--radius-pill);
background: rgba(246, 196, 83, 0.08);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 800;
}
.checklist-hide-toggle {
display: inline-grid;
grid-template-columns: 18px 34px;
width: fit-content;
min-height: 34px;
align-items: center;
gap: 8px;
padding: 0 6px 0 10px;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-pill);
background: rgba(5, 7, 17, 0.24);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-weight: 800;
}
.checklist-hide-toggle .ui-icon {
width: 16px;
height: 16px;
}
.checklist-hide-toggle i {
position: relative;
display: block;
width: 34px;
height: 18px;
border: 1px solid rgba(165, 180, 252, 0.18);
border-radius: var(--radius-pill);
background: rgba(7, 10, 24, 0.72);
}
.checklist-hide-toggle i::before {
content: "";
position: absolute;
top: 50%;
left: 3px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-text-muted);
transform: translateY(-50%);
transition:
left var(--duration-fast) var(--ease-standard),
background-color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
}
.checklist-section-collapse-button {
display: inline-grid;
width: 30px;
min-width: 30px;
min-height: 30px;
place-items: center;
padding: 0;
border: 1px solid rgba(246, 196, 83, 0.28);
border-radius: var(--radius-md);
background:
linear-gradient(135deg, rgba(246, 196, 83, 0.12), rgba(139, 92, 246, 0.08)) padding-box,
linear-gradient(135deg, rgba(246, 196, 83, 0.3), rgba(196, 181, 253, 0.18)) border-box;
color: #fff;
}
.checklist-section-collapse-button .ui-icon {
width: 16px;
height: 16px;
background-color: #fff;
opacity: 1;
filter: drop-shadow(0 0 7px rgba(255, 255, 255, 0.22));
}
.checklist-section-items {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.checklist-item {
display: grid;
grid-template-columns: 1fr 42px;
@ -998,8 +1226,12 @@ textarea:focus {
.calculator-entry > button:not(.calculator-entry-summary, .calculator-entry-value, .danger):hover,
.counter-actions button:not(.danger):hover,
.link-item button:not(.danger):hover,
.module-scroll-button:hover,
.module-scroll-button.active,
.calculator-scroll-toggle:hover,
.calculator-scroll-toggle.active {
.calculator-scroll-toggle.active,
.checklist-hide-toggle:hover,
.checklist-hide-toggle.active {
border-color: rgba(196, 181, 253, 0.32);
background:
linear-gradient(135deg, rgba(31, 37, 68, 0.94), rgba(18, 22, 44, 0.96)) padding-box,
@ -1010,6 +1242,17 @@ textarea:focus {
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.checklist-section-collapse-button:hover {
border-color: rgba(246, 196, 83, 0.58);
background:
linear-gradient(135deg, rgba(246, 196, 83, 0.18), rgba(139, 92, 246, 0.12)) padding-box,
linear-gradient(135deg, rgba(246, 196, 83, 0.62), rgba(196, 181, 253, 0.32)) border-box;
color: #fff;
box-shadow:
0 0 10px rgba(246, 196, 83, 0.14),
0 0 16px rgba(139, 92, 246, 0.08);
}
.calculator-action-button.danger,
.calculator-entry > button.danger,
.counter-actions button.danger,
@ -1041,24 +1284,34 @@ textarea:focus {
filter: drop-shadow(0 0 6px rgba(251, 113, 133, 0.26));
}
.calculator-scroll-toggle.active {
.module-scroll-button.active,
.calculator-scroll-toggle.active,
.checklist-hide-toggle.active {
border-color: rgba(246, 196, 83, 0.34);
background:
linear-gradient(135deg, rgba(72, 43, 160, 0.82), rgba(45, 52, 146, 0.8)) padding-box,
linear-gradient(135deg, rgba(246, 196, 83, 0.58), rgba(139, 92, 246, 0.32)) border-box;
}
.calculator-scroll-toggle.active i {
.module-scroll-button.active i,
.calculator-scroll-toggle.active i,
.checklist-hide-toggle.active i {
border-color: rgba(246, 196, 83, 0.36);
background: rgba(246, 196, 83, 0.12);
}
.calculator-scroll-toggle.active i::before {
.module-scroll-button.active i::before,
.calculator-scroll-toggle.active i::before,
.checklist-hide-toggle.active i::before {
left: 17px;
background: var(--color-accent-gold);
box-shadow: 0 0 10px rgba(246, 196, 83, 0.28);
}
.module-scroll-button.active i::before {
left: 15px;
}
.calculator-tree {
display: grid;
align-content: start;