style / tools optis & new tools calculator
All checks were successful
Deploy Sokko G / deploy (push) Successful in 6s

This commit is contained in:
Shinuwa 2026-07-23 21:06:05 +02:00
parent 6b84607b25
commit 0db35a0d2c
23 changed files with 1106 additions and 24 deletions

View file

@ -0,0 +1,243 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
function calculateExpression(expression) {
const normalized = String(expression || "").replaceAll(",", ".").trim();
if (!normalized || !/^[\d\s+\-*/().]+$/.test(normalized)) return null;
try {
const value = Function(`"use strict"; return (${normalized})`)();
return Number.isFinite(value) ? value : null;
} catch {
return null;
}
}
function formatResult(value) {
if (!Number.isFinite(value)) return "";
return Number.parseFloat(value.toFixed(6)).toString();
}
function getChildren(entries, parentId) {
return entries.filter((entry) => (entry.parentId || "") === parentId);
}
function getDescendantIds(entries, parentId) {
const children = getChildren(entries, parentId);
return children.flatMap((child) => [child.id, ...getDescendantIds(entries, child.id)]);
}
function getEntriesInTreeOrder(entries, parentId = "") {
return getChildren(entries, parentId).flatMap((entry) => [entry, ...getEntriesInTreeOrder(entries, entry.id)]);
}
export function CalculatorModule({ toolboxId, moduleId, context }) {
const data = context.normalizeCalculatorData(context.getModuleData(toolboxId, moduleId, { entries: [] }));
const textContent = context.moduleText?.calculator || {};
const [expression, setExpression] = useState("");
const [label, setLabel] = useState("");
const [activeParentId, setActiveParentId] = useState("");
const [calculatorHeight, setCalculatorHeight] = useState(0);
const [copied, setCopied] = useState(false);
const calculatorCardRef = useRef(null);
const result = useMemo(() => calculateExpression(expression), [expression]);
const activeParent = data.entries.find((entry) => entry.id === activeParentId);
useLayoutEffect(() => {
const element = calculatorCardRef.current;
if (!element) return undefined;
function updateHeight() {
setCalculatorHeight(Math.ceil(element.getBoundingClientRect().height));
}
updateHeight();
const observer = new ResizeObserver(updateHeight);
observer.observe(element);
return () => observer.disconnect();
}, []);
function save(entries) {
context.setModuleData(toolboxId, moduleId, { ...data, entries });
}
function setScrollResults(scrollResults) {
context.setModuleData(toolboxId, moduleId, { ...data, scrollResults });
}
function saveResult(event) {
event.preventDefault();
if (result == null) return;
const cleanLabel = label.trim();
save([
...data.entries,
{
id: context.uid("calc"),
parentId: activeParentId,
label: cleanLabel,
value: result
}
]);
setExpression("");
setLabel("");
}
function useEntry(entry) {
setExpression(formatResult(entry.value));
setActiveParentId(entry.id);
}
function deleteEntry(entryId) {
const deletedIds = new Set([entryId, ...getDescendantIds(data.entries, entryId)]);
if (deletedIds.has(activeParentId)) setActiveParentId("");
save(data.entries.filter((entry) => !deletedIds.has(entry.id)));
}
function renameEntry(entryId, label) {
const cleanLabel = label.trim();
save(data.entries.map((entry) => entry.id === entryId ? { ...entry, label: cleanLabel } : entry));
}
function resetCalculator() {
setExpression("");
setLabel("");
setActiveParentId("");
}
async function copyChecklistImport() {
const text = getEntriesInTreeOrder(data.entries)
.map((entry) => `${entry.label || formatResult(entry.value)}:${formatResult(entry.value)}`)
.join("\n");
if (!text || !await context.copyText(text)) return;
setCopied(true);
window.setTimeout(() => setCopied(false), 1400);
}
return (
<div className="calculator-module">
<form className="calculator-form" onSubmit={saveResult}>
<div className="calculator-card" ref={calculatorCardRef}>
<label>
<span>Calcul</span>
<input value={expression} onChange={(event) => setExpression(event.target.value)} placeholder={textContent.expressionPlaceholder || "10*10"} inputMode="decimal" />
</label>
<div className="calculator-result" aria-live="polite">
<span>Résultat</span>
<strong>{result == null ? "-" : formatResult(result)}</strong>
</div>
<label>
<span>Libellé</span>
<input value={label} onChange={(event) => setLabel(event.target.value)} placeholder={textContent.labelPlaceholder || "Lingots de fer"} />
</label>
<button className="primary" disabled={result == null}>Enregistrer</button>
</div>
</form>
<div
className={`calculator-saved ${data.scrollResults ? "is-scrollable" : ""}`}
style={data.scrollResults && calculatorHeight ? { "--calculator-scroll-height": `${calculatorHeight}px` } : undefined}
>
<div className="calculator-result-actions">
{activeParent && (
<button className="calculator-root-button" type="button" onClick={() => setActiveParentId("")}>
Revenir à la racine
</button>
)}
<button className="calculator-action-button danger" type="button" onClick={resetCalculator} aria-label="Réinitialiser le calculateur" title="Réinitialiser">
<Icon name="rubber" />
</button>
<button className="calculator-action-button" type="button" onClick={copyChecklistImport} disabled={!data.entries.length} aria-label="Copier pour checklist" title={copied ? "Copié" : "Copier pour checklist"}>
<Icon name="copy" />
</button>
<button
className={`calculator-scroll-toggle ${data.scrollResults ? "active" : ""}`}
type="button"
onClick={() => setScrollResults(!data.scrollResults)}
aria-pressed={data.scrollResults}
title={textContent.scrollableTitle || "Liste scrollable"}
aria-label={textContent.scrollableTitle || "Liste scrollable"}
>
<Icon name="scrollable" />
<i aria-hidden="true" />
</button>
</div>
<div className={`calculator-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}>
{data.entries.length ? (
<CalculatorEntries entries={data.entries} parentId="" activeParentId={activeParentId} onUse={useEntry} onRename={renameEntry} onDelete={deleteEntry} />
) : (
<p className="muted">Aucun résultat enregistré.</p>
)}
</div>
</div>
</div>
);
}
function CalculatorEntries({ entries, parentId, activeParentId, onUse, onRename, onDelete }) {
const children = getChildren(entries, parentId);
const [editingId, setEditingId] = useState("");
if (!children.length) return null;
return (
<ul className="calculator-entry-list">
{children.map((entry) => (
<li className={`${entry.id === activeParentId ? "active" : ""} ${entry.id === editingId ? "is-editing" : ""}`} key={entry.id}>
<div className="calculator-entry">
{entry.id === editingId ? (
<>
<span className="calculator-entry-value is-readonly" title="Quantité non modifiable">
<strong>{formatResult(entry.value)}</strong>
</span>
<EditableCalculatorLabel entry={entry} onRename={onRename} onDone={() => setEditingId("")} />
</>
) : (
<button className="calculator-entry-summary" type="button" onClick={() => onUse(entry)} title="Utiliser comme base">
<span><strong>{formatResult(entry.value)}</strong> {entry.label}</span>
</button>
)}
<button type="button" onClick={() => setEditingId(entry.id === editingId ? "" : entry.id)} aria-label={`Renommer ${entry.label}`} title="Renommer">
<Icon name="edit" />
</button>
<button type="button" className="danger" onClick={() => onDelete(entry.id)} aria-label={`Supprimer ${entry.label}`} title="Supprimer">
<Icon name="trash" />
</button>
</div>
<CalculatorEntries entries={entries} parentId={entry.id} activeParentId={activeParentId} onUse={onUse} onRename={onRename} onDelete={onDelete} />
</li>
))}
</ul>
);
}
function EditableCalculatorLabel({ entry, onRename, onDone }) {
const [label, setLabel] = useState(entry.label);
const inputRef = useRef(null);
useLayoutEffect(() => {
inputRef.current?.focus();
}, [entry.id]);
function saveLabel() {
const cleanLabel = label.trim();
if (cleanLabel !== entry.label) onRename(entry.id, cleanLabel);
onDone();
}
return (
<input
ref={inputRef}
className="calculator-entry-label"
value={label}
onChange={(event) => setLabel(event.target.value)}
onBlur={saveLabel}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") {
setLabel(entry.label);
onDone();
}
}}
aria-label={`Renommer ${entry.label}`}
/>
);
}

View file

@ -1,9 +1,12 @@
import { useState } from "react";
import { parseColonImportLines } from "./textImport.js";
export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeChecklistData(context.getModuleData(toolboxId, moduleId, { items: [] }));
const textContent = context.moduleText?.checklist || {};
const [label, setLabel] = useState("");
const [qty, setQty] = useState(1);
const [textImport, setTextImport] = useState("");
function save(items) {
context.setModuleData(toolboxId, moduleId, { items });
@ -18,14 +21,38 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
setQty(1);
}
function importItems(event) {
event.preventDefault();
const imported = parseColonImportLines(textImport).map((entry) => ({
id: context.uid("item"),
label: entry.label,
qtyTarget: Math.max(1, Number.parseInt(entry.value, 10) || 1),
qtyCurrent: 0
}));
if (!imported.length) return;
save([...data.items, ...imported]);
setTextImport("");
}
return (
<>
{editing && (
<form className="inline-form checklist-add-form module-add-panel" onSubmit={addItem}>
<input name="label" placeholder="Nouvel item" value={label} onChange={(event) => setLabel(event.target.value)} />
<input className="checklist-qty-input" name="qty" type="number" min="1" value={qty} onChange={(event) => setQty(event.target.value)} aria-label="Quantité cible" />
<button className="primary">Ajouter</button>
</form>
<div className="module-add-panel">
<form className="inline-form checklist-add-form" onSubmit={addItem}>
<input name="label" placeholder={textContent.itemPlaceholder || "Nouvel item"} value={label} onChange={(event) => setLabel(event.target.value)} />
<input className="checklist-qty-input" name="qty" type="number" min="1" value={qty} onChange={(event) => setQty(event.target.value)} aria-label="Quantité cible" />
<button className="primary">Ajouter</button>
</form>
<form className="text-import-form" onSubmit={importItems}>
<textarea
value={textImport}
onChange={(event) => setTextImport(event.target.value)}
placeholder={textContent.importPlaceholder || "Importer plusieurs items...\nPotion:10\nMéga potion:5"}
rows={3}
/>
<button type="submit">Importer le texte</button>
</form>
</div>
)}
<ul className="checklist">
{data.items.map((item) => (
@ -56,7 +83,17 @@ function ChecklistItem({ item, context, items, save }) {
) : (
<div className="checklist-qty-controls" aria-label={`Quantité ${item.label}`}>
<button type="button" onClick={() => updateItem((entry) => ({ ...entry, qtyCurrent: context.clampQty(entry.qtyCurrent - 1, entry.qtyTarget) }))} aria-label="Retirer une quantité">-</button>
<small>{context.clampQty(item.qtyCurrent, item.qtyTarget)}/{item.qtyTarget}</small>
<label className="checklist-qty-current">
<input
type="number"
max={item.qtyTarget}
value={context.clampQty(item.qtyCurrent, item.qtyTarget)}
style={{ "--qty-current-digits": String(context.clampQty(item.qtyCurrent, item.qtyTarget)).length }}
onChange={(event) => updateItem((entry) => ({ ...entry, qtyCurrent: context.clampQty(event.target.value, entry.qtyTarget) }))}
aria-label={`Quantité actuelle pour ${item.label}`}
/>
<span>/ {item.qtyTarget}</span>
</label>
<button type="button" onClick={() => updateItem((entry) => ({ ...entry, qtyCurrent: context.clampQty(entry.qtyCurrent + 1, entry.qtyTarget) }))} aria-label="Ajouter une quantité">+</button>
</div>
)}

View file

@ -3,6 +3,7 @@ import { Icon } from "../../../components/Icon.jsx";
export function CountersModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeCountersData(context.getModuleData(toolboxId, moduleId, { counters: [] }));
const textContent = context.moduleText?.counters || {};
const [label, setLabel] = useState("");
function save(counters) {
@ -26,7 +27,7 @@ export function CountersModule({ toolboxId, moduleId, context, editing }) {
<>
{editing && (
<form className="inline-form counters-add-form module-add-panel" onSubmit={addCounter}>
<input name="label" placeholder="Nom du compteur" value={label} onChange={(event) => setLabel(event.target.value)} />
<input name="label" placeholder={textContent.labelPlaceholder || "Nom du compteur"} value={label} onChange={(event) => setLabel(event.target.value)} />
<button className="primary">Ajouter</button>
</form>
)}

View file

@ -1,10 +1,13 @@
import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { parseColonImportLines } from "./textImport.js";
export function LinksModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeLinksData(context.getModuleData(toolboxId, moduleId, { links: [] }));
const textContent = context.moduleText?.links || {};
const [title, setTitle] = useState("");
const [url, setUrl] = useState("");
const [textImport, setTextImport] = useState("");
const [copiedId, setCopiedId] = useState("");
function save(links) {
@ -28,6 +31,20 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) {
setUrl("");
}
function importLinks(event) {
event.preventDefault();
const imported = parseColonImportLines(textImport)
.map((entry) => ({
id: context.uid("link"),
title: entry.label,
url: context.normalizeUrl(entry.value)
}))
.filter((link) => link.url);
if (!imported.length) return;
save([...data.links, ...imported]);
setTextImport("");
}
async function copyUrl(link) {
const copied = await context.copyText(link.url);
if (!copied) return;
@ -38,11 +55,22 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) {
return (
<>
{editing && (
<form className="inline-form links-add-form module-add-panel" onSubmit={addLink}>
<input name="title" placeholder="Nom du lien" value={title} onChange={(event) => setTitle(event.target.value)} />
<input name="url" placeholder="https://..." value={url} onChange={(event) => setUrl(event.target.value)} />
<button className="primary">Ajouter</button>
</form>
<div className="module-add-panel">
<form className="inline-form links-add-form" onSubmit={addLink}>
<input name="title" placeholder={textContent.titlePlaceholder || "Nom du lien"} value={title} onChange={(event) => setTitle(event.target.value)} />
<input name="url" placeholder={textContent.urlPlaceholder || "https://..."} value={url} onChange={(event) => setUrl(event.target.value)} />
<button className="primary">Ajouter</button>
</form>
<form className="text-import-form" onSubmit={importLinks}>
<textarea
value={textImport}
onChange={(event) => setTextImport(event.target.value)}
placeholder={textContent.importPlaceholder || "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map"}
rows={3}
/>
<button type="submit">Importer le texte</button>
</form>
</div>
)}
<ul className="links-list">
{data.links.map((link) => (

View file

@ -3,6 +3,7 @@ import { useState } from "react";
export function NotepadModule({ toolboxId, moduleId, context }) {
const data = context.getModuleData(toolboxId, moduleId, { text: "" });
const [text, setText] = useState(data.text || "");
const textContent = context.moduleText?.notepad || {};
return (
<textarea
@ -12,7 +13,7 @@ export function NotepadModule({ toolboxId, moduleId, context }) {
setText(event.target.value);
context.setModuleData(toolboxId, moduleId, { text: event.target.value });
}}
placeholder="Notes rapides..."
placeholder={textContent.placeholder || "Notes rapides..."}
/>
);
}

View file

@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "../../../components/Icon.jsx";
import { usePointerReorder } from "../../../hooks/usePointerReorder.js";
import { CalculatorModule } from "./CalculatorModule.jsx";
import { ChecklistModule } from "./ChecklistModule.jsx";
import { CountersModule } from "./CountersModule.jsx";
import { LinksModule } from "./LinksModule.jsx";
@ -13,7 +14,8 @@ const MODULE_COMPONENTS = {
checklist: { label: "Checklist", icon: "checklist", Component: ChecklistModule, editable: true },
screenshots: { label: "Screenshots", icon: "picture", Component: ScreenshotsModule, editable: true },
links: { label: "Liens", icon: "link", Component: LinksModule, editable: true },
counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true }
counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true },
calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: false }
};
const MODULE_COLUMN_GAP_PX = 16;

View file

@ -0,0 +1,15 @@
export function parseColonImportLines(text) {
return String(text || "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const separatorIndex = line.indexOf(":");
if (separatorIndex === -1) return { label: line, value: "" };
return {
label: line.slice(0, separatorIndex).trim(),
value: line.slice(separatorIndex + 1).trim()
};
})
.filter((entry) => entry.label);
}