Improve toolbox checklists, image browsing, and guide integration
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s
This commit is contained in:
parent
00e73f0062
commit
386212041f
47 changed files with 744 additions and 112 deletions
|
|
@ -1991,6 +1991,7 @@
|
|||
"sectionPlaceholder": "Catégorie",
|
||||
"quantityLabel": "Quantité cible",
|
||||
"addButton": "Ajouter",
|
||||
"emptyItems": "Aucun élément dans cette checklist.",
|
||||
"importPlaceholder": "Importer plusieurs items...\n# Potions\nPotion:10\nMéga potion:5\n\n# Armures\nCasque Rey Dau:1",
|
||||
"importOpenButton": "Importer du texte",
|
||||
"exportOpenButton": "Exporter en format texte",
|
||||
|
|
@ -2003,6 +2004,7 @@
|
|||
"importError": "Format de checklist invalide.",
|
||||
"hideCompletedSectionsTitle": "Réduire les catégories terminées",
|
||||
"hideCompletedSectionsFullyTitle": "Cacher les catégories terminées",
|
||||
"hideCompletedItemsTitle": "Masquer les éléments terminés",
|
||||
"completedSectionsModeTitle": "Affichage des catégories terminées",
|
||||
"completedSectionsVisibleLabel": "Visible quand terminée",
|
||||
"completedSectionsReducedLabel": "Réduite quand terminée",
|
||||
|
|
@ -2033,12 +2035,20 @@
|
|||
"importError": "Format d'images invalide.",
|
||||
"exportInfo": "Les images sont exportées en base64 complet. Le texte peut donc être très long.",
|
||||
"addImages": "Ajouter des images",
|
||||
"emptyImages": "Aucune image.",
|
||||
"pastePlaceholder": "Coller une image ici",
|
||||
"pasteAriaLabel": "Coller une image depuis le presse-papiers",
|
||||
"imageAlt": "Image",
|
||||
"labelPlaceholder": "Libellé de l'image",
|
||||
"labelAriaLabel": "Libellé de l'image",
|
||||
"previewAriaLabel": "Agrandir l'image",
|
||||
"galleryButton": "Galerie",
|
||||
"galleryTitle": "Galerie d'images",
|
||||
"galleryImageAriaLabel": "Afficher en grand",
|
||||
"returnGalleryTitle": "Revenir à la galerie",
|
||||
"previousImageTitle": "Image précédente",
|
||||
"nextImageTitle": "Image suivante",
|
||||
"positionLabel": "Image {current} sur {total}",
|
||||
"reorderAriaLabel": "Déplacer l'image",
|
||||
"reorderTitle": "Déplacer",
|
||||
"annotateAriaLabel": "Annoter l'image",
|
||||
|
|
@ -2051,6 +2061,7 @@
|
|||
"urlPlaceholder": "https://...",
|
||||
"categoryPlaceholder": "Catégorie",
|
||||
"addButton": "Ajouter",
|
||||
"emptyLinks": "Aucun lien.",
|
||||
"reorderCategoryTitle": "Déplacer la catégorie",
|
||||
"showCategoryTitle": "Afficher la catégorie",
|
||||
"hideCategoryTitle": "Réduire la catégorie",
|
||||
|
|
@ -2072,6 +2083,7 @@
|
|||
"counters": {
|
||||
"labelPlaceholder": "Nom du compteur",
|
||||
"addButton": "Ajouter",
|
||||
"emptyCounters": "Aucun compteur.",
|
||||
"importPlaceholder": "Victoire: 12\nDéfaite: -2",
|
||||
"importOpenButton": "Importer du texte",
|
||||
"exportOpenButton": "Exporter en format texte",
|
||||
|
|
@ -2759,7 +2771,7 @@
|
|||
"associate": "Associer à la page",
|
||||
"noMatches": "Cette checklist ne contient plus de correspondance avec le guide.",
|
||||
"matches": "correspondances",
|
||||
"unlink": "Dissocier",
|
||||
"unlink": "Dissocier la checklist",
|
||||
"hideCompleted": "Masquer les cochés",
|
||||
"completed": "Coché",
|
||||
"checkCard": "Cocher dans la checklist",
|
||||
|
|
|
|||
42
website/src/components/ImageGallery.jsx
Normal file
42
website/src/components/ImageGallery.jsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Rôle : affiche la collection d'un outil Images dans la galerie de la visionneuse.
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function ImageGallery({ images, activeIndex, focusIndex = -1, onSelect, textContent = {} }) {
|
||||
const galleryRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusIndex < 0) return;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
galleryRef.current?.querySelector(`[data-image-index="${focusIndex}"]`)?.focus();
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [focusIndex]);
|
||||
|
||||
return (
|
||||
<div ref={galleryRef} className="image-viewer-gallery themed-scrollbar">
|
||||
{images.map((image, index) => {
|
||||
const position = `${index + 1} / ${images.length}`;
|
||||
const label = image.label || `${textContent.imageAlt || "Image"} ${index + 1}`;
|
||||
return (
|
||||
<button
|
||||
className={`image-viewer-gallery-item nds-card ${index === activeIndex ? "is-active" : ""}`}
|
||||
data-variant="interactive"
|
||||
data-image-index={index}
|
||||
type="button"
|
||||
key={image.id || `${image.dataUrl}-${index}`}
|
||||
onClick={() => onSelect(index)}
|
||||
aria-label={`${textContent.galleryImageAriaLabel || "Afficher en grand"} : ${label}`}
|
||||
>
|
||||
<span className="image-viewer-gallery-frame">
|
||||
<img src={image.dataUrl} alt="" loading="lazy" decoding="async" />
|
||||
</span>
|
||||
<span className="image-viewer-gallery-caption">
|
||||
<strong>{label}</strong>
|
||||
<small>{position}</small>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
// Rôle : affiche les images en modale, avec support des annotations.
|
||||
// Rôle : affiche en modale une image annotable ou une collection navigable avec galerie.
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { DrawingControls } from "../features/toolboxes/modules/DrawingControls.jsx";
|
||||
import { DrawingOverlay } from "../features/toolboxes/modules/DrawingOverlay.jsx";
|
||||
import { lockBodyScroll } from "../utils/bodyScrollLock.js";
|
||||
import { DRAWING_COLORS, DRAWING_WIDTHS } from "../utils/drawingOptions.js";
|
||||
import { Icon } from "./Icon.jsx";
|
||||
import { ImageGallery } from "./ImageGallery.jsx";
|
||||
import { Tooltip } from "./Tooltip.jsx";
|
||||
|
||||
function clampPercent(value) {
|
||||
|
|
@ -32,27 +33,49 @@ function openImageInNewTab(dataUrl) {
|
|||
}
|
||||
}
|
||||
|
||||
function formatPositionLabel(template, current, total) {
|
||||
return String(template || "Image {current} sur {total}")
|
||||
.replace("{current}", current)
|
||||
.replace("{total}", total);
|
||||
}
|
||||
|
||||
export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.crypto?.randomUUID?.() || `marker-${Date.now()}` }) {
|
||||
useEffect(() => lockBodyScroll(), []);
|
||||
const canAnnotate = Boolean(image.canAnnotate && image.onChangeMarkers);
|
||||
const canDraw = Boolean(canAnnotate && image.onChangeDrawings);
|
||||
const [viewerMarkers, setViewerMarkers] = useState(() => Array.isArray(image.markers) ? image.markers : []);
|
||||
const [viewerDrawings, setViewerDrawings] = useState(() => image.drawings?.strokes ? image.drawings : { strokes: [] });
|
||||
const [drawingMode, setDrawingMode] = useState(image.drawingMode === "temporary" ? "temporary" : "permanent");
|
||||
const isCollection = image.kind === "collection" && Array.isArray(image.images) && image.images.length > 0;
|
||||
const collection = isCollection ? image.images : [image];
|
||||
const initialIndex = isCollection ? Math.min(collection.length - 1, Math.max(0, Number(image.activeIndex) || 0)) : 0;
|
||||
const initialView = isCollection && image.initialView === "gallery" ? "gallery" : "detail";
|
||||
const [activeIndex, setActiveIndex] = useState(initialIndex);
|
||||
const [view, setView] = useState(initialView);
|
||||
const [galleryFocusIndex, setGalleryFocusIndex] = useState(-1);
|
||||
const [previousRequest, setPreviousRequest] = useState(image);
|
||||
if (previousRequest !== image) {
|
||||
setPreviousRequest(image);
|
||||
setActiveIndex(initialIndex);
|
||||
setView(initialView);
|
||||
setGalleryFocusIndex(-1);
|
||||
}
|
||||
const activeImage = collection[activeIndex] || collection[0] || image;
|
||||
const textContent = isCollection ? image.textContent || {} : {};
|
||||
const canAnnotate = Boolean(activeImage.canAnnotate && activeImage.onChangeMarkers);
|
||||
const canDraw = Boolean(canAnnotate && activeImage.onChangeDrawings);
|
||||
const [viewerMarkers, setViewerMarkers] = useState(() => Array.isArray(activeImage.markers) ? activeImage.markers : []);
|
||||
const [viewerDrawings, setViewerDrawings] = useState(() => activeImage.drawings?.strokes ? activeImage.drawings : { strokes: [] });
|
||||
const [drawingMode, setDrawingMode] = useState(activeImage.drawingMode === "temporary" ? "temporary" : "permanent");
|
||||
const [drawingActive, setDrawingActive] = useState(false);
|
||||
const [eraseActive, setEraseActive] = useState(false);
|
||||
const [drawingColor, setDrawingColor] = useState("#f6c453");
|
||||
const [drawingWidth, setDrawingWidth] = useState(4);
|
||||
const [previousImage, setPreviousImage] = useState(image);
|
||||
const [previousImage, setPreviousImage] = useState(activeImage);
|
||||
const viewerRef = useRef(null);
|
||||
const mediaRef = useRef(null);
|
||||
const hasMarkers = viewerMarkers.length > 0;
|
||||
|
||||
if (previousImage !== image) {
|
||||
setPreviousImage(image);
|
||||
setViewerMarkers(Array.isArray(image.markers) ? image.markers : []);
|
||||
setViewerDrawings(image.drawings?.strokes ? image.drawings : { strokes: [] });
|
||||
setDrawingMode(image.drawingMode === "temporary" ? "temporary" : "permanent");
|
||||
if (previousImage !== activeImage) {
|
||||
setPreviousImage(activeImage);
|
||||
setViewerMarkers(Array.isArray(activeImage.markers) ? activeImage.markers : []);
|
||||
setViewerDrawings(activeImage.drawings?.strokes ? activeImage.drawings : { strokes: [] });
|
||||
setDrawingMode(activeImage.drawingMode === "temporary" ? "temporary" : "permanent");
|
||||
setDrawingActive(false);
|
||||
setEraseActive(false);
|
||||
}
|
||||
|
|
@ -76,31 +99,43 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
|
|||
observer.disconnect();
|
||||
window.removeEventListener("resize", syncImageHeight);
|
||||
};
|
||||
}, [image.dataUrl, canAnnotate]);
|
||||
}, [activeImage.dataUrl, canAnnotate, view]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (!isCollection || view !== "detail") return;
|
||||
if (event.key === "ArrowLeft" && activeIndex > 0) {
|
||||
event.preventDefault();
|
||||
setActiveIndex((index) => index - 1);
|
||||
}
|
||||
if (event.key === "ArrowRight" && activeIndex < collection.length - 1) {
|
||||
event.preventDefault();
|
||||
setActiveIndex((index) => index + 1);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [onClose]);
|
||||
}, [activeIndex, collection.length, isCollection, onClose, view]);
|
||||
|
||||
function updateViewerMarkers(nextMarkers) {
|
||||
setViewerMarkers(nextMarkers);
|
||||
image.onChangeMarkers?.(nextMarkers);
|
||||
activeImage.onChangeMarkers?.(nextMarkers);
|
||||
}
|
||||
|
||||
function updateViewerDrawings(nextStrokes) {
|
||||
const nextDrawings = { strokes: nextStrokes };
|
||||
setViewerDrawings(nextDrawings);
|
||||
image.onChangeDrawings?.(nextDrawings, drawingMode);
|
||||
activeImage.onChangeDrawings?.(nextDrawings, drawingMode);
|
||||
}
|
||||
|
||||
function toggleDrawingMode() {
|
||||
const nextMode = drawingMode === "permanent" ? "temporary" : "permanent";
|
||||
setDrawingMode(nextMode);
|
||||
image.onChangeDrawingMode?.(nextMode, viewerDrawings);
|
||||
activeImage.onChangeDrawingMode?.(nextMode, viewerDrawings);
|
||||
}
|
||||
|
||||
function getMediaSurfaceSize() {
|
||||
|
|
@ -126,6 +161,19 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
|
|||
updateViewerMarkers(nextMarkers);
|
||||
}
|
||||
|
||||
function openGallery() {
|
||||
setGalleryFocusIndex(activeIndex);
|
||||
setView("gallery");
|
||||
}
|
||||
|
||||
function openGalleryImage(index) {
|
||||
setGalleryFocusIndex(-1);
|
||||
setActiveIndex(index);
|
||||
setView("detail");
|
||||
}
|
||||
|
||||
const positionLabel = formatPositionLabel(textContent.positionLabel, activeIndex + 1, collection.length);
|
||||
|
||||
function updateViewerMarker(markerId, updater) {
|
||||
updateViewerMarkers(viewerMarkers.map((marker) => marker.id === markerId ? updater(marker) : marker));
|
||||
}
|
||||
|
|
@ -137,17 +185,32 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
|
|||
return (
|
||||
<div className="image-viewer-root">
|
||||
<div className="image-viewer-backdrop" onClick={onClose} />
|
||||
<section ref={viewerRef} className="image-viewer" role="dialog" aria-modal="true" aria-label="Image">
|
||||
<section ref={viewerRef} className={`image-viewer ${view === "gallery" ? "is-gallery" : ""}`} role="dialog" aria-modal="true" aria-label={view === "gallery" ? textContent.galleryTitle || "Galerie d'images" : "Image"}>
|
||||
<header>
|
||||
{image.label && <strong className="image-viewer-title">{image.label}</strong>}
|
||||
<div className="image-viewer-heading">
|
||||
{(view === "gallery" || isCollection || activeImage.label) && (
|
||||
<strong className="image-viewer-title">{view === "gallery" ? textContent.galleryTitle || "Galerie d'images" : activeImage.label || textContent.imageAlt || "Image"}</strong>
|
||||
)}
|
||||
{isCollection && view === "detail" && <span className="image-viewer-position" aria-label={positionLabel} title={positionLabel} aria-live="polite">{activeIndex + 1} / {collection.length}</span>}
|
||||
</div>
|
||||
<div className="image-viewer-actions">
|
||||
{!canAnnotate && !hasMarkers && (
|
||||
<button className="image-viewer-button nds-icon-button" data-size="sm" onClick={() => openImageInNewTab(image.dataUrl)} aria-label="Ouvrir l'image en taille réelle" title="Taille réelle"><Icon name="zoom" /></button>
|
||||
{isCollection && view === "detail" && (
|
||||
<button className="image-viewer-button nds-icon-button" data-size="sm" onClick={openGallery} aria-label={textContent.returnGalleryTitle || "Revenir à la galerie"} title={textContent.returnGalleryTitle || "Revenir à la galerie"}><Icon name="picture" /></button>
|
||||
)}
|
||||
{view === "detail" && !canAnnotate && !hasMarkers && (
|
||||
<button className="image-viewer-button nds-icon-button" data-size="sm" onClick={() => openImageInNewTab(activeImage.dataUrl)} aria-label="Ouvrir l'image en taille réelle" title="Taille réelle"><Icon name="zoom" /></button>
|
||||
)}
|
||||
<button className="image-viewer-button nds-icon-button" data-size="sm" onClick={onClose} aria-label="Fermer" title="Fermer"><Icon name="close" /></button>
|
||||
</div>
|
||||
</header>
|
||||
<div className={`image-viewer-body ${canAnnotate ? "has-annotation-side" : ""}`}>
|
||||
{view === "gallery" ? (
|
||||
<ImageGallery images={collection} activeIndex={activeIndex} focusIndex={galleryFocusIndex} onSelect={openGalleryImage} textContent={textContent} />
|
||||
) : <div className={`image-viewer-body ${canAnnotate ? "has-annotation-side" : ""} ${isCollection && collection.length > 1 ? "has-navigation" : ""}`}>
|
||||
{isCollection && collection.length > 1 && (
|
||||
<button className="image-viewer-nav image-viewer-nav-previous nds-icon-button" data-size="md" type="button" disabled={activeIndex === 0} onClick={() => setActiveIndex((index) => index - 1)} aria-label={textContent.previousImageTitle || "Image précédente"} title={textContent.previousImageTitle || "Image précédente"}>
|
||||
<Icon name="chevron-down" />
|
||||
</button>
|
||||
)}
|
||||
<div className={`image-viewer-media ${canAnnotate ? "can-annotate" : ""}`}>
|
||||
<div
|
||||
ref={mediaRef}
|
||||
|
|
@ -155,9 +218,9 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
|
|||
onClick={addViewerMarker}
|
||||
role={canAnnotate ? "button" : undefined}
|
||||
tabIndex={canAnnotate ? 0 : undefined}
|
||||
aria-label={canAnnotate ? image.addMarkerAriaLabel || "Ajouter un marqueur sur l'image" : undefined}
|
||||
aria-label={canAnnotate ? activeImage.addMarkerAriaLabel || "Ajouter un marqueur sur l'image" : undefined}
|
||||
>
|
||||
<img src={image.dataUrl} alt={image.alt || "Image"} />
|
||||
<img src={activeImage.dataUrl} alt={activeImage.alt || activeImage.label || textContent.imageAlt || "Image"} />
|
||||
<DrawingOverlay
|
||||
active={drawingActive}
|
||||
erase={eraseActive}
|
||||
|
|
@ -166,12 +229,12 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
|
|||
width={drawingWidth}
|
||||
onChange={updateViewerDrawings}
|
||||
getSurfaceSize={getMediaSurfaceSize}
|
||||
createStrokeId={image.createStrokeId}
|
||||
createStrokeId={activeImage.createStrokeId}
|
||||
coordinateMode="percent"
|
||||
/>
|
||||
{canDraw && (
|
||||
<DrawingControls
|
||||
textContent={image}
|
||||
textContent={activeImage}
|
||||
active={drawingActive}
|
||||
onToggleActive={() => setDrawingActive((value) => !value)}
|
||||
eraseActive={eraseActive}
|
||||
|
|
@ -193,7 +256,7 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
|
|||
as="span"
|
||||
className="annotation-marker image-viewer-marker"
|
||||
key={marker.id || `${marker.x}-${marker.y}-${index}`}
|
||||
content={marker.label || `${image.markerPrefix || "Marqueur"} ${index + 1}`}
|
||||
content={marker.label || `${activeImage.markerPrefix || "Marqueur"} ${index + 1}`}
|
||||
position="top"
|
||||
style={{ "--marker-x": `${marker.x}%`, "--marker-y": `${marker.y}%` }}
|
||||
>
|
||||
|
|
@ -202,6 +265,11 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
{isCollection && collection.length > 1 && (
|
||||
<button className="image-viewer-nav image-viewer-nav-next nds-icon-button" data-size="md" type="button" disabled={activeIndex === collection.length - 1} onClick={() => setActiveIndex((index) => index + 1)} aria-label={textContent.nextImageTitle || "Image suivante"} title={textContent.nextImageTitle || "Image suivante"}>
|
||||
<Icon name="chevron-down" />
|
||||
</button>
|
||||
)}
|
||||
{canAnnotate && (
|
||||
<aside className="image-viewer-side">
|
||||
<strong>{viewerMarkers.length} {viewerMarkers.length > 1 ? "marqueurs" : "marqueur"}</strong>
|
||||
|
|
@ -215,11 +283,11 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
|
|||
data-size="sm"
|
||||
type="text"
|
||||
value={marker.label}
|
||||
placeholder={image.markerPlaceholder || "Libellé du marqueur"}
|
||||
placeholder={activeImage.markerPlaceholder || "Libellé du marqueur"}
|
||||
onChange={(event) => updateViewerMarker(marker.id, (item) => ({ ...item, label: event.target.value }))}
|
||||
aria-label={`Libellé du marqueur ${index + 1}`}
|
||||
/>
|
||||
<button className="tool-split-action-button nds-icon-button" data-size="sm" data-variant="danger" type="button" onClick={() => removeViewerMarker(marker.id)} aria-label={`${image.deleteMarkerTitle || "Supprimer"} ${marker.label || `${image.markerPrefix || "Marqueur"} ${index + 1}`}`} title={image.deleteMarkerTitle || "Supprimer"}>
|
||||
<button className="tool-split-action-button nds-icon-button" data-size="sm" data-variant="danger" type="button" onClick={() => removeViewerMarker(marker.id)} aria-label={`${activeImage.deleteMarkerTitle || "Supprimer"} ${marker.label || `${activeImage.markerPrefix || "Marqueur"} ${index + 1}`}`} title={activeImage.deleteMarkerTitle || "Supprimer"}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</li>
|
||||
|
|
@ -230,7 +298,7 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
|
|||
)}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ import { Icon } from "./Icon.jsx";
|
|||
import { ImportButton } from "./ImportButton.jsx";
|
||||
import { Tooltip } from "./Tooltip.jsx";
|
||||
|
||||
export function Shell({ route, content, games, toolboxes, links, drawerGameId = "", actions, children }) {
|
||||
export function Shell({ route, content, games, drawerGameId = "", actions, children }) {
|
||||
const gameId = route.split("/")[1] === "games" ? route.split("/")[2] || "" : "";
|
||||
const game = games.find((item) => item.id === gameId);
|
||||
const linkedToolbox = game ? toolboxes.find((item) => item.id === links[game.id]) : null;
|
||||
const topbarLabel = route === "/design-system" && import.meta.env.DEV ? "Design system" : route.startsWith("/toolbox") ? content.topbar.toolbox : route.startsWith("/games") ? content.topbar.games : route === "/library" ? content.topbar.library : content.topbar.dashboard;
|
||||
const toolboxLabel = linkedToolbox ? `Ouvrir la toolbox ${linkedToolbox.name}` : "Associer une toolbox";
|
||||
const toolboxOpen = Boolean(game && drawerGameId === game.id);
|
||||
const toolboxLabel = toolboxOpen ? "Masquer la toolbox" : "Afficher la toolbox";
|
||||
const compactSidebar = Boolean(drawerGameId);
|
||||
|
||||
return (
|
||||
|
|
@ -48,15 +48,16 @@ export function Shell({ route, content, games, toolboxes, links, drawerGameId =
|
|||
{game && (
|
||||
<div className="topbar-actions">
|
||||
<button
|
||||
className={`drawer-button toolbox-icon-button nds-icon-button ${drawerGameId === game.id ? "active" : ""}`}
|
||||
className={`drawer-button toolbox-button nds-button ${toolboxOpen ? "active" : ""}`}
|
||||
data-size="sm"
|
||||
data-variant="primary"
|
||||
onClick={() => actions.setDrawerGameId(drawerGameId === game.id ? "" : game.id)}
|
||||
onClick={() => actions.setDrawerGameId(toolboxOpen ? "" : game.id)}
|
||||
aria-label={toolboxLabel}
|
||||
aria-pressed={drawerGameId === game.id}
|
||||
aria-pressed={toolboxOpen}
|
||||
title={toolboxLabel}
|
||||
>
|
||||
<img src="/static/icons/toolbox.svg" alt="" aria-hidden="true" />
|
||||
<span>{toolboxLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,21 @@ export function GuideChecklistControls({ checklist, list, id, open }) {
|
|||
</div>;
|
||||
}
|
||||
|
||||
export function GuideChecklistPageControls({ checklist }) {
|
||||
if (!checklist.selected) return null;
|
||||
const { content, hideCompleted } = checklist;
|
||||
return (
|
||||
<aside className="guide-checklist-page-controls nds-tool-panel" aria-label={content.label}>
|
||||
<button className={`nds-switch ${hideCompleted ? "active" : ""}`} data-size="sm" type="button" aria-pressed={hideCompleted} onClick={checklist.toggleHidden}>
|
||||
<Icon name="hide" />
|
||||
<span>{content.hideCompleted}</span>
|
||||
<i aria-hidden="true" />
|
||||
</button>
|
||||
<button className="nds-button" data-size="xs" data-variant="secondary" type="button" onClick={() => checklist.associate("")}>{content.unlink}</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function GuideChecklistMark({ completed, label }) {
|
||||
return completed ? <span className="guide-checklist-mark nds-badge" data-variant="neutral" role="img" aria-label={label} title={label}>✓</span> : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Rôle : assemble l'accueil Aniimo et la grille filtrable de ses variantes.
|
||||
import { useMemo } from "react";
|
||||
import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx";
|
||||
import { GuideChecklistPageControls } from "../GuideChecklistControls.jsx";
|
||||
import { useGuideChecklist } from "../useGuideChecklist.js";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { GameBreadcrumb } from "../GameBreadcrumb.jsx";
|
||||
|
|
@ -31,7 +32,10 @@ function AniimoListing({ game, aniimo, filters, setFilters, content }) {
|
|||
</div>
|
||||
<p>{content.description}</p>
|
||||
</div>
|
||||
<button className="nds-switch" type="button" aria-pressed={active.grouped} onClick={() => update({ ...active, grouped: !active.grouped })}><Icon name="multiple" /><span>{content.groupVariants}</span><i aria-hidden="true" /></button>
|
||||
<div className="actions game-heading-actions">
|
||||
<GuideChecklistPageControls checklist={checklist} />
|
||||
<button className="nds-switch" type="button" aria-pressed={active.grouped} onClick={() => update({ ...active, grouped: !active.grouped })}><Icon name="multiple" /><span>{content.groupVariants}</span><i aria-hidden="true" /></button>
|
||||
</div>
|
||||
</section>
|
||||
<section className="game-layout aniimo-layout">
|
||||
<aside className="game-filters"><AniimoFilters filters={active} onChange={update} options={aniimo.filterOptions} t={t} content={content} /></aside>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { useGuideChecklist } from "../useGuideChecklist.js";
|
||||
import { useMemo } from "react";
|
||||
import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx";
|
||||
import { GuideChecklistPageControls } from "../GuideChecklistControls.jsx";
|
||||
import { GameBreadcrumb } from "../GameBreadcrumb.jsx";
|
||||
import { Diablo4AffixCard } from "./Diablo4AffixCard.jsx";
|
||||
import { Diablo4Filters } from "./Diablo4Filters.jsx";
|
||||
|
|
@ -30,6 +31,9 @@ export function Diablo4Listing({ game, diablo4, filters, setFilters }) {
|
|||
</div>
|
||||
<p>Filtrez les affixes par nom et catégories pour retrouver rapidement les statistiques utiles à votre build.</p>
|
||||
</div>
|
||||
<div className="actions game-heading-actions">
|
||||
<GuideChecklistPageControls checklist={checklist} />
|
||||
</div>
|
||||
</section>
|
||||
<section className="game-layout diablo4-layout" data-game-category="diablo4-affixes">
|
||||
<aside className="game-filters">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Rôle : reconnaît les lignes de checklist et valide les associations temporaires des guides.
|
||||
export const GUIDE_CHECKLIST_SESSION_KEY = "sokkog:guide-checklists";
|
||||
// Rôle : reconnaît les lignes de checklist et valide les associations persistantes des guides.
|
||||
export const GUIDE_CHECKLIST_STORAGE_KEY = "sokkog:guide-checklists";
|
||||
const GUIDES = new Set(["aniimo/aniimos", "mhwilds/monsters", "mhwilds/endemic", "diablo4/affixes"]);
|
||||
|
||||
export function normalizeGuideLabel(value) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { GameBreadcrumb } from "../GameBreadcrumb.jsx";
|
|||
import { EndemicCard } from "./cards/EndemicCard.jsx";
|
||||
import { MonsterCard } from "./cards/MonsterCard.jsx";
|
||||
import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx";
|
||||
import { GuideChecklistPageControls } from "../GuideChecklistControls.jsx";
|
||||
import { MhwildsFilters } from "./MhwildsFilters.jsx";
|
||||
import { getFilteredMhwildsItems, getMhwildsFilterKey, getUniqueConditionValues } from "./utils.js";
|
||||
|
||||
|
|
@ -32,9 +33,12 @@ export function MhwildsListing({ category, game, mhwilds, filters, setFilters, t
|
|||
</div>
|
||||
<p>{isMonsters ? "Filtrez les monstres par nom et faiblesses, puis consultez leurs dégâts détaillés." : "Filtrez la faune par nom et zones d'apparition."}</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<a className="nds-button" data-size="sm" data-variant={isMonsters ? "primary" : "secondary"} href="#/games/mhwilds/monsters">Monstres</a>
|
||||
<a className="nds-button" data-size="sm" data-variant={!isMonsters ? "primary" : "secondary"} href="#/games/mhwilds/endemic">Faune</a>
|
||||
<div className="actions game-heading-actions">
|
||||
<GuideChecklistPageControls checklist={checklist} />
|
||||
<div className="game-heading-navigation">
|
||||
<a className="nds-button" data-size="sm" data-variant={isMonsters ? "primary" : "secondary"} href="#/games/mhwilds/monsters">Monstres</a>
|
||||
<a className="nds-button" data-size="sm" data-variant={!isMonsters ? "primary" : "secondary"} href="#/games/mhwilds/endemic">Faune</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="game-layout" data-mhwilds-category={category}>
|
||||
|
|
|
|||
|
|
@ -12,13 +12,13 @@ export function useGuideChecklistPanel(checklist, module) {
|
|||
}
|
||||
|
||||
export function useGuideChecklist(requestedGuideId) {
|
||||
const { toolboxes, links, moduleData, guideChecklistSession, actions, siteContent, route, aniimo, mhwilds, diablo4 } = useContext(GuideChecklistContext);
|
||||
const { toolboxes, links, moduleData, guideChecklistStorage, actions, siteContent, route, aniimo, mhwilds, diablo4 } = useContext(GuideChecklistContext);
|
||||
const guideId = requestedGuideId || route.replace(/^\/games\//, "");
|
||||
const entries = useMemo(() => getGuideChecklistEntries(guideId, { aniimo, mhwilds, diablo4 }), [guideId, aniimo, mhwilds, diablo4]);
|
||||
const gameId = guideId.split("/")[0];
|
||||
const content = siteContent.guideChecklist;
|
||||
const toolbox = toolboxes.find((item) => item.id === links[gameId]);
|
||||
const association = guideChecklistSession.associations[guideId];
|
||||
const association = guideChecklistStorage.associations[guideId];
|
||||
const lists = useMemo(() => (toolbox?.modules || []).filter((module) => module.type === "checklist").map((module) => ({
|
||||
id: module.id,
|
||||
...matchGuideChecklist(entries || [], moduleData[`${toolbox.id}:${module.id}`])
|
||||
|
|
@ -37,7 +37,7 @@ export function useGuideChecklist(requestedGuideId) {
|
|||
content, toolbox, selected, hideCompleted, isCompleted, canToggle, toggleCompleted, guideId, available: entries !== null,
|
||||
lists: lists.filter((list) => list.count || list.id === selected?.id),
|
||||
isVisible: (key) => !hideCompleted || !isCompleted(key),
|
||||
associate: (moduleId) => guideChecklistSession.setAssociation(guideId, moduleId ? { toolboxId: toolbox.id, moduleId, hideCompleted } : null),
|
||||
toggleHidden: () => guideChecklistSession.setAssociation(guideId, { ...association, hideCompleted: !hideCompleted })
|
||||
associate: (moduleId) => guideChecklistStorage.setAssociation(guideId, moduleId ? { toolboxId: toolbox.id, moduleId, hideCompleted } : null),
|
||||
toggleHidden: () => guideChecklistStorage.setAssociation(guideId, { ...association, hideCompleted: !hideCompleted })
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
|
|||
const toolboxGameCover = getGameCardCover(toolboxGame);
|
||||
const moduleText = siteContent.toolboxes.modules;
|
||||
const [showTutorial, setShowTutorial] = useState(() => shouldShowTutorial(toolbox, pendingTutorialToolboxId));
|
||||
const [pendingModuleId, setPendingModuleId] = useState("");
|
||||
const tutorialRequested = shouldShowTutorial(toolbox, pendingTutorialToolboxId);
|
||||
const [previousTutorial, setPreviousTutorial] = useState({ toolboxId, requested: tutorialRequested });
|
||||
// Consommer la demande de lancement ne doit pas interrompre la visite en cours.
|
||||
|
|
@ -245,6 +246,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
|
|||
modules,
|
||||
moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, module.id)
|
||||
});
|
||||
setPendingModuleId(module.id);
|
||||
if (initialData) updateModuleData(toolbox.id, module.id, initialData, type);
|
||||
}
|
||||
|
||||
|
|
@ -257,6 +259,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
|
|||
modules,
|
||||
moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, moduleId)
|
||||
});
|
||||
setPendingModuleId(moduleId);
|
||||
updateModuleData(toolbox.id, moduleId, { image: dataUrl, markers: [] }, "imageAnnotation");
|
||||
}
|
||||
|
||||
|
|
@ -320,6 +323,8 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
|
|||
toolbox={toolbox}
|
||||
moduleColumns={moduleColumns}
|
||||
context={moduleContext}
|
||||
scrollToModuleId={pendingModuleId}
|
||||
onModuleScrolled={() => setPendingModuleId("")}
|
||||
onRename={(moduleId, title) => updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? { ...module, title } : module) })}
|
||||
onUpdateModule={(moduleId, updater) => updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? updater(module) : module) })}
|
||||
onModuleOrderChange={(moduleOrder) => updateToolbox({ ...toolbox, moduleOrder })}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompl
|
|||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js";
|
||||
import { ChecklistSection } from "./ChecklistParts.jsx";
|
||||
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
import {
|
||||
appendChecklistItems,
|
||||
|
|
@ -127,8 +128,20 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc
|
|||
<TextExchangeActions type="checklist" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "checklist")} />
|
||||
</div>
|
||||
)}
|
||||
{namedSections.length > 0 && (
|
||||
<div className="checklist-toolbar">
|
||||
<div className="checklist-toolbar">
|
||||
<button
|
||||
className={`checklist-hide-completed-toggle nds-switch ${data.hideCompletedItems ? "active" : ""}`}
|
||||
data-size="lg"
|
||||
type="button"
|
||||
onClick={() => save(data.sections, { hideCompletedItems: !data.hideCompletedItems })}
|
||||
aria-pressed={data.hideCompletedItems}
|
||||
aria-label={textContent.hideCompletedItemsTitle || "Masquer les éléments terminés"}
|
||||
title={textContent.hideCompletedItemsTitle || "Masquer les éléments terminés"}
|
||||
>
|
||||
<Icon name={data.hideCompletedItems ? "eye-closed" : "eye-open"} />
|
||||
<i aria-hidden="true" />
|
||||
</button>
|
||||
{namedSections.length > 0 && (
|
||||
<div className="checklist-complete-mode-group nds-radio-button-group" data-size="md" role="radiogroup" aria-label={textContent.completedSectionsModeTitle || "Affichage des catégories terminées"}>
|
||||
{[
|
||||
{ mode: "visible", label: textContent.completedSectionsVisibleLabel || "Visible", icon: "eye-open" },
|
||||
|
|
@ -149,8 +162,9 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc
|
|||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
{!data.sections.length && <ModuleEmptyMessage>{textContent.emptyItems || "Aucun élément dans cette checklist."}</ModuleEmptyMessage>}
|
||||
<ul className="checklist">
|
||||
{visibleSections.map((section, index) => {
|
||||
const isLastSection = index === visibleSections.length - 1;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ export function ChecklistSection({ section, context, data, reorder, onRenameSect
|
|||
const hideWhenComplete = getSectionHideWhenComplete(section, data.hideCompletedSections);
|
||||
const isAutoCollapsed = sectionComplete && hideWhenComplete;
|
||||
const isCollapsed = Boolean(section.collapsed) || isAutoCollapsed;
|
||||
const visibleItems = data.hideCompletedItems
|
||||
? section.items.filter((item) => context.clampQty(item.qtyCurrent, item.qtyTarget) < item.qtyTarget)
|
||||
: section.items;
|
||||
const textContent = context.moduleText?.checklist || {};
|
||||
const className = [
|
||||
"checklist-section",
|
||||
|
|
@ -106,7 +109,7 @@ export function ChecklistSection({ section, context, data, reorder, onRenameSect
|
|||
)}
|
||||
{!isCollapsed && (
|
||||
<ul className="checklist-section-items">
|
||||
{section.items.map((item) => (
|
||||
{visibleItems.map((item) => (
|
||||
<ChecklistItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Icon } from "../../../components/Icon.jsx";
|
|||
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
||||
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
|
||||
|
||||
export function CountersModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
|
||||
const data = context.normalizeCountersData(context.getModuleData(toolboxId, moduleId, { counters: [] }));
|
||||
|
|
@ -57,6 +58,7 @@ export function CountersModule({ toolboxId, moduleId, context, editing, textExch
|
|||
<TextExchangeActions type="counters" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "counters")} />
|
||||
</div>
|
||||
)}
|
||||
{!data.counters.length && <ModuleEmptyMessage>{textContent.emptyCounters || "Aucun compteur."}</ModuleEmptyMessage>}
|
||||
<div className="counters-grid">
|
||||
{data.counters.map((counter) => {
|
||||
const className = [
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
// Rôle : fournit l'outil images avec import fichier/texte, collage, libellés et annotation rapide.
|
||||
// Rôle : fournit l'outil images avec import, galerie, libellés et annotation rapide.
|
||||
import { useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
|
||||
export function ImagesModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
|
||||
|
|
@ -41,6 +42,16 @@ export function ImagesModule({ toolboxId, moduleId, context, editing, textExchan
|
|||
});
|
||||
}
|
||||
|
||||
function openCollection(initialView, activeIndex = 0) {
|
||||
context.setImage({
|
||||
kind: "collection",
|
||||
images: data.images,
|
||||
activeIndex,
|
||||
initialView,
|
||||
textContent
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{editing && (
|
||||
|
|
@ -93,8 +104,17 @@ export function ImagesModule({ toolboxId, moduleId, context, editing, textExchan
|
|||
<TextExchangeActions type="images" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "images")} />
|
||||
</div>
|
||||
)}
|
||||
{data.images.length > 0 && (
|
||||
<div className="images-toolbar">
|
||||
<button className="nds-button" data-size="sm" type="button" onClick={() => openCollection("gallery")}>
|
||||
<Icon name="picture" />
|
||||
<span>{textContent.galleryButton || "Galerie"}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!data.images.length && <ModuleEmptyMessage>{textContent.emptyImages || "Aucune image."}</ModuleEmptyMessage>}
|
||||
<div className="images">
|
||||
{data.images.map((image) => {
|
||||
{data.images.map((image, index) => {
|
||||
const className = [
|
||||
isItemDragging(image.id) ? "is-dragging" : "",
|
||||
isItemDropTarget(image.id) ? "is-drop-target" : "",
|
||||
|
|
@ -114,7 +134,7 @@ export function ImagesModule({ toolboxId, moduleId, context, editing, textExchan
|
|||
>
|
||||
<Icon name="drag" />
|
||||
</button>
|
||||
<button className="image-preview" onClick={() => context.setImage(image)} aria-label={textContent.previewAriaLabel || "Agrandir l'image"}>
|
||||
<button className="image-preview" onClick={() => openCollection("detail", index)} aria-label={textContent.previewAriaLabel || "Agrandir l'image"}>
|
||||
<img src={image.dataUrl} alt={textContent.imageAlt || "Image"} />
|
||||
</button>
|
||||
{editing ? (
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Icon } from "../../../components/Icon.jsx";
|
|||
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
||||
import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
|
||||
|
||||
function getLinkCategory(link) {
|
||||
return String(link?.category || "").trim();
|
||||
|
|
@ -173,6 +174,7 @@ export function LinksModule({ toolboxId, moduleId, context, editing, textExchang
|
|||
<TextExchangeActions type="links" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "links")} />
|
||||
</div>
|
||||
)}
|
||||
{!data.links.length && <ModuleEmptyMessage>{textContent.emptyLinks || "Aucun lien."}</ModuleEmptyMessage>}
|
||||
<div className="links-category-list">
|
||||
{getLinkEntries(data).map((entry) => {
|
||||
if (entry.type === "group") {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
// Rôle : rend le message compact commun aux outils sans contenu.
|
||||
export function ModuleEmptyMessage({ children }) {
|
||||
return <p className="toolbox-module-empty">{children}</p>;
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// Rôle : rend l'en-tête, les contrôles et le contenu repliable d'un outil toolbox.
|
||||
import { Suspense, useState } from "react";
|
||||
import { Suspense, useEffect, useRef, useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||
import { GuideChecklistControls, GuideChecklistToggle } from "../../games/GuideChecklistControls.jsx";
|
||||
|
|
@ -8,12 +8,13 @@ import { ModuleDataGate, ModuleLoadingFallback } from "./ModuleDataGate.jsx";
|
|||
import { supportsTextExchange } from "./textImport.js";
|
||||
import { MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";
|
||||
|
||||
export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRename, onUpdateModule, onDelete }) {
|
||||
export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", scrollIntoView = false, onScrolledIntoView, onRename, onUpdateModule, onDelete }) {
|
||||
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
|
||||
const Component = definition.Component;
|
||||
const label = definition.label || module.type;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [textExchangeOpen, setTextExchangeOpen] = useState(false);
|
||||
const moduleRef = useRef(null);
|
||||
const guidePanel = useGuideChecklistPanel(context.guideChecklist, module);
|
||||
const hasAddPanel = definition.editable && definition.addable !== false;
|
||||
const hasTextExchange = supportsTextExchange(module.type);
|
||||
|
|
@ -40,6 +41,15 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
|
|||
textExchangeOpen
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollIntoView) return undefined;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
moduleRef.current?.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
|
||||
onScrolledIntoView?.();
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [onScrolledIntoView, scrollIntoView]);
|
||||
|
||||
function toggleCollapsed() {
|
||||
if (!collapsed) {
|
||||
setEditing(false);
|
||||
|
|
@ -58,8 +68,10 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
|
|||
|
||||
return (
|
||||
<article
|
||||
ref={moduleRef}
|
||||
className={className}
|
||||
data-module-type={module.type}
|
||||
data-module-id={module.id}
|
||||
{...reorder.getItemProps({ itemId: module.id, groupId: itemGroup, parentId: toolbox.id })}
|
||||
>
|
||||
<header>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ function moveModuleInTwoColumnOrder(order, operation) {
|
|||
return { ...order, two: columns };
|
||||
}
|
||||
|
||||
export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onUpdateModule, onDelete, onModuleOrderChange }) {
|
||||
export function ToolboxModules({ toolbox, moduleColumns, context, scrollToModuleId = "", onModuleScrolled, onRename, onUpdateModule, onDelete, onModuleOrderChange }) {
|
||||
const twoColumnOrder = useMemo(() => toolbox.moduleOrder?.two || [[], []], [toolbox.moduleOrder?.two]);
|
||||
const orderedModules = useMemo(() => getModulesByIds(toolbox.modules, toolbox.moduleOrder?.one || []), [toolbox.modules, toolbox.moduleOrder?.one]);
|
||||
const columns = useMemo(() => [
|
||||
|
|
@ -101,6 +101,8 @@ export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onUp
|
|||
context={context}
|
||||
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
|
||||
itemGroup=""
|
||||
scrollIntoView={module.id === scrollToModuleId}
|
||||
onScrolledIntoView={onModuleScrolled}
|
||||
onRename={onRename}
|
||||
onUpdateModule={onUpdateModule}
|
||||
onDelete={onDelete}
|
||||
|
|
@ -128,6 +130,8 @@ export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onUp
|
|||
context={context}
|
||||
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
|
||||
itemGroup={MODULE_COLUMN_IDS[index]}
|
||||
scrollIntoView={module.id === scrollToModuleId}
|
||||
onScrolledIntoView={onModuleScrolled}
|
||||
onRename={onRename}
|
||||
onUpdateModule={onUpdateModule}
|
||||
onDelete={onDelete}
|
||||
|
|
|
|||
|
|
@ -30,15 +30,16 @@ function normalizeChecklistSection(section, fallbackTitle = "") {
|
|||
}
|
||||
|
||||
export function normalizeChecklistData(data) {
|
||||
const hideCompletedItems = Boolean(data?.hideCompletedItems);
|
||||
const hideCompletedSections = Boolean(data?.hideCompletedSections);
|
||||
const hideCompletedSectionsFully = Boolean(data?.hideCompletedSectionsFully);
|
||||
const sections = Array.isArray(data?.sections)
|
||||
? data.sections.map((section) => normalizeChecklistSection(section)).filter((section) => section.items.length)
|
||||
: [];
|
||||
const simpleItems = (data?.items || []).map(normalizeChecklistItem).filter((item) => item.label);
|
||||
if (!sections.length && simpleItems.length) return { hideCompletedSections, hideCompletedSectionsFully, sections: [{ id: uid("section"), title: "", items: simpleItems }], items: simpleItems };
|
||||
if (!sections.length && simpleItems.length) return { hideCompletedItems, hideCompletedSections, hideCompletedSectionsFully, sections: [{ id: uid("section"), title: "", items: simpleItems }], items: simpleItems };
|
||||
const items = sections.flatMap((section) => section.items);
|
||||
return { hideCompletedSections, hideCompletedSectionsFully, sections, items };
|
||||
return { hideCompletedItems, hideCompletedSections, hideCompletedSectionsFully, sections, items };
|
||||
}
|
||||
|
||||
function compactChecklistItemForStorage(item) {
|
||||
|
|
@ -64,11 +65,12 @@ export function compactChecklistSectionForStorage(section) {
|
|||
export function compactChecklistDataForStorage(value) {
|
||||
const normalized = normalizeChecklistData(value);
|
||||
const sections = normalized.sections.map(compactChecklistSectionForStorage).filter((section) => section.items.length);
|
||||
if (!sections.length) return null;
|
||||
const settings = {
|
||||
...(normalized.hideCompletedItems ? { hideCompletedItems: true } : {}),
|
||||
...(normalized.hideCompletedSections ? { hideCompletedSections: true } : {}),
|
||||
...(normalized.hideCompletedSectionsFully ? { hideCompletedSectionsFully: true } : {})
|
||||
};
|
||||
if (!sections.length) return Object.keys(settings).length ? settings : null;
|
||||
if (sections.length === 1 && !sections[0].title && typeof sections[0].hideWhenComplete !== "boolean") return { ...settings, items: sections[0].items };
|
||||
return { ...settings, sections };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,16 +191,19 @@ function remapModuleDataForExport(type, data, nextId) {
|
|||
const compact = compactModuleDataForStorage(type, data);
|
||||
if (!compact) return null;
|
||||
if (type === "checklist") {
|
||||
const { sections, items, ...settings } = compact;
|
||||
if (compact.sections) {
|
||||
return {
|
||||
sections: compact.sections.map((section) => ({
|
||||
...settings,
|
||||
sections: sections.map((section) => ({
|
||||
...section,
|
||||
id: nextId("section"),
|
||||
items: section.items.map((item) => ({ ...item, id: nextId("item") }))
|
||||
}))
|
||||
};
|
||||
}
|
||||
return { items: compact.items.map((item) => ({ ...item, id: nextId("item") })) };
|
||||
if (items) return { ...settings, items: items.map((item) => ({ ...item, id: nextId("item") })) };
|
||||
return settings;
|
||||
}
|
||||
if (type === "images") {
|
||||
return { images: compact.images.map((image) => ({ ...image, id: nextId("image") })) };
|
||||
|
|
|
|||
|
|
@ -1,25 +1,27 @@
|
|||
// Rôle : conserve les liaisons guide/checklist pendant la session et retire les références supprimées.
|
||||
// Rôle : persiste les liaisons guide/checklist et retire les références devenues invalides.
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { GUIDE_CHECKLIST_SESSION_KEY, validGuideAssociations } from "../features/games/guideChecklist.js";
|
||||
import { GUIDE_CHECKLIST_STORAGE_KEY, validGuideAssociations } from "../features/games/guideChecklist.js";
|
||||
|
||||
function readSession() {
|
||||
function readStoredAssociations() {
|
||||
try {
|
||||
const value = JSON.parse(sessionStorage.getItem(GUIDE_CHECKLIST_SESSION_KEY) || "{}");
|
||||
const value = JSON.parse(localStorage.getItem(GUIDE_CHECKLIST_STORAGE_KEY) || "{}");
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||
} catch { return {}; }
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function useGuideChecklistSession({ toolboxes, links, ready }) {
|
||||
const [associations, setAssociations] = useState(readSession);
|
||||
export function useGuideChecklistStorage({ toolboxes, links, ready }) {
|
||||
const [associations, setAssociations] = useState(readStoredAssociations);
|
||||
const valid = useMemo(() => ready ? validGuideAssociations(associations, toolboxes, links) : associations, [associations, toolboxes, links, ready]);
|
||||
if (ready && Object.keys(valid).length !== Object.keys(associations).length) setAssociations(valid);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
try {
|
||||
if (Object.keys(valid).length) sessionStorage.setItem(GUIDE_CHECKLIST_SESSION_KEY, JSON.stringify(valid));
|
||||
else sessionStorage.removeItem(GUIDE_CHECKLIST_SESSION_KEY);
|
||||
} catch { /* La session reste disponible en mémoire si le navigateur refuse le stockage. */ }
|
||||
if (Object.keys(valid).length) localStorage.setItem(GUIDE_CHECKLIST_STORAGE_KEY, JSON.stringify(valid));
|
||||
else localStorage.removeItem(GUIDE_CHECKLIST_STORAGE_KEY);
|
||||
} catch { /* Les liaisons restent disponibles en mémoire si le stockage local est indisponible. */ }
|
||||
}, [ready, valid]);
|
||||
|
||||
function setAssociation(guideId, association) {
|
||||
|
|
@ -30,5 +32,6 @@ export function useGuideChecklistSession({ toolboxes, links, ready }) {
|
|||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return { associations: valid, setAssociation };
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ import { useIndexedToolboxes } from "./features/toolboxes/storage/useIndexedTool
|
|||
import { useToolboxActions } from "./features/toolboxes/useToolboxActions.js";
|
||||
import { useAppData } from "./hooks/useAppData.js";
|
||||
import { GuideChecklistContext } from "./features/games/useGuideChecklist.js";
|
||||
import { useGuideChecklistSession } from "./hooks/useGuideChecklistSession.js";
|
||||
import { useGuideChecklistStorage } from "./hooks/useGuideChecklistStorage.js";
|
||||
import { useGameFilters } from "./hooks/useGameFilters.js";
|
||||
import { useToolboxAlerts } from "./hooks/useToolboxAlerts.js";
|
||||
import { RouteContent } from "./router/RouteContent.jsx";
|
||||
|
|
@ -39,6 +39,7 @@ function App() {
|
|||
const [autoBackupModalOpen, setAutoBackupModalOpen] = useState(false);
|
||||
const [linkModalGameId, setLinkModalGameId] = useState("");
|
||||
const [drawerGameId, setDrawerGameId] = useState("");
|
||||
const [drawerRoute, setDrawerRoute] = useState(route);
|
||||
const [image, setImage] = useState(null);
|
||||
const [pendingTutorialToolboxId, setPendingTutorialToolboxId] = useState("");
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
|
|
@ -48,7 +49,13 @@ function App() {
|
|||
const store = useIndexedToolboxes(handleStorageError);
|
||||
const autoBackup = useAutoBackup(store);
|
||||
const { backupNeeded, requestPermission: requestBackupPermission } = autoBackup;
|
||||
const guideChecklistSession = useGuideChecklistSession(store);
|
||||
const guideChecklistStorage = useGuideChecklistStorage(store);
|
||||
|
||||
if (drawerRoute !== route) {
|
||||
setDrawerRoute(route);
|
||||
const activeGameId = route.match(/^\/games\/([^/]+)/)?.[1] || "";
|
||||
if (activeGameId !== drawerGameId) setDrawerGameId("");
|
||||
}
|
||||
|
||||
const notify = useCallback((message, options = {}) => {
|
||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
|
@ -154,7 +161,7 @@ function App() {
|
|||
}
|
||||
|
||||
return (
|
||||
<GuideChecklistContext.Provider value={{ route, aniimo, mhwilds, diablo4, siteContent, toolboxes: store.toolboxes, links: store.links, moduleData: store.moduleData, guideChecklistSession, actions }}>
|
||||
<GuideChecklistContext.Provider value={{ route, aniimo, mhwilds, diablo4, siteContent, toolboxes: store.toolboxes, links: store.links, moduleData: store.moduleData, guideChecklistStorage, actions }}>
|
||||
<Shell route={route} content={siteContent} games={games} toolboxes={store.toolboxes} links={store.links} drawerGameId={drawerGameId} actions={actions}>
|
||||
<AppErrorBoundary route={route}>
|
||||
<RouteContent
|
||||
|
|
|
|||
|
|
@ -91,6 +91,16 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.game-heading-actions,
|
||||
.guide-checklist-page-controls {
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.guide-checklist-page-controls {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.home-onboarding-steps-action {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
|
@ -236,6 +246,34 @@
|
|||
height: min(280px, 32vh);
|
||||
}
|
||||
|
||||
.image-viewer-root {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.image-viewer {
|
||||
width: calc(100vw - 16px);
|
||||
max-height: calc(100vh - 16px);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.image-viewer.is-gallery {
|
||||
height: calc(100vh - 16px);
|
||||
}
|
||||
|
||||
.image-viewer-body.has-navigation {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.image-viewer-gallery {
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.image-viewer-gallery-frame {
|
||||
min-height: 180px;
|
||||
height: min(42vh, 260px);
|
||||
}
|
||||
|
||||
.module-column {
|
||||
display: contents;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -407,13 +407,14 @@
|
|||
gap: 10px;
|
||||
}
|
||||
|
||||
.toolbox-icon-button {
|
||||
padding: 0;
|
||||
.toolbox-button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbox-icon-button img {
|
||||
.toolbox-button img {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
flex: 0 0 auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -173,9 +173,9 @@
|
|||
}
|
||||
|
||||
.nds-filter-search {
|
||||
flex: 0 1 280px;
|
||||
width: min(100%, 280px);
|
||||
max-width: 280px;
|
||||
flex: 0 1 260px;
|
||||
width: min(100%, 260px);
|
||||
max-width: 260px;
|
||||
min-width: min(100%, 190px);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,28 @@
|
|||
.guide-checklist-actions { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: var(--space-2); padding-top: var(--space-2); }
|
||||
}
|
||||
.module-guide-button[data-linked] .ui-icon { color: var(--color-accent-gold); }
|
||||
.game-heading-actions {
|
||||
display: flex;
|
||||
align-self: stretch;
|
||||
align-items: flex-end;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.guide-checklist-page-controls {
|
||||
display: flex;
|
||||
align-self: flex-end;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
width: fit-content;
|
||||
padding: 8px;
|
||||
}
|
||||
.guide-checklist-page-controls .nds-switch { max-width: 100%; }
|
||||
.game-heading-navigation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.guide-checklist-checkbox {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Rôle : stylise le viewer d'image et son panneau d'annotation.
|
||||
// Rôle : stylise la visionneuse d'image, sa galerie et son panneau d'annotation.
|
||||
.image-viewer-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
|
@ -38,6 +38,13 @@
|
|||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.image-viewer-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.image-viewer-title {
|
||||
overflow: hidden;
|
||||
color: var(--color-text);
|
||||
|
|
@ -46,6 +53,17 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.image-viewer-position {
|
||||
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;
|
||||
}
|
||||
|
||||
.image-viewer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
|
@ -69,6 +87,24 @@
|
|||
justify-items: stretch;
|
||||
}
|
||||
|
||||
.image-viewer-body.has-navigation {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.image-viewer-nav {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.image-viewer-nav .ui-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.image-viewer-nav-next .ui-icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.image-viewer-media {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
|
|
@ -158,3 +194,73 @@
|
|||
overscroll-behavior: contain;
|
||||
padding: 4px 4px 4px 0;
|
||||
}
|
||||
|
||||
.image-viewer.is-gallery {
|
||||
height: min(860px, calc(100vh - 48px));
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.image-viewer-gallery {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
align-content: start;
|
||||
gap: var(--space-4);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 2px 6px 2px 2px;
|
||||
}
|
||||
|
||||
.image-viewer-gallery-item {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
color: var(--color-text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-viewer-gallery-item.is-active {
|
||||
border-color: rgba(246, 196, 83, 0.52);
|
||||
box-shadow: inset 0 0 0 1px rgba(246, 196, 83, 0.16);
|
||||
}
|
||||
|
||||
.image-viewer-gallery-frame {
|
||||
display: grid;
|
||||
min-height: 200px;
|
||||
height: clamp(200px, 24vh, 280px);
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(5, 7, 17, 0.72);
|
||||
}
|
||||
|
||||
.image-viewer-gallery-frame img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.image-viewer-gallery-caption {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.image-viewer-gallery-caption strong {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.image-viewer-gallery-caption small {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,16 @@
|
|||
|
||||
.checklist-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4) var(--space-4) 0;
|
||||
}
|
||||
|
||||
.checklist-hide-completed-toggle {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.checklist-complete-mode-group {
|
||||
@include controls.radio-button-group;
|
||||
width: fit-content;
|
||||
|
|
|
|||
|
|
@ -194,6 +194,21 @@
|
|||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.images-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: var(--space-4) var(--space-4) 0;
|
||||
}
|
||||
|
||||
.images-toolbar .ui-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.images-toolbar + .images {
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
.images {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
|
|
|
|||
|
|
@ -145,6 +145,15 @@
|
|||
-webkit-mask-size: contain;
|
||||
}
|
||||
|
||||
.toolbox-module-empty {
|
||||
margin: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
border: 1px dashed rgba(165, 180, 252, 0.18);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.module-icon-notepad {
|
||||
mask-image: url("/static/icons/notepad.svg");
|
||||
-webkit-mask-image: url("/static/icons/notepad.svg");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue