Add planner-aware memos and unified tool configuration panels
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-10-03 15:58:33 +02:00
parent acd9b2fe11
commit 808610dcf2
53 changed files with 842 additions and 329 deletions

View file

@ -1,7 +1,8 @@
// Rôle : affiche en bas de page les mémos actifs de la toolbox courante ou liée au jeu.
import { useMemo } from "react";
// Rôle : affiche en bas de page les mémos et alertes de tâches de la toolbox courante ou liée au jeu.
import { useEffect, useMemo, useState } from "react";
import { moduleStorageKey, normalizeMemosData } from "./storage/toolboxStorage.js";
import { MemosTicker } from "./modules/MemosTicker.jsx";
import { getPlannerTaskTickerItems, resolveMemosPlannerLink } from "./modules/memosPlannerUtils.js";
function getRouteToolboxId(route, links) {
const toolboxMatch = route.match(/^\/toolbox\/([^/]+)/);
@ -11,6 +12,13 @@ function getRouteToolboxId(route, links) {
}
export function GlobalMemosTicker({ route, toolboxes, links, moduleData, textContent }) {
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const intervalId = window.setInterval(() => setNowMs(Date.now()), 60000);
return () => window.clearInterval(intervalId);
}, []);
const items = useMemo(() => {
const toolboxId = getRouteToolboxId(route, links);
const toolbox = toolboxes.find((item) => item.id === toolboxId);
@ -20,9 +28,20 @@ export function GlobalMemosTicker({ route, toolboxes, links, moduleData, textCon
.flatMap((module) => {
const data = normalizeMemosData(moduleData[moduleStorageKey(toolbox.id, module.id)]);
if (!data.tickerEnabled) return [];
return data.items.map((memo) => ({ ...memo, id: `${module.id}:${memo.id}` }));
const plannerLink = resolveMemosPlannerLink(data.plannerLink, toolbox.modules);
const taskItems = getPlannerTaskTickerItems({
plannerLink,
modules: toolbox.modules,
getPlannerData: (plannerId) => moduleData[moduleStorageKey(toolbox.id, plannerId)],
nowMs,
alertTemplate: textContent?.taskAlertTemplate
});
return [
...data.items.map((memo) => ({ ...memo, id: `${module.id}:${memo.id}` })),
...taskItems.map((item) => ({ ...item, id: `${module.id}:${item.id}` }))
];
});
}, [route, toolboxes, links, moduleData]);
}, [route, toolboxes, links, moduleData, nowMs, textContent?.taskAlertTemplate]);
if (!items.length) return null;
return (

View file

@ -11,6 +11,7 @@ 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 { getMemosDataAfterPlannerRemoval } from "./modules/memosPlannerUtils.js";
import { ToolboxSpaces } from "./ToolboxSpaces.jsx";
import {
addModuleToToolboxSpace,
@ -195,7 +196,9 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
isModuleDataReady: actions.isModuleDataReady,
storageStatus: siteContent.toolboxes.storageStatus,
storageReady,
toolboxModules: toolbox.modules,
moduleText,
moduleConfigurationText: siteContent.toolboxes.moduleConfiguration,
normalizeChecklistData,
normalizeCombosData,
normalizeLinksData,
@ -403,6 +406,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
context={moduleContext}
reorder={moduleReorder}
spacesText={spacesText}
configurationText={siteContent.toolboxes.moduleConfiguration}
scrollToModuleId={pendingModuleId}
onModuleScrolled={() => setPendingModuleId("")}
onRename={(moduleId, title) => updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? { ...module, title } : module) })}
@ -419,6 +423,15 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
danger: true,
onResolve: (confirmed) => {
if (!confirmed) return;
const removedModule = toolbox.modules.find((module) => module.id === moduleId);
const remainingModules = toolbox.modules.filter((module) => module.id !== moduleId);
if (removedModule?.type === "taskPlanner") {
toolbox.modules.filter((module) => module.type === "memos").forEach((memosModule) => {
const memosData = normalizeMemosData(getModuleData(toolbox.id, memosModule.id, { items: [] }));
const nextMemosData = getMemosDataAfterPlannerRemoval(memosData, moduleId, remainingModules);
if (nextMemosData !== memosData) updateModuleData(toolbox.id, memosModule.id, nextMemosData, "memos");
});
}
actions.removeModuleData(toolbox.id, moduleId);
updateToolbox(removeModuleFromToolboxSpaces(toolbox, moduleId));
}

View file

@ -4,7 +4,7 @@ import { Icon } from "../../../components/Icon.jsx";
import { MixTagsInput } from "../../../components/MixTagsInput.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { CalculatorEntries, FormattedCalculatorNumber } from "./CalculatorEntries.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import {
BASE_TOKEN,
calculateRemainingValue,
@ -36,7 +36,7 @@ function focusExpressionInputAtEnd(element) {
selection?.addRange(range);
}
export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpen }) {
export function CalculatorModule({ toolboxId, moduleId, context, configurationTarget }) {
const data = context.normalizeCalculatorData(context.getModuleData(toolboxId, moduleId, { entries: [] }));
const calculatedEntries = useMemo(() => evaluateCalculatorEntries(data.entries), [data.entries]);
const calculatedData = useMemo(() => ({ ...data, entries: calculatedEntries }), [data, calculatedEntries]);
@ -223,11 +223,7 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
return (
<div className="calculator-module">
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="calculator" data={calculatedData} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "calculator")} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="calculator" data={calculatedData} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "calculator")} />
<form className="calculator-form" onSubmit={saveResult}>
<div className="calculator-card" ref={calculatorCardRef}>
<div className="calculator-field">

View file

