435 lines
19 KiB
JavaScript
435 lines
19 KiB
JavaScript
// Rôle : affiche les pages toolbox, le panneau latéral et leurs contrôles.
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { GuidedTooltipTour } from "../../components/GuidedTooltipTour.jsx";
|
|
import { Icon } from "../../components/Icon.jsx";
|
|
import { ImportButton } from "../../components/ImportButton.jsx";
|
|
import { useGuideChecklist } from "../games/useGuideChecklist.js";
|
|
import { StorageQuota } from "../../components/StorageQuota.jsx";
|
|
import { ToastPositionSwitch } from "../../components/ToastPositionSwitch.jsx";
|
|
import { useGroupedReorder } from "../../hooks/useGroupedReorder.js";
|
|
import { useInlineEdit } from "../../hooks/useInlineEdit.js";
|
|
import { compressImage } from "../../utils/imageCompression.js";
|
|
import { getGameCardCover, ToolboxCard, ToolboxGameIcon, ToolboxIconPicker } from "./ToolboxCard.jsx";
|
|
import { AddToolControls, ToolboxModules } from "./modules/index.jsx";
|
|
import {
|
|
clampQty,
|
|
hostnameFromUrl,
|
|
normalizeCalculatorData,
|
|
normalizeCalendarData,
|
|
normalizeChecklistData,
|
|
normalizeCombosData,
|
|
normalizeCountersData,
|
|
normalizeEquipmentPlannerData,
|
|
normalizeGoalTreeData,
|
|
normalizeImageAnnotationData,
|
|
normalizeLinksData,
|
|
normalizeMemosData,
|
|
normalizeModuleOrder,
|
|
normalizeNotepadData,
|
|
normalizeRandomizerData,
|
|
normalizeRngSimulatorData,
|
|
normalizeTableData,
|
|
normalizeTaskPlannerData,
|
|
normalizeTimerData,
|
|
summarizeEquipmentPlannerData,
|
|
normalizeUrl,
|
|
uid
|
|
} from "./storage/toolboxStorage.js";
|
|
|
|
const DRAWER_INITIAL_WIDTH = 700;
|
|
|
|
function getDrawerMaxWidth() {
|
|
return Math.max(DRAWER_INITIAL_WIDTH, Math.floor(window.innerWidth * 0.94));
|
|
}
|
|
|
|
function addModuleToOrder(moduleOrder, modules, moduleId) {
|
|
const order = normalizeModuleOrder(moduleOrder, modules);
|
|
const two = [
|
|
order.two[0].filter((id) => id !== moduleId),
|
|
order.two[1].filter((id) => id !== moduleId)
|
|
];
|
|
const targetColumnIndex = two[0].length <= two[1].length ? 0 : 1;
|
|
two[targetColumnIndex].push(moduleId);
|
|
return {
|
|
one: [...order.one.filter((id) => id !== moduleId), moduleId],
|
|
two
|
|
};
|
|
}
|
|
|
|
function removeModuleFromOrder(moduleOrder, modules, moduleId) {
|
|
const order = normalizeModuleOrder(moduleOrder, modules);
|
|
return {
|
|
one: order.one.filter((id) => id !== moduleId),
|
|
two: [
|
|
order.two[0].filter((id) => id !== moduleId),
|
|
order.two[1].filter((id) => id !== moduleId)
|
|
]
|
|
};
|
|
}
|
|
|
|
async function copyText(value) {
|
|
try {
|
|
await navigator.clipboard.writeText(value);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions, storageUsage, toastPosition, setToastPosition, onOpenAutoBackup }) {
|
|
const content = siteContent.toolboxes;
|
|
const {
|
|
itemReorder,
|
|
getItemProps,
|
|
isItemDragging,
|
|
isItemDropTarget,
|
|
getDropPlacement
|
|
} = useGroupedReorder({
|
|
namespace: "toolbox-cards",
|
|
items: toolboxes,
|
|
getItemId: (toolbox) => toolbox.id,
|
|
orientation: "horizontal",
|
|
getPlacement: (event, target) => {
|
|
const rect = target.getBoundingClientRect();
|
|
return event.clientY > rect.top + rect.height / 2 || event.clientX > rect.left + rect.width / 2 ? "after" : "before";
|
|
},
|
|
onItemMove: (operation) => {
|
|
const nextIds = toolboxes.map((toolbox) => toolbox.id).filter((id) => id !== operation.sourceId);
|
|
const targetIndex = nextIds.indexOf(operation.targetId);
|
|
nextIds.splice(operation.placement === "after" ? targetIndex + 1 : targetIndex, 0, operation.sourceId);
|
|
actions.updateToolboxOrder(nextIds);
|
|
}
|
|
});
|
|
|
|
return (
|
|
<div className="toolbox-page">
|
|
<section className="page-hero nds-page-hero">
|
|
<div>
|
|
<p className="eyebrow">{content.eyebrow}</p>
|
|
<h1>{content.title}</h1>
|
|
<p>{content.summary}</p>
|
|
<a className="hero-library-button library-link-button nds-button" data-size="sm" href="#/library"><Icon name="book" />{content.libraryLink}</a>
|
|
</div>
|
|
</section>
|
|
<section className="toolbar nds-toolbar toolbox-actions-row">
|
|
<div>
|
|
<button className="nds-button" data-size="sm" data-variant="primary" onClick={() => actions.setCreateModal({})}>{content.newButton}</button>
|
|
<ImportButton className="nds-button" data-size="sm" label={content.importOne} onFile={actions.importToolbox} />
|
|
</div>
|
|
<div>
|
|
<button className="nds-button" data-size="sm" data-variant="primary" type="button" onClick={onOpenAutoBackup}>{content.autoBackup.title}</button>
|
|
<button className="nds-button" data-size="sm" onClick={actions.exportAllToolboxes}>{content.exportAll}</button>
|
|
<ImportButton className="nds-button" data-size="sm" label={content.importAll} onFile={actions.importAllToolboxes} />
|
|
</div>
|
|
</section>
|
|
<section className="toolbox-preferences-row" aria-label={content.preferencesLabel}>
|
|
<div>
|
|
<p className="eyebrow">{content.preferencesLabel}</p>
|
|
<strong>{content.toastPositionLabel}</strong>
|
|
</div>
|
|
<ToastPositionSwitch
|
|
position={toastPosition}
|
|
onChange={setToastPosition}
|
|
labels={{
|
|
title: content.toastPositionLabel,
|
|
left: content.toastPositionLeft,
|
|
right: content.toastPositionRight
|
|
}}
|
|
/>
|
|
</section>
|
|
<section className="cards">
|
|
<StorageHelpCard help={content.storageHelp} />
|
|
{toolboxes.length ? toolboxes.map((toolbox) => (
|
|
<ToolboxCard
|
|
key={toolbox.id}
|
|
toolbox={toolbox}
|
|
game={getToolboxGame(toolbox)}
|
|
actions={actions}
|
|
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
|
|
/>
|
|
)) : (
|
|
<div className="empty nds-empty-state"><h2>{content.emptyTitle}</h2><p>{content.emptyText}</p></div>
|
|
)}
|
|
</section>
|
|
<StorageQuota usage={storageUsage} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StorageHelpCard({ help }) {
|
|
return (
|
|
<article className="card nds-card storage-help-card" aria-labelledby="storage-help-title">
|
|
<div className="card-body nds-card-body">
|
|
<div className="storage-help-heading">
|
|
<span className="storage-help-mark" aria-hidden="true" />
|
|
<div><p className="eyebrow">IndexedDB</p><h2 id="storage-help-title">{help.title}</h2></div>
|
|
</div>
|
|
<p className="storage-help-intro">{help.text}</p>
|
|
<ul className="storage-help-list">
|
|
{help.items.map((item, index) => <li key={item}><span>{index + 1}</span><p>{item}</p></li>)}
|
|
</ul>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
export function ToolboxPage(props) {
|
|
const { toolboxId, toolboxes, getToolboxGame } = props;
|
|
const toolbox = toolboxes.find((item) => item.id === toolboxId);
|
|
if (!toolbox) return <div className="empty nds-empty-state"><h1>Toolbox introuvable</h1><a className="nds-button" data-size="sm" href="#/toolboxes">Retour</a></div>;
|
|
return <ToolboxView {...props} toolbox={toolbox} toolboxGame={getToolboxGame(toolbox)} embedded={false} />;
|
|
}
|
|
|
|
function shouldShowTutorial(toolbox, pendingTutorialToolboxId) {
|
|
return toolbox?.tutorial === true || pendingTutorialToolboxId === toolbox?.id;
|
|
}
|
|
|
|
function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles, pendingTutorialToolboxId = "", setPendingTutorialToolboxId, guideChecklist }) {
|
|
const toolboxId = toolbox.id;
|
|
const moduleColumns = embedded || Number(toolbox.moduleColumns) === 1 ? 1 : 2;
|
|
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.
|
|
if (previousTutorial.toolboxId !== toolboxId || previousTutorial.requested !== tutorialRequested) {
|
|
setPreviousTutorial({ toolboxId, requested: tutorialRequested });
|
|
if (previousTutorial.toolboxId !== toolboxId || tutorialRequested) setShowTutorial(tutorialRequested);
|
|
}
|
|
const moduleContext = {
|
|
guideChecklist,
|
|
getModuleData,
|
|
ensureModuleData: actions.ensureModuleData,
|
|
isModuleDataReady: actions.isModuleDataReady,
|
|
storageStatus: siteContent.toolboxes.storageStatus,
|
|
storageReady,
|
|
moduleText,
|
|
normalizeChecklistData,
|
|
normalizeCombosData,
|
|
normalizeLinksData,
|
|
normalizeMemosData,
|
|
normalizeCountersData,
|
|
normalizeEquipmentPlannerData,
|
|
normalizeGoalTreeData,
|
|
normalizeCalculatorData,
|
|
normalizeCalendarData,
|
|
normalizeImageAnnotationData,
|
|
normalizeNotepadData,
|
|
normalizeRandomizerData,
|
|
normalizeRngSimulatorData,
|
|
normalizeTableData,
|
|
normalizeTimerData,
|
|
normalizeTaskPlannerData,
|
|
summarizeEquipmentPlannerData,
|
|
normalizeUrl,
|
|
hostnameFromUrl,
|
|
copyText,
|
|
notify: actions.notify,
|
|
compressImageFile: compressImage,
|
|
clampQty,
|
|
uid,
|
|
setModuleData: updateModuleData,
|
|
addImageFiles,
|
|
createImageAnnotationModule,
|
|
setImage: actions.setImage,
|
|
refresh: () => {}
|
|
};
|
|
|
|
function addModule(type, initialData = null) {
|
|
if (!type) return;
|
|
const module = { id: uid("mod"), type };
|
|
const modules = [...toolbox.modules, module];
|
|
updateToolbox({
|
|
...toolbox,
|
|
modules,
|
|
moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, module.id)
|
|
});
|
|
setPendingModuleId(module.id);
|
|
if (initialData) updateModuleData(toolbox.id, module.id, initialData, type);
|
|
}
|
|
|
|
function createImageAnnotationModule(dataUrl) {
|
|
if (!dataUrl) return;
|
|
const moduleId = uid("mod");
|
|
const modules = [...toolbox.modules, { id: moduleId, type: "imageAnnotation" }];
|
|
updateToolbox({
|
|
...toolbox,
|
|
modules,
|
|
moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, moduleId)
|
|
});
|
|
setPendingModuleId(moduleId);
|
|
updateModuleData(toolbox.id, moduleId, { image: dataUrl, markers: [] }, "imageAnnotation");
|
|
}
|
|
|
|
return (
|
|
<div className={embedded ? "toolbox-embedded" : "toolbox-page"}>
|
|
<section className={embedded ? "toolbox-head page-hero nds-page-hero toolbox-head-embedded" : "toolbox-head page-hero nds-page-hero"}>
|
|
<div>
|
|
<p className="eyebrow">Toolbox</p>
|
|
<div className="toolbox-title-row">
|
|
{!embedded && !toolboxGame && <ToolboxIconPicker toolbox={toolbox} onChange={(icon, customCover = toolbox.customCover) => updateToolbox({ ...toolbox, icon, customCover })} tutorialTarget="icon" />}
|
|
{!embedded && toolboxGameCover && <ToolboxGameIcon game={toolboxGame} />}
|
|
{embedded ? <h1>{toolbox.name}</h1> : (
|
|
<EditableTitle className="toolbox-title" value={toolbox.name} fallback="Nouvelle toolbox" tutorialTarget="title" onSave={(name) => name !== toolbox.name && updateToolbox({ ...toolbox, name })} />
|
|
)}
|
|
</div>
|
|
{toolboxGame && (
|
|
<a
|
|
className="toolbox-hero-link nds-button"
|
|
data-size="sm"
|
|
href={embedded ? `#/toolbox/${toolbox.id}` : `#/games/${toolboxGame.id}`}
|
|
onClick={embedded ? () => actions.setDrawerGameId("") : undefined}
|
|
>
|
|
<Icon name="enter" />
|
|
<span>{embedded ? "Vers la toolbox complète" : "Vers la page de jeu"}</span>
|
|
</a>
|
|
)}
|
|
</div>
|
|
<div className="actions nds-toolbar">
|
|
<AddToolControls onAdd={addModule} />
|
|
</div>
|
|
</section>
|
|
{!embedded && showTutorial && (
|
|
<GuidedTooltipTour
|
|
key={toolboxId}
|
|
steps={siteContent.toolboxes.tutorial?.items || []}
|
|
labels={{
|
|
dismiss: siteContent.toolboxes.tutorial?.dismissLabel,
|
|
next: siteContent.toolboxes.tutorial?.nextLabel,
|
|
done: siteContent.toolboxes.tutorial?.doneLabel
|
|
}}
|
|
targetAttribute="data-toolbox-tutorial-target"
|
|
onDismiss={() => {
|
|
if (pendingTutorialToolboxId === toolbox.id) setPendingTutorialToolboxId?.("");
|
|
setShowTutorial(false);
|
|
}}
|
|
onStarted={() => {
|
|
if (pendingTutorialToolboxId === toolbox.id) setPendingTutorialToolboxId?.("");
|
|
if (toolbox.tutorial === true) updateToolbox({ ...toolbox, tutorial: false });
|
|
}}
|
|
/>
|
|
)}
|
|
{!embedded && (
|
|
<section className="modules-toolbar" aria-label="Options d'affichage des outils">
|
|
<div className="layout-switch nds-radio-button-group" data-size="sm" role="group" aria-label="Mode d'affichage des outils">
|
|
<button className={moduleColumns === 1 ? "active" : ""} onClick={() => updateToolbox({ ...toolbox, moduleColumns: 1 })} aria-pressed={moduleColumns === 1} aria-label="Afficher en lignes" title="Afficher en lignes"><Icon name="rows" /></button>
|
|
<button className={moduleColumns === 2 ? "active" : ""} onClick={() => updateToolbox({ ...toolbox, moduleColumns: 2 })} aria-pressed={moduleColumns === 2} aria-label="Afficher en colonnes" title="Afficher en colonnes"><Icon name="columns" /></button>
|
|
</div>
|
|
</section>
|
|
)}
|
|
<ToolboxModules
|
|
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 })}
|
|
onDelete={(moduleId) => actions.setConfirmModal({
|
|
title: "Retirer l'outil",
|
|
message: "Retirer cet outil de la toolbox ?",
|
|
confirmLabel: "Retirer",
|
|
danger: true,
|
|
onResolve: (confirmed) => {
|
|
if (!confirmed) return;
|
|
actions.removeModuleData(toolbox.id, moduleId);
|
|
const modules = toolbox.modules.filter((module) => module.id !== moduleId);
|
|
updateToolbox({
|
|
...toolbox,
|
|
modules,
|
|
moduleOrder: removeModuleFromOrder(toolbox.moduleOrder, modules, moduleId)
|
|
});
|
|
}
|
|
})}
|
|
/>
|
|
{!embedded && <StorageQuota usage={storageUsage} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles }) {
|
|
const checklist = useGuideChecklist();
|
|
const guideChecklist = checklist.available && checklist.guideId.split("/")[0] === gameId ? checklist : undefined;
|
|
const selectedId = links[gameId] || "";
|
|
const toolbox = toolboxes.find((item) => item.id === selectedId);
|
|
const [width, setWidth] = useState(DRAWER_INITIAL_WIDTH);
|
|
const panelRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
const clampToViewport = () => {
|
|
if (window.innerWidth < DRAWER_INITIAL_WIDTH) return;
|
|
setWidth((currentWidth) => Math.min(currentWidth, getDrawerMaxWidth()));
|
|
};
|
|
window.addEventListener("resize", clampToViewport);
|
|
return () => window.removeEventListener("resize", clampToViewport);
|
|
}, []);
|
|
|
|
function startResize(event) {
|
|
const panel = panelRef.current;
|
|
if (!panel) return;
|
|
event.preventDefault();
|
|
const startX = event.clientX;
|
|
const startWidth = panel.getBoundingClientRect().width;
|
|
const maxWidth = getDrawerMaxWidth();
|
|
document.body.classList.add("is-resizing-drawer");
|
|
const onMove = (moveEvent) => {
|
|
const nextWidth = Math.min(Math.max(startWidth + startX - moveEvent.clientX, DRAWER_INITIAL_WIDTH), maxWidth);
|
|
setWidth(nextWidth);
|
|
};
|
|
const stop = () => {
|
|
document.body.classList.remove("is-resizing-drawer");
|
|
window.removeEventListener("pointermove", onMove);
|
|
window.removeEventListener("pointerup", stop);
|
|
window.removeEventListener("pointercancel", stop);
|
|
};
|
|
window.addEventListener("pointermove", onMove);
|
|
window.addEventListener("pointerup", stop);
|
|
window.addEventListener("pointercancel", stop);
|
|
}
|
|
|
|
return (
|
|
<aside className="drawer" id="toolbox-drawer" aria-hidden="false" style={{ "--drawer-panel-width": `${width}px` }}>
|
|
<span className="drawer-resize-handle nds-drawer-resize-handle" aria-hidden="true" onPointerDown={startResize} />
|
|
<section className="drawer-panel nds-drawer-panel legacy-scrollbar" ref={panelRef}>
|
|
<div className="drawer-content">
|
|
<header>
|
|
<div>
|
|
<p className="eyebrow">Toolbox liée</p>
|
|
<div className="drawer-toolbox-actions nds-toolbar" aria-label="Actions toolbox liée">
|
|
<button className="drawer-action-button nds-icon-button" data-size="sm" onClick={() => actions.setLinkModalGameId(gameId)} aria-label="Lier une toolbox" title="Lier"><Icon name="link" /></button>
|
|
<ImportButton className="drawer-action-button nds-icon-button import-icon-button" data-size="sm" ariaLabel="Importer une toolbox" title="Importer" icon="import" onFile={(file) => actions.importToolbox(file, gameId)} />
|
|
<button className="drawer-action-button nds-icon-button" data-size="sm" onClick={() => actions.exportToolbox(selectedId)} disabled={!selectedId} aria-label="Exporter la toolbox liée" title="Exporter"><Icon name="export" /></button>
|
|
</div>
|
|
</div>
|
|
<button className="drawer-close-button nds-icon-button" data-size="sm" onClick={() => actions.setDrawerGameId("")} aria-label="Fermer" title="Fermer"><Icon name="close" /></button>
|
|
</header>
|
|
{toolbox ? <ToolboxView siteContent={siteContent} toolbox={toolbox} toolboxGame={game} embedded actions={actions} storageUsage={storageUsage} storageReady={storageReady} getModuleData={getModuleData} updateToolbox={updateToolbox} updateModuleData={updateModuleData} addImageFiles={addImageFiles} guideChecklist={guideChecklist} /> : (
|
|
<div className="empty nds-empty-state"><p>Aucune toolbox associée à cette page jeu.</p><button className="nds-button" data-size="sm" data-variant="primary" onClick={() => actions.setCreateModal({ gameId })}>Créer et associer</button></div>
|
|
)}
|
|
<StorageQuota usage={storageUsage} />
|
|
</div>
|
|
</section>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function EditableTitle({ value, fallback, onSave, className = "module-title", tutorialTarget = "" }) {
|
|
const inlineEdit = useInlineEdit({
|
|
value,
|
|
onCommit: (title) => onSave(title || fallback),
|
|
blurOnEscape: true
|
|
});
|
|
|
|
return (
|
|
<h1
|
|
{...inlineEdit.getContentEditableProps({
|
|
className,
|
|
"data-toolbox-tutorial-target": tutorialTarget || undefined,
|
|
title: "Cliquer pour renommer"
|
|
})}
|
|
>{value}</h1>
|
|
);
|
|
}
|