Improve toolbox checklists, image browsing, and guide integration
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-27 22:26:39 +02:00
parent 00e73f0062
commit 386212041f
47 changed files with 744 additions and 112 deletions

View 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>
);
}

View file

@ -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>
);

View file

@ -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>
)}

View file

@ -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;
}

View file

@ -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>

View file

@ -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">

View file

@ -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) {

View file

@ -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}>

View file

@ -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 })
};
}

View file

@ -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 })}

View file

@ -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;

View file

@ -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}

View file

@ -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 = [

View file

@ -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 ? (

View file

@ -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") {

View file

@ -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>;
}

View file

@ -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>

View file

@ -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}

View file

@ -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 };
}

View file

@ -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") })) };

View file

@ -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 };
}

View file

@ -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

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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;

View file

@ -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));

View file

@ -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");