Add custom toolbox card covers
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-05 15:41:56 +02:00
parent 25889a23ba
commit 06de3a3a6e
13 changed files with 7480 additions and 38 deletions

View file

@ -2,11 +2,13 @@
/* eslint-disable react-refresh/only-export-components -- expose aussi les helpers publics de rendu des cartes toolbox. */
import { CompactDropdown } from "../../components/CompactDropdown.jsx";
import { Icon } from "../../components/Icon.jsx";
import { ToolboxCoverEditor } from "./ToolboxCoverEditor.jsx";
import {
DEFAULT_TOOLBOX_ICON,
normalizeToolboxIcon,
TOOLBOX_ICONS
} from "./storage/toolboxStorage.js";
import { useRef, useState } from "react";
export function getGameCardCover(game) {
return game?.images?.cardCover || game?.image || "";
@ -22,7 +24,9 @@ export function formatDate(value) {
export function ToolboxCard({ toolbox, game, actions, reorder }) {
const gameCoverImage = getGameCardCover(game);
const coverImage = gameCoverImage || toolbox.icon || DEFAULT_TOOLBOX_ICON;
const customCoverImage = !gameCoverImage && toolbox.customCover ? toolbox.customCover : "";
const coverImage = gameCoverImage || customCoverImage || toolbox.icon || DEFAULT_TOOLBOX_ICON;
const hasRectCover = Boolean(gameCoverImage || customCoverImage);
const className = [
"card",
"nds-card",
@ -46,13 +50,13 @@ export function ToolboxCard({ toolbox, game, actions, reorder }) {
<Icon name="drag" />
</button>
<a
className={`card-cover nds-card-cover toolbox-card-cover-link ${gameCoverImage ? "toolbox-card-cover" : "toolbox-icon-cover"}`}
className={`card-cover nds-card-cover toolbox-card-cover-link ${hasRectCover ? "toolbox-card-cover" : "toolbox-icon-cover"}`}
href={`#/toolbox/${toolbox.id}`}
aria-label={`Ouvrir ${toolbox.name}`}
title={`Ouvrir ${toolbox.name}`}
style={{ "--game-cover": getGameCardBackground(game), background: gameCoverImage ? "" : "var(--gradient-nebula)" }}
style={{ "--game-cover": getGameCardBackground(game), background: hasRectCover ? "" : "var(--gradient-nebula)" }}
>
<img src={coverImage} alt={game ? game.title : `Icône de ${toolbox.name}`} loading="lazy" />
<img src={coverImage} alt={game ? game.title : customCoverImage ? `Image de ${toolbox.name}` : `Icône de ${toolbox.name}`} loading="lazy" />
</a>
<div className="card-body nds-card-body">
<p className="eyebrow">{game ? game.title : "Toolbox libre"}</p>
@ -76,49 +80,97 @@ export function ToolboxCard({ toolbox, game, actions, reorder }) {
export function ToolboxIconPicker({ toolbox, onChange, tutorialTarget = "" }) {
const icon = normalizeToolboxIcon(toolbox.icon);
const triggerImage = toolbox.customCover || icon;
const fileInputRef = useRef(null);
const [coverEditor, setCoverEditor] = useState(null);
function closeCoverEditor() {
setCoverEditor(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}
return (
<CompactDropdown
className="toolbox-icon-picker"
menuClassName="toolbox-icon-picker-menu"
label="Icônes de toolbox"
renderTrigger={({ open, toggle }) => (
<button
className={`toolbox-icon-picker-button nds-large-icon-picker-trigger ${open ? "active" : ""}`}
type="button"
onClick={toggle}
aria-label="Changer l'icône de la toolbox"
aria-expanded={open}
aria-pressed={open}
data-toolbox-tutorial-target={tutorialTarget || undefined}
title="Changer l'icône"
>
<img src={icon} alt="" aria-hidden="true" />
</button>
)}
>
{({ close }) => (
<>
{TOOLBOX_ICONS.map((path) => (
<>
<input
ref={fileInputRef}
type="file"
accept="image/*"
hidden
onChange={(event) => {
const file = event.target.files?.[0];
if (!file?.type?.startsWith("image/")) return;
setCoverEditor({ file });
}}
/>
<CompactDropdown
className="toolbox-icon-picker"
menuClassName="toolbox-icon-picker-menu"
label="Icônes de toolbox"
renderTrigger={({ open, toggle }) => (
<button
className={`toolbox-icon-picker-button nds-large-icon-picker-trigger ${toolbox.customCover ? "has-custom-cover" : ""} ${open ? "active" : ""}`}
type="button"
onClick={toggle}
aria-label="Changer l'image de la toolbox"
aria-expanded={open}
aria-pressed={open}
data-toolbox-tutorial-target={tutorialTarget || undefined}
title="Changer l'image"
>
<img src={triggerImage} alt="" aria-hidden="true" />
</button>
)}
>
{({ close }) => (
<>
{TOOLBOX_ICONS.map((path) => (
<button
key={path}
className={`nds-icon-button ${!toolbox.customCover && path === icon ? "active" : ""}`}
data-size="md"
type="button"
onClick={() => {
onChange(path, "");
close();
}}
aria-label={`Utiliser l'icône ${path.split("/").pop().replace(".png", "")}`}
aria-pressed={!toolbox.customCover && path === icon}
title={path.split("/").pop().replace(".png", "")}
>
<img src={path} alt="" aria-hidden="true" loading="lazy" />
</button>
))}
<button
key={path}
className={`nds-icon-button ${path === icon ? "active" : ""}`}
data-size="md"
className={`toolbox-cover-menu-action nds-button ${toolbox.customCover ? "active" : ""}`}
data-size="sm"
data-variant="secondary"
type="button"
onClick={() => {
onChange(path);
fileInputRef.current?.click();
close();
}}
aria-label={`Utiliser l'icône ${path.split("/").pop().replace(".png", "")}`}
aria-pressed={path === icon}
title={path.split("/").pop().replace(".png", "")}
aria-label={toolbox.customCover ? "Remplacer l'image de card" : "Ajouter une image de card"}
aria-pressed={Boolean(toolbox.customCover)}
title={toolbox.customCover ? "Remplacer l'image de card" : "Ajouter une image de card"}
>
<img src={path} alt="" aria-hidden="true" loading="lazy" />
<Icon name="picture" />
<span>Image personnalisée</span>
</button>
))}
</>
</>
)}
</CompactDropdown>
{coverEditor && (
<ToolboxCoverEditor
file={coverEditor.file}
existingCover={coverEditor.existingCover || toolbox.customCover || ""}
onClose={closeCoverEditor}
onSave={(customCover) => {
onChange(icon, customCover);
closeCoverEditor();
}}
/>
)}
</CompactDropdown>
</>
);
}

View file

@ -0,0 +1,213 @@
// Rôle : édite une image utilisateur en cover rectangulaire de card toolbox.
/* eslint-disable react-refresh/only-export-components -- expose aussi les helpers purs de calcul/export du crop. */
import { useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../components/Icon.jsx";
import { lockBodyScroll } from "../../utils/bodyScrollLock.js";
const OUTPUT_WIDTH = 620;
const OUTPUT_HEIGHT = 248;
const WEBP_QUALITY = 0.82;
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function canvasToDataUrl(canvas) {
const webp = canvas.toDataURL("image/webp", WEBP_QUALITY);
return webp.startsWith("data:image/webp") ? webp : canvas.toDataURL("image/jpeg", WEBP_QUALITY);
}
export function getCoverCropSourceRect({ imageWidth, imageHeight, frameWidth, frameHeight, scale, offsetX, offsetY }) {
const displayWidth = imageWidth * scale;
const displayHeight = imageHeight * scale;
const sourceX = ((displayWidth - frameWidth) / 2 - offsetX) / scale;
const sourceY = ((displayHeight - frameHeight) / 2 - offsetY) / scale;
const x = clamp(sourceX, 0, imageWidth);
const y = clamp(sourceY, 0, imageHeight);
return {
x,
y,
width: Math.min(imageWidth - x, frameWidth / scale),
height: Math.min(imageHeight - y, frameHeight / scale)
};
}
export async function createToolboxCoverDataUrl(bitmap, frame, transform) {
const crop = getCoverCropSourceRect({
imageWidth: bitmap.width,
imageHeight: bitmap.height,
frameWidth: frame.width,
frameHeight: frame.height,
scale: transform.scale,
offsetX: transform.x,
offsetY: transform.y
});
const canvas = document.createElement("canvas");
canvas.width = OUTPUT_WIDTH;
canvas.height = OUTPUT_HEIGHT;
canvas.getContext("2d").drawImage(bitmap, crop.x, crop.y, crop.width, crop.height, 0, 0, OUTPUT_WIDTH, OUTPUT_HEIGHT);
return canvasToDataUrl(canvas);
}
function createPreviewSource(file, existingCover) {
if (file) return URL.createObjectURL(file);
return existingCover || "";
}
async function decodePreviewImage(source, file) {
if (file) return createImageBitmap(file);
const response = await fetch(source);
return createImageBitmap(await response.blob());
}
export function ToolboxCoverEditor({ file, existingCover = "", onClose, onSave }) {
const frameRef = useRef(null);
const imageBitmapRef = useRef(null);
const dragRef = useRef(null);
const [source, setSource] = useState("");
const [imageSize, setImageSize] = useState({ width: 0, height: 0 });
const [frame, setFrame] = useState({ width: 0, height: 0 });
const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 });
const [baseScale, setBaseScale] = useState(1);
const [error, setError] = useState("");
const maxScale = useMemo(() => baseScale * 4, [baseScale]);
useEffect(() => lockBodyScroll(), []);
useEffect(() => () => imageBitmapRef.current?.close?.(), []);
useEffect(() => {
const nextSource = createPreviewSource(file, existingCover);
setSource(nextSource);
setError("");
return () => {
if (file && nextSource) URL.revokeObjectURL(nextSource);
};
}, [existingCover, file]);
useEffect(() => {
if (!source) return undefined;
let cancelled = false;
decodePreviewImage(source, file)
.then((bitmap) => {
if (cancelled) {
bitmap.close?.();
return;
}
imageBitmapRef.current?.close?.();
imageBitmapRef.current = bitmap;
setImageSize({ width: bitmap.width, height: bitmap.height });
})
.catch(() => setError("Image illisible. Choisis un autre fichier."));
return () => {
cancelled = true;
};
}, [file, source]);
useEffect(() => {
const frameElement = frameRef.current;
if (!frameElement) return undefined;
const updateFrame = () => {
const rect = frameElement.getBoundingClientRect();
setFrame({ width: rect.width, height: rect.height });
};
updateFrame();
const observer = new ResizeObserver(updateFrame);
observer.observe(frameElement);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!frame.width || !frame.height || !imageSize.width || !imageSize.height) return;
const nextBaseScale = Math.max(frame.width / imageSize.width, frame.height / imageSize.height);
setBaseScale(nextBaseScale);
setTransform({ x: 0, y: 0, scale: nextBaseScale });
}, [frame.height, frame.width, imageSize.height, imageSize.width]);
function clampTransform(nextTransform) {
const displayWidth = imageSize.width * nextTransform.scale;
const displayHeight = imageSize.height * nextTransform.scale;
const maxX = Math.max(0, (displayWidth - frame.width) / 2);
const maxY = Math.max(0, (displayHeight - frame.height) / 2);
return {
scale: nextTransform.scale,
x: clamp(nextTransform.x, -maxX, maxX),
y: clamp(nextTransform.y, -maxY, maxY)
};
}
function updateScale(value) {
const scale = clamp(Number(value) || baseScale, baseScale, maxScale);
setTransform((current) => clampTransform({ ...current, scale }));
}
function startDrag(event) {
if (!imageSize.width) return;
event.currentTarget.setPointerCapture?.(event.pointerId);
dragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, start: transform };
}
function moveDrag(event) {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
setTransform(clampTransform({
...drag.start,
x: drag.start.x + event.clientX - drag.x,
y: drag.start.y + event.clientY - drag.y
}));
}
function endDrag(event) {
if (dragRef.current?.pointerId === event.pointerId) dragRef.current = null;
}
async function saveCover() {
const bitmap = imageBitmapRef.current;
if (!bitmap || !frame.width || !frame.height) return;
onSave(await createToolboxCoverDataUrl(bitmap, frame, transform));
}
return (
<div className="confirm-modal-root toolbox-cover-editor-root">
<div className="confirm-backdrop nds-modal-backdrop" onClick={onClose} />
<section className="confirm-modal nds-modal toolbox-cover-editor" role="dialog" aria-modal="true" aria-labelledby="toolbox-cover-editor-title">
<header>
<h2 id="toolbox-cover-editor-title">Image de card</h2>
<button className="nds-icon-button" data-size="xs" type="button" onClick={onClose} aria-label="Fermer" title="Fermer"><Icon name="close" /></button>
</header>
<div
className="toolbox-cover-crop-frame"
ref={frameRef}
onPointerDown={startDrag}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
{source && imageSize.width > 0 && (
<img
src={source}
alt=""
draggable="false"
style={{
width: `${imageSize.width * transform.scale}px`,
height: `${imageSize.height * transform.scale}px`,
transform: `translate(-50%, -50%) translate(${transform.x}px, ${transform.y}px)`
}}
/>
)}
</div>
{error && <p className="nds-inline-notice toolbox-cover-editor-error" data-variant="danger">{error}</p>}
<label className="toolbox-cover-zoom-field">
<span>Zoom</span>
<input className="nds-input" data-size="sm" type="range" min={baseScale} max={maxScale} step={Math.max(0.001, baseScale / 50)} value={transform.scale} onChange={(event) => updateScale(event.target.value)} />
</label>
<footer className="nds-modal-footer">
<button className="nds-button" data-size="sm" type="button" onClick={() => setTransform({ x: 0, y: 0, scale: baseScale })}>Réinitialiser</button>
<button className="nds-button" data-size="sm" type="button" onClick={onClose}>Annuler</button>
<button className="nds-button" data-size="sm" data-variant="primary" type="button" disabled={!imageSize.width || Boolean(error)} onClick={saveCover}>Valider</button>
</footer>
</section>
</div>
);
}

View file

@ -261,7 +261,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
<div>
<p className="eyebrow">Toolbox</p>
<div className="toolbox-title-row">
{!embedded && !toolboxGame && <ToolboxIconPicker toolbox={toolbox} onChange={(icon) => updateToolbox({ ...toolbox, icon })} tutorialTarget="icon" />}
{!embedded && !toolboxGame && <ToolboxIconPicker toolbox={toolbox} onChange={(icon, customCover = toolbox.customCover) => updateToolbox({ ...toolbox, icon, customCover })} tutorialTarget="icon" />}
{!embedded && toolboxGameCover && <ToolboxGameIcon game={toolboxGame} />}
{embedded ? <h1>{toolbox.name}</h1> : (
<EditableTitle className="toolbox-title" value={toolbox.name} fallback="Nouvelle toolbox" tutorialTarget="title" onSave={(name) => name !== toolbox.name && updateToolbox({ ...toolbox, name })} />

View file

@ -38,6 +38,7 @@ const TOOLBOX_ICON_FILES = [
"mining.png",
"parachute.png",
"puzzle.png",
"reflexion.png",
"shield.png",
"sword.png",
"target.png",
@ -53,6 +54,11 @@ export function normalizeToolboxIcon(icon) {
return TOOLBOX_ICON_FILES.includes(file) ? `${TOOLBOX_ICON_BASE}${file}` : DEFAULT_TOOLBOX_ICON;
}
export function normalizeToolboxCustomCover(customCover) {
const value = String(customCover || "").trim();
return /^data:image\/(?:jpeg|jpg|png|webp);base64,[a-z0-9+/]+=*$/i.test(value) ? value : "";
}
function normalizeToolboxModule(module) {
if (!module || typeof module !== "object" || !module.type) return null;
const title = String(module.title || "").trim();
@ -123,6 +129,7 @@ export function normalizeToolbox(toolbox) {
id: toolbox.id || uid("tbx"),
name: String(toolbox.name || "Nouvelle toolbox").trim() || "Nouvelle toolbox",
icon: normalizeToolboxIcon(toolbox.icon),
customCover: normalizeToolboxCustomCover(toolbox.customCover),
moduleColumns: Number(toolbox.moduleColumns) === 1 ? 1 : 2,
modules,
moduleOrder: normalizeModuleOrder(toolbox.moduleOrder, modules),
@ -144,6 +151,7 @@ export function compactToolboxForStorage(toolbox) {
};
if (normalized.moduleColumns === 1) compact.moduleColumns = 1;
if (normalized.icon !== DEFAULT_TOOLBOX_ICON) compact.icon = normalized.icon;
if (normalized.customCover) compact.customCover = normalized.customCover;
if (normalized.tutorial === true) compact.tutorial = true;
return compact;
}