@ -2,7 +2,7 @@
import { Icon } from "../../../components/Icon.jsx";
import { CalendarEventForm } from "./CalendarEventForm.jsx";
import { CalendarMonthView, CalendarWeekGrid } from "./CalendarViews.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import {
addMonths,
formatLocalDateKey,
@ -31,7 +31,7 @@ export function CalendarContent({
nowDayIndex,
nowMinutes,
hideEmptyHours,
textExchangeOpen,
configurationTarget,
onKeyDown,
onSubmit,
onDraftChange,
@ -74,11 +74,7 @@ export function CalendarContent({
onDisplayedMonthChange={onDisplayedMonthChange}
/>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="calendar" data={data} context={context} textContent={textContent} onImport={onImport} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="calendar" data={data} context={context} textContent={textContent} onImport={onImport} />
{data.viewMode === "week" && (
<CalendarWeekOptions textContent={textContent} hideEmptyHours={hideEmptyHours} onToggle={onHideEmptyHoursToggle} />

View file

@ -3,7 +3,7 @@ import { CalendarContent } from "./CalendarContent.jsx";
import { CalendarModeChoice } from "./CalendarModeChoice.jsx";
import { useCalendarController } from "./useCalendarController.js";
export function CalendarModule({ toolboxId, moduleId, context, editing = false, setEditing, textExchangeOpen }) {
export function CalendarModule({ toolboxId, moduleId, context, editing = false, setEditing, configurationTarget }) {
const data = context.normalizeCalendarData(context.getModuleData(toolboxId, moduleId, { initialized: false, viewMode: "week", events: [] }));
const textContent = context.moduleText?.calendar || {};
const controller = useCalendarController({ data, context, toolboxId, moduleId, editing, setEditing, textContent });
@ -18,5 +18,5 @@ export function CalendarModule({ toolboxId, moduleId, context, editing = false,
);
}
return <CalendarContent {...controller.contentProps} textExchangeOpen={textExchangeOpen} />;
return <CalendarContent {...controller.contentProps} configurationTarget={configurationTarget} />;
}

View file

@ -5,7 +5,7 @@ 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 { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import {
appendChecklistItems,
getChecklistReorderItems,
@ -17,7 +17,7 @@ import {
updateChecklistItem
} from "./checklistUtils.js";
export function ChecklistModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
export function ChecklistModule({ toolboxId, moduleId, context, editing, configurationTarget }) {
const data = context.normalizeChecklistData(context.getModuleData(toolboxId, moduleId, { items: [] }));
const textContent = context.moduleText?.checklist || {};
const [label, setLabel] = useState("");
@ -123,11 +123,7 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="checklist" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "checklist")} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="checklist" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "checklist")} />
<div className="checklist-toolbar">
<button
className={`checklist-hide-completed-toggle nds-switch ${data.hideCompletedItems ? "active" : ""}`}

View file

@ -1,7 +1,7 @@
// Rôle : fournit l'outil Combos avec palettes d'inputs, rendu visuel et échange texte.
import { toggleListValue } from "../../../utils/collectionUtils.js";
import { getGroupedEntries } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import { CombosEditor } from "./CombosEditor.jsx";
import { CombosList } from "./CombosList.jsx";
import {
@ -10,7 +10,7 @@ import {
import { useCombosEditorState } from "./useCombosEditorState.js";
import { useCombosReorder } from "./useCombosReorder.js";
export function CombosModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
export function CombosModule({ toolboxId, moduleId, context, editing, configurationTarget }) {
const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [] }));
const textContent = context.moduleText?.combos || {};
const categoryOptions = [
@ -86,11 +86,7 @@ export function CombosModule({ toolboxId, moduleId, context, editing, textExchan
onComboEditCancel={editorState.cancelComboEdit}
/>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="combos" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="combos" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
{!data.combos.length && (
<p className="empty-state">{textContent.emptyCombos || "Aucun combo enregistré."}</p>

View file

@ -2,10 +2,10 @@
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 { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
export function CountersModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
export function CountersModule({ toolboxId, moduleId, context, editing, configurationTarget }) {
const data = context.normalizeCountersData(context.getModuleData(toolboxId, moduleId, { counters: [] }));
const textContent = context.moduleText?.counters || {};
const counterDraft = useDraftForm({ label: "" });
@ -53,11 +53,7 @@ export function CountersModule({ toolboxId, moduleId, context, editing, textExch
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="counters" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "counters")} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="counters" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "counters")} />
{!data.counters.length && <ModuleEmptyMessage>{textContent.emptyCounters || "Aucun compteur."}</ModuleEmptyMessage>}
<div className="counters-grid">
{data.counters.map((counter) => {

View file

@ -1,13 +1,13 @@
// Rôle : fournit l'outil de planification d'équipements, sertissages, résumé et échange texte.
import { useMemo, useState } from "react";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import { EQUIPMENT_ICONS } from "./equipmentPlannerOptions.js";
import { EquipmentItem } from "./EquipmentPlannerItem.jsx";
import { IconChoiceDropdown } from "./EquipmentPlannerPickers.jsx";
import { EquipmentPlannerSummary } from "./EquipmentPlannerSummary.jsx";
export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing, configurationTarget }) {
const data = context.normalizeEquipmentPlannerData(context.getModuleData(toolboxId, moduleId, { types: [], equipments: [] }));
const summary = useMemo(() => context.summarizeEquipmentPlannerData(data), [context, data]);
const textContent = context.moduleText?.equipmentPlanner || {};
@ -114,11 +114,7 @@ export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing,
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="equipmentPlanner" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="equipmentPlanner" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
<EquipmentPlannerSummary summary={summary} textContent={textContent} />

View file

@ -1,10 +1,10 @@
// Rôle : fournit l'outil arbre d'objectifs avec quantités, formules, resets et échange texte.
import { GoalTreeEditor } from "./GoalTreeEditor.jsx";
import { GoalTreeView } from "./GoalTreeView.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import { useGoalTreeController } from "./useGoalTreeController.js";
export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditing = () => {}, openEditor = () => {}, textExchangeOpen }) {
export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditing = () => {}, openEditor = () => {}, configurationTarget }) {
const controller = useGoalTreeController({ toolboxId, moduleId, context, editing, setEditing, openEditor });
return (
@ -30,11 +30,7 @@ export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditi
onToggleLinkedParent={controller.toggleLinkedParent}
/>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="goalTree" data={controller.evaluatedData} context={context} textContent={controller.textContent} onImport={controller.importData} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="goalTree" data={controller.evaluatedData} context={context} textContent={controller.textContent} onImport={controller.importData} />
<GoalTreeView
evaluatedNodes={controller.evaluatedNodes}
@ -46,7 +42,6 @@ export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditi
verticalView={controller.verticalView}
viewScale={controller.viewScale}
storageReady={controller.storageReady}
textExchangeOpen={textExchangeOpen}
onSelect={controller.setSelectedNodeId}
onAdd={controller.addNode}
onAddContentItem={controller.addNodeContentItem}

View file

@ -15,7 +15,6 @@ export function GoalTreeView({
verticalView,
viewScale,
storageReady,
textExchangeOpen,
onSelect,
onAdd,
onAddContentItem,
@ -57,7 +56,7 @@ export function GoalTreeView({
timeoutIds.forEach(clearTimeout);
window.removeEventListener("load", refreshConnectors);
};
}, [storageReady, connectorTopologyKey, evaluatedNodes.length, editing, textExchangeOpen]);
}, [storageReady, connectorTopologyKey, evaluatedNodes.length, editing]);
useLayoutEffect(() => {
const canvas = treeViewRef.current;
@ -82,7 +81,7 @@ export function GoalTreeView({
cancelAnimationFrame(frameId);
resizeObserver.disconnect();
};
}, [connectorTopologyKey, editing, textExchangeOpen, viewScale]);
}, [connectorTopologyKey, editing, viewScale]);
return (
<section className={`goal-tree-view tool-split-results ${verticalView ? "is-vertical" : "is-horizontal"} ${editing ? "is-editing" : ""}`} aria-label={textContent.treeTitle || "Arbre d'objectifs"}>

View file

@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx";
import { DrawingOverlay } from "./DrawingOverlay.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import { useKeyedState } from "../../../hooks/useKeyedState.js";
function readTemporaryDrawings(sessionKey) {
@ -19,7 +19,7 @@ function markerLabel(index, marker, textContent) {
return marker.label || `${textContent.markerPrefix || "Marqueur"} ${index + 1}`;
}
export function ImageAnnotationModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
export function ImageAnnotationModule({ toolboxId, moduleId, context, editing, configurationTarget }) {
const data = context.normalizeImageAnnotationData(context.getModuleData(toolboxId, moduleId, { image: "", markers: [] }));
const textContent = context.moduleText?.imageAnnotation || {};
const notify = context.notify;
@ -123,14 +123,10 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing, t
</div>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="imageAnnotation" data={data} context={context} textContent={textContent} onImport={(nextData) => {
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="imageAnnotation" data={data} context={context} textContent={textContent} onImport={(nextData) => {
save(nextData);
setTemporaryDrawings({ strokes: [] });
}} />
</div>
)}
{data.image ? (
<div className="annotation-workspace">

View file

@ -3,9 +3,9 @@ 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";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
export function ImagesModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
export function ImagesModule({ toolboxId, moduleId, context, editing, configurationTarget }) {
const data = context.getModuleData(toolboxId, moduleId, { images: [] });
const textContent = context.moduleText?.images || {};
const [dragOver, setDragOver] = useState(false);
@ -99,11 +99,7 @@ export function ImagesModule({ toolboxId, moduleId, context, editing, textExchan
</div>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="images" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "images")} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="images" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "images")} />
{data.images.length > 0 && (
<div className="images-toolbar">
<button className="nds-button" data-size="sm" type="button" onClick={() => openCollection("gallery")}>

View file

@ -5,7 +5,7 @@ import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompl
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 { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
function getLinkCategory(link) {
@ -32,7 +32,7 @@ function getLinkEntries(data) {
return [...ungrouped, ...categories];
}
export function LinksModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
export function LinksModule({ toolboxId, moduleId, context, editing, configurationTarget }) {
const data = context.normalizeLinksData(context.getModuleData(toolboxId, moduleId, { links: [] }));
const textContent = context.moduleText?.links || {};
const linkDraft = useDraftForm({ title: "", url: "", category: "" });
@ -169,11 +169,7 @@ export function LinksModule({ toolboxId, moduleId, context, editing, textExchang
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="links" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "links")} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="links" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "links")} />
{!data.links.length && <ModuleEmptyMessage>{textContent.emptyLinks || "Aucun lien."}</ModuleEmptyMessage>}
<div className="links-category-list">
{getLinkEntries(data).map((entry) => {

View file

@ -1,18 +1,35 @@
// Rôle : fournit l'outil de mémos courts avec post-it, bande déroulante et tri manuel.
// Rôle : fournit les mémos courts, leur tri et les alertes de planificateur dans la bande déroulante.
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
import { Icon } from "../../../components/Icon.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import {
ALL_TASK_PLANNERS_LINK,
getPlannerTaskTickerItems,
getTaskPlannerModules,
resolveMemosPlannerLink
} from "./memosPlannerUtils.js";
import { MemoTextEditor } from "./MemoTextEditor.jsx";
import { MemosTicker } from "./MemosTicker.jsx";
import { ModuleConfigurationPortal } from "./ModuleConfiguration.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { TEXT_COLORS } from "./notepadUtils.js";
export function MemosModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
export function MemosModule({ toolboxId, moduleId, context, editing, configurationTarget }) {
const storedData = context.getModuleData(toolboxId, moduleId, { items: [] });
const data = useMemo(() => context.normalizeMemosData(storedData), [context, storedData]);
const textContent = context.moduleText?.memos || {};
const tickerItems = data.tickerEnabled ? data.items : [];
const [nowMs, setNowMs] = useState(() => Date.now());
const plannerModules = useMemo(() => getTaskPlannerModules(context.toolboxModules), [context.toolboxModules]);
const plannerLink = resolveMemosPlannerLink(data.plannerLink, plannerModules);
const plannerTickerItems = useMemo(() => getPlannerTaskTickerItems({
plannerLink,
modules: plannerModules,
getPlannerData: (plannerId) => context.getModuleData(toolboxId, plannerId, { tasks: [] }),
nowMs,
alertTemplate: textContent.taskAlertTemplate
}), [context, nowMs, plannerLink, plannerModules, textContent.taskAlertTemplate, toolboxId]);
const tickerItems = data.tickerEnabled ? [...data.items, ...plannerTickerItems] : [];
const {
itemReorder,
getItemProps,
@ -37,6 +54,15 @@ export function MemosModule({ toolboxId, moduleId, context, editing, textExchang
save({ ...data, items });
}
useEffect(() => {
const intervalId = window.setInterval(() => setNowMs(Date.now()), 60000);
return () => window.clearInterval(intervalId);
}, []);
useEffect(() => {
if (data.plannerLink && !plannerLink) context.setModuleData(toolboxId, moduleId, { ...data, plannerLink: "" }, "memos");
}, [context, data, moduleId, plannerLink, toolboxId]);
function addMemo() {
const now = new Date().toISOString();
save({
@ -71,11 +97,33 @@ export function MemosModule({ toolboxId, moduleId, context, editing, textExchang
return (
<div className="memos-module">
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="memos" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}
<ModuleConfigurationPortal target={configurationTarget}>
<section className="module-configuration-section module-configuration-specific">
<h3>{textContent.settingsTitle || "Paramètres des mémos"}</h3>
<div className="memos-settings">
<label>
<span>{textContent.plannerLinkLabel || "Planificateur lié"}</span>
<select
className="nds-select"
data-size="xs"
value={plannerLink}
onChange={(event) => save({ ...data, plannerLink: event.target.value })}
aria-label={textContent.plannerLinkLabel || "Planificateur lié"}
>
<option value="">{textContent.plannerNoneLabel || "Aucun"}</option>
{plannerModules.length > 0 && <option value={ALL_TASK_PLANNERS_LINK}>{textContent.plannerAllLabel || "Tous les planificateurs"}</option>}
{plannerModules.map((planner) => (
<option key={planner.id} value={planner.id}>{planner.title || textContent.plannerDefaultLabel || "Planificateur de tâches"}</option>
))}
</select>
</label>
</div>
</section>
<section className="module-configuration-section module-configuration-data">
<h3>{context.moduleConfigurationText?.dataTitle || "Données"}</h3>
<TextExchangeActions type="memos" data={data} context={context} textContent={textContent} onImport={(nextData) => save({ ...data, items: nextData.items })} />
</section>
</ModuleConfigurationPortal>
{editing && (
<div className="module-add-panel">

View file

@ -1,11 +1,14 @@
// Rôle : affiche la bande déroulante partagée par l'outil Mémos et les pages associées.
// Rôle : affiche la bande déroulante partagée pour les mémos et alertes de tâches.
import { Icon } from "../../../components/Icon.jsx";
export function MemosTicker({ items, label = "Mémos", className = "" }) {
if (!items?.length) return null;
return (
<div className={["memos-ticker", className].filter(Boolean).join(" ")} aria-label={label}>
<div className="memos-ticker-track">
{[...items, ...items].map((memo, index) => (
<span className="memos-ticker-item" key={`${memo.id}-${index}`} style={{ "--memo-color": memo.color }}>
<span className={`memos-ticker-item ${memo.kind === "task-warning" ? "is-task-warning" : ""}`} key={`${memo.id}-${index}`} style={{ "--memo-color": memo.color }}>
{memo.kind === "task-warning" && <Icon name="warning" />}
{memo.text}
</span>
))}

View file

@ -0,0 +1,18 @@
// Rôle : déplace les réglages propres aux outils dans le panneau Configuration commun.
import { createPortal } from "react-dom";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
export function ModuleConfigurationPortal({ target, children }) {
return target ? createPortal(children, target) : null;
}
export function ModuleTextExchangeConfiguration({ target, title = "Données", ...props }) {
return (
<ModuleConfigurationPortal target={target}>
<section className="module-configuration-section module-configuration-data">
<h3>{title}</h3>
<TextExchangeActions {...props} />
</section>
</ModuleConfigurationPortal>
);
}

View file

@ -1,23 +1,26 @@
// Rôle : rend l'en-tête, les contrôles et le contenu repliable d'un outil toolbox.
import { Suspense, useEffect, useRef, useState } from "react";
import { Suspense, useEffect, useId, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { GuideChecklistControls, GuideChecklistToggle } from "../../games/GuideChecklistControls.jsx";
import { GuideChecklistControls } from "../../games/GuideChecklistControls.jsx";
import { useGuideChecklistPanel } from "../../games/useGuideChecklist.js";
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 = "", spaces = [], activeSpaceId = "", moveText = "Déplacer vers", scrollIntoView = false, onScrolledIntoView, onRename, onUpdateModule, onDelete, onMoveToSpace }) {
export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", spaces = [], activeSpaceId = "", moveText = "Déplacer vers", configurationText = {}, scrollIntoView = false, onScrolledIntoView, onRename, onUpdateModule, onDelete, onMoveToSpace }) {
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 [configurationOpen, setConfigurationOpen] = useState(false);
const [configurationTarget, setConfigurationTarget] = useState(null);
const configurationId = useId();
const moduleRef = useRef(null);
const guidePanel = useGuideChecklistPanel(context.guideChecklist, module);
const hasAddPanel = definition.editable && definition.addable !== false;
const hasTextExchange = supportsTextExchange(module.type);
const hasConfiguration = spaces.length > 1 || Boolean(guidePanel.list) || hasTextExchange;
const scrollable = definition.scrollable && module.scrollable === true;
const collapsed = module.collapsed === true;
const className = [
@ -35,10 +38,10 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
editing,
setEditing,
openEditor: () => {
setTextExchangeOpen(false);
setConfigurationOpen(false);
setEditing(true);
},
textExchangeOpen
configurationTarget
};
useEffect(() => {
@ -50,10 +53,19 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
return () => window.cancelAnimationFrame(frame);
}, [onScrolledIntoView, scrollIntoView]);
useEffect(() => {
if (!configurationOpen) return undefined;
const closeOnEscape = (event) => {
if (event.key === "Escape" && !document.querySelector('[aria-modal="true"]')) setConfigurationOpen(false);
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [configurationOpen]);
function toggleCollapsed() {
if (!collapsed) {
setEditing(false);
setTextExchangeOpen(false);
setConfigurationOpen(false);
}
onUpdateModule(module.id, (currentModule) => {
const nextModule = { ...currentModule };
@ -74,8 +86,8 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
data-module-id={module.id}
{...reorder.getItemProps({ itemId: module.id, groupId: itemGroup, parentId: toolbox.id })}
>
<header>
<div>
<header className="module-header">
<div className="module-header-main">
<button
className="module-drag-handle nds-icon-button"
data-size="md"
@ -92,20 +104,7 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
</span>
<EditableModuleTitle value={module.title || label} fallback={label} tutorialTarget={module.type === "memos" ? "moduleTitle" : ""} onSave={(title) => onRename(module.id, title)} />
</div>
<div>
{spaces.length > 1 && (
<select
className="module-space-select nds-select"
data-size="sm"
value=""
aria-label={`${moveText} — ${module.title || label}`}
title={moveText}
onChange={(event) => onMoveToSpace(module.id, event.target.value)}
>
<option value="" disabled>{moveText}</option>
{spaces.filter((space) => space.id !== activeSpaceId).map((space) => <option key={space.id} value={space.id}>{space.name}</option>)}
</select>
)}
<div className="module-header-actions">
{definition.scrollable && (
<button
className={`module-scroll-button nds-switch ${scrollable ? "active" : ""}`}
@ -133,7 +132,7 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
data-size="md"
onClick={() => setEditing((value) => {
const nextEditing = !value;
if (nextEditing) setTextExchangeOpen(false);
if (nextEditing) setConfigurationOpen(false);
return nextEditing;
})}
aria-label={`${editing ? "Masquer" : "Afficher"} l'ajout de ${module.title || label}`}
@ -143,21 +142,23 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
<Icon name="add" />
</button>
)}
<GuideChecklistToggle checklist={context.guideChecklist} panel={guidePanel} label={module.title || label} />
{hasTextExchange && (
{hasConfiguration && (
<button
className={`module-exchange-button nds-icon-button ${textExchangeOpen ? "active" : ""}`}
className={`module-configuration-button nds-icon-button ${configurationOpen ? "active" : ""}`}
data-size="md"
onClick={() => setTextExchangeOpen((value) => {
data-guide-linked={guidePanel.list && context.guideChecklist?.selected?.id === guidePanel.list.id || undefined}
type="button"
onClick={() => setConfigurationOpen((value) => {
const nextOpen = !value;
if (nextOpen) setEditing(false);
return nextOpen;
})}
aria-label={`${textExchangeOpen ? "Masquer" : "Afficher"} l'import/export de ${module.title || label}`}
aria-pressed={textExchangeOpen}
title={textExchangeOpen ? "Masquer import/export" : "Import / export"}
aria-label={configurationText.buttonLabel || "Configuration"}
aria-expanded={configurationOpen}
aria-controls={configurationId}
title={configurationText.buttonLabel || "Configuration"}
>
<Icon name="import" />
<Icon name="settings" />
</button>
)}
<button className="module-delete-button nds-icon-button" data-size="md" data-variant="danger" onClick={() => onDelete(module.id)} aria-label={`Retirer ${module.title || label}`} data-toolbox-tutorial-target={module.type === "links" ? "deleteTool" : undefined} title="Retirer">
@ -176,14 +177,45 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
</button>
</div>
</header>
{!collapsed && (
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
<GuideChecklistControls checklist={context.guideChecklist} {...guidePanel} />
<Suspense fallback={<ModuleLoadingFallback label={label} />}>
<ModuleDataGate key={`${toolbox.id}:${module.id}`} context={context} toolboxId={toolbox.id} moduleId={module.id}>
<Component {...componentProps} />
</ModuleDataGate>
</Suspense>
{configurationOpen && (
<section
id={configurationId}
className="module-configuration-panel"
aria-label={`${configurationText.panelLabel || "Configuration de"} ${module.title || label}`}
>
{spaces.length > 1 && (
<section className="module-configuration-section module-configuration-organization">
<h3>{configurationText.organizationTitle || "Espace"}</h3>
<select
className="module-space-select nds-select"
data-size="sm"
value=""
aria-label={configurationText.spaceLabel || moveText}
onChange={(event) => onMoveToSpace(module.id, event.target.value)}
>
<option value="" disabled>{moveText}</option>
{spaces.filter((space) => space.id !== activeSpaceId).map((space) => <option key={space.id} value={space.id}>{space.name}</option>)}
</select>
</section>
)}
{guidePanel.list && (
<section className="module-configuration-section module-configuration-specific">
<h3>{configurationText.guideTitle || "Guide"}</h3>
<GuideChecklistControls checklist={context.guideChecklist} {...guidePanel} open />
</section>
)}
<div className="module-configuration-target" ref={setConfigurationTarget} />
</section>
)}
{(!collapsed || configurationOpen) && (
<div className="module-component-host" hidden={collapsed}>
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
<Suspense fallback={<ModuleLoadingFallback label={label} />}>
<ModuleDataGate key={`${toolbox.id}:${module.id}`} context={context} toolboxId={toolbox.id} moduleId={module.id}>
<Component {...componentProps} />
</ModuleDataGate>
</Suspense>
</div>
</div>
)}
</article>

View file

@ -5,7 +5,7 @@ import { Icon } from "../../../components/Icon.jsx";
import { DrawingControls } from "./DrawingControls.jsx";
import { DrawingOverlay } from "./DrawingOverlay.jsx";
import { NotepadToolbar } from "./NotepadToolbar.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import { normalizeNotepadData } from "../storage/toolboxStorage.js";
import { useKeyedState } from "../../../hooks/useKeyedState.js";
import {
@ -31,7 +31,7 @@ function readTemporaryStrokes(sessionKey) {
}
}
export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }) {
export function NotepadModule({ toolboxId, moduleId, context, configurationTarget }) {
const storedData = context.getModuleData(toolboxId, moduleId, EMPTY_NOTEPAD_DATA);
const normalizedData = useMemo(() => normalizeNotepadData(storedData), [storedData]);
const notepadDataRef = useRef(normalizedData);
@ -295,11 +295,7 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
return (
<div className="notepad-module">
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="notepad" data={normalizedData} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "notepad")} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="notepad" data={normalizedData} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "notepad")} />
<CompactDropdown
className="notepad-pages-dropdown"
menuClassName="notepad-pages-menu"

View file

@ -1,12 +1,12 @@
// Rôle : orchestre les onglets de l'outil Randomizer sans porter les sous-vues.
import { Tabs } from "../../../components/Tabs.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import { RANDOMIZER_TABS } from "./randomizerUtils.js";
import { DiceRandomizer } from "./randomizer/RandomizerDice.jsx";
import { TeamsRandomizer } from "./randomizer/RandomizerTeams.jsx";
import { WheelRandomizer } from "./randomizer/RandomizerWheel.jsx";
export function RandomizerModule({ toolboxId, moduleId, context, textExchangeOpen }) {
export function RandomizerModule({ toolboxId, moduleId, context, configurationTarget }) {
const data = context.normalizeRandomizerData(context.getModuleData(toolboxId, moduleId, {}));
const textContent = context.moduleText?.randomizer || {};
@ -20,11 +20,7 @@ export function RandomizerModule({ toolboxId, moduleId, context, textExchangeOpe
return (
<>
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="randomizer" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "randomizer")} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="randomizer" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "randomizer")} />
<div className="randomizer-tool">
<Tabs
tabs={RANDOMIZER_TABS.map((tab) => ({ ...tab, label: textContent[`${tab.id}Tab`] || tab.label }))}

View file

@ -4,7 +4,7 @@ import { Icon } from "../../../components/Icon.jsx";
import { Tabs } from "../../../components/Tabs.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { useKeyedState } from "../../../hooks/useKeyedState.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import {
RNG_SIMULATOR_DEFAULT_RATE,
RNG_SIMULATOR_HOURLY_DURATION_PRESETS,
@ -146,7 +146,7 @@ function getToneFromDelta(value) {
return value > 0 ? "positive" : "negative";
}
export function RngSimulatorModule({ toolboxId, moduleId, context, editing = false, setEditing = () => {}, textExchangeOpen }) {
export function RngSimulatorModule({ toolboxId, moduleId, context, editing = false, setEditing = () => {}, configurationTarget }) {
const rawData = context.getModuleData(toolboxId, moduleId, {});
const data = useMemo(() => context.normalizeRngSimulatorData(rawData), [context, rawData]);
const textContent = context.moduleText?.rngSimulator || {};
@ -405,11 +405,7 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
return (
<>
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="rngSimulator" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "rngSimulator")} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="rngSimulator" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "rngSimulator")} />
<div className="rng-simulator-tool">
<section className="rng-simulator-panel rng-simulator-rates-panel">
<header>

View file

@ -1,6 +1,6 @@
// Rôle : fournit l'outil tableau avec cellules libres, formules simples et échange TSV.
import { useMemo } from "react";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import { columnIndexToName, evaluateTable } from "./tableFormulaEngine.js";
import { TableGrid } from "./TableGrid.jsx";
import { TableToolbar } from "./TableToolbar.jsx";
@ -14,7 +14,7 @@ import {
} from "./tableUtils.js";
import { useTableInteractions } from "./useTableInteractions.js";
export function TableModule({ toolboxId, moduleId, context, textExchangeOpen }) {
export function TableModule({ toolboxId, moduleId, context, configurationTarget }) {
const rawData = context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} });
const data = useMemo(() => context.normalizeTableData(rawData), [context, rawData]);
const textContent = context.moduleText?.table || {};
@ -61,11 +61,7 @@ export function TableModule({ toolboxId, moduleId, context, textExchangeOpen })
return (
<div className="table-module">
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="table" data={data} context={context} textContent={textContent} exportLabel="Exporter TSV" onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "table")} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="table" data={data} context={context} textContent={textContent} exportLabel="Exporter TSV" onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "table")} />
<TableToolbar rows={data.rows} columns={data.columns} textContent={textContent} onResize={resize} onCopy={tableInteractions.copyTsv} />
<TableGrid
data={data}

View file

@ -3,15 +3,17 @@ import { toggleListValue } from "../../../utils/collectionUtils.js";
import { useEffect, useMemo, useRef, useState } from "react";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { TaskPlannerBranch } from "./TaskPlannerBranch.jsx";
import { ModuleConfigurationPortal } from "./ModuleConfiguration.jsx";
import { TaskPlannerSettings } from "./TaskPlannerSettings.jsx";
import { TaskPlannerTopbar } from "./TaskPlannerTopbar.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import {
applyDueResets,
getTaskParentMap
} from "./taskPlannerUtils.js";
import { useTaskPlannerReorder } from "./useTaskPlannerReorder.js";
export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
export function TaskPlannerModule({ toolboxId, moduleId, context, editing, configurationTarget }) {
const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] }));
const textContent = context.moduleText?.taskPlanner || {};
const taskDraft = useDraftForm({ title: "", type: "daily", category: "" });
@ -20,7 +22,6 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textE
...data.tasks.map((task) => task.category).filter(Boolean).filter((category) => !data.categoryOrder.includes(category))
], [data.categoryOrder, data.tasks]);
const [nowMs, setNowMs] = useState(() => Date.now());
const [settingsOpen, setSettingsOpen] = useState(false);
const [openDescriptions, setOpenDescriptions] = useState(() => new Set());
const [openTaskSettings, setOpenTaskSettings] = useState(() => new Set());
const [prerequisiteNoticeTaskIds, setPrerequisiteNoticeTaskIds] = useState(() => new Set());
@ -170,31 +171,32 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textE
return (
<div className="task-planner-module">
<ModuleConfigurationPortal target={configurationTarget}>
<section className="module-configuration-section module-configuration-specific">
<h3>{textContent.settingsTitle || "Réglages du planificateur"}</h3>
<TaskPlannerSettings
data={data}
textContent={textContent}
onWeeklyResetDayChange={(weeklyResetDay) => save({ ...data, weeklyResetDay })}
onResetTimeChange={(resetTime) => save({ ...data, resetTime })}
onClearCompletedUniqueTasks={clearCompletedUniqueTasks}
/>
</section>
<section className="module-configuration-section module-configuration-data">
<h3>{context.moduleConfigurationText?.dataTitle || "Données"}</h3>
<TextExchangeActions type="taskPlanner" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</section>
</ModuleConfigurationPortal>
<TaskPlannerTopbar
data={data}
context={context}
editing={editing}
settingsOpen={settingsOpen}
categoryOptions={categoryOptions}
taskDraft={taskDraft}
textContent={textContent}
textExchangeOpen={textExchangeOpen}
onAddTask={addTask}
onHideCompletedToggle={() => save({ ...data, hideCompleted: !data.hideCompleted })}
onSettingsToggle={() => setSettingsOpen((value) => !value)}
onImport={(nextData) => save(nextData)}
/>
{settingsOpen && (
<TaskPlannerSettings
data={data}
textContent={textContent}
onWeeklyResetDayChange={(weeklyResetDay) => save({ ...data, weeklyResetDay })}
onResetTimeChange={(resetTime) => save({ ...data, resetTime })}
onClearCompletedUniqueTasks={clearCompletedUniqueTasks}
/>
)}
<ul className="task-planner-list">
{data.tasks.length ? (
<TaskPlannerBranch

View file

@ -7,18 +7,18 @@ export function TaskPlannerSettings({ data, textContent, onWeeklyResetDayChange,
<section className="task-planner-settings" aria-label={textContent.settingsTitle || "Réglages du planificateur"}>
<label>
<span>{textContent.globalWeeklyResetDayLabel || "Reset hebdo"}</span>
<select className="nds-select" data-size="sm" value={data.weeklyResetDay} onChange={(event) => onWeeklyResetDayChange(Number(event.target.value))}>
<select className="nds-select" data-size="xs" value={data.weeklyResetDay} onChange={(event) => onWeeklyResetDayChange(Number(event.target.value))}>
{WEEK_DAYS.map((day) => <option key={day.value} value={day.value}>{day.label}</option>)}
</select>
</label>
<label>
<span>{textContent.resetTimeLabel || "Heure de reset"}</span>
<input className="nds-time" data-size="sm" type="time" value={data.resetTime} onChange={(event) => onResetTimeChange(event.target.value || "00:00")} />
<input className="nds-time" data-size="xs" type="time" value={data.resetTime} onChange={(event) => onResetTimeChange(event.target.value || "00:00")} />
</label>
<div className="task-planner-settings-actions">
<button
className="task-planner-clear-completed-button nds-button"
data-size="sm"
data-size="xs"
data-variant="danger"
type="button"
onClick={onClearCompletedUniqueTasks}

View file

@ -1,7 +1,6 @@
// Rôle : rend la barre haute du task planner, l'ajout rapide et l'échange texte.
// Rôle : rend les contrôles fréquents et l'ajout rapide du planificateur de tâches.
import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx";
import { Icon } from "../../../components/Icon.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import {
TASK_TYPES,
getTaskTypeLabel
@ -9,17 +8,12 @@ import {
export function TaskPlannerTopbar({
data,
context,
editing,
settingsOpen,
categoryOptions,
taskDraft,
textContent,
textExchangeOpen,
onAddTask,
onHideCompletedToggle,
onSettingsToggle,
onImport
onHideCompletedToggle
}) {
return (
<div className="task-planner-topbar">
@ -36,18 +30,6 @@ export function TaskPlannerTopbar({
<Icon name={data.hideCompleted ? "eye-closed" : "eye-open"} />
<i aria-hidden="true" />
</button>
<button
className={`task-planner-settings-toggle nds-button ${settingsOpen ? "active" : ""}`}
data-size="sm"
type="button"
onClick={onSettingsToggle}
aria-expanded={settingsOpen}
aria-label={textContent.settingsTitle || "Réglages du planificateur"}
title={textContent.settingsTitle || "Réglages du planificateur"}
>
<Icon name="settings" />
<span>{textContent.settingsButtonLabel || "Paramètres des réinitialisations"}</span>
</button>
</div>
{editing && (
<div className="task-planner-add-stack">
@ -61,11 +43,6 @@ export function TaskPlannerTopbar({
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="taskPlanner" data={data} context={context} textContent={textContent} onImport={onImport} />
</div>
)}
</div>
);
}

View file

@ -5,17 +5,20 @@ import { useTextExchange } from "./useTextExchange.js";
export function TextExchangeActions({ type, data, context, onImport, textContent = {}, exportLabel = "" }) {
const exchange = useTextExchange({ type, data, context, onImport, textContent });
const importAccessibleLabel = textContent.importOpenButton || "Importer du texte";
const exportAccessibleLabel = textContent.exportOpenButton || exportLabel || "Exporter en format texte";
const configurationText = context.moduleConfigurationText || {};
return (
<>
<div className="text-import-actions">
<button className="nds-button" data-size="sm" type="button" onClick={exchange.openImport}>
<button className="nds-button" data-size="sm" type="button" onClick={exchange.openImport} aria-label={importAccessibleLabel}>
<Icon name="import" />
<span>{textContent.importOpenButton || "Importer du texte"}</span>
<span>{configurationText.importButtonLabel || "Importer"}</span>
</button>
<button className="nds-button" data-size="sm" type="button" onClick={exchange.openExport} disabled={!exchange.canExport}>
<button className="nds-button" data-size="sm" type="button" onClick={exchange.openExport} disabled={!exchange.canExport} aria-label={exportAccessibleLabel}>
<Icon name="export" />
<span>{textContent.exportOpenButton || exportLabel || "Exporter en format texte"}</span>
<span>{configurationText.exportButtonLabel || "Exporter"}</span>
</button>
</div>
{exchange.mode === "import" && (

View file

@ -7,7 +7,10 @@ import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
export function TextImportModal({ title, value, placeholder, submitLabel, readOnly = false, error = "", info = "", onChange, onSubmit, onCopy, onClose }) {
useEffect(() => {
function handleKeyDown(event) {
if (event.key === "Escape") onClose();
if (event.key === "Escape") {
event.stopPropagation();
onClose();
}
}
const unlock = lockBodyScroll();

View file

@ -13,7 +13,7 @@ import {
timePartsToDurationMs,
timePartsToString
} from "./timerUtils.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import { CountdownControls, CountdownList } from "./TimerCountdown.jsx";
import { StopwatchControls, StopwatchLapList } from "./TimerStopwatch.jsx";
import {
@ -30,7 +30,7 @@ import {
isAlertGuarded
} from "./timerOptions.js";
export function TimerModule({ toolboxId, moduleId, context, textExchangeOpen }) {
export function TimerModule({ toolboxId, moduleId, context, configurationTarget }) {
const rawData = context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" });
const data = useMemo(() => context.normalizeTimerData(rawData), [context, rawData]);
const textContent = context.moduleText?.timer || {};
@ -252,11 +252,7 @@ export function TimerModule({ toolboxId, moduleId, context, textExchangeOpen })
return (
<div className="timer-module">
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="timer" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="timer" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
<section className="timer-control-card">
<Tabs
className="timer-tabs"

View file

@ -10,7 +10,7 @@ function getModulesByIds(modules, ids) {
return (Array.isArray(ids) ? ids : []).map((id) => moduleMap.get(id)).filter(Boolean);
}
export function ToolboxModules({ toolbox, space, moduleColumns, context, reorder, spacesText, scrollToModuleId = "", onModuleScrolled, onRename, onUpdateModule, onDelete, onMoveToSpace }) {
export function ToolboxModules({ toolbox, space, moduleColumns, context, reorder, spacesText, configurationText, scrollToModuleId = "", onModuleScrolled, onRename, onUpdateModule, onDelete, onMoveToSpace }) {
const twoColumnOrder = useMemo(() => space.moduleOrder?.two || [[], []], [space.moduleOrder?.two]);
const orderedModules = useMemo(() => getModulesByIds(toolbox.modules, space.moduleOrder?.one || []), [toolbox.modules, space.moduleOrder?.one]);
const columns = useMemo(() => [
@ -32,6 +32,7 @@ export function ToolboxModules({ toolbox, space, moduleColumns, context, reorder
spaces={toolbox.spaces}
activeSpaceId={space.id}
moveText={spacesText.moveLabel}
configurationText={configurationText}
onMoveToSpace={onMoveToSpace}
scrollIntoView={module.id === scrollToModuleId}
onScrolledIntoView={onModuleScrolled}
@ -65,6 +66,7 @@ export function ToolboxModules({ toolbox, space, moduleColumns, context, reorder
spaces={toolbox.spaces}
activeSpaceId={space.id}
moveText={spacesText.moveLabel}
configurationText={configurationText}
onMoveToSpace={onMoveToSpace}
scrollIntoView={module.id === scrollToModuleId}
onScrolledIntoView={onModuleScrolled}

View file

@ -0,0 +1,62 @@
// Rôle : résout les liaisons de Mémos vers les planificateurs et produit leurs alertes de reset.
import { normalizeTaskPlannerData } from "../storage/modules/taskPlanner.js";
import {
applyDueResets,
getEffectiveTaskCategory,
getTaskParentMap,
isTaskResetSoon
} from "./taskPlannerUtils.js";
export const ALL_TASK_PLANNERS_LINK = "all";
export function getTaskPlannerModules(modules) {
return (Array.isArray(modules) ? modules : []).filter((module) => module.type === "taskPlanner");
}
export function resolveMemosPlannerLink(plannerLink, modules) {
const value = String(plannerLink || "");
const planners = getTaskPlannerModules(modules);
if (value === ALL_TASK_PLANNERS_LINK) return planners.length ? value : "";
return planners.some((planner) => planner.id === value) ? value : "";
}
export function getLinkedTaskPlannerModules(plannerLink, modules) {
const planners = getTaskPlannerModules(modules);
if (plannerLink === ALL_TASK_PLANNERS_LINK) return planners;
return planners.filter((planner) => planner.id === plannerLink);
}
export function getMemosDataAfterPlannerRemoval(data, removedPlannerId, remainingModules) {
const remainingPlanners = getTaskPlannerModules(remainingModules);
const link = String(data?.plannerLink || "");
if (link === removedPlannerId || (link === ALL_TASK_PLANNERS_LINK && !remainingPlanners.length)) {
return { ...data, plannerLink: "" };
}
return data;
}
function formatTaskAlert(task, category, template) {
return String(template || "Tâche : {category}{title} expire bientôt")
.replace("{category}", category ? `${category} ` : "")
.replace("{title}", task.title);
}
export function getPlannerTaskTickerItems({
plannerLink,
modules,
getPlannerData,
nowMs = Date.now(),
alertTemplate
}) {
return getLinkedTaskPlannerModules(plannerLink, modules).flatMap((planner) => {
const data = applyDueResets(normalizeTaskPlannerData(getPlannerData(planner.id)), nowMs);
const parentMap = getTaskParentMap(data.tasks, data.relations);
return data.tasks
.filter((task) => isTaskResetSoon(task, data, nowMs))
.map((task) => ({
id: `planner:${planner.id}:${task.id}`,
kind: "task-warning",
text: formatTaskAlert(task, getEffectiveTaskCategory(task, data.tasks, parentMap), alertTemplate)
}));
});
}

View file

@ -1,4 +1,4 @@
// Rôle : normalise et compacte les mémos courts des toolboxes.
// Rôle : normalise et compacte les mémos courts et leur liaison éventuelle aux planificateurs.
import { NOTEPAD_ALLOWED_COLORS } from "./drawing.js";
import { normalizeIsoDate, uid } from "./shared.js";
@ -20,6 +20,7 @@ export function normalizeMemosData(data) {
const source = data && typeof data === "object" ? data : {};
return {
tickerEnabled: source.tickerEnabled !== false,
plannerLink: String(source.plannerLink || "").trim(),
items: (Array.isArray(source.items) ? source.items : []).map(normalizeMemoItem).filter(Boolean)
};
}
@ -28,6 +29,7 @@ export function compactMemosDataForStorage(value) {
const normalized = normalizeMemosData(value);
const compact = {};
if (normalized.tickerEnabled === false) compact.tickerEnabled = false;
if (normalized.plannerLink) compact.plannerLink = normalized.plannerLink;
if (normalized.items.length) {
compact.items = normalized.items.map((item) => {
const compactItem = {

View file

@ -242,6 +242,11 @@ export function compactModuleDataForStorage(type, value) {
return compactor ? compactor(value) : value;
}
export function remapMemosPlannerLink(data, resolveModuleId) {
if (!data || typeof data !== "object" || !data.plannerLink || data.plannerLink === "all") return data;
return { ...data, plannerLink: resolveModuleId(data.plannerLink) || "" };
}
export function prepareModuleDataForStorage(toolboxes, toolboxId, moduleId, value, moduleType = "") {
const module = toolboxes.find((toolbox) => toolbox.id === toolboxId)?.modules.find((item) => item.id === moduleId);
return compactModuleDataForStorage(moduleType || module?.type, value);
@ -255,7 +260,7 @@ function createExportIdFactory() {
};
}
function remapModuleDataForExport(type, data, nextId) {
function remapModuleDataForExport(type, data, nextId, moduleIdMap) {
const compact = compactModuleDataForStorage(type, data);
if (!compact) return null;
if (type === "checklist") {
@ -332,6 +337,8 @@ function remapModuleDataForExport(type, data, nextId) {
if (type === "memos") {
const remapped = {};
if (compact.tickerEnabled === false) remapped.tickerEnabled = false;
if (compact.plannerLink === "all") remapped.plannerLink = "all";
else if (compact.plannerLink && moduleIdMap.has(compact.plannerLink)) remapped.plannerLink = moduleIdMap.get(compact.plannerLink);
if (compact.items) remapped.items = compact.items.map((memo) => ({ ...memo, id: nextId("memo") }));
return remapped;
}
@ -490,7 +497,7 @@ export function createToolboxExportPayload(toolbox, moduleData) {
const modules = Object.fromEntries(source.modules
.map((module) => [
moduleIdMap.get(module.id),
remapModuleDataForExport(module.type, moduleData[moduleStorageKey(source.id, module.id)] || null, nextId)
remapModuleDataForExport(module.type, moduleData[moduleStorageKey(source.id, module.id)] || null, nextId, moduleIdMap)
])
.filter(([, data]) => data != null));
return { version: 2, toolbox: exportedToolbox, modules };

View file

@ -7,6 +7,7 @@ import {
labelFromFileName,
MAX_TOOLBOX_SPACES,
normalizeToolbox,
remapMemosPlannerLink,
remapModuleOrderIds,
uid
} from "./storage/toolboxStorage.js";
@ -207,7 +208,8 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
const nextId = moduleIdMap.get(oldId);
const module = normalizedImport.modules.find((item) => item.id === nextId);
if (nextId) {
const compact = compactModuleDataForStorage(module?.type, data);
const remappedData = module?.type === "memos" ? remapMemosPlannerLink(data, (plannerId) => moduleIdMap.get(plannerId)) : data;
const compact = compactModuleDataForStorage(module?.type, remappedData);
if (compact) nextModuleData[`${normalizedImport.id}:${nextId}`] = compact;
}
});
@ -270,7 +272,10 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
const nextModuleId = moduleIdMap.get(key);
const toolbox = importedToolboxes.find((item) => item.id === nextToolboxId);
const module = toolbox?.modules.find((item) => item.id === nextModuleId);
const compact = compactModuleDataForStorage(module?.type, data);
const remappedData = module?.type === "memos"
? remapMemosPlannerLink(data, (plannerId) => moduleIdMap.get(globalModuleKey(oldToolboxId, plannerId)))
: data;
const compact = compactModuleDataForStorage(module?.type, remappedData);
if (nextToolboxId && nextModuleId && compact) nextModuleData[`${nextToolboxId}:${nextModuleId}`] = compact;
});
const saved = await store.persistToolboxSnapshot((current) => ({