Move guide checklist controls into a collapsible header toggle
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-24 08:26:48 +02:00
parent ef0c592c11
commit 14f1b79cbc
12 changed files with 144 additions and 89 deletions

View file

@ -2720,11 +2720,8 @@
"emptyText": "Créez une première toolbox pour stocker vos outils dans ce navigateur."
},
"guideChecklist": {
"label": "Checklist associée",
"untitled": "Checklist sans titre",
"none": "Aucune checklist",
"linkToolbox": "Associer une toolbox pour suivre une checklist",
"noCompatible": "Aucune checklist de cette toolbox ne correspond aux éléments du guide.",
"label": "Lien avec le guide",
"associate": "Associer à la page",
"noMatches": "Cette checklist ne contient plus de correspondance avec le guide.",
"matches": "correspondances",
"unlink": "Dissocier",

View file

@ -1,32 +1,32 @@
// Rôle : affiche le sélecteur de checklist et le masquage commun aux pages guides.
// Rôle : expose le suivi du guide dans chaque checklist compatible et marque les cartes terminées.
import { Icon } from "../../components/Icon.jsx";
import { useGuideChecklist } from "./useGuideChecklist.js";
export function CurrentGuideChecklistControls({ gameId }) {
const checklist = useGuideChecklist();
return checklist.available && checklist.guideId.split("/")[0] === gameId ? <GuideChecklistControls checklist={checklist} /> : null;
export function GuideChecklistToggle({ checklist, panel, label }) {
if (!panel.list) return null;
return <button
className={`module-guide-button nds-icon-button ${panel.open ? "active" : ""}`}
data-size="md"
data-linked={checklist.selected?.id === panel.list.id || undefined}
type="button"
aria-label={`${checklist.content.label} — ${label}`}
title={checklist.content.label}
aria-expanded={panel.open}
aria-pressed={panel.open}
aria-controls={panel.id}
onClick={panel.toggle}
><Icon name="link" /></button>;
}
export function GuideChecklistControls({ checklist }) {
const { content, toolbox, selected, lists, hideCompleted } = checklist;
return <div className="guide-checklist-controls nds-panel" aria-label={content.label}>
{!toolbox ? <button type="button" className="nds-button" data-size="sm" onClick={checklist.linkToolbox}>{content.linkToolbox}</button> : <>
<label className="field compact">
<span>{content.label}</span>
<select className="nds-select" data-size="sm" value={selected?.id || ""} disabled={!lists.length} onChange={(event) => checklist.associate(event.target.value)}>
<option value="">{content.none}</option>
{lists.map((list) => <option key={list.id} value={list.id}>{list.title} · {list.count} {content.matches}</option>)}
</select>
</label>
{!lists.length && <p>{content.noCompatible}</p>}
{selected && <>
<div className="guide-checklist-actions">
<button type="button" className="nds-button" data-size="sm" onClick={() => checklist.associate("")}>{content.unlink}</button>
<button type="button" className="nds-switch" aria-pressed={hideCompleted} onClick={checklist.toggleHidden}><Icon name="hide" /><span>{content.hideCompleted}</span><i aria-hidden="true" /></button>
</div>
{!selected.count && <p>{content.noMatches}</p>}
</>}
</>}
export function GuideChecklistControls({ checklist, list, id, open }) {
if (!list) return null;
const { content, selected, hideCompleted } = checklist;
const active = selected?.id === list.id;
return <div id={id} hidden={!open} className="guide-checklist-controls" data-linked={active || undefined}>
<div className="guide-checklist-actions">
<button type="button" className="guide-checklist-link nds-button" data-size="sm" data-variant={active ? "primary" : "secondary"} aria-pressed={active} onClick={() => checklist.associate(active ? "" : list.id)}>{active ? content.unlink : content.associate}</button>
<button type="button" className="nds-switch" disabled={!active} aria-pressed={active && hideCompleted} onClick={checklist.toggleHidden}><Icon name="hide" /><span>{content.hideCompleted}</span><i aria-hidden="true" /></button>
</div>
<p>{list.count ? `${list.count} ${content.matches}` : content.noMatches}</p>
</div>;
}

View file

@ -1,21 +1,28 @@
// Rôle : partage la progression réactive et les commandes de liaison entre les guides.
import { createContext, useContext, useMemo } from "react";
import { createContext, useContext, useId, useMemo, useState } from "react";
import { getGuideChecklistEntries, matchGuideChecklist } from "./guideChecklist.js";
export const GuideChecklistContext = createContext(null);
export function useGuideChecklistPanel(checklist, module) {
const id = useId();
const [open, setOpen] = useState(false);
const list = module.type === "checklist" ? checklist?.lists.find((item) => item.id === module.id) : undefined;
return { id, open, list, toggle: () => setOpen((value) => !value) };
}
export function useGuideChecklist(requestedGuideId) {
const { toolboxes, links, moduleData, guideChecklistSession, actions, siteContent, route, aniimo, mhwilds, diablo4 } = useContext(GuideChecklistContext);
const { toolboxes, links, moduleData, guideChecklistSession, 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 lists = useMemo(() => (toolbox?.modules || []).filter((module) => module.type === "checklist").map((module, index) => ({
id: module.id, title: module.title || `${content.untitled} ${index + 1}`,
const lists = useMemo(() => (toolbox?.modules || []).filter((module) => module.type === "checklist").map((module) => ({
id: module.id,
...matchGuideChecklist(entries || [], moduleData[`${toolbox.id}:${module.id}`])
})), [entries, toolbox, moduleData, content.untitled]);
})), [entries, toolbox, moduleData]);
const selected = lists.find((list) => list.id === association?.moduleId);
const hideCompleted = Boolean(selected && association.hideCompleted);
const isCompleted = (key) => Boolean(selected?.completed.has(key));
@ -24,7 +31,6 @@ export function useGuideChecklist(requestedGuideId) {
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 }),
linkToolbox: () => actions.setLinkModalGameId(gameId)
toggleHidden: () => guideChecklistSession.setAssociation(guideId, { ...association, hideCompleted: !hideCompleted })
};
}

View file

@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react";
import { GuidedTooltipTour } from "../../components/GuidedTooltipTour.jsx";
import { Icon } from "../../components/Icon.jsx";
import { ImportButton } from "../../components/ImportButton.jsx";
import { CurrentGuideChecklistControls } from "../games/GuideChecklistControls.jsx";
import { useGuideChecklist } from "../games/useGuideChecklist.js";
import { StorageQuota } from "../../components/StorageQuota.jsx";
import { ToastPositionSwitch } from "../../components/ToastPositionSwitch.jsx";
import { useGroupedReorder } from "../../hooks/useGroupedReorder.js";
@ -180,7 +180,7 @@ function shouldShowTutorial(toolbox, pendingTutorialToolboxId) {
return toolbox?.tutorial === true || pendingTutorialToolboxId === toolbox?.id;
}
function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles, pendingTutorialToolboxId = "", setPendingTutorialToolboxId }) {
function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles, pendingTutorialToolboxId = "", setPendingTutorialToolboxId, guideChecklist }) {
const toolboxId = toolbox.id;
const moduleColumns = embedded || Number(toolbox.moduleColumns) === 1 ? 1 : 2;
const toolboxGameCover = getGameCardCover(toolboxGame);
@ -194,6 +194,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
if (previousTutorial.toolboxId !== toolboxId || tutorialRequested) setShowTutorial(tutorialRequested);
}
const moduleContext = {
guideChecklist,
getModuleData,
ensureModuleData: actions.ensureModuleData,
isModuleDataReady: actions.isModuleDataReady,
@ -341,6 +342,8 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
}
export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles }) {
const checklist = useGuideChecklist();
const guideChecklist = checklist.available && checklist.guideId.split("/")[0] === gameId ? checklist : undefined;
const selectedId = links[gameId] || "";
const toolbox = toolboxes.find((item) => item.id === selectedId);
const [width, setWidth] = useState("");
@ -400,8 +403,7 @@ export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, act
</div>
<button className="drawer-close-button nds-icon-button" data-size="sm" onClick={() => actions.setDrawerGameId("")} aria-label="Fermer" title="Fermer"><Icon name="close" /></button>
</header>
<CurrentGuideChecklistControls gameId={gameId} />
{toolbox ? <ToolboxView siteContent={siteContent} toolbox={toolbox} toolboxGame={game} embedded actions={actions} storageUsage={storageUsage} storageReady={storageReady} getModuleData={getModuleData} updateToolbox={updateToolbox} updateModuleData={updateModuleData} addImageFiles={addImageFiles} /> : (
{toolbox ? <ToolboxView siteContent={siteContent} toolbox={toolbox} toolboxGame={game} embedded actions={actions} storageUsage={storageUsage} storageReady={storageReady} getModuleData={getModuleData} updateToolbox={updateToolbox} updateModuleData={updateModuleData} addImageFiles={addImageFiles} guideChecklist={guideChecklist} /> : (
<div className="empty nds-empty-state"><p>Aucune toolbox associée à cette page jeu.</p><button className="nds-button" data-size="sm" data-variant="primary" onClick={() => actions.setCreateModal({ gameId })}>Créer et associer</button></div>
)}
<StorageQuota usage={storageUsage} />

View file

@ -1,7 +1,11 @@
// Rôle : attend les médias persistés avant de monter un outil éditable et propose une reprise locale en cas d'échec.
// Rôle : affiche le chargement des outils et attend leurs médias persistés, avec reprise locale en cas d'échec.
import { useEffect, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
export function ModuleLoadingFallback({ label }) {
return <div className="module-loading-fallback">Chargement de {label}...</div>;
}
export function ModuleDataGate({ context, toolboxId, moduleId, children }) {
const ready = context.isModuleDataReady?.(toolboxId, moduleId) ?? true;
const load = context.ensureModuleData;

View file

@ -1,20 +1,18 @@
// Rôle : rend les modules d'une toolbox, leur shell commun et leur réorganisation.
import { Suspense, useMemo, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { GuideChecklistControls, GuideChecklistToggle } from "../../games/GuideChecklistControls.jsx";
import { useGuideChecklistPanel } from "../../games/useGuideChecklist.js";
import { useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { supportsTextExchange } from "./textImport.js";
import { MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";
import { ModuleDataGate } from "./ModuleDataGate.jsx";
import { ModuleDataGate, ModuleLoadingFallback } from "./ModuleDataGate.jsx";
const MODULE_COLUMN_IDS = ["left", "right"];
function getModuleMap(modules) {
return new Map(modules.map((module) => [module.id, module]));
}
function getModulesByIds(modules, ids) {
const moduleMap = getModuleMap(modules);
const moduleMap = new Map(modules.map((module) => [module.id, module]));
return (Array.isArray(ids) ? ids : []).map((id) => moduleMap.get(id)).filter(Boolean);
}
@ -157,6 +155,7 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
const label = definition.label || module.type;
const [editing, setEditing] = useState(false);
const [textExchangeOpen, setTextExchangeOpen] = useState(false);
const guidePanel = useGuideChecklistPanel(context.guideChecklist, module);
const hasAddPanel = definition.editable && definition.addable !== false;
const hasTextExchange = supportsTextExchange(module.type);
const scrollable = definition.scrollable && module.scrollable === true;
@ -242,6 +241,7 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
<Icon name="add" />
</button>
)}
<GuideChecklistToggle checklist={context.guideChecklist} panel={guidePanel} label={module.title || label} />
{hasTextExchange && (
<button
className={`module-exchange-button nds-icon-button ${textExchangeOpen ? "active" : ""}`}
@ -264,6 +264,7 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
</div>
</header>
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
<GuideChecklistControls checklist={context.guideChecklist} {...guidePanel} />
<Suspense fallback={<ModuleLoadingFallback label={label} />}>
<ModuleDataGate key={`${toolbox.id}:${module.id}`} context={context} toolboxId={toolbox.id} moduleId={module.id}>
<Component {...componentProps} />
@ -274,10 +275,6 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
);
}
function ModuleLoadingFallback({ label }) {
return <div className="module-loading-fallback">Chargement de {label}...</div>;
}
function EditableModuleTitle({ value, fallback, onSave, tutorialTarget = "" }) {
const inlineEdit = useInlineEdit({
value,

View file

@ -1,18 +1,15 @@
// Rôle : présente la liaison checklist et l'état terminé communs aux cartes de guides.
.guide-checklist-controls {
display: grid;
align-items: center;
text-align: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
padding: var(--space-3);
margin-bottom: var(--space-3);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-border);
min-width: 0;
.field { min-width: 0; margin: 0; }
select { width: 100%; min-width: 0; }
p { margin: 0; color: var(--color-text-muted); font-size: var(--font-size-sm); }
p { margin: var(--space-2) 0 0; color: var(--color-text-muted); font-size: var(--font-size-xs); }
.nds-switch { max-width: 100%; }
.guide-checklist-actions { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: var(--space-3); }
.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); }
.guide-checklist-mark {
position: absolute;
top: 10px;