new tools modules & sass opti
All checks were successful
Deploy Sokko G / deploy (push) Successful in 6s

This commit is contained in:
Shinuwa 2026-07-21 13:01:14 +02:00
parent 821e8e0a36
commit 9e275e0167
24 changed files with 3266 additions and 2372 deletions

View file

@ -24,6 +24,9 @@ export function MonsterCard({ monster, t }) {
>
<div className="mhwilds-card-inner">
<div className="mhwilds-card-face mhwilds-card-front">
<span className="mhwilds-flip-indicator" aria-hidden="true">
<span className="ui-icon ui-icon-flip" />
</span>
<div className="mhwilds-card-art">
<img src={assetPath(monster.name)} alt={t(monster.name, { capitalize: true })} loading="lazy" />
</div>
@ -35,6 +38,9 @@ export function MonsterCard({ monster, t }) {
</div>
</div>
<div className="mhwilds-card-face mhwilds-card-back">
<span className="mhwilds-flip-indicator" aria-hidden="true">
<span className="ui-icon ui-icon-flip" />
</span>
<div className="mhwilds-card-body">
<p className="eyebrow">Détails</p>
<h2>{t(monster.name, { capitalize: true })}</h2>

View file

@ -1,6 +1,6 @@
import { useState } from "react";
export function ChecklistModule({ toolboxId, moduleId, context }) {
export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeChecklistData(context.getModuleData(toolboxId, moduleId, { items: [] }));
const [label, setLabel] = useState("");
const [qty, setQty] = useState(1);
@ -20,11 +20,13 @@ export function ChecklistModule({ toolboxId, moduleId, context }) {
return (
<>
<form className="inline-form checklist-add-form" 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>
{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>
)}
<ul className="checklist">
{data.items.map((item) => (
<ChecklistItem key={item.id} item={item} toolboxId={toolboxId} moduleId={moduleId} context={context} items={data.items} save={save} />

View file

@ -0,0 +1,55 @@
import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
export function CountersModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeCountersData(context.getModuleData(toolboxId, moduleId, { counters: [] }));
const [label, setLabel] = useState("");
function save(counters) {
context.setModuleData(toolboxId, moduleId, { counters });
}
function addCounter(event) {
event.preventDefault();
const cleanLabel = label.trim();
if (!cleanLabel) return;
save([...data.counters, { id: context.uid("counter"), label: cleanLabel, value: 0 }]);
setLabel("");
}
function updateCounter(counterId, updater) {
save(data.counters.map((counter) => counter.id === counterId ? updater(counter) : counter));
}
return (
<>
{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)} />
<button className="primary">Ajouter</button>
</form>
)}
<div className="counters-grid">
{data.counters.map((counter) => (
<article className="counter-item" key={counter.id}>
<div>
<strong>{counter.value}</strong>
<span>{counter.label}</span>
</div>
<div className="counter-actions">
<button type="button" onClick={() => updateCounter(counter.id, (item) => ({ ...item, value: item.value - 1 }))} aria-label={`Décrémenter ${counter.label}`}>-</button>
<button type="button" onClick={() => updateCounter(counter.id, (item) => ({ ...item, value: item.value + 1 }))} aria-label={`Incrémenter ${counter.label}`}>+</button>
<button type="button" onClick={() => updateCounter(counter.id, (item) => ({ ...item, value: 0 }))} aria-label={`Réinitialiser ${counter.label}`} title="Réinitialiser">
<Icon name="rubber" />
</button>
<button type="button" className="danger" onClick={() => save(data.counters.filter((item) => item.id !== counter.id))} aria-label={`Supprimer ${counter.label}`} title="Supprimer">
<Icon name="trash" />
</button>
</div>
</article>
))}
</div>
</>
);
}

View file

@ -0,0 +1,67 @@
import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
export function LinksModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeLinksData(context.getModuleData(toolboxId, moduleId, { links: [] }));
const [title, setTitle] = useState("");
const [url, setUrl] = useState("");
const [copiedId, setCopiedId] = useState("");
function save(links) {
context.setModuleData(toolboxId, moduleId, { links });
}
function addLink(event) {
event.preventDefault();
const cleanUrl = context.normalizeUrl(url);
if (!cleanUrl) return;
save([
...data.links,
{
id: context.uid("link"),
title: title.trim(),
url: cleanUrl
}
]);
setTitle("");
setUrl("");
}
async function copyUrl(link) {
const copied = await context.copyText(link.url);
if (!copied) return;
setCopiedId(link.id);
window.setTimeout(() => setCopiedId(""), 1400);
}
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>
)}
<ul className="links-list">
{data.links.map((link) => (
<li className="link-item" key={link.id}>
<a href={link.url} target="_blank" rel="noreferrer" title={link.url}>
<strong>{link.title || context.hostnameFromUrl(link.url)}</strong>
<span>{link.url}</span>
</a>
<div>
<button type="button" onClick={() => copyUrl(link)} aria-label={`Copier ${link.title || link.url}`} title={copiedId === link.id ? "Copié" : "Copier"}>
<Icon name="copy" />
</button>
<button type="button" className="danger" onClick={() => save(data.links.filter((item) => item.id !== link.id))} aria-label={`Supprimer ${link.title || link.url}`} title="Supprimer">
<Icon name="trash" />
</button>
</div>
</li>
))}
</ul>
</>
);
}

