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
|
|
@ -686,10 +686,13 @@ Type : `links`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"categoryOrder": ["Builds", "Guides"],
|
||||||
|
"collapsedCategories": ["Guides"],
|
||||||
"links": [
|
"links": [
|
||||||
{
|
{
|
||||||
"id": "link1",
|
"id": "link1",
|
||||||
"title": "Build rogue",
|
"title": "Build rogue",
|
||||||
|
"category": "Builds",
|
||||||
"url": "https://example.com/build"
|
"url": "https://example.com/build"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -700,7 +703,11 @@ Stockage compact :
|
||||||
|
|
||||||
- `title` est facultatif.
|
- `title` est facultatif.
|
||||||
- `title` est omis si vide.
|
- `title` est omis si vide.
|
||||||
|
- `category` est facultatif et sert à l'autocomplete de création.
|
||||||
|
- `category` est omis si vide.
|
||||||
- `url` est obligatoire.
|
- `url` est obligatoire.
|
||||||
|
- `categoryOrder` stocke l'ordre d'affichage des catégories, y compris les catégories vides.
|
||||||
|
- `collapsedCategories` stocke les noms des catégories repliées.
|
||||||
|
|
||||||
## Outil Compteurs
|
## Outil Compteurs
|
||||||
|
|
||||||
|
|
@ -811,7 +818,11 @@ Type : `rngSimulator`
|
||||||
{
|
{
|
||||||
"id": "rate1",
|
"id": "rate1",
|
||||||
"label": "Drop rare",
|
"label": "Drop rare",
|
||||||
"rate": "2.500"
|
"rate": "2.500",
|
||||||
|
"multiplier": 50,
|
||||||
|
"perHour": 30,
|
||||||
|
"perDay": 120,
|
||||||
|
"perMonth": 3600
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "rate2",
|
"id": "rate2",
|
||||||
|
|
@ -820,11 +831,7 @@ Type : `rngSimulator`
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"settings": {
|
"settings": {
|
||||||
"multiplier": 50,
|
"simulationDraws": 1000
|
||||||
"simulationDraws": 1000,
|
|
||||||
"perHour": 30,
|
|
||||||
"perDay": 120,
|
|
||||||
"perMonth": 3600
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -835,10 +842,11 @@ Stockage compact :
|
||||||
- `rate` est une chaîne en pourcentage afin de conserver la précision saisie, par exemple `"2.500"` ou `"0.000004"`.
|
- `rate` est une chaîne en pourcentage afin de conserver la précision saisie, par exemple `"2.500"` ou `"0.000004"`.
|
||||||
- un taux doit être strictement supérieur à `0` et inférieur ou égal à `100`; les valeurs supérieures sont bornées à `"100"`.
|
- un taux doit être strictement supérieur à `0` et inférieur ou égal à `100`; les valeurs supérieures sont bornées à `"100"`.
|
||||||
- `activeRateId` référence un taux existant; sinon le premier taux est sélectionné.
|
- `activeRateId` référence un taux existant; sinon le premier taux est sélectionné.
|
||||||
- `settings.multiplier` est un bonus ou malus en pourcentage appliqué au taux actif pour les calculs et simulations, vaut 0 par défaut et le taux effectif est borné entre 0% et 100%.
|
- `rates[].multiplier` est un bonus ou malus en pourcentage associé au taux, vaut 0 par défaut et le taux effectif est borné entre 0% et 100%.
|
||||||
|
- `rates[].perHour`, `rates[].perDay` et `rates[].perMonth` sont numériques, positifs ou nuls, associés au taux, et valent 0 par défaut.
|
||||||
- `settings.simulationDraws` est borné de 1 à 1 000 000 et vaut 1000 par défaut.
|
- `settings.simulationDraws` est borné de 1 à 1 000 000 et vaut 1000 par défaut.
|
||||||
- `settings.perHour`, `settings.perDay` et `settings.perMonth` sont numériques, positifs ou nuls, et valent 0 par défaut.
|
- `settings` est omis si `simulationDraws` vaut sa valeur par défaut.
|
||||||
- `settings` est omis si tous ses champs ont leurs valeurs par défaut.
|
- `rates[].multiplier`, `rates[].perHour`, `rates[].perDay` et `rates[].perMonth` sont omis si leur valeur vaut 0.
|
||||||
- l'historique des simulations n'est pas persisté en IndexedDB; il est limité aux 20 dernières entrées en `sessionStorage`.
|
- l'historique des simulations n'est pas persisté en IndexedDB; il est limité aux 20 dernières entrées en `sessionStorage`.
|
||||||
|
|
||||||
## Outil Combos
|
## Outil Combos
|
||||||
|
|
@ -1026,7 +1034,7 @@ Formats :
|
||||||
- `notepad` : format structuré `@notepad`, blocs `@page: Titre`, `@html` / `@endhtml` pour le HTML riche nettoyé, meta `@drawingMode: permanent` et `@drawings: {...}` pour les dessins permanents. Un collage texte simple reste accepté et devient une page sans mise en forme.
|
- `notepad` : format structuré `@notepad`, blocs `@page: Titre`, `@html` / `@endhtml` pour le HTML riche nettoyé, meta `@drawingMode: permanent` et `@drawings: {...}` pour les dessins permanents. Un collage texte simple reste accepté et devient une page sans mise en forme.
|
||||||
- `memos` : une ligne par mémo, avec préfixe `-` optionnel et suffixe couleur `| color=#f6c453` ; les anciens préfixes `*` sont acceptés puis normalisés.
|
- `memos` : une ligne par mémo, avec préfixe `-` optionnel et suffixe couleur `| color=#f6c453` ; les anciens préfixes `*` sont acceptés puis normalisés.
|
||||||
- `checklist` : sections `# Catégorie`, lignes `Item: quantité cible`.
|
- `checklist` : sections `# Catégorie`, lignes `Item: quantité cible`.
|
||||||
- `links` : lignes `Titre: https://...` ou URL seule.
|
- `links` : sections `# Catégorie`, lignes `Titre: https://...` ou URL seule; le suffixe optionnel `| category=Catégorie` reste accepté à l'import.
|
||||||
- `counters` : lignes `Libellé: valeur`, valeurs négatives acceptées.
|
- `counters` : lignes `Libellé: valeur`, valeurs négatives acceptées.
|
||||||
- `calculator` : lignes `Libellé: valeur | formula=base*5`, indentation de deux espaces pour les enfants, meta `@scrollResults: true`. Le suffixe `formula=...` est optionnel et les anciens exports `Libellé: valeur` restent acceptés.
|
- `calculator` : lignes `Libellé: valeur | formula=base*5`, indentation de deux espaces pour les enfants, meta `@scrollResults: true`. Le suffixe `formula=...` est optionnel et les anciens exports `Libellé: valeur` restent acceptés.
|
||||||
- `table` : TSV pur accepté ; l'export peut ajouter `@size`, `@columns`, `@rows` puis `@tsv` pour préserver dimensions et intitulés.
|
- `table` : TSV pur accepté ; l'export peut ajouter `@size`, `@columns`, `@rows` puis `@tsv` pour préserver dimensions et intitulés.
|
||||||
|
|
|
||||||
|
|
@ -120,10 +120,15 @@ test("text exchange round trips simple toolbox modules and resets checklist prog
|
||||||
["Mega potion", 5, 0]
|
["Mega potion", 5, 0]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const links = normalizeLinksData({ links: [{ id: "l1", title: "Build", url: "https://example.com/build" }] });
|
const links = normalizeLinksData({
|
||||||
|
categoryOrder: ["Builds", "Guides"],
|
||||||
|
links: [{ id: "l1", title: "Build", url: "https://example.com/build", category: "Builds" }]
|
||||||
|
});
|
||||||
const linksImport = importModuleText("links", exportModuleText("links", links), context);
|
const linksImport = importModuleText("links", exportModuleText("links", links), context);
|
||||||
assert.equal(linksImport.ok, true);
|
assert.equal(linksImport.ok, true);
|
||||||
assert.equal(linksImport.data.links[0].url, "https://example.com/build");
|
assert.equal(linksImport.data.links[0].url, "https://example.com/build");
|
||||||
|
assert.equal(linksImport.data.links[0].category, "Builds");
|
||||||
|
assert.deepEqual(linksImport.data.categoryOrder, ["Builds", "Guides"]);
|
||||||
|
|
||||||
const counters = normalizeCountersData({ counters: [{ id: "c1", label: "Win", value: 12 }, { id: "c2", label: "Loss", value: -2 }] });
|
const counters = normalizeCountersData({ counters: [{ id: "c1", label: "Win", value: 12 }, { id: "c2", label: "Loss", value: -2 }] });
|
||||||
const countersImport = importModuleText("counters", exportModuleText("counters", counters), context);
|
const countersImport = importModuleText("counters", exportModuleText("counters", counters), context);
|
||||||
|
|
@ -295,11 +300,11 @@ test("rng simulator preserves rate precision and compacts storage", () => {
|
||||||
const normalized = normalizeRngSimulatorData({
|
const normalized = normalizeRngSimulatorData({
|
||||||
activeRateId: "rate2",
|
activeRateId: "rate2",
|
||||||
rates: [
|
rates: [
|
||||||
{ id: "rate1", label: "Drop rare", rate: "2.500" },
|
{ id: "rate1", label: "Drop rare", rate: "2.500", multiplier: 25, perHour: 10 },
|
||||||
{ id: "rate2", label: "Ultra rare", rate: "0.000004" },
|
{ id: "rate2", label: "Ultra rare", rate: "0.000004", multiplier: 50, perHour: 30, perDay: 120, perMonth: 3600 },
|
||||||
{ id: "bad", label: "", rate: "4" }
|
{ id: "bad", label: "", rate: "4" }
|
||||||
],
|
],
|
||||||
settings: { multiplier: 50, simulationDraws: 2000, perHour: 30, perDay: 120, perMonth: 3600 },
|
settings: { simulationDraws: 2000 },
|
||||||
history: [
|
history: [
|
||||||
{ id: "h1", at: "2026-08-30T12:00:00.000Z", label: "Drop rare", rate: "2.500", multiplier: 50, draws: 100, successes: 4, fails: 400, longestFailStreak: 40 }
|
{ id: "h1", at: "2026-08-30T12:00:00.000Z", label: "Drop rare", rate: "2.500", multiplier: 50, draws: 100, successes: 4, fails: 400, longestFailStreak: 40 }
|
||||||
]
|
]
|
||||||
|
|
@ -307,10 +312,12 @@ test("rng simulator preserves rate precision and compacts storage", () => {
|
||||||
|
|
||||||
assert.equal(normalized.activeRateId, "rate2");
|
assert.equal(normalized.activeRateId, "rate2");
|
||||||
assert.deepEqual(normalized.rates.map((rate) => [rate.label, rate.rate]), [["Drop rare", "2.500"], ["Ultra rare", "0.000004"]]);
|
assert.deepEqual(normalized.rates.map((rate) => [rate.label, rate.rate]), [["Drop rare", "2.500"], ["Ultra rare", "0.000004"]]);
|
||||||
|
assert.deepEqual(normalized.rates.map((rate) => [rate.multiplier, rate.perHour, rate.perDay, rate.perMonth]), [[25, 10, 0, 0], [50, 30, 120, 3600]]);
|
||||||
assert.equal(normalized.history, undefined);
|
assert.equal(normalized.history, undefined);
|
||||||
assert.equal(compactModuleDataForStorage("rngSimulator", normalizeRngSimulatorData({})), null);
|
assert.equal(compactModuleDataForStorage("rngSimulator", normalizeRngSimulatorData({})), null);
|
||||||
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).settings.multiplier, 50);
|
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].multiplier, 50);
|
||||||
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).settings.perMonth, 3600);
|
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].perMonth, 3600);
|
||||||
|
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).settings.simulationDraws, 2000);
|
||||||
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).history, undefined);
|
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).history, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -357,10 +364,10 @@ test("rng simulator text exchange round trips rates and settings", () => {
|
||||||
const rngSimulator = normalizeRngSimulatorData({
|
const rngSimulator = normalizeRngSimulatorData({
|
||||||
activeRateId: "rate2",
|
activeRateId: "rate2",
|
||||||
rates: [
|
rates: [
|
||||||
{ id: "rate1", label: "Drop rare", rate: "2.500" },
|
{ id: "rate1", label: "Drop rare", rate: "2.500", multiplier: 25, perHour: 20 },
|
||||||
{ id: "rate2", label: "Ultra rare", rate: "0.000004" }
|
{ id: "rate2", label: "Ultra rare", rate: "0.000004", multiplier: 50, perHour: 40, perDay: 120, perMonth: 3600 }
|
||||||
],
|
],
|
||||||
settings: { multiplier: 50, simulationDraws: 5000, perHour: 40, perDay: 120, perMonth: 3600 }
|
settings: { simulationDraws: 5000 }
|
||||||
});
|
});
|
||||||
const exported = exportModuleText("rngSimulator", rngSimulator);
|
const exported = exportModuleText("rngSimulator", rngSimulator);
|
||||||
assert.doesNotMatch(exported, /\[history\]/);
|
assert.doesNotMatch(exported, /\[history\]/);
|
||||||
|
|
@ -368,9 +375,11 @@ test("rng simulator text exchange round trips rates and settings", () => {
|
||||||
assert.equal(imported.ok, true);
|
assert.equal(imported.ok, true);
|
||||||
assert.deepEqual(imported.data.rates.map((rate) => [rate.label, rate.rate]), [["Drop rare", "2.500"], ["Ultra rare", "0.000004"]]);
|
assert.deepEqual(imported.data.rates.map((rate) => [rate.label, rate.rate]), [["Drop rare", "2.500"], ["Ultra rare", "0.000004"]]);
|
||||||
assert.equal(imported.data.rates.find((rate) => rate.id === imported.data.activeRateId)?.label, "Ultra rare");
|
assert.equal(imported.data.rates.find((rate) => rate.id === imported.data.activeRateId)?.label, "Ultra rare");
|
||||||
assert.equal(imported.data.settings.multiplier, 50);
|
assert.equal(imported.data.rates.find((rate) => rate.label === "Drop rare")?.multiplier, 25);
|
||||||
|
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.multiplier, 50);
|
||||||
|
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.perHour, 40);
|
||||||
|
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.perMonth, 3600);
|
||||||
assert.equal(imported.data.settings.simulationDraws, 5000);
|
assert.equal(imported.data.settings.simulationDraws, 5000);
|
||||||
assert.equal(imported.data.settings.perHour, 40);
|
|
||||||
assert.equal(imported.data.history, undefined);
|
assert.equal(imported.data.history, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -543,7 +543,7 @@
|
||||||
],
|
],
|
||||||
"importFormat": {
|
"importFormat": {
|
||||||
"text": "Le format texte contient les sections [rates] et [settings]. Les taux sont exprimés en pourcentage.",
|
"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": {
|
"images": {
|
||||||
|
|
@ -1996,7 +1996,12 @@
|
||||||
"links": {
|
"links": {
|
||||||
"titlePlaceholder": "Nom du lien",
|
"titlePlaceholder": "Nom du lien",
|
||||||
"urlPlaceholder": "https://...",
|
"urlPlaceholder": "https://...",
|
||||||
|
"categoryPlaceholder": "Catégorie",
|
||||||
"addButton": "Ajouter",
|
"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",
|
"importPlaceholder": "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map",
|
||||||
"importOpenButton": "Importer du texte",
|
"importOpenButton": "Importer du texte",
|
||||||
"exportOpenButton": "Exporter en format texte",
|
"exportOpenButton": "Exporter en format texte",
|
||||||
|
|
@ -2137,7 +2142,7 @@
|
||||||
"observedRateLabel": "Taux de réussite",
|
"observedRateLabel": "Taux de réussite",
|
||||||
"multiplierShortLabel": "Bonus",
|
"multiplierShortLabel": "Bonus",
|
||||||
"longestFailStreakLabel": "Pire série",
|
"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",
|
"importOpenButton": "Importer du texte",
|
||||||
"exportOpenButton": "Exporter en format texte",
|
"exportOpenButton": "Exporter en format texte",
|
||||||
"importModalTitle": "Importer un simulateur de chance",
|
"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.
|
// Rôle : fournit l'outil checklist avec quantités, catégories et échange texte.
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js";
|
import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js";
|
||||||
import { ChecklistSection } from "./ChecklistParts.jsx";
|
import { ChecklistSection } from "./ChecklistParts.jsx";
|
||||||
|
|
@ -22,6 +23,7 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc
|
||||||
const [sectionTitle, setSectionTitle] = useState("");
|
const [sectionTitle, setSectionTitle] = useState("");
|
||||||
const [qty, setQty] = useState(1);
|
const [qty, setQty] = useState(1);
|
||||||
const namedSections = data.sections.filter((section) => section.title);
|
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 completedSectionsMode = data.hideCompletedSectionsFully ? "hidden" : data.hideCompletedSections ? "reduced" : "visible";
|
||||||
const reorderItems = getChecklistReorderItems(data.sections);
|
const reorderItems = getChecklistReorderItems(data.sections);
|
||||||
const {
|
const {
|
||||||
|
|
@ -113,7 +115,7 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc
|
||||||
{editing && (
|
{editing && (
|
||||||
<div className="module-add-panel">
|
<div className="module-add-panel">
|
||||||
<form className="inline-form checklist-add-form" onSubmit={addItem}>
|
<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="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"} />
|
<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>
|
<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}
|
onUpdateItem={updateItem}
|
||||||
onMoveItemToCategory={moveItemToCategory}
|
onMoveItemToCategory={moveItemToCategory}
|
||||||
onDeleteItem={deleteItem}
|
onDeleteItem={deleteItem}
|
||||||
|
categoryOptions={categoryOptions}
|
||||||
/>,
|
/>,
|
||||||
showBoundaryDropZones && isLastSection ? (
|
showBoundaryDropZones && isLastSection ? (
|
||||||
<li
|
<li
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// Rôle : rend les sections et items de checklist avec édition inline et quantités.
|
// Rôle : rend les sections et items de checklist avec édition inline et quantités.
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -8,7 +9,7 @@ import {
|
||||||
keepChecklistSection
|
keepChecklistSection
|
||||||
} from "./checklistUtils.js";
|
} 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 showTitle = Boolean(section.title);
|
||||||
const sectionComplete = showTitle && isSectionComplete(section, context);
|
const sectionComplete = showTitle && isSectionComplete(section, context);
|
||||||
const hideWhenComplete = getSectionHideWhenComplete(section, data.hideCompletedSections);
|
const hideWhenComplete = getSectionHideWhenComplete(section, data.hideCompletedSections);
|
||||||
|
|
@ -117,6 +118,7 @@ export function ChecklistSection({ section, context, data, reorder, onRenameSect
|
||||||
onUpdateItem={onUpdateItem}
|
onUpdateItem={onUpdateItem}
|
||||||
onMoveItemToCategory={onMoveItemToCategory}
|
onMoveItemToCategory={onMoveItemToCategory}
|
||||||
onDeleteItem={onDeleteItem}
|
onDeleteItem={onDeleteItem}
|
||||||
|
categoryOptions={categoryOptions}
|
||||||
editing={editingItemId === item.id}
|
editing={editingItemId === item.id}
|
||||||
onToggleEditing={() => setEditingItemId(editingItemId === item.id ? "" : item.id)}
|
onToggleEditing={() => setEditingItemId(editingItemId === item.id ? "" : item.id)}
|
||||||
onCloseEditing={() => setEditingItemId("")}
|
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 itemRef = useRef(null);
|
||||||
const done = context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget;
|
const done = context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget;
|
||||||
const textContent = context.moduleText?.checklist || {};
|
const textContent = context.moduleText?.checklist || {};
|
||||||
|
|
@ -221,13 +223,13 @@ function ChecklistItem({ item, section, context, reorder, items, save, onUpdateI
|
||||||
"aria-label": textContent.itemEditLabel || "Libellé"
|
"aria-label": textContent.itemEditLabel || "Libellé"
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<input
|
<CategoryAutocompleteInput
|
||||||
{...categoryEdit.getInputProps({
|
{...categoryEdit.getInputProps({
|
||||||
className: "checklist-category-input nds-inline-input",
|
className: "checklist-category-input nds-inline-input",
|
||||||
type: "text",
|
|
||||||
placeholder: textContent.sectionPlaceholder || "Catégorie",
|
placeholder: textContent.sectionPlaceholder || "Catégorie",
|
||||||
"aria-label": textContent.categoryEditLabel || "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.
|
// 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 { Icon } from "../../../components/Icon.jsx";
|
||||||
import { ComboInputToken, ComboSequence } from "./CombosParts.jsx";
|
import { ComboInputToken, ComboSequence } from "./CombosParts.jsx";
|
||||||
import {
|
import {
|
||||||
|
|
@ -10,6 +11,7 @@ import {
|
||||||
export function CombosEditor({
|
export function CombosEditor({
|
||||||
data,
|
data,
|
||||||
textContent,
|
textContent,
|
||||||
|
categoryOptions,
|
||||||
comboCategory,
|
comboCategory,
|
||||||
comboName,
|
comboName,
|
||||||
customKey,
|
customKey,
|
||||||
|
|
@ -78,7 +80,7 @@ export function CombosEditor({
|
||||||
<div className="combos-form-grid">
|
<div className="combos-form-grid">
|
||||||
<label>
|
<label>
|
||||||
<span>{textContent.targetCategoryLabel || "Catégorie"}</span>
|
<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>
|
||||||
<label>
|
<label>
|
||||||
<span>{textContent.comboNameLabel || "Nom"}</span>
|
<span>{textContent.comboNameLabel || "Nom"}</span>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ import { useCombosReorder } from "./useCombosReorder.js";
|
||||||
export function CombosModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
|
export function CombosModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
|
||||||
const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [] }));
|
const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [] }));
|
||||||
const textContent = context.moduleText?.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, {
|
const entries = getGroupedEntries(data.combos, {
|
||||||
groupOrder: data.categoryOrder,
|
groupOrder: data.categoryOrder,
|
||||||
getItemGroup: getComboCategory,
|
getItemGroup: getComboCategory,
|
||||||
|
|
@ -48,6 +52,7 @@ export function CombosModule({ toolboxId, moduleId, context, editing, textExchan
|
||||||
<CombosEditor
|
<CombosEditor
|
||||||
data={data}
|
data={data}
|
||||||
textContent={textContent}
|
textContent={textContent}
|
||||||
|
categoryOptions={categoryOptions}
|
||||||
comboCategory={editorState.comboCategory}
|
comboCategory={editorState.comboCategory}
|
||||||
comboName={editorState.comboName}
|
comboName={editorState.comboName}
|
||||||
customKey={editorState.customKey}
|
customKey={editorState.customKey}
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,154 @@
|
||||||
// Rôle : fournit l'outil liens avec ajout manuel et échange texte.
|
// Rôle : fournit l'outil liens avec ajout manuel et échange texte.
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
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";
|
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 }) {
|
export function LinksModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
|
||||||
const data = context.normalizeLinksData(context.getModuleData(toolboxId, moduleId, { links: [] }));
|
const data = context.normalizeLinksData(context.getModuleData(toolboxId, moduleId, { links: [] }));
|
||||||
const textContent = context.moduleText?.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 [copiedId, setCopiedId] = useState("");
|
||||||
const {
|
const {
|
||||||
itemReorder,
|
itemReorder,
|
||||||
|
groupReorder,
|
||||||
getItemProps,
|
getItemProps,
|
||||||
|
getGroupProps,
|
||||||
|
getGroupBoundaryProps,
|
||||||
isItemDragging,
|
isItemDragging,
|
||||||
isItemDropTarget,
|
isItemDropTarget,
|
||||||
|
isGroupDragging,
|
||||||
|
isGroupDropTarget,
|
||||||
|
isBoundaryDropTarget,
|
||||||
getDropPlacement
|
getDropPlacement
|
||||||
} = useGroupedReorder({
|
} = useGroupedReorder({
|
||||||
namespace: "links",
|
namespace: `links-${moduleId}`,
|
||||||
items: data.links,
|
items: data.links,
|
||||||
getItemId: (link) => link.id,
|
getItemId: (link) => link.id,
|
||||||
getParentId: () => moduleId,
|
getItemGroup: getLinkCategory,
|
||||||
onItemMove: (operation) => save(moveItem(data.links, operation.sourceId, operation.targetId, operation.placement)),
|
reorderFeatures: {
|
||||||
hierarchy: { enabled: true, stickyParents: true }
|
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) {
|
function save(nextData) {
|
||||||
context.setModuleData(toolboxId, moduleId, { links });
|
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) {
|
function addLink(event) {
|
||||||
linkDraft.handleSubmit(event, (draft, { reset }) => {
|
linkDraft.handleSubmit(event, (draft, { reset }) => {
|
||||||
const cleanUrl = context.normalizeUrl(draft.url);
|
const cleanUrl = context.normalizeUrl(draft.url);
|
||||||
if (!cleanUrl) return;
|
const category = draft.category.trim();
|
||||||
|
|
||||||
save([
|
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,
|
...data.links,
|
||||||
{
|
{
|
||||||
id: context.uid("link"),
|
id: context.uid("link"),
|
||||||
title: draft.title.trim(),
|
title: draft.title.trim(),
|
||||||
url: cleanUrl
|
url: cleanUrl,
|
||||||
|
...(category ? { category } : {})
|
||||||
}
|
}
|
||||||
]);
|
]
|
||||||
|
});
|
||||||
reset();
|
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) {
|
async function copyUrl(link) {
|
||||||
const copied = await context.copyText(link.url);
|
const copied = await context.copyText(link.url);
|
||||||
if (!copied) return;
|
if (!copied) return;
|
||||||
|
|
@ -61,6 +164,7 @@ export function LinksModule({ toolboxId, moduleId, context, editing, textExchang
|
||||||
<form className="inline-form links-add-form" onSubmit={addLink}>
|
<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("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://..." })} />
|
<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>
|
<button className="nds-button" data-size="sm" data-variant="primary">{textContent.addButton || "Ajouter"}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -70,8 +174,92 @@ 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")} />
|
<TextExchangeActions type="links" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "links")} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ul className="links-list">
|
<div className="links-category-list">
|
||||||
{data.links.map((link) => {
|
{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 (
|
||||||
|
<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);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
function renderLink(link) {
|
||||||
const className = [
|
const className = [
|
||||||
"link-item",
|
"link-item",
|
||||||
isItemDragging(link.id) ? "is-dragging" : "",
|
isItemDragging(link.id) ? "is-dragging" : "",
|
||||||
|
|
@ -80,7 +268,7 @@ export function LinksModule({ toolboxId, moduleId, context, editing, textExchang
|
||||||
].filter(Boolean).join(" ");
|
].filter(Boolean).join(" ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={className} key={link.id} {...getItemProps({ itemId: link.id, parentId: moduleId })}>
|
<article className={className} key={link.id} {...getItemProps({ itemId: link.id, groupId: getLinkCategory(link) })}>
|
||||||
<button
|
<button
|
||||||
className="link-drag-handle nds-icon-button"
|
className="link-drag-handle nds-icon-button"
|
||||||
data-size="sm"
|
data-size="sm"
|
||||||
|
|
@ -100,14 +288,11 @@ export function LinksModule({ toolboxId, moduleId, context, editing, textExchang
|
||||||
<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"}>
|
<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" />
|
<Icon name="copy" />
|
||||||
</button>
|
</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"}>
|
<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" />
|
<Icon name="trash" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</article>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,12 +104,24 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
|
||||||
const [occurrenceDrafts, setOccurrenceDrafts] = useState({ perHour: null, perDay: null, perMonth: null });
|
const [occurrenceDrafts, setOccurrenceDrafts] = useState({ perHour: null, perDay: null, perMonth: null });
|
||||||
const rateDraft = useDraftForm({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
|
const rateDraft = useDraftForm({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
|
||||||
const activeRate = data.rates.find((rate) => rate.id === data.activeRateId) || data.rates[0] || null;
|
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(() => {
|
useEffect(() => {
|
||||||
setHistory(readSimulationHistory(historyStorageKey));
|
setHistory(readSimulationHistory(historyStorageKey));
|
||||||
}, [historyStorageKey]);
|
}, [historyStorageKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMultiplierDraft(null);
|
||||||
|
setOccurrenceDrafts({ perHour: null, perDay: null, perMonth: null });
|
||||||
|
}, [activeRate?.id]);
|
||||||
|
|
||||||
function save(nextData) {
|
function save(nextData) {
|
||||||
context.setModuleData(toolboxId, moduleId, nextData, "rngSimulator");
|
context.setModuleData(toolboxId, moduleId, nextData, "rngSimulator");
|
||||||
}
|
}
|
||||||
|
|
@ -118,21 +130,28 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
|
||||||
const settings = { ...data.settings };
|
const settings = { ...data.settings };
|
||||||
if (field === "simulationDraws") {
|
if (field === "simulationDraws") {
|
||||||
settings[field] = clampInteger(value, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, data.settings.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 });
|
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) {
|
function updateMultiplier(value) {
|
||||||
setMultiplierDraft(value);
|
setMultiplierDraft(value);
|
||||||
if (String(value).trim() === "") {
|
if (String(value).trim() === "") {
|
||||||
updateSettings("multiplier", 0);
|
updateActiveRateSettings("multiplier", 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateSettings("multiplier", value);
|
updateActiveRateSettings("multiplier", value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function blurMultiplier() {
|
function blurMultiplier() {
|
||||||
|
|
@ -141,7 +160,7 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
|
||||||
const parsed = Number(raw.replace(",", "."));
|
const parsed = Number(raw.replace(",", "."));
|
||||||
if (!raw || !Number.isFinite(parsed)) {
|
if (!raw || !Number.isFinite(parsed)) {
|
||||||
setMultiplierDraft(null);
|
setMultiplierDraft(null);
|
||||||
updateSettings("multiplier", 0);
|
updateActiveRateSettings("multiplier", 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMultiplierDraft(null);
|
setMultiplierDraft(null);
|
||||||
|
|
@ -149,16 +168,16 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
|
||||||
|
|
||||||
function clearMultiplier() {
|
function clearMultiplier() {
|
||||||
setMultiplierDraft(null);
|
setMultiplierDraft(null);
|
||||||
updateSettings("multiplier", 0);
|
updateActiveRateSettings("multiplier", 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateOccurrence(field, value) {
|
function updateOccurrence(field, value) {
|
||||||
setOccurrenceDrafts((drafts) => ({ ...drafts, [field]: value }));
|
setOccurrenceDrafts((drafts) => ({ ...drafts, [field]: value }));
|
||||||
if (String(value).trim() === "") {
|
if (String(value).trim() === "") {
|
||||||
updateSettings(field, 0);
|
updateActiveRateSettings(field, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateSettings(field, value);
|
updateActiveRateSettings(field, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function blurOccurrence(field) {
|
function blurOccurrence(field) {
|
||||||
|
|
@ -166,7 +185,7 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
|
||||||
if (value === null) return;
|
if (value === null) return;
|
||||||
const raw = value.trim();
|
const raw = value.trim();
|
||||||
const parsed = Number(raw.replace(",", "."));
|
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 }));
|
setOccurrenceDrafts((drafts) => ({ ...drafts, [field]: null }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,12 +226,12 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
|
||||||
|
|
||||||
function runSimulation() {
|
function runSimulation() {
|
||||||
if (!activeRate) return;
|
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 = {
|
const entry = {
|
||||||
id: context.uid("rng"),
|
id: context.uid("rng"),
|
||||||
label: activeRate.label,
|
label: activeRate.label,
|
||||||
rate: activeRate.rate,
|
rate: activeRate.rate,
|
||||||
multiplier: data.settings.multiplier,
|
multiplier: activeRate.multiplier,
|
||||||
...result
|
...result
|
||||||
};
|
};
|
||||||
const nextHistory = addRngSimulationHistory(history, entry);
|
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">
|
<div className="rng-simulator-field rng-simulator-multiplier-field">
|
||||||
<span>{textContent.multiplierLabel || "Multiplicateur"}</span>
|
<span>{textContent.multiplierLabel || "Multiplicateur"}</span>
|
||||||
<div className="rng-simulator-percent-input">
|
<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>
|
<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"}>
|
<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" />
|
<Icon name="rubber" />
|
||||||
|
|
@ -320,15 +339,15 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
|
||||||
<div className="rng-simulator-settings rng-simulator-three-columns">
|
<div className="rng-simulator-settings rng-simulator-three-columns">
|
||||||
<label className="rng-simulator-field">
|
<label className="rng-simulator-field">
|
||||||
<span>{textContent.perHourLabel || "Par heure"}</span>
|
<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>
|
||||||
<label className="rng-simulator-field">
|
<label className="rng-simulator-field">
|
||||||
<span>{textContent.perDayLabel || "Par jour"}</span>
|
<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>
|
||||||
<label className="rng-simulator-field">
|
<label className="rng-simulator-field">
|
||||||
<span>{textContent.perMonthLabel || "Par mois"}</span>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="rng-simulator-time-grid">
|
<div className="rng-simulator-time-grid">
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,11 @@ import { useTaskPlannerReorder } from "./useTaskPlannerReorder.js";
|
||||||
export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
|
export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
|
||||||
const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] }));
|
const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] }));
|
||||||
const textContent = context.moduleText?.taskPlanner || {};
|
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 [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [openDescriptions, setOpenDescriptions] = useState(() => new Set());
|
const [openDescriptions, setOpenDescriptions] = useState(() => new Set());
|
||||||
|
|
@ -45,19 +49,20 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textE
|
||||||
taskDraft.handleSubmit(event, (draft, { reset }) => {
|
taskDraft.handleSubmit(event, (draft, { reset }) => {
|
||||||
const cleanTitle = draft.title.trim();
|
const cleanTitle = draft.title.trim();
|
||||||
if (!cleanTitle) return;
|
if (!cleanTitle) return;
|
||||||
save({
|
const category = draft.category.trim();
|
||||||
...data,
|
const task = {
|
||||||
tasks: [
|
|
||||||
...data.tasks,
|
|
||||||
{
|
|
||||||
id: context.uid("task"),
|
id: context.uid("task"),
|
||||||
title: cleanTitle,
|
title: cleanTitle,
|
||||||
description: "",
|
description: "",
|
||||||
type: draft.type,
|
type: draft.type,
|
||||||
checked: false,
|
checked: false,
|
||||||
checkedAt: 0
|
checkedAt: 0
|
||||||
}
|
};
|
||||||
]
|
if (category) task.category = category;
|
||||||
|
save({
|
||||||
|
...data,
|
||||||
|
tasks: [...data.tasks, task],
|
||||||
|
categoryOrder: category && !data.categoryOrder.includes(category) ? [...data.categoryOrder, category] : data.categoryOrder
|
||||||
});
|
});
|
||||||
reset();
|
reset();
|
||||||
});
|
});
|
||||||
|
|
@ -171,6 +176,7 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textE
|
||||||
context={context}
|
context={context}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
settingsOpen={settingsOpen}
|
settingsOpen={settingsOpen}
|
||||||
|
categoryOptions={categoryOptions}
|
||||||
taskDraft={taskDraft}
|
taskDraft={taskDraft}
|
||||||
textContent={textContent}
|
textContent={textContent}
|
||||||
textExchangeOpen={textExchangeOpen}
|
textExchangeOpen={textExchangeOpen}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
// Rôle : rend la barre haute du task planner, l'ajout rapide et l'échange texte.
|
// 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 { Icon } from "../../../components/Icon.jsx";
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||||
import {
|
import {
|
||||||
|
|
@ -11,6 +12,7 @@ export function TaskPlannerTopbar({
|
||||||
context,
|
context,
|
||||||
editing,
|
editing,
|
||||||
settingsOpen,
|
settingsOpen,
|
||||||
|
categoryOptions,
|
||||||
taskDraft,
|
taskDraft,
|
||||||
textContent,
|
textContent,
|
||||||
textExchangeOpen,
|
textExchangeOpen,
|
||||||
|
|
@ -51,6 +53,7 @@ export function TaskPlannerTopbar({
|
||||||
<div className="task-planner-add-stack">
|
<div className="task-planner-add-stack">
|
||||||
<form className="inline-form task-planner-add-form" onSubmit={onAddTask}>
|
<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" })} />
|
<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" })}>
|
<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>)}
|
{TASK_TYPES.map((taskType) => <option key={taskType} value={taskType}>{getTaskTypeLabel(taskType, textContent)}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Rôle : gère l'import/export texte des outils simples et tabulaires.
|
// Rôle : gère l'import/export texte des outils simples et tabulaires.
|
||||||
import { cellAddress, columnIndexToName } from "../tableFormulaEngine.js";
|
import { cellAddress, columnIndexToName } from "../tableFormulaEngine.js";
|
||||||
import { evaluateCalculatorEntries } from "../calculatorUtils.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) {
|
export function exportNotepadText(data) {
|
||||||
const pages = Array.isArray(data?.pages) && data.pages.length ? data.pages : [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) {
|
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) {
|
export function importLinksText(text, context) {
|
||||||
const links = [];
|
const links = [];
|
||||||
|
const categoryOrder = [];
|
||||||
|
let category = "";
|
||||||
splitLines(text).forEach((rawLine, index) => {
|
splitLines(text).forEach((rawLine, index) => {
|
||||||
const line = rawLine.trim();
|
const line = rawLine.trim();
|
||||||
if (!line) return;
|
if (!line) return;
|
||||||
const separatorIndex = line.indexOf(":");
|
if (line.startsWith("#")) {
|
||||||
const maybeUrl = separatorIndex >= 0 ? line.slice(separatorIndex + 1).trim() : line;
|
category = line.replace(/^#+/, "").trim();
|
||||||
const title = separatorIndex >= 0 && !/^[a-z][a-z0-9+.-]*:\/\//i.test(line) ? line.slice(0, separatorIndex).trim() : "";
|
if (category && !categoryOrder.includes(category)) categoryOrder.push(category);
|
||||||
const url = context.normalizeUrl(title ? maybeUrl : line);
|
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}`);
|
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("@activeRateId: " + (data.activeRateId || ""));
|
||||||
lines.push("[rates]");
|
lines.push("[rates]");
|
||||||
(data.rates || []).forEach((rate) => {
|
(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(
|
lines.push(
|
||||||
"",
|
"",
|
||||||
"[settings]",
|
"[settings]",
|
||||||
`multiplier: ${data.settings?.multiplier ?? 0}`,
|
`simulationDraws: ${data.settings?.simulationDraws || 1000}`
|
||||||
`simulationDraws: ${data.settings?.simulationDraws || 1000}`,
|
|
||||||
`perHour: ${data.settings?.perHour || 0}`,
|
|
||||||
`perDay: ${data.settings?.perDay || 0}`,
|
|
||||||
`perMonth: ${data.settings?.perMonth || 0}`
|
|
||||||
);
|
);
|
||||||
return lines.join("\n").trim();
|
return lines.join("\n").trim();
|
||||||
}
|
}
|
||||||
|
|
@ -24,7 +20,7 @@ export function importRngSimulatorText(text, context) {
|
||||||
const data = {
|
const data = {
|
||||||
activeRateId: "",
|
activeRateId: "",
|
||||||
rates: [],
|
rates: [],
|
||||||
settings: { multiplier: 0, simulationDraws: 1000, perHour: 0, perDay: 0, perMonth: 0 }
|
settings: { simulationDraws: 1000 }
|
||||||
};
|
};
|
||||||
let section = "";
|
let section = "";
|
||||||
|
|
||||||
|
|
@ -44,18 +40,22 @@ export function importRngSimulatorText(text, context) {
|
||||||
if (separatorIndex > 0 && !cleanLine.startsWith("-")) {
|
if (separatorIndex > 0 && !cleanLine.startsWith("-")) {
|
||||||
const key = cleanLine.slice(0, separatorIndex).trim();
|
const key = cleanLine.slice(0, separatorIndex).trim();
|
||||||
const value = cleanLine.slice(separatorIndex + 1).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 === "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;
|
return;
|
||||||
}
|
}
|
||||||
if (section === "rates" && cleanLine.startsWith("-")) {
|
if (section === "rates" && cleanLine.startsWith("-")) {
|
||||||
const { title, meta: pipeMeta } = parsePipeParts(cleanLine.replace(/^[-*]\s*/, ""));
|
const { title, meta: pipeMeta } = parsePipeParts(cleanLine.replace(/^[-*]\s*/, ""));
|
||||||
if (!title) throw lineError(index, "libellé de taux manquant.");
|
if (!title) throw lineError(index, "libellé de taux manquant.");
|
||||||
const id = context.uid("rng");
|
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;
|
if (pipeMeta.active === "true") data.activeRateId = id;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,18 +23,33 @@ export function hostnameFromUrl(value) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeLinksData(data) {
|
export function normalizeLinksData(data) {
|
||||||
return {
|
const links = (data?.links || [])
|
||||||
links: (data?.links || [])
|
|
||||||
.map((link) => {
|
.map((link) => {
|
||||||
const url = normalizeUrl(link?.url);
|
const url = normalizeUrl(link?.url);
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
|
const category = String(link?.category || "").trim();
|
||||||
return {
|
return {
|
||||||
id: link?.id || uid("link"),
|
id: link?.id || uid("link"),
|
||||||
title: String(link?.title || "").trim(),
|
title: String(link?.title || "").trim(),
|
||||||
url
|
url,
|
||||||
|
...(category ? { category } : {})
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.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 {
|
||||||
|
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 links = normalizeLinksData(value).links.map((link) => {
|
||||||
const compact = { id: link.id, url: link.url };
|
const compact = { id: link.id, url: link.url };
|
||||||
if (link.title) compact.title = link.title;
|
if (link.title) compact.title = link.title;
|
||||||
|
if (link.category) compact.category = link.category;
|
||||||
return compact;
|
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 {
|
return {
|
||||||
id: entry?.id || uid("rng"),
|
id: entry?.id || uid("rng"),
|
||||||
label,
|
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) {
|
function normalizeSettings(settings) {
|
||||||
return {
|
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)
|
||||||
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)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,13 +43,16 @@ export function compactRngSimulatorDataForStorage(value) {
|
||||||
const normalized = normalizeRngSimulatorData(value);
|
const normalized = normalizeRngSimulatorData(value);
|
||||||
const compact = {};
|
const compact = {};
|
||||||
if (normalized.activeRateId) compact.activeRateId = normalized.activeRateId;
|
if (normalized.activeRateId) compact.activeRateId = normalized.activeRateId;
|
||||||
if (normalized.rates.length) compact.rates = normalized.rates;
|
if (normalized.rates.length) {
|
||||||
if (
|
compact.rates = normalized.rates.map((rate) => {
|
||||||
normalized.settings.simulationDraws !== 1000 ||
|
const compactRate = { id: rate.id, label: rate.label, rate: rate.rate };
|
||||||
normalized.settings.multiplier !== 0 ||
|
if (rate.multiplier !== 0) compactRate.multiplier = rate.multiplier;
|
||||||
normalized.settings.perHour !== 0 ||
|
if (rate.perHour !== 0) compactRate.perHour = rate.perHour;
|
||||||
normalized.settings.perDay !== 0 ||
|
if (rate.perDay !== 0) compactRate.perDay = rate.perDay;
|
||||||
normalized.settings.perMonth !== 0
|
if (rate.perMonth !== 0) compactRate.perMonth = rate.perMonth;
|
||||||
) compact.settings = normalized.settings;
|
return compactRate;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (normalized.settings.simulationDraws !== 1000) compact.settings = normalized.settings;
|
||||||
return Object.keys(compact).length ? compact : null;
|
return Object.keys(compact).length ? compact : null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ export function useToolboxAlerts({ toolboxes, moduleData, links, route, drawerGa
|
||||||
|
|
||||||
const currentOccurrence = getCurrentCalendarOccurrence(event, nowMs);
|
const currentOccurrence = getCurrentCalendarOccurrence(event, nowMs);
|
||||||
if (!currentOccurrence) return;
|
if (!currentOccurrence) return;
|
||||||
|
if (!getCalendarAlertOffsets(event).includes(0)) return;
|
||||||
const resumeKey = `${key}:${currentOccurrence.startMs}`;
|
const resumeKey = `${key}:${currentOccurrence.startMs}`;
|
||||||
if (resumedCalendarEventsRef.current.has(resumeKey)) return;
|
if (resumedCalendarEventsRef.current.has(resumeKey)) return;
|
||||||
resumedCalendarEventsRef.current.add(resumeKey);
|
resumedCalendarEventsRef.current.add(resumeKey);
|
||||||
|
|
|
||||||
|
|
@ -105,3 +105,57 @@
|
||||||
.nds-inline-input:focus {
|
.nds-inline-input:focus {
|
||||||
@include controls.inline-field-hover;
|
@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 {
|
.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 {
|
.module-add-panel + .links-category-list,
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
margin: var(--space-4);
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-add-panel + .links-list,
|
|
||||||
.module-add-panel + .checklist,
|
.module-add-panel + .checklist,
|
||||||
.module-add-panel + .task-planner-list,
|
.module-add-panel + .task-planner-list,
|
||||||
.module-add-panel + .counters-grid {
|
.module-add-panel + .counters-grid {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,77 @@
|
||||||
// Rôle : gère l’outil liens et ses actions de liste.
|
// 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 {
|
.link-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 34px minmax(0, 1fr) auto;
|
grid-template-columns: 34px minmax(0, 1fr) auto;
|
||||||
|
|
@ -10,6 +83,18 @@
|
||||||
background: rgba(7, 10, 24, 0.42);
|
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 {
|
.link-drag-handle {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
|
|
||||||
|
|
@ -119,5 +119,5 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-planner-add-form {
|
.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