Add guided onboarding tour for first toolbox creation
All checks were successful
Deploy Sokko G / deploy (push) Successful in 14s

This commit is contained in:
Shinuwa 2026-08-14 13:20:47 +02:00
parent 3007aa1389
commit 4f73e0068b
20 changed files with 470 additions and 20 deletions

View file

@ -71,7 +71,7 @@ export function ToolboxCard({ toolbox, game, actions, reorder }) {
);
}
export function ToolboxIconPicker({ toolbox, onChange }) {
export function ToolboxIconPicker({ toolbox, onChange, tutorialTarget = "" }) {
const icon = normalizeToolboxIcon(toolbox.icon);
return (
@ -86,6 +86,7 @@ export function ToolboxIconPicker({ toolbox, onChange }) {
onClick={toggle}
aria-label="Changer l'icône de la toolbox"
aria-expanded={open}
data-toolbox-tutorial-target={tutorialTarget || undefined}
title="Changer l'icône"
>
<img src={icon} alt="" aria-hidden="true" />

View file

@ -1,5 +1,6 @@
// Rôle : affiche les pages toolbox, le panneau latéral et leurs contrôles.
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 { StorageQuota } from "../../components/StorageQuota.jsx";
@ -169,10 +170,18 @@ export function ToolboxPage(props) {
return <ToolboxView {...props} toolbox={toolbox} toolboxGame={getToolboxGame(toolbox)} embedded={false} />;
}
function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, getModuleData, updateToolbox, updateModuleData, addImageFiles }) {
function shouldShowTutorial(toolbox, pendingTutorialToolboxId) {
return toolbox?.tutorial === true || pendingTutorialToolboxId === toolbox?.id;
}
function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, getModuleData, updateToolbox, updateModuleData, addImageFiles, pendingTutorialToolboxId = "", setPendingTutorialToolboxId }) {
const toolboxId = toolbox.id;
const toolboxTutorial = toolbox.tutorial;
const moduleColumns = embedded || Number(toolbox.moduleColumns) === 1 ? 1 : 2;
const toolboxGameCover = getGameCardCover(toolboxGame);
const moduleText = siteContent.toolboxes.modules;
const [showTutorial, setShowTutorial] = useState(() => shouldShowTutorial(toolbox, pendingTutorialToolboxId));
const currentToolboxIdRef = useRef(toolboxId);
const moduleContext = {
getModuleData,
moduleText,
@ -202,6 +211,15 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
refresh: () => {}
};
useEffect(() => {
if (currentToolboxIdRef.current === toolboxId) {
if (shouldShowTutorial(toolbox, pendingTutorialToolboxId)) setShowTutorial(true);
return;
}
currentToolboxIdRef.current = toolboxId;
setShowTutorial(shouldShowTutorial(toolbox, pendingTutorialToolboxId));
}, [pendingTutorialToolboxId, toolbox, toolboxId, toolboxTutorial]);
function addModule(type) {
if (!type) return;
const module = { id: uid("mod"), type };
@ -231,10 +249,10 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
<div>
<p className="eyebrow">Toolbox</p>
<div className="toolbox-title-row">
{!embedded && !toolboxGame && <ToolboxIconPicker toolbox={toolbox} onChange={(icon) => updateToolbox({ ...toolbox, icon })} />}
{!embedded && !toolboxGame && <ToolboxIconPicker toolbox={toolbox} onChange={(icon) => updateToolbox({ ...toolbox, icon })} tutorialTarget="icon" />}
{!embedded && toolboxGameCover && <ToolboxGameIcon game={toolboxGame} />}
{embedded ? <h1>{toolbox.name}</h1> : (
<EditableTitle className="toolbox-title" value={toolbox.name} fallback="Nouvelle toolbox" onSave={(name) => name !== toolbox.name && updateToolbox({ ...toolbox, name })} />
<EditableTitle className="toolbox-title" value={toolbox.name} fallback="Nouvelle toolbox" tutorialTarget="title" onSave={(name) => name !== toolbox.name && updateToolbox({ ...toolbox, name })} />
)}
</div>
{toolboxGame && (
@ -252,6 +270,25 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
<AddToolControls onAdd={addModule} />
</div>
</section>
{!embedded && showTutorial && (
<GuidedTooltipTour
steps={siteContent.toolboxes.tutorial?.items || []}
labels={{
dismiss: siteContent.toolboxes.tutorial?.dismissLabel,
next: siteContent.toolboxes.tutorial?.nextLabel,
done: siteContent.toolboxes.tutorial?.doneLabel
}}
targetAttribute="data-toolbox-tutorial-target"
onDismiss={() => {
if (pendingTutorialToolboxId === toolbox.id) setPendingTutorialToolboxId?.("");
setShowTutorial(false);
}}
onStarted={() => {
if (pendingTutorialToolboxId === toolbox.id) setPendingTutorialToolboxId?.("");
if (toolbox.tutorial === true) updateToolbox({ ...toolbox, tutorial: false });
}}
/>
)}
{!embedded && (
<section className="modules-toolbar" aria-label="Options d'affichage des outils">
<div className="layout-switch" role="group" aria-label="Mode d'affichage des outils">
@ -359,7 +396,7 @@ export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, act
);
}
function EditableTitle({ value, fallback, onSave, className = "module-title" }) {
function EditableTitle({ value, fallback, onSave, className = "module-title", tutorialTarget = "" }) {
const inlineEdit = useInlineEdit({
value,
onCommit: (title) => onSave(title || fallback),
@ -370,6 +407,7 @@ function EditableTitle({ value, fallback, onSave, className = "module-title" })
<h1
{...inlineEdit.getContentEditableProps({
className,
"data-toolbox-tutorial-target": tutorialTarget || undefined,
title: "Cliquer pour renommer"
})}
>{value}</h1>

View file

@ -115,6 +115,7 @@ export function AddToolControls({ onAdd }) {
className="primary tool-add-modal-button"
type="button"
onClick={() => setOpen(true)}
data-toolbox-tutorial-target="addTool"
>
Ajouter un outil
</button>
@ -311,7 +312,7 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
<span className="module-icon" aria-hidden="true">
<span className={`module-icon-svg module-icon-${definition.icon || "notepad"}`} />
</span>
<EditableModuleTitle value={module.title || label} fallback={label} onSave={(title) => onRename(module.id, title)} />
<EditableModuleTitle value={module.title || label} fallback={label} tutorialTarget={module.type === "notepad" ? "moduleTitle" : ""} onSave={(title) => onRename(module.id, title)} />
</div>
<div>
{definition.scrollable && (
@ -345,7 +346,7 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
<Icon name="add" />
</button>
)}
<button className="module-delete-button danger" onClick={() => onDelete(module.id)} aria-label={`Retirer ${module.title || label}`} title="Retirer">
<button className="module-delete-button danger" onClick={() => onDelete(module.id)} aria-label={`Retirer ${module.title || label}`} data-toolbox-tutorial-target={module.type === "links" ? "deleteTool" : undefined} title="Retirer">
<span className="ui-icon ui-icon-trash" aria-hidden="true" />
</button>
</div>
@ -357,7 +358,7 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
);
}
function EditableModuleTitle({ value, fallback, onSave }) {
function EditableModuleTitle({ value, fallback, onSave, tutorialTarget = "" }) {
const inlineEdit = useInlineEdit({
value,
onCommit: (title) => onSave(title || fallback),
@ -368,6 +369,7 @@ function EditableModuleTitle({ value, fallback, onSave }) {
<h2
{...inlineEdit.getContentEditableProps({
className: "module-title",
"data-toolbox-tutorial-target": tutorialTarget || undefined,
title: "Cliquer pour renommer"
})}
>

View file

@ -114,7 +114,7 @@ export function remapModuleOrderIds(moduleOrder, moduleIdMap) {
export function normalizeToolbox(toolbox) {
if (!toolbox || typeof toolbox !== "object") return null;
const modules = (Array.isArray(toolbox.modules) ? toolbox.modules : []).map(normalizeToolboxModule).filter(Boolean);
return {
const normalized = {
id: toolbox.id || uid("tbx"),
name: String(toolbox.name || "Nouvelle toolbox").trim() || "Nouvelle toolbox",
icon: normalizeToolboxIcon(toolbox.icon),
@ -123,6 +123,8 @@ export function normalizeToolbox(toolbox) {
moduleOrder: normalizeModuleOrder(toolbox.moduleOrder, modules),
updatedAt: toolbox.updatedAt || new Date().toISOString()
};
if (toolbox.tutorial === true) normalized.tutorial = true;
return normalized;
}
export function compactToolboxForStorage(toolbox) {
@ -137,6 +139,7 @@ export function compactToolboxForStorage(toolbox) {
};
if (normalized.moduleColumns === 1) compact.moduleColumns = 1;
if (normalized.icon !== DEFAULT_TOOLBOX_ICON) compact.icon = normalized.icon;
if (normalized.tutorial === true) compact.tutorial = true;
return compact;
}

View file

@ -20,19 +20,36 @@ function downloadJson(payload, filename) {
URL.revokeObjectURL(link.href);
}
export function useToolboxActions({ store, notify, setConfirmModal, setCreateModal, setLinkModalGameId, setDrawerGameId, setImage }) {
function createToolbox(name, gameId = "") {
function getLibraryUrl() {
if (typeof window === "undefined") return "https://sokko-g.local/#/library";
return `${window.location.origin}${window.location.pathname}#/library`;
}
export function useToolboxActions({ store, notify, creationTemplate, setConfirmModal, setCreateModal, setLinkModalGameId, setDrawerGameId, setImage }) {
function createToolbox(name, gameId = "", options = {}) {
const now = new Date().toISOString();
const notepadModuleId = uid("mod");
const linksModuleId = uid("mod");
const welcomeText = creationTemplate?.notepad?.text || "Profite bien de ta nouvelle toolbox.";
const welcomeHtml = creationTemplate?.notepad?.html || `<p>${welcomeText}</p>`;
const libraryLink = {
id: uid("link"),
title: creationTemplate?.links?.title || "Librairie des outils",
url: getLibraryUrl()
};
const toolbox = {
id: uid("tbx"),
name: name.trim() || "Nouvelle toolbox",
updatedAt: now,
modules: [
{ id: uid("mod"), type: "notepad", title: "Notes rapides" },
{ id: uid("mod"), type: "checklist" }
{ id: notepadModuleId, type: "notepad", title: creationTemplate?.notepad?.title || "Bienvenue" },
{ id: linksModuleId, type: "links", title: creationTemplate?.links?.moduleTitle || "Liens utiles" }
]
};
if (options.tutorial === true) toolbox.tutorial = true;
if (!store.setToolboxes([toolbox, ...store.toolboxes])) return null;
store.updateModuleData(toolbox.id, notepadModuleId, { html: welcomeHtml, text: welcomeText, updatedAt: now }, "notepad");
store.updateModuleData(toolbox.id, linksModuleId, { links: [libraryLink] }, "links");
if (gameId) store.setLinks({ ...store.links, [gameId]: toolbox.id });
return toolbox;
}