View file

@ -1,6 +1,6 @@
import { useState } from "react";
export function ScreenshotsModule({ toolboxId, moduleId, context }) {
export function ScreenshotsModule({ toolboxId, moduleId, context, editing }) {
const data = context.getModuleData(toolboxId, moduleId, { shots: [] });
const [dragOver, setDragOver] = useState(false);
@ -12,47 +12,51 @@ export function ScreenshotsModule({ toolboxId, moduleId, context }) {
return (
<>
<label
className={`dropzone ${dragOver ? "is-drag-over" : ""}`}
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(event) => {
event.preventDefault();
addFiles(event.dataTransfer.files);
}}
>
<input type="file" accept="image/*" multiple hidden onChange={(event) => addFiles(event.target.files)} />
Ajouter des screenshots
</label>
<div
className="paste-target"
contentEditable
suppressContentEditableWarning
role="textbox"
aria-label="Coller une image depuis le presse-papiers"
onFocus={(event) => {
if (event.currentTarget.textContent.trim() === "Coller une image ici") event.currentTarget.textContent = "";
}}
onBlur={(event) => {
if (!event.currentTarget.textContent.trim()) event.currentTarget.textContent = "Coller une image ici";
}}
onPaste={(event) => {
const files = [...(event.clipboardData?.items || [])]
.filter((item) => item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter(Boolean);
if (!files.length) return;
event.preventDefault();
event.currentTarget.textContent = "Coller une image ici";
addFiles(files);
}}
>
Coller une image ici
</div>
{editing && (
<div className="module-add-panel">
<label
className={`dropzone ${dragOver ? "is-drag-over" : ""}`}
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(event) => {
event.preventDefault();
addFiles(event.dataTransfer.files);
}}
>
<input type="file" accept="image/*" multiple hidden onChange={(event) => addFiles(event.target.files)} />
Ajouter des screenshots
</label>
<div
className="paste-target"
contentEditable
suppressContentEditableWarning
role="textbox"
aria-label="Coller une image depuis le presse-papiers"
onFocus={(event) => {
if (event.currentTarget.textContent.trim() === "Coller une image ici") event.currentTarget.textContent = "";
}}
onBlur={(event) => {
if (!event.currentTarget.textContent.trim()) event.currentTarget.textContent = "Coller une image ici";
}}
onPaste={(event) => {
const files = [...(event.clipboardData?.items || [])]
.filter((item) => item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter(Boolean);
if (!files.length) return;
event.preventDefault();
event.currentTarget.textContent = "Coller une image ici";
addFiles(files);
}}
>
Coller une image ici
</div>
</div>
)}
<div className="shots">
{data.shots.map((shot) => (
<figure key={shot.id}>

View file

@ -1,11 +1,18 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "../../../components/Icon.jsx";
import { ChecklistModule } from "./ChecklistModule.jsx";
import { CountersModule } from "./CountersModule.jsx";
import { LinksModule } from "./LinksModule.jsx";
import { NotepadModule } from "./NotepadModule.jsx";
import { ScreenshotsModule } from "./ScreenshotsModule.jsx";
const MODULE_COMPONENTS = {
notepad: { label: "Bloc notes", icon: "notepad", Component: NotepadModule },
checklist: { label: "Checklist", icon: "checklist", Component: ChecklistModule },
screenshots: { label: "Screenshots", icon: "picture", Component: ScreenshotsModule }
notepad: { label: "Bloc notes", icon: "notepad", Component: NotepadModule, editable: false },
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 }
};
export const TOOLBOX_MODULES = Object.fromEntries(Object.entries(MODULE_COMPONENTS).map(([type, module]) => [
@ -13,11 +20,183 @@ export const TOOLBOX_MODULES = Object.fromEntries(Object.entries(MODULE_COMPONEN
{ label: module.label, icon: module.icon }
]));
export function AddToolControls({ onAdd }) {
const [open, setOpen] = useState(false);
const [quickOpen, setQuickOpen] = useState(false);
const controlsRef = useRef(null);
const modules = Object.entries(TOOLBOX_MODULES);
useEffect(() => {
if (!quickOpen) return undefined;
function handlePointerDown(event) {
if (!controlsRef.current?.contains(event.target)) setQuickOpen(false);
}
function handleKeyDown(event) {
if (event.key === "Escape") setQuickOpen(false);
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [quickOpen]);
function addTool(type) {
onAdd(type);
setOpen(false);
setQuickOpen(false);
}
return (
<>
<div
className="tool-add-controls"
ref={controlsRef}
onMouseLeave={() => setQuickOpen(false)}
onFocusCapture={(event) => {
if (event.target.closest(".tool-add-quick-toggle")) setQuickOpen(true);
}}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setQuickOpen(false);
}}
aria-label="Ajouter un outil"
>
<button
className="primary tool-add-modal-button"
type="button"
onClick={() => {
setQuickOpen(false);
setOpen(true);
}}
>
Ajouter un outil
</button>
<button
className={`primary tool-add-quick-toggle ${quickOpen ? "active" : ""}`}
type="button"
onMouseEnter={() => setQuickOpen(true)}
onClick={() => setQuickOpen((value) => !value)}
aria-label="Afficher l'ajout rapide"
aria-expanded={quickOpen}
title="Ajout rapide"
>
<Icon name="dropdown" />
</button>
<div className={`tool-quick-add ${quickOpen ? "is-open" : ""}`} aria-label="Ajout rapide">
{modules.map(([type, module]) => (
<button
key={type}
className="tool-quick-add-button"
type="button"
onClick={() => onAdd(type)}
aria-label={`Ajouter ${module.label}`}
title={module.label}
>
<span className="module-icon" aria-hidden="true">
<span className={`module-icon-svg module-icon-${module.icon}`} />
</span>
</button>
))}
</div>
</div>
{open && createPortal(
<div className="tool-add-modal-root" role="dialog" aria-modal="true" aria-labelledby="tool-add-modal-title">
<div className="confirm-backdrop" onClick={() => setOpen(false)} />
<section className="confirm-modal tool-add-modal">
<header>
<h2 id="tool-add-modal-title">Ajouter un outil</h2>
<button className="drawer-close-button" type="button" onClick={() => setOpen(false)} aria-label="Fermer" title="Fermer">
<Icon name="close" />
</button>
</header>
<div className="tool-add-grid">
{modules.map(([type, module]) => (
<button key={type} className="tool-add-card" type="button" onClick={() => addTool(type)}>
<span className="module-icon" aria-hidden="true">
<span className={`module-icon-svg module-icon-${module.icon}`} />
</span>
<span>{module.label}</span>
</button>
))}
</div>
</section>
</div>,
document.body
)}
</>
);
}
export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onDelete, onMove }) {
const [draggingModuleId, setDraggingModuleId] = useState("");
const [dropTarget, setDropTarget] = useState({ id: "", placement: "before" });
useEffect(() => {
if (!draggingModuleId) return undefined;
function getDropTarget(event) {
const element = document.elementFromPoint(event.clientX, event.clientY);
const target = element?.closest?.(".module");
if (!target || target.dataset.toolboxId !== toolbox.id || target.dataset.moduleId === draggingModuleId) {
return { id: "", placement: "before" };
}
const rect = target.getBoundingClientRect();
return {
id: target.dataset.moduleId,
placement: event.clientY > rect.top + rect.height / 2 ? "after" : "before"
};
}
function handlePointerMove(event) {
setDropTarget(getDropTarget(event));
}
function handlePointerUp(event) {
const target = getDropTarget(event);
if (target.id) onMove(draggingModuleId, target.id, target.placement);
setDraggingModuleId("");
setDropTarget({ id: "", placement: "before" });
}
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp, { once: true });
window.addEventListener("pointercancel", handlePointerUp, { once: true });
return () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
};
}, [draggingModuleId, onMove, toolbox.id]);
function startModuleDrag(event, moduleId) {
if (event.button !== 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture?.(event.pointerId);
setDraggingModuleId(moduleId);
setDropTarget({ id: "", placement: "before" });
}
if (moduleColumns === 1) {
return (
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
{toolbox.modules.map((module) => <ModuleShell key={module.id} toolbox={toolbox} module={module} context={context} onRename={onRename} onDelete={onDelete} onMove={onMove} />)}
{toolbox.modules.map((module) => (
<ModuleShell
key={module.id}
toolbox={toolbox}
module={module}
context={context}
draggingModuleId={draggingModuleId}
dropTarget={dropTarget}
onDragStart={startModuleDrag}
onRename={onRename}
onDelete={onDelete}
onMove={onMove}
/>
))}
</section>
);
}
@ -29,77 +208,80 @@ export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onDe
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
{columns.map((modules, index) => (
<div className="module-column" key={index}>
{modules.map((module) => <ModuleShell key={module.id} toolbox={toolbox} module={module} context={context} onRename={onRename} onDelete={onDelete} onMove={onMove} />)}
{modules.map((module) => (
<ModuleShell
key={module.id}
toolbox={toolbox}
module={module}
context={context}
draggingModuleId={draggingModuleId}
dropTarget={dropTarget}
onDragStart={startModuleDrag}
onRename={onRename}
onDelete={onDelete}
onMove={onMove}
/>
))}
</div>
))}
</section>
);
}
function ModuleShell({ toolbox, module, context, onRename, onDelete, onMove }) {
function ModuleShell({ toolbox, module, context, draggingModuleId, dropTarget, onDragStart, onRename, onDelete }) {
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
const Component = definition.Component;
const label = definition.label || module.type;
function handleDragStart(event) {
if (event.target.closest("button, input, select, textarea, [contenteditable='true']")) {
event.preventDefault();
return;
}
event.currentTarget.classList.add("is-dragging");
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", module.id);
}
function handleDragOver(event) {
const fromModuleId = event.dataTransfer.getData("text/plain");
if (!fromModuleId || fromModuleId === module.id) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
event.currentTarget.classList.add("is-drop-target");
const rect = event.currentTarget.getBoundingClientRect();
event.currentTarget.classList.toggle("drop-after", event.clientY > rect.top + rect.height / 2);
}
function handleDrop(event) {
const fromModuleId = event.dataTransfer.getData("text/plain");
if (!fromModuleId || fromModuleId === module.id) return;
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
onMove(fromModuleId, module.id, event.clientY > rect.top + rect.height / 2 ? "after" : "before");
}
function clearDragClasses(event) {
event.currentTarget.classList.remove("is-dragging", "is-drop-target", "drop-after");
}
const [editing, setEditing] = useState(false);
const isDragging = draggingModuleId === module.id;
const isDropTarget = dropTarget.id === module.id;
const className = [
"module",
isDragging ? "is-dragging" : "",
isDropTarget ? "is-drop-target" : "",
isDropTarget && dropTarget.placement === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
return (
<article
className="module"
className={className}
data-toolbox-id={toolbox.id}
data-module-id={module.id}
draggable
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={clearDragClasses}
onDrop={handleDrop}
onDragEnd={clearDragClasses}
>
<header>
<div>
<span className="module-drag-handle" aria-hidden="true" title="Déplacer l'outil" />
<button
className="module-drag-handle"
type="button"
onPointerDown={(event) => onDragStart(event, module.id)}
aria-label={`Déplacer ${module.title || label}`}
title="Déplacer"
>
<Icon name="drag" />
</button>
<span className="module-icon" aria-hidden="true">
<span className={`module-icon-svg module-icon-${definition.icon || "notepad"}`} />
</span>
<EditableModuleTitle value={module.title || label} fallback={label} onSave={(title) => onRename(module.id, title)} />
</div>
<div>
{definition.editable && (
<button
className={`module-edit-button ${editing ? "active" : ""}`}
onClick={() => setEditing((value) => !value)}
aria-label={`${editing ? "Masquer" : "Afficher"} l'ajout de ${module.title || label}`}
aria-pressed={editing}
title={editing ? "Masquer l'ajout" : "Ajouter"}
>
<Icon name="edit" />
</button>
)}
<button className="module-delete-button danger" onClick={() => onDelete(module.id)} aria-label={`Retirer ${module.title || label}`} title="Retirer">
<span className="ui-icon ui-icon-trash" aria-hidden="true" />
</button>
</div>
</header>
<Component toolboxId={toolbox.id} moduleId={module.id} context={context} />
<Component toolboxId={toolbox.id} moduleId={module.id} context={context} editing={editing} />
</article>
);
}