update checklist component
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
This commit is contained in:
parent
772b9951de
commit
d9d22683dc
13 changed files with 709 additions and 65 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
47
website/src/features/toolboxes/modules/TextImportModal.jsx
Normal file
47
website/src/features/toolboxes/modules/TextImportModal.jsx
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue