Unify category autocomplete fields
All checks were successful
Deploy Sokko G / deploy (push) Successful in 17s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 17s
This commit is contained in:
parent
e3d823d0b1
commit
055fb78d60
21 changed files with 739 additions and 168 deletions
|
|
@ -543,7 +543,7 @@
|
|||
],
|
||||
"importFormat": {
|
||||
"text": "Le format texte contient les sections [rates] et [settings]. Les taux sont exprimés en pourcentage.",
|
||||
"example": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true\n- Pen Eklecta | rate=20.02\n\n[settings]\nmultiplier: 320\nsimulationDraws: 100000\nperHour: 60\nperDay: 0\nperMonth: 0"
|
||||
"example": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true | multiplier=320 | perHour=60 | perDay=0 | perMonth=0\n- Pen Eklecta | rate=20.02 | multiplier=0 | perHour=0 | perDay=0 | perMonth=0\n\n[settings]\nsimulationDraws: 1000"
|
||||
}
|
||||
},
|
||||
"images": {
|
||||
|
|
@ -1996,7 +1996,12 @@
|
|||
"links": {
|
||||
"titlePlaceholder": "Nom du lien",
|
||||
"urlPlaceholder": "https://...",
|
||||
"categoryPlaceholder": "Catégorie",
|
||||
"addButton": "Ajouter",
|
||||
"reorderCategoryTitle": "Déplacer la catégorie",
|
||||
"showCategoryTitle": "Afficher la catégorie",
|
||||
"hideCategoryTitle": "Réduire la catégorie",
|
||||
"deleteCategoryTitle": "Supprimer la catégorie",
|
||||
"importPlaceholder": "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map",
|
||||
"importOpenButton": "Importer du texte",
|
||||
"exportOpenButton": "Exporter en format texte",
|
||||
|
|
@ -2137,7 +2142,7 @@
|
|||
"observedRateLabel": "Taux de réussite",
|
||||
"multiplierShortLabel": "Bonus",
|
||||
"longestFailStreakLabel": "Pire série",
|
||||
"importPlaceholder": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true\n- Pen Eklecta | rate=20.02\n\n[settings]\nmultiplier: 320\nsimulationDraws: 100000\nperHour: 60\nperDay: 0\nperMonth: 0",
|
||||
"importPlaceholder": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true | multiplier=320 | perHour=60 | perDay=0 | perMonth=0\n- Pen Eklecta | rate=20.02 | multiplier=0 | perHour=0 | perDay=0 | perMonth=0\n\n[settings]\nsimulationDraws: 1000",
|
||||
"importOpenButton": "Importer du texte",
|
||||
"exportOpenButton": "Exporter en format texte",
|
||||
"importModalTitle": "Importer un simulateur de chance",
|
||||
|
|
|
|||
146
website/src/components/CategoryAutocompleteInput.jsx
Normal file
146
website/src/components/CategoryAutocompleteInput.jsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// Rôle : fournit un champ NDS d'autocomplete libre pour les catégories.
|
||||
import { forwardRef, useId, useMemo, useRef, useState } from "react";
|
||||
|
||||
function normalizeOptions(options) {
|
||||
const seen = new Set();
|
||||
return (options || [])
|
||||
.map((option) => String(option || "").trim())
|
||||
.filter((option) => {
|
||||
if (!option || seen.has(option)) return false;
|
||||
seen.add(option);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export const CategoryAutocompleteInput = forwardRef(function CategoryAutocompleteInput({
|
||||
className = "nds-input",
|
||||
id,
|
||||
name,
|
||||
options,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
autoComplete = "off",
|
||||
maxOptions = 8,
|
||||
...props
|
||||
}, forwardedRef) {
|
||||
const generatedId = useId();
|
||||
const inputId = id || `category-autocomplete-${generatedId}`;
|
||||
const rootRef = useRef(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const cleanOptions = useMemo(() => normalizeOptions(options), [options]);
|
||||
const cleanValue = String(value || "");
|
||||
const filteredOptions = useMemo(() => {
|
||||
const query = cleanValue.trim().toLowerCase();
|
||||
const matches = query
|
||||
? cleanOptions.filter((option) => option.toLowerCase().includes(query))
|
||||
: cleanOptions;
|
||||
return matches.slice(0, maxOptions);
|
||||
}, [cleanOptions, cleanValue, maxOptions]);
|
||||
const menuId = `${inputId}-menu`;
|
||||
const showMenu = open && filteredOptions.length > 0;
|
||||
|
||||
function setInputRef(element) {
|
||||
if (typeof forwardedRef === "function") forwardedRef(element);
|
||||
else if (forwardedRef) forwardedRef.current = element;
|
||||
}
|
||||
|
||||
function emitChange(nextValue) {
|
||||
onChange?.({ target: { name, value: nextValue } });
|
||||
}
|
||||
|
||||
function selectOption(option) {
|
||||
emitChange(option);
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
|
||||
function handleChange(event) {
|
||||
onChange?.(event);
|
||||
setOpen(true);
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
|
||||
function handleFocus(event) {
|
||||
setOpen(true);
|
||||
onFocus?.(event);
|
||||
}
|
||||
|
||||
function handleBlur(event) {
|
||||
if (rootRef.current?.contains(event.relatedTarget)) return;
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
onBlur?.(event);
|
||||
}
|
||||
|
||||
function handleKeyDown(event) {
|
||||
if (!filteredOptions.length) {
|
||||
onKeyDown?.(event);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
setActiveIndex((index) => Math.min(index + 1, filteredOptions.length - 1));
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
setActiveIndex((index) => Math.max(index - 1, 0));
|
||||
} else if (event.key === "Enter" && open && activeIndex >= 0) {
|
||||
event.preventDefault();
|
||||
selectOption(filteredOptions[activeIndex]);
|
||||
} else if (event.key === "Escape" && open) {
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
} else {
|
||||
onKeyDown?.(event);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="nds-autocomplete-field" ref={rootRef}>
|
||||
<input
|
||||
{...props}
|
||||
ref={setInputRef}
|
||||
id={inputId}
|
||||
className={`${className} nds-category-autocomplete-input`.trim()}
|
||||
name={name}
|
||||
type="text"
|
||||
value={cleanValue}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={showMenu}
|
||||
aria-controls={menuId}
|
||||
aria-activedescendant={activeIndex >= 0 ? `${menuId}-${activeIndex}` : undefined}
|
||||
/>
|
||||
{showMenu && (
|
||||
<div className="nds-autocomplete-menu" id={menuId} role="listbox">
|
||||
{filteredOptions.map((option, index) => (
|
||||
<button
|
||||
className={`nds-autocomplete-option ${index === activeIndex ? "is-active" : ""}`}
|
||||
id={`${menuId}-${index}`}
|
||||
key={option}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => selectOption(option)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
// Rôle : fournit l'outil checklist avec quantités, catégories et échange texte.
|
||||
import { useState } from "react";
|
||||
import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js";
|
||||
import { ChecklistSection } from "./ChecklistParts.jsx";
|
||||
|
|
@ -22,6 +23,7 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc
|
|||
const [sectionTitle, setSectionTitle] = useState("");
|
||||
const [qty, setQty] = useState(1);
|
||||
const namedSections = data.sections.filter((section) => section.title);
|
||||
const categoryOptions = namedSections.map((section) => section.title);
|
||||
const completedSectionsMode = data.hideCompletedSectionsFully ? "hidden" : data.hideCompletedSections ? "reduced" : "visible";
|
||||
const reorderItems = getChecklistReorderItems(data.sections);
|
||||
const {
|
||||
|
|
@ -113,7 +115,7 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc
|
|||
{editing && (
|
||||
<div className="module-add-panel">
|
||||
<form className="inline-form checklist-add-form" onSubmit={addItem}>
|
||||
<input className="nds-input" data-size="sm" name="section" type="text" placeholder={textContent.sectionPlaceholder || "Catégorie"} value={sectionTitle} onChange={(event) => setSectionTitle(event.target.value)} />
|
||||
<CategoryAutocompleteInput className="nds-input" data-size="sm" name="section" placeholder={textContent.sectionPlaceholder || "Catégorie"} value={sectionTitle} onChange={(event) => setSectionTitle(event.target.value)} options={categoryOptions} />
|
||||
<input className="nds-input" data-size="sm" name="label" type="text" placeholder={textContent.itemPlaceholder || "Nouvel item"} value={label} onChange={(event) => setLabel(event.target.value)} />
|
||||
<input className="checklist-qty-input nds-input" data-size="sm" name="qty" type="number" min="1" value={qty} onChange={(event) => setQty(event.target.value)} aria-label={textContent.quantityLabel || "Quantité cible"} />
|
||||
<button className="nds-button" data-size="sm" data-variant="primary">{textContent.addButton || "Ajouter"}</button>
|
||||
|
|
@ -172,6 +174,7 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc
|
|||
onUpdateItem={updateItem}
|
||||
onMoveItemToCategory={moveItemToCategory}
|
||||
onDeleteItem={deleteItem}
|
||||
categoryOptions={categoryOptions}
|
||||
/>,
|
||||
showBoundaryDropZones && isLastSection ? (
|
||||
<li
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// Rôle : rend les sections et items de checklist avec édition inline et quantités.
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||
import {
|
||||
|
|
@ -8,7 +9,7 @@ import {
|
|||
keepChecklistSection
|
||||
} from "./checklistUtils.js";
|
||||
|
||||
export function ChecklistSection({ section, context, data, reorder, onRenameSection, onUpdateSection, onSaveSections, onUpdateItem, onMoveItemToCategory, onDeleteItem }) {
|
||||
export function ChecklistSection({ section, context, data, reorder, onRenameSection, onUpdateSection, onSaveSections, onUpdateItem, onMoveItemToCategory, onDeleteItem, categoryOptions = [] }) {
|
||||
const showTitle = Boolean(section.title);
|
||||
const sectionComplete = showTitle && isSectionComplete(section, context);
|
||||
const hideWhenComplete = getSectionHideWhenComplete(section, data.hideCompletedSections);
|
||||
|
|
@ -117,6 +118,7 @@ export function ChecklistSection({ section, context, data, reorder, onRenameSect
|
|||
onUpdateItem={onUpdateItem}
|
||||
onMoveItemToCategory={onMoveItemToCategory}
|
||||
onDeleteItem={onDeleteItem}
|
||||
categoryOptions={categoryOptions}
|
||||
editing={editingItemId === item.id}
|
||||
onToggleEditing={() => setEditingItemId(editingItemId === item.id ? "" : item.id)}
|
||||
onCloseEditing={() => setEditingItemId("")}
|
||||
|
|
@ -128,7 +130,7 @@ export function ChecklistSection({ section, context, data, reorder, onRenameSect
|
|||
);
|
||||
}
|
||||
|
||||
function ChecklistItem({ item, section, context, reorder, items, save, onUpdateItem, onMoveItemToCategory, onDeleteItem, editing, onToggleEditing, onCloseEditing }) {
|
||||
function ChecklistItem({ item, section, context, reorder, items, save, onUpdateItem, onMoveItemToCategory, onDeleteItem, categoryOptions, editing, onToggleEditing, onCloseEditing }) {
|
||||
const itemRef = useRef(null);
|
||||
const done = context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget;
|
||||
const textContent = context.moduleText?.checklist || {};
|
||||
|
|
@ -221,13 +223,13 @@ function ChecklistItem({ item, section, context, reorder, items, save, onUpdateI
|
|||
"aria-label": textContent.itemEditLabel || "Libellé"
|
||||
})}
|
||||
/>
|
||||
<input
|
||||
<CategoryAutocompleteInput
|
||||
{...categoryEdit.getInputProps({
|
||||
className: "checklist-category-input nds-inline-input",
|
||||
type: "text",
|
||||
placeholder: textContent.sectionPlaceholder || "Catégorie",
|
||||
"aria-label": textContent.categoryEditLabel || "Catégorie"
|
||||
})}
|
||||
options={categoryOptions}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// Rôle : rend le formulaire de création et édition des combos.
|
||||
import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { ComboInputToken, ComboSequence } from "./CombosParts.jsx";
|
||||
import {
|
||||
|
|
@ -10,6 +11,7 @@ import {
|
|||
export function CombosEditor({
|
||||
data,
|
||||
textContent,
|
||||
categoryOptions,
|
||||
comboCategory,
|
||||
comboName,
|
||||
customKey,
|
||||
|
|
@ -78,7 +80,7 @@ export function CombosEditor({
|
|||
<div className="combos-form-grid">
|
||||
<label>
|
||||
<span>{textContent.targetCategoryLabel || "Catégorie"}</span>
|
||||
<input className="nds-input" data-size="sm" type="text" value={comboCategory} onChange={(event) => onComboCategoryChange(event.target.value)} placeholder={textContent.noCategoryLabel || "Sans catégorie"} />
|
||||
<CategoryAutocompleteInput className="nds-input" data-size="sm" value={comboCategory} onChange={(event) => onComboCategoryChange(event.target.value)} placeholder={textContent.noCategoryLabel || "Sans catégorie"} options={categoryOptions} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{textContent.comboNameLabel || "Nom"}</span>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ import { useCombosReorder } from "./useCombosReorder.js";
|
|||
export function CombosModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
|
||||
const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [] }));
|
||||
const textContent = context.moduleText?.combos || {};
|
||||
const categoryOptions = [
|
||||
...data.categoryOrder,
|
||||
...data.combos.map(getComboCategory).filter(Boolean).filter((category) => !data.categoryOrder.includes(category))
|
||||
];
|
||||
const entries = getGroupedEntries(data.combos, {
|
||||
groupOrder: data.categoryOrder,
|
||||
getItemGroup: getComboCategory,
|
||||
|
|
@ -48,6 +52,7 @@ export function CombosModule({ toolboxId, moduleId, context, editing, textExchan
|
|||
<CombosEditor
|
||||
data={data}
|
||||
textContent={textContent}
|
||||
categoryOptions={categoryOptions}
|
||||
comboCategory={editorState.comboCategory}
|
||||
comboName={editorState.comboName}
|
||||
customKey={editorState.customKey}
|
||||
|
|
|
|||
|
|
@ -1,51 +1,154 @@
|
|||
// Rôle : fournit l'outil liens avec ajout manuel et échange texte.
|
||||
import { useState } from "react";
|
||||
import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
||||
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||
import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
|
||||
function getLinkCategory(link) {
|
||||
return String(link?.category || "").trim();
|
||||
}
|
||||
|
||||
function setLinkCategory(link, category) {
|
||||
const cleanCategory = String(category || "").trim();
|
||||
const nextLink = { ...link };
|
||||
if (cleanCategory) nextLink.category = cleanCategory;
|
||||
else delete nextLink.category;
|
||||
return nextLink;
|
||||
}
|
||||
|
||||
function getLinkEntries(data) {
|
||||
const ungrouped = data.links.filter((link) => !getLinkCategory(link)).map((link) => ({ type: "item", item: link }));
|
||||
const categories = data.categoryOrder.map((category) => ({
|
||||
type: "group",
|
||||
group: {
|
||||
category,
|
||||
links: data.links.filter((link) => getLinkCategory(link) === category)
|
||||
}
|
||||
}));
|
||||
return [...ungrouped, ...categories];
|
||||
}
|
||||
|
||||
export function LinksModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
|
||||
const data = context.normalizeLinksData(context.getModuleData(toolboxId, moduleId, { links: [] }));
|
||||
const textContent = context.moduleText?.links || {};
|
||||
const linkDraft = useDraftForm({ title: "", url: "" });
|
||||
const linkDraft = useDraftForm({ title: "", url: "", category: "" });
|
||||
const categoryOptions = data.categoryOrder;
|
||||
const [copiedId, setCopiedId] = useState("");
|
||||
const {
|
||||
itemReorder,
|
||||
groupReorder,
|
||||
getItemProps,
|
||||
getGroupProps,
|
||||
getGroupBoundaryProps,
|
||||
isItemDragging,
|
||||
isItemDropTarget,
|
||||
isGroupDragging,
|
||||
isGroupDropTarget,
|
||||
isBoundaryDropTarget,
|
||||
getDropPlacement
|
||||
} = useGroupedReorder({
|
||||
namespace: "links",
|
||||
namespace: `links-${moduleId}`,
|
||||
items: data.links,
|
||||
getItemId: (link) => link.id,
|
||||
getParentId: () => moduleId,
|
||||
onItemMove: (operation) => save(moveItem(data.links, operation.sourceId, operation.targetId, operation.placement)),
|
||||
hierarchy: { enabled: true, stickyParents: true }
|
||||
getItemGroup: getLinkCategory,
|
||||
reorderFeatures: {
|
||||
item: {
|
||||
groupChange: true,
|
||||
boundaryDrop: true,
|
||||
ungroupedDrop: true
|
||||
},
|
||||
group: {
|
||||
reorder: true,
|
||||
boundaryDrop: true,
|
||||
ungroupedDrop: true,
|
||||
itemDrop: true
|
||||
}
|
||||
},
|
||||
onItemMove: applyLinkReorder,
|
||||
onGroupMove: applyLinkReorder,
|
||||
hierarchy: { enabled: false, stickyParents: false }
|
||||
});
|
||||
|
||||
function save(links) {
|
||||
context.setModuleData(toolboxId, moduleId, { links });
|
||||
function save(nextData) {
|
||||
context.setModuleData(toolboxId, moduleId, nextData, "links");
|
||||
}
|
||||
|
||||
function applyLinkReorder(operation) {
|
||||
if (operation.type === "group" && data.categoryOrder.includes(operation.sourceId)) {
|
||||
const categoryOrder = data.categoryOrder.filter((category) => category !== operation.sourceId);
|
||||
const targetIndex = categoryOrder.indexOf(operation.targetId);
|
||||
if (targetIndex >= 0) {
|
||||
categoryOrder.splice(operation.placement === "after" ? targetIndex + 1 : targetIndex, 0, operation.sourceId);
|
||||
save({ ...data, categoryOrder });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const nextData = applyGroupedReorderOperation(data, {
|
||||
operation,
|
||||
itemsKey: "links",
|
||||
groupOrderKey: "categoryOrder",
|
||||
collapsedGroupsKey: "collapsedCategories",
|
||||
getItemGroup: getLinkCategory,
|
||||
setItemGroup: setLinkCategory
|
||||
});
|
||||
save({
|
||||
...nextData,
|
||||
categoryOrder: [
|
||||
...data.categoryOrder,
|
||||
...nextData.categoryOrder.filter((category) => !data.categoryOrder.includes(category))
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
function addLink(event) {
|
||||
linkDraft.handleSubmit(event, (draft, { reset }) => {
|
||||
const cleanUrl = context.normalizeUrl(draft.url);
|
||||
if (!cleanUrl) return;
|
||||
const category = draft.category.trim();
|
||||
|
||||
save([
|
||||
...data.links,
|
||||
{
|
||||
id: context.uid("link"),
|
||||
title: draft.title.trim(),
|
||||
url: cleanUrl
|
||||
if (!cleanUrl) {
|
||||
if (category && !data.categoryOrder.includes(category)) {
|
||||
save({ ...data, categoryOrder: [...data.categoryOrder, category] });
|
||||
reset();
|
||||
}
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryOrder = category && !data.categoryOrder.includes(category) ? [...data.categoryOrder, category] : data.categoryOrder;
|
||||
save({
|
||||
...data,
|
||||
categoryOrder,
|
||||
links: [
|
||||
...data.links,
|
||||
{
|
||||
id: context.uid("link"),
|
||||
title: draft.title.trim(),
|
||||
url: cleanUrl,
|
||||
...(category ? { category } : {})
|
||||
}
|
||||
]
|
||||
});
|
||||
reset();
|
||||
});
|
||||
}
|
||||
|
||||
function deleteCategory(category) {
|
||||
save({
|
||||
...data,
|
||||
categoryOrder: data.categoryOrder.filter((item) => item !== category),
|
||||
collapsedCategories: data.collapsedCategories.filter((item) => item !== category),
|
||||
links: data.links.map((link) => getLinkCategory(link) === category ? setLinkCategory(link, "") : link)
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCategoryCollapsed(category) {
|
||||
const collapsedCategories = data.collapsedCategories.includes(category)
|
||||
? data.collapsedCategories.filter((item) => item !== category)
|
||||
: [...data.collapsedCategories, category];
|
||||
save({ ...data, collapsedCategories });
|
||||
}
|
||||
|
||||
async function copyUrl(link) {
|
||||
const copied = await context.copyText(link.url);
|
||||
if (!copied) return;
|
||||
|
|
@ -61,6 +164,7 @@ export function LinksModule({ toolboxId, moduleId, context, editing, textExchang
|
|||
<form className="inline-form links-add-form" onSubmit={addLink}>
|
||||
<input {...linkDraft.getFieldProps("title", { className: "nds-input", "data-size": "sm", type: "text", placeholder: textContent.titlePlaceholder || "Nom du lien" })} />
|
||||
<input {...linkDraft.getFieldProps("url", { className: "nds-input", "data-size": "sm", type: "url", placeholder: textContent.urlPlaceholder || "https://..." })} />
|
||||
<CategoryAutocompleteInput {...linkDraft.getFieldProps("category", { className: "nds-input", "data-size": "sm", placeholder: textContent.categoryPlaceholder || "Catégorie" })} options={categoryOptions} />
|
||||
<button className="nds-button" data-size="sm" data-variant="primary">{textContent.addButton || "Ajouter"}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -70,44 +174,125 @@ export function LinksModule({ toolboxId, moduleId, context, editing, textExchang
|
|||
<TextExchangeActions type="links" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "links")} />
|
||||
</div>
|
||||
)}
|
||||
<ul className="links-list">
|
||||
{data.links.map((link) => {
|
||||
const className = [
|
||||
"link-item",
|
||||
isItemDragging(link.id) ? "is-dragging" : "",
|
||||
isItemDropTarget(link.id) ? "is-drop-target" : "",
|
||||
getDropPlacement("item", link.id) === "after" ? "drop-after" : ""
|
||||
].filter(Boolean).join(" ");
|
||||
<div className="links-category-list">
|
||||
{getLinkEntries(data).map((entry) => {
|
||||
if (entry.type === "group") {
|
||||
const category = entry.group.category;
|
||||
const isCollapsed = data.collapsedCategories.includes(category);
|
||||
const showBoundaryDropZones = Boolean(itemReorder.draggingId || groupReorder.draggingId);
|
||||
const categoryClassName = [
|
||||
"links-category-section checklist-section is-grouped",
|
||||
isCollapsed ? "is-collapsed" : "",
|
||||
isGroupDragging(category) ? "is-dragging" : "",
|
||||
isGroupDropTarget(category) ? "is-drop-target" : "",
|
||||
getDropPlacement("group", category) === "after" ? "drop-after" : ""
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<li className={className} key={link.id} {...getItemProps({ itemId: link.id, parentId: moduleId })}>
|
||||
<button
|
||||
className="link-drag-handle nds-icon-button"
|
||||
data-size="sm"
|
||||
data-variant="secondary"
|
||||
type="button"
|
||||
onPointerDown={(event) => itemReorder.startDrag(event, link.id)}
|
||||
aria-label={`${textContent.reorderTitle || "Déplacer"} ${link.title || link.url}`}
|
||||
title={textContent.reorderTitle || "Déplacer"}
|
||||
>
|
||||
<Icon name="drag" />
|
||||
</button>
|
||||
<a href={link.url} target="_blank" rel="noreferrer" title={link.url}>
|
||||
<strong>{link.title || context.hostnameFromUrl(link.url)}</strong>
|
||||
<span>{link.url}</span>
|
||||
</a>
|
||||
<div>
|
||||
<button type="button" className="nds-icon-button" data-size="sm" onClick={() => copyUrl(link)} aria-label={`${textContent.copyTitle || "Copier"} ${link.title || link.url}`} title={copiedId === link.id ? textContent.copiedTitle || "Copié" : textContent.copyTitle || "Copier"}>
|
||||
<Icon name="copy" />
|
||||
</button>
|
||||
<button type="button" className="nds-icon-button" data-size="sm" data-variant="danger" onClick={() => save(data.links.filter((item) => item.id !== link.id))} aria-label={`${textContent.deleteTitle || "Supprimer"} ${link.title || link.url}`} title={textContent.deleteTitle || "Supprimer"}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
return (
|
||||
<div className="links-category-block" key={`category:${category}`}>
|
||||
{showBoundaryDropZones && (
|
||||
<div
|
||||
className={`links-category-boundary-drop-zone ${isBoundaryDropTarget({ groupId: category, placement: "before" }) ? "is-drop-target" : ""}`}
|
||||
{...getGroupBoundaryProps({ groupId: category, placement: "before" })}
|
||||
/>
|
||||
)}
|
||||
<section className={categoryClassName} {...getGroupProps({ groupId: category })}>
|
||||
<div className="checklist-section-header links-category-header">
|
||||
<div className="links-category-title">
|
||||
<button
|
||||
className="task-planner-category-drag-handle nds-icon-button"
|
||||
data-size="xs"
|
||||
data-variant="secondary"
|
||||
type="button"
|
||||
onPointerDown={(event) => groupReorder.startDrag(event, { groupId: category })}
|
||||
aria-label={`${textContent.reorderCategoryTitle || "Déplacer la catégorie"} ${category}`}
|
||||
title={textContent.reorderCategoryTitle || "Déplacer la catégorie"}
|
||||
>
|
||||
<Icon name="drag" />
|
||||
</button>
|
||||
<h3>{category}</h3>
|
||||
</div>
|
||||
<div className="checklist-section-actions links-category-actions">
|
||||
<span>{entry.group.links.length}</span>
|
||||
<button
|
||||
className="checklist-section-collapse-button nds-icon-button"
|
||||
data-size="xs"
|
||||
type="button"
|
||||
onClick={() => toggleCategoryCollapsed(category)}
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-label={isCollapsed ? textContent.showCategoryTitle || "Afficher la catégorie" : textContent.hideCategoryTitle || "Réduire la catégorie"}
|
||||
title={isCollapsed ? textContent.showCategoryTitle || "Afficher la catégorie" : textContent.hideCategoryTitle || "Réduire la catégorie"}
|
||||
>
|
||||
<Icon name={isCollapsed ? "chevron-down" : "chevron-up"} />
|
||||
</button>
|
||||
<button
|
||||
className="nds-icon-button"
|
||||
data-size="xs"
|
||||
data-variant="danger"
|
||||
type="button"
|
||||
onClick={() => deleteCategory(category)}
|
||||
aria-label={`${textContent.deleteCategoryTitle || "Supprimer la catégorie"} ${category}`}
|
||||
title={textContent.deleteCategoryTitle || "Supprimer la catégorie"}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="links-list">
|
||||
{entry.group.links.map(renderLink)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{showBoundaryDropZones && (
|
||||
<div
|
||||
className={`links-category-boundary-drop-zone ${isBoundaryDropTarget({ groupId: category, placement: "after" }) ? "is-drop-target" : ""}`}
|
||||
{...getGroupBoundaryProps({ groupId: category, placement: "after" })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return renderLink(entry.item);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
function renderLink(link) {
|
||||
const className = [
|
||||
"link-item",
|
||||
isItemDragging(link.id) ? "is-dragging" : "",
|
||||
isItemDropTarget(link.id) ? "is-drop-target" : "",
|
||||
getDropPlacement("item", link.id) === "after" ? "drop-after" : ""
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<article className={className} key={link.id} {...getItemProps({ itemId: link.id, groupId: getLinkCategory(link) })}>
|
||||
<button
|
||||
className="link-drag-handle nds-icon-button"
|
||||
data-size="sm"
|
||||
data-variant="secondary"
|
||||
type="button"
|
||||
onPointerDown={(event) => itemReorder.startDrag(event, link.id)}
|
||||
aria-label={`${textContent.reorderTitle || "Déplacer"} ${link.title || link.url}`}
|
||||
title={textContent.reorderTitle || "Déplacer"}
|
||||
>
|
||||
<Icon name="drag" />
|
||||
</button>
|
||||
<a href={link.url} target="_blank" rel="noreferrer" title={link.url}>
|
||||
<strong>{link.title || context.hostnameFromUrl(link.url)}</strong>
|
||||
<span>{link.url}</span>
|
||||
</a>
|
||||
<div>
|
||||
<button type="button" className="nds-icon-button" data-size="sm" onClick={() => copyUrl(link)} aria-label={`${textContent.copyTitle || "Copier"} ${link.title || link.url}`} title={copiedId === link.id ? textContent.copiedTitle || "Copié" : textContent.copyTitle || "Copier"}>
|
||||
<Icon name="copy" />
|
||||
</button>
|
||||
<button type="button" className="nds-icon-button" data-size="sm" data-variant="danger" onClick={() => save({ ...data, links: data.links.filter((item) => item.id !== link.id) })} aria-label={`${textContent.deleteTitle || "Supprimer"} ${link.title || link.url}`} title={textContent.deleteTitle || "Supprimer"}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,12 +104,24 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
|
|||
const [occurrenceDrafts, setOccurrenceDrafts] = useState({ perHour: null, perDay: null, perMonth: null });
|
||||
const rateDraft = useDraftForm({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
|
||||
const activeRate = data.rates.find((rate) => rate.id === data.activeRateId) || data.rates[0] || null;
|
||||
const summary = useMemo(() => activeRate ? calculateRngSummary({ rate: activeRate.rate, ...data.settings }) : null, [activeRate, data.settings]);
|
||||
const summary = useMemo(() => activeRate ? calculateRngSummary({
|
||||
rate: activeRate.rate,
|
||||
multiplier: activeRate.multiplier,
|
||||
simulationDraws: data.settings.simulationDraws,
|
||||
perHour: activeRate.perHour,
|
||||
perDay: activeRate.perDay,
|
||||
perMonth: activeRate.perMonth
|
||||
}) : null, [activeRate, data.settings.simulationDraws]);
|
||||
|
||||
useEffect(() => {
|
||||
setHistory(readSimulationHistory(historyStorageKey));
|
||||
}, [historyStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setMultiplierDraft(null);
|
||||
setOccurrenceDrafts({ perHour: null, perDay: null, perMonth: null });
|
||||
}, [activeRate?.id]);
|
||||
|
||||
function save(nextData) {
|
||||
context.setModuleData(toolboxId, moduleId, nextData, "rngSimulator");
|
||||
}
|
||||
|
|
@ -118,21 +130,28 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
|
|||
const settings = { ...data.settings };
|
||||
if (field === "simulationDraws") {
|
||||
settings[field] = clampInteger(value, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, data.settings.simulationDraws);
|
||||
} else if (field === "multiplier") {
|
||||
settings[field] = clampDecimal(value, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0);
|
||||
} else {
|
||||
settings[field] = clampDecimal(value, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, data.settings[field]);
|
||||
}
|
||||
save({ ...data, settings });
|
||||
}
|
||||
|
||||
function updateActiveRateSettings(field, value) {
|
||||
if (!activeRate) return;
|
||||
const nextValue = field === "multiplier"
|
||||
? clampDecimal(value, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0)
|
||||
: clampDecimal(value, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, activeRate[field]);
|
||||
save({
|
||||
...data,
|
||||
rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, [field]: nextValue } : rate)
|
||||
});
|
||||
}
|
||||
|
||||
function updateMultiplier(value) {
|
||||
setMultiplierDraft(value);
|
||||
if (String(value).trim() === "") {
|
||||
updateSettings("multiplier", 0);
|
||||
updateActiveRateSettings("multiplier", 0);
|
||||
return;
|
||||
}
|
||||
updateSettings("multiplier", value);
|
||||
updateActiveRateSettings("multiplier", value);
|
||||
}
|
||||
|
||||
function blurMultiplier() {
|
||||
|
|
@ -141,7 +160,7 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
|
|||
const parsed = Number(raw.replace(",", "."));
|
||||
if (!raw || !Number.isFinite(parsed)) {
|
||||
setMultiplierDraft(null);
|
||||
updateSettings("multiplier", 0);
|
||||
updateActiveRateSettings("multiplier", 0);
|
||||
return;
|
||||
}
|
||||
setMultiplierDraft(null);
|
||||
|
|
@ -149,16 +168,16 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
|
|||
|
||||
function clearMultiplier() {
|
||||
setMultiplierDraft(null);
|
||||
updateSettings("multiplier", 0);
|
||||
updateActiveRateSettings("multiplier", 0);
|
||||
}
|
||||
|
||||
function updateOccurrence(field, value) {
|
||||
setOccurrenceDrafts((drafts) => ({ ...drafts, [field]: value }));
|
||||
if (String(value).trim() === "") {
|
||||
updateSettings(field, 0);
|
||||
updateActiveRateSettings(field, 0);
|
||||
return;
|
||||
}
|
||||
updateSettings(field, value);
|
||||
updateActiveRateSettings(field, value);
|
||||
}
|
||||
|
||||
function blurOccurrence(field) {
|
||||
|
|
@ -166,7 +185,7 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
|
|||
if (value === null) return;
|
||||
const raw = value.trim();
|
||||
const parsed = Number(raw.replace(",", "."));
|
||||
if (!raw || !Number.isFinite(parsed)) updateSettings(field, 0);
|
||||
if (!raw || !Number.isFinite(parsed)) updateActiveRateSettings(field, 0);
|
||||
setOccurrenceDrafts((drafts) => ({ ...drafts, [field]: null }));
|
||||
}
|
||||
|
||||
|
|
@ -207,12 +226,12 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
|
|||
|
||||
function runSimulation() {
|
||||
if (!activeRate) return;
|
||||
const result = simulateRngDraws(activeRate.rate, data.settings.simulationDraws, Math.random, data.settings.multiplier);
|
||||
const result = simulateRngDraws(activeRate.rate, data.settings.simulationDraws, Math.random, activeRate.multiplier);
|
||||
const entry = {
|
||||
id: context.uid("rng"),
|
||||
label: activeRate.label,
|
||||
rate: activeRate.rate,
|
||||
multiplier: data.settings.multiplier,
|
||||
multiplier: activeRate.multiplier,
|
||||
...result
|
||||
};
|
||||
const nextHistory = addRngSimulationHistory(history, entry);
|
||||
|
|
@ -302,7 +321,7 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
|
|||
<div className="rng-simulator-field rng-simulator-multiplier-field">
|
||||
<span>{textContent.multiplierLabel || "Multiplicateur"}</span>
|
||||
<div className="rng-simulator-percent-input">
|
||||
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={multiplierDraft ?? String(data.settings.multiplier ?? 0)} onChange={(event) => updateMultiplier(event.target.value)} onBlur={blurMultiplier} aria-label={textContent.multiplierLabel || "Multiplicateur"} />
|
||||
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={multiplierDraft ?? String(activeRate.multiplier ?? 0)} onChange={(event) => updateMultiplier(event.target.value)} onBlur={blurMultiplier} aria-label={textContent.multiplierLabel || "Multiplicateur"} />
|
||||
<span>%</span>
|
||||
<button className="nds-icon-button" data-size="xs" type="button" onClick={clearMultiplier} aria-label={textContent.clearMultiplierButton || "Réinitialiser le multiplicateur"} title={textContent.clearMultiplierButton || "Réinitialiser le multiplicateur"}>
|
||||
<Icon name="rubber" />
|
||||
|
|
@ -320,15 +339,15 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
|
|||
<div className="rng-simulator-settings rng-simulator-three-columns">
|
||||
<label className="rng-simulator-field">
|
||||
<span>{textContent.perHourLabel || "Par heure"}</span>
|
||||
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDrafts.perHour ?? String(data.settings.perHour ?? 0)} onChange={(event) => updateOccurrence("perHour", event.target.value)} onBlur={() => blurOccurrence("perHour")} />
|
||||
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDrafts.perHour ?? String(activeRate.perHour ?? 0)} onChange={(event) => updateOccurrence("perHour", event.target.value)} onBlur={() => blurOccurrence("perHour")} />
|
||||
</label>
|
||||
<label className="rng-simulator-field">
|
||||
<span>{textContent.perDayLabel || "Par jour"}</span>
|
||||
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDrafts.perDay ?? String(data.settings.perDay ?? 0)} onChange={(event) => updateOccurrence("perDay", event.target.value)} onBlur={() => blurOccurrence("perDay")} />
|
||||
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDrafts.perDay ?? String(activeRate.perDay ?? 0)} onChange={(event) => updateOccurrence("perDay", event.target.value)} onBlur={() => blurOccurrence("perDay")} />
|
||||
</label>
|
||||
<label className="rng-simulator-field">
|
||||
<span>{textContent.perMonthLabel || "Par mois"}</span>
|
||||
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDrafts.perMonth ?? String(data.settings.perMonth ?? 0)} onChange={(event) => updateOccurrence("perMonth", event.target.value)} onBlur={() => blurOccurrence("perMonth")} />
|
||||
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDrafts.perMonth ?? String(activeRate.perMonth ?? 0)} onChange={(event) => updateOccurrence("perMonth", event.target.value)} onBlur={() => blurOccurrence("perMonth")} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="rng-simulator-time-grid">
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ import { useTaskPlannerReorder } from "./useTaskPlannerReorder.js";
|
|||
export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
|
||||
const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] }));
|
||||
const textContent = context.moduleText?.taskPlanner || {};
|
||||
const taskDraft = useDraftForm({ title: "", type: "daily" });
|
||||
const taskDraft = useDraftForm({ title: "", type: "daily", category: "" });
|
||||
const categoryOptions = useMemo(() => [
|
||||
...data.categoryOrder,
|
||||
...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());
|
||||
|
|
@ -45,19 +49,20 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textE
|
|||
taskDraft.handleSubmit(event, (draft, { reset }) => {
|
||||
const cleanTitle = draft.title.trim();
|
||||
if (!cleanTitle) return;
|
||||
const category = draft.category.trim();
|
||||
const task = {
|
||||
id: context.uid("task"),
|
||||
title: cleanTitle,
|
||||
description: "",
|
||||
type: draft.type,
|
||||
checked: false,
|
||||
checkedAt: 0
|
||||
};
|
||||
if (category) task.category = category;
|
||||
save({
|
||||
...data,
|
||||
tasks: [
|
||||
...data.tasks,
|
||||
{
|
||||
id: context.uid("task"),
|
||||
title: cleanTitle,
|
||||
description: "",
|
||||
type: draft.type,
|
||||
checked: false,
|
||||
checkedAt: 0
|
||||
}
|
||||
]
|
||||
tasks: [...data.tasks, task],
|
||||
categoryOrder: category && !data.categoryOrder.includes(category) ? [...data.categoryOrder, category] : data.categoryOrder
|
||||
});
|
||||
reset();
|
||||
});
|
||||
|
|
@ -171,6 +176,7 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textE
|
|||
context={context}
|
||||
editing={editing}
|
||||
settingsOpen={settingsOpen}
|
||||
categoryOptions={categoryOptions}
|
||||
taskDraft={taskDraft}
|
||||
textContent={textContent}
|
||||
textExchangeOpen={textExchangeOpen}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// Rôle : rend la barre haute du task planner, l'ajout rapide et l'échange texte.
|
||||
import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
import {
|
||||
|
|
@ -11,6 +12,7 @@ export function TaskPlannerTopbar({
|
|||
context,
|
||||
editing,
|
||||
settingsOpen,
|
||||
categoryOptions,
|
||||
taskDraft,
|
||||
textContent,
|
||||
textExchangeOpen,
|
||||
|
|
@ -51,6 +53,7 @@ export function TaskPlannerTopbar({
|
|||
<div className="task-planner-add-stack">
|
||||
<form className="inline-form task-planner-add-form" onSubmit={onAddTask}>
|
||||
<input {...taskDraft.getFieldProps("title", { className: "nds-input", "data-size": "sm", type: "text", placeholder: textContent.titlePlaceholder || "Nouvelle tâche" })} />
|
||||
<CategoryAutocompleteInput {...taskDraft.getFieldProps("category", { className: "nds-input", "data-size": "sm", placeholder: textContent.categoryPlaceholder || "Nom de catégorie" })} options={categoryOptions} />
|
||||
<select {...taskDraft.getFieldProps("type", { className: "nds-select", "data-size": "sm", "aria-label": textContent.typeLabel || "Type" })}>
|
||||
{TASK_TYPES.map((taskType) => <option key={taskType} value={taskType}>{getTaskTypeLabel(taskType, textContent)}</option>)}
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Rôle : gère l'import/export texte des outils simples et tabulaires.
|
||||
import { cellAddress, columnIndexToName } from "../tableFormulaEngine.js";
|
||||
import { evaluateCalculatorEntries } from "../calculatorUtils.js";
|
||||
import { cloneDrawingData, lineError, parseBoolean, parseColonImportLines, parseInteger, parseMetaLine, parseNumber, splitLines, textToNotepadExchangeHtml } from "./shared.js";
|
||||
import { cloneDrawingData, lineError, parseBoolean, parseColonImportLines, parseInteger, parseMetaLine, parseNumber, parsePipeParts, splitLines, textToNotepadExchangeHtml } from "./shared.js";
|
||||
|
||||
export function exportNotepadText(data) {
|
||||
const pages = Array.isArray(data?.pages) && data.pages.length ? data.pages : [data];
|
||||
|
|
@ -171,23 +171,45 @@ export function importChecklistText(text, context) {
|
|||
|
||||
|
||||
export function exportLinksText(data) {
|
||||
return (data.links || []).map((link) => link.title ? `${link.title}: ${link.url}` : link.url).join("\n");
|
||||
const lines = [];
|
||||
(data.links || []).filter((link) => !link.category).forEach((link) => {
|
||||
const line = link.title ? `${link.title}: ${link.url}` : link.url;
|
||||
lines.push(line);
|
||||
});
|
||||
(data.categoryOrder || []).forEach((category) => {
|
||||
lines.push(`# ${category}`);
|
||||
(data.links || []).filter((link) => link.category === category).forEach((link) => {
|
||||
lines.push(link.title ? `${link.title}: ${link.url}` : link.url);
|
||||
});
|
||||
});
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
|
||||
export function importLinksText(text, context) {
|
||||
const links = [];
|
||||
const categoryOrder = [];
|
||||
let category = "";
|
||||
splitLines(text).forEach((rawLine, index) => {
|
||||
const line = rawLine.trim();
|
||||
if (!line) return;
|
||||
const separatorIndex = line.indexOf(":");
|
||||
const maybeUrl = separatorIndex >= 0 ? line.slice(separatorIndex + 1).trim() : line;
|
||||
const title = separatorIndex >= 0 && !/^[a-z][a-z0-9+.-]*:\/\//i.test(line) ? line.slice(0, separatorIndex).trim() : "";
|
||||
const url = context.normalizeUrl(title ? maybeUrl : line);
|
||||
if (line.startsWith("#")) {
|
||||
category = line.replace(/^#+/, "").trim();
|
||||
if (category && !categoryOrder.includes(category)) categoryOrder.push(category);
|
||||
return;
|
||||
}
|
||||
const parsed = parsePipeParts(line);
|
||||
const entryLine = parsed.title;
|
||||
const separatorIndex = entryLine.indexOf(":");
|
||||
const maybeUrl = separatorIndex >= 0 ? entryLine.slice(separatorIndex + 1).trim() : entryLine;
|
||||
const title = separatorIndex >= 0 && !/^[a-z][a-z0-9+.-]*:\/\//i.test(entryLine) ? entryLine.slice(0, separatorIndex).trim() : "";
|
||||
const url = context.normalizeUrl(title ? maybeUrl : entryLine);
|
||||
if (!url) throw lineError(index, `URL invalide: ${line}`);
|
||||
links.push({ id: context.uid("link"), title, url });
|
||||
const linkCategory = parsed.meta.category || category;
|
||||
if (linkCategory && !categoryOrder.includes(linkCategory)) categoryOrder.push(linkCategory);
|
||||
links.push({ id: context.uid("link"), title, url, ...(linkCategory ? { category: linkCategory } : {}) });
|
||||
});
|
||||
return context.normalizeLinksData({ links });
|
||||
return context.normalizeLinksData({ links, categoryOrder });
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,16 +6,12 @@ export function exportRngSimulatorText(data) {
|
|||
lines.push("@activeRateId: " + (data.activeRateId || ""));
|
||||
lines.push("[rates]");
|
||||
(data.rates || []).forEach((rate) => {
|
||||
lines.push(`- ${rate.label} | rate=${rate.rate}${rate.id === data.activeRateId ? " | active=true" : ""}`);
|
||||
lines.push(`- ${rate.label} | rate=${rate.rate}${rate.id === data.activeRateId ? " | active=true" : ""} | multiplier=${rate.multiplier ?? 0} | perHour=${rate.perHour || 0} | perDay=${rate.perDay || 0} | perMonth=${rate.perMonth || 0}`);
|
||||
});
|
||||
lines.push(
|
||||
"",
|
||||
"[settings]",
|
||||
`multiplier: ${data.settings?.multiplier ?? 0}`,
|
||||
`simulationDraws: ${data.settings?.simulationDraws || 1000}`,
|
||||
`perHour: ${data.settings?.perHour || 0}`,
|
||||
`perDay: ${data.settings?.perDay || 0}`,
|
||||
`perMonth: ${data.settings?.perMonth || 0}`
|
||||
`simulationDraws: ${data.settings?.simulationDraws || 1000}`
|
||||
);
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
|
@ -24,7 +20,7 @@ export function importRngSimulatorText(text, context) {
|
|||
const data = {
|
||||
activeRateId: "",
|
||||
rates: [],
|
||||
settings: { multiplier: 0, simulationDraws: 1000, perHour: 0, perDay: 0, perMonth: 0 }
|
||||
settings: { simulationDraws: 1000 }
|
||||
};
|
||||
let section = "";
|
||||
|
||||
|
|
@ -44,18 +40,22 @@ export function importRngSimulatorText(text, context) {
|
|||
if (separatorIndex > 0 && !cleanLine.startsWith("-")) {
|
||||
const key = cleanLine.slice(0, separatorIndex).trim();
|
||||
const value = cleanLine.slice(separatorIndex + 1).trim();
|
||||
if (section === "settings" && key === "multiplier") data.settings.multiplier = value;
|
||||
if (section === "settings" && key === "simulationDraws") data.settings.simulationDraws = parseInteger(value, 1000);
|
||||
if (section === "settings" && key === "perHour") data.settings.perHour = value;
|
||||
if (section === "settings" && key === "perDay") data.settings.perDay = value;
|
||||
if (section === "settings" && key === "perMonth") data.settings.perMonth = value;
|
||||
return;
|
||||
}
|
||||
if (section === "rates" && cleanLine.startsWith("-")) {
|
||||
const { title, meta: pipeMeta } = parsePipeParts(cleanLine.replace(/^[-*]\s*/, ""));
|
||||
if (!title) throw lineError(index, "libellé de taux manquant.");
|
||||
const id = context.uid("rng");
|
||||
data.rates.push({ id, label: title, rate: pipeMeta.rate });
|
||||
data.rates.push({
|
||||
id,
|
||||
label: title,
|
||||
rate: pipeMeta.rate,
|
||||
multiplier: pipeMeta.multiplier,
|
||||
perHour: pipeMeta.perHour,
|
||||
perDay: pipeMeta.perDay,
|
||||
perMonth: pipeMeta.perMonth
|
||||
});
|
||||
if (pipeMeta.active === "true") data.activeRateId = id;
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,18 +23,33 @@ export function hostnameFromUrl(value) {
|
|||
}
|
||||
|
||||
export function normalizeLinksData(data) {
|
||||
const links = (data?.links || [])
|
||||
.map((link) => {
|
||||
const url = normalizeUrl(link?.url);
|
||||
if (!url) return null;
|
||||
const category = String(link?.category || "").trim();
|
||||
return {
|
||||
id: link?.id || uid("link"),
|
||||
title: String(link?.title || "").trim(),
|
||||
url,
|
||||
...(category ? { category } : {})
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
const linkCategories = [...new Set(links.map((link) => link.category).filter(Boolean))];
|
||||
const categoryOrder = [
|
||||
...(Array.isArray(data?.categoryOrder) ? data.categoryOrder : [])
|
||||
.map((category) => String(category || "").trim())
|
||||
.filter((category, index, categories) => category && categories.indexOf(category) === index),
|
||||
...linkCategories
|
||||
].filter((category, index, categories) => categories.indexOf(category) === index);
|
||||
|
||||
return {
|
||||
links: (data?.links || [])
|
||||
.map((link) => {
|
||||
const url = normalizeUrl(link?.url);
|
||||
if (!url) return null;
|
||||
return {
|
||||
id: link?.id || uid("link"),
|
||||
title: String(link?.title || "").trim(),
|
||||
url
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
categoryOrder,
|
||||
collapsedCategories: (Array.isArray(data?.collapsedCategories) ? data.collapsedCategories : [])
|
||||
.map((category) => String(category || "").trim())
|
||||
.filter((category, index, categories) => category && categoryOrder.includes(category) && categories.indexOf(category) === index),
|
||||
links
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +57,13 @@ export function compactLinksDataForStorage(value) {
|
|||
const links = normalizeLinksData(value).links.map((link) => {
|
||||
const compact = { id: link.id, url: link.url };
|
||||
if (link.title) compact.title = link.title;
|
||||
if (link.category) compact.category = link.category;
|
||||
return compact;
|
||||
});
|
||||
return links.length ? { links } : null;
|
||||
const normalized = normalizeLinksData(value);
|
||||
const compact = {};
|
||||
if (normalized.categoryOrder.length) compact.categoryOrder = normalized.categoryOrder;
|
||||
if (normalized.collapsedCategories.length) compact.collapsedCategories = normalized.collapsedCategories;
|
||||
if (links.length) compact.links = links;
|
||||
return Object.keys(compact).length ? compact : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,17 +13,17 @@ function normalizeRateEntry(entry) {
|
|||
return {
|
||||
id: entry?.id || uid("rng"),
|
||||
label,
|
||||
rate: normalizeRateString(entry?.rate)
|
||||
rate: normalizeRateString(entry?.rate),
|
||||
multiplier: clampDecimal(entry?.multiplier, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0),
|
||||
perHour: clampDecimal(entry?.perHour, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0),
|
||||
perDay: clampDecimal(entry?.perDay, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0),
|
||||
perMonth: clampDecimal(entry?.perMonth, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSettings(settings) {
|
||||
return {
|
||||
multiplier: clampDecimal(settings?.multiplier, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0),
|
||||
simulationDraws: clampInteger(settings?.simulationDraws, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, 1000),
|
||||
perHour: clampDecimal(settings?.perHour, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0),
|
||||
perDay: clampDecimal(settings?.perDay, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0),
|
||||
perMonth: clampDecimal(settings?.perMonth, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0)
|
||||
simulationDraws: clampInteger(settings?.simulationDraws, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, 1000)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -43,13 +43,16 @@ export function compactRngSimulatorDataForStorage(value) {
|
|||
const normalized = normalizeRngSimulatorData(value);
|
||||
const compact = {};
|
||||
if (normalized.activeRateId) compact.activeRateId = normalized.activeRateId;
|
||||
if (normalized.rates.length) compact.rates = normalized.rates;
|
||||
if (
|
||||
normalized.settings.simulationDraws !== 1000 ||
|
||||
normalized.settings.multiplier !== 0 ||
|
||||
normalized.settings.perHour !== 0 ||
|
||||
normalized.settings.perDay !== 0 ||
|
||||
normalized.settings.perMonth !== 0
|
||||
) compact.settings = normalized.settings;
|
||||
if (normalized.rates.length) {
|
||||
compact.rates = normalized.rates.map((rate) => {
|
||||
const compactRate = { id: rate.id, label: rate.label, rate: rate.rate };
|
||||
if (rate.multiplier !== 0) compactRate.multiplier = rate.multiplier;
|
||||
if (rate.perHour !== 0) compactRate.perHour = rate.perHour;
|
||||
if (rate.perDay !== 0) compactRate.perDay = rate.perDay;
|
||||
if (rate.perMonth !== 0) compactRate.perMonth = rate.perMonth;
|
||||
return compactRate;
|
||||
});
|
||||
}
|
||||
if (normalized.settings.simulationDraws !== 1000) compact.settings = normalized.settings;
|
||||
return Object.keys(compact).length ? compact : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ export function useToolboxAlerts({ toolboxes, moduleData, links, route, drawerGa
|
|||
|
||||
const currentOccurrence = getCurrentCalendarOccurrence(event, nowMs);
|
||||
if (!currentOccurrence) return;
|
||||
if (!getCalendarAlertOffsets(event).includes(0)) return;
|
||||
const resumeKey = `${key}:${currentOccurrence.startMs}`;
|
||||
if (resumedCalendarEventsRef.current.has(resumeKey)) return;
|
||||
resumedCalendarEventsRef.current.add(resumeKey);
|
||||
|
|
|
|||
|
|
@ -105,3 +105,57 @@
|
|||
.nds-inline-input:focus {
|
||||
@include controls.inline-field-hover;
|
||||
}
|
||||
|
||||
.nds-autocomplete-field {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nds-autocomplete-field,
|
||||
.nds-autocomplete-field > input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nds-category-autocomplete-input::-webkit-calendar-picker-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.nds-autocomplete-menu {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
max-height: 220px;
|
||||
padding: 6px;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(165, 180, 252, 0.18);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(10, 13, 28, 0.98);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.nds-autocomplete-option {
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font: inherit;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 800;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nds-autocomplete-option:hover,
|
||||
.nds-autocomplete-option.is-active {
|
||||
border-color: rgba(246, 196, 83, 0.36);
|
||||
background: rgba(246, 196, 83, 0.1);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -301,18 +301,10 @@
|
|||
}
|
||||
|
||||
.links-add-form {
|
||||
grid-template-columns: minmax(120px, 0.7fr) minmax(180px, 1fr) auto;
|
||||
grid-template-columns: minmax(120px, 0.6fr) minmax(180px, 1fr) minmax(120px, 0.46fr) auto;
|
||||
}
|
||||
|
||||
.links-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: var(--space-4);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.module-add-panel + .links-list,
|
||||
.module-add-panel + .links-category-list,
|
||||
.module-add-panel + .checklist,
|
||||
.module-add-panel + .task-planner-list,
|
||||
.module-add-panel + .counters-grid {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,77 @@
|
|||
// Rôle : gère l’outil liens et ses actions de liste.
|
||||
.links-category-list,
|
||||
.links-category-block,
|
||||
.links-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.links-category-list {
|
||||
margin: var(--space-4);
|
||||
}
|
||||
|
||||
.links-category-section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(246, 196, 83, 0.16);
|
||||
border-radius: var(--radius-md);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(246, 196, 83, 0.04), transparent 42%),
|
||||
rgba(7, 10, 24, 0.18);
|
||||
}
|
||||
|
||||
.links-category-header {
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.links-category-title {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.links-category-title h3 {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--color-accent-gold);
|
||||
font-size: var(--font-size-sm);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.links-category-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.links-category-actions > span {
|
||||
flex: 0 0 auto;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid rgba(246, 196, 83, 0.22);
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(246, 196, 83, 0.08);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.links-category-boundary-drop-zone {
|
||||
min-height: 10px;
|
||||
border: 1px dashed rgba(165, 180, 252, 0.16);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.links-category-boundary-drop-zone.is-drop-target {
|
||||
border-color: rgba(246, 196, 83, 0.72);
|
||||
background: rgba(246, 196, 83, 0.08);
|
||||
box-shadow: inset 0 0 0 1px rgba(246, 196, 83, 0.24);
|
||||
}
|
||||
|
||||
.link-item {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) auto;
|
||||
|
|
@ -10,6 +83,18 @@
|
|||
background: rgba(7, 10, 24, 0.42);
|
||||
}
|
||||
|
||||
.link-item.is-dragging,
|
||||
.links-category-section.is-dragging {
|
||||
cursor: grabbing;
|
||||
opacity: 0.58;
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.link-item.is-drop-target,
|
||||
.links-category-section.is-drop-target {
|
||||
border-color: rgba(246, 196, 83, 0.72);
|
||||
}
|
||||
|
||||
.link-drag-handle {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
|
|
|
|||
|
|
@ -119,5 +119,5 @@
|
|||
}
|
||||
|
||||
.task-planner-add-form {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(120px, 0.36fr) auto;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(120px, 0.36fr) minmax(120px, 0.32fr) auto;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue