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

@ -55,6 +55,7 @@ Les toolboxes sont stockées dans `kv/toolboxes`.
"one": ["mod1"], "one": ["mod1"],
"two": [["mod1"], []] "two": [["mod1"], []]
}, },
"tutorial": true,
"updatedAt": "2026-07-25T12:00:00.000Z" "updatedAt": "2026-07-25T12:00:00.000Z"
} }
``` ```
@ -69,6 +70,7 @@ Notes :
- Chaque identifiant d'outil présent dans `modules` doit apparaître exactement une fois dans `moduleOrder.one` et exactement une fois dans l'une des deux colonnes de `moduleOrder.two`. - Chaque identifiant d'outil présent dans `modules` doit apparaître exactement une fois dans `moduleOrder.one` et exactement une fois dans l'une des deux colonnes de `moduleOrder.two`.
- Si une ancienne toolbox n'a pas encore `moduleOrder.two`, les outils sont répartis automatiquement depuis l'ordre une colonne avec une moitié en colonne gauche et une moitié en colonne droite. - Si une ancienne toolbox n'a pas encore `moduleOrder.two`, les outils sont répartis automatiquement depuis l'ordre une colonne avec une moitié en colonne gauche et une moitié en colonne droite.
- Les données lourdes des outils ne sont pas stockées dans la toolbox, mais dans `modules`. - Les données lourdes des outils ne sont pas stockées dans la toolbox, mais dans `modules`.
- `tutorial` est un indicateur temporaire réservé aux toolboxes créées depuis la prise en main. Il déclenche les premiers repères à l'ouverture, puis il est retiré automatiquement du stockage.
## Liens Jeu / Toolbox ## Liens Jeu / Toolbox

View file

@ -596,6 +596,8 @@ export function validateSiteContent(site) {
} }
}); });
assertNonEmptyString(site, "home.onboarding.primaryAction");
const onboardingCards = valueAt(site, "home.onboarding.cards"); const onboardingCards = valueAt(site, "home.onboarding.cards");
assert.ok(Array.isArray(onboardingCards), "home.onboarding.cards must be an array"); assert.ok(Array.isArray(onboardingCards), "home.onboarding.cards must be an array");
assert.ok(onboardingCards.length >= 2, "home.onboarding.cards must contain at least 2 items"); assert.ok(onboardingCards.length >= 2, "home.onboarding.cards must contain at least 2 items");
@ -629,6 +631,35 @@ export function validateSiteContent(site) {
assertNonEmptyString({ slide }, "slide.text"); assertNonEmptyString({ slide }, "slide.text");
}); });
const toolboxCreationTemplate = valueAt(site, "toolboxes.creationTemplate");
assert.equal(typeof toolboxCreationTemplate, "object", "toolboxes.creationTemplate must be an object");
assertNonEmptyString({ toolboxCreationTemplate }, "toolboxCreationTemplate.notepad.title");
assertNonEmptyString({ toolboxCreationTemplate }, "toolboxCreationTemplate.notepad.text");
assertNonEmptyString({ toolboxCreationTemplate }, "toolboxCreationTemplate.notepad.html");
assertNonEmptyString({ toolboxCreationTemplate }, "toolboxCreationTemplate.links.moduleTitle");
assertNonEmptyString({ toolboxCreationTemplate }, "toolboxCreationTemplate.links.title");
const toolboxTutorial = valueAt(site, "toolboxes.tutorial");
assert.equal(typeof toolboxTutorial, "object", "toolboxes.tutorial must be an object");
assertNonEmptyString({ toolboxTutorial }, "toolboxTutorial.eyebrow");
assertNonEmptyString({ toolboxTutorial }, "toolboxTutorial.title");
assertNonEmptyString({ toolboxTutorial }, "toolboxTutorial.text");
assertNonEmptyString({ toolboxTutorial }, "toolboxTutorial.dismissLabel");
assertNonEmptyString({ toolboxTutorial }, "toolboxTutorial.nextLabel");
assertNonEmptyString({ toolboxTutorial }, "toolboxTutorial.doneLabel");
assert.ok(Array.isArray(toolboxTutorial.items), "toolboxes.tutorial.items must be an array");
assert.ok(toolboxTutorial.items.length >= 4, "toolboxes.tutorial.items must contain at least 4 items");
toolboxTutorial.items.forEach((item, index) => {
assertNonEmptyString({ item }, "item.target");
assertNonEmptyString({ item }, "item.icon");
assertNonEmptyString({ item }, "item.title");
assertNonEmptyString({ item }, "item.text");
if (item.placement) {
assert.ok(["above", "below", "right"].includes(item.placement), "item.placement must be above, below or right");
}
assert.ok(index < 6, "toolboxes.tutorial.items should stay compact");
});
const lines = valueAt(site, "home.origin.lines"); const lines = valueAt(site, "home.origin.lines");
assert.ok(Array.isArray(lines), "home.origin.lines must be an array"); assert.ok(Array.isArray(lines), "home.origin.lines must be an array");
assert.ok(lines.length >= 2, "home.origin.lines must contain at least 2 items"); assert.ok(lines.length >= 2, "home.origin.lines must contain at least 2 items");

View file

@ -64,9 +64,12 @@ test("vite entrypoint and app shell are wired", async () => {
assert.match(homePage, /export function HomePage/); assert.match(homePage, /export function HomePage/);
assert.match(homePage, /HomeOnboardingSection/); assert.match(homePage, /HomeOnboardingSection/);
assert.match(homePage, /import\.meta\.env\.DEV \|\| toolboxes\.length === 0/); assert.match(homePage, /import\.meta\.env\.DEV \|\| toolboxes\.length === 0/);
assert.match(homePage, /setCreateModal\(\{ tutorial: true \}\)/);
assert.match(homePage, /dragon-home\.png/); assert.match(homePage, /dragon-home\.png/);
assert.match(homeOnboarding, /export function HomeOnboardingSection/); assert.match(homeOnboarding, /export function HomeOnboardingSection/);
assert.match(homeOnboarding, /prefers-reduced-motion: reduce/); assert.match(homeOnboarding, /prefers-reduced-motion: reduce/);
assert.match(homeOnboarding, /onCreateToolbox/);
assert.match(homeOnboarding, /content\.primaryAction/);
assert.match(homeOnboarding, /content\.sliderTitle/); assert.match(homeOnboarding, /content\.sliderTitle/);
assert.match(homeOnboarding, /home-onboarding-dots/); assert.match(homeOnboarding, /home-onboarding-dots/);
assert.match(aboutPage, /export function AboutPage/); assert.match(aboutPage, /export function AboutPage/);

View file

@ -21,6 +21,7 @@ test("toolbox storage, cards and pages are wired", async () => {
const indexedToolboxesHook = await readFile("website/src/features/toolboxes/storage/useIndexedToolboxes.js", "utf8"); const indexedToolboxesHook = await readFile("website/src/features/toolboxes/storage/useIndexedToolboxes.js", "utf8");
const storageQuota = await readFile("website/src/components/StorageQuota.jsx", "utf8"); const storageQuota = await readFile("website/src/components/StorageQuota.jsx", "utf8");
const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8"); const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8");
const guidedTooltipTour = await readFile("website/src/components/GuidedTooltipTour.jsx", "utf8");
const reorderHook = await readFile("website/src/hooks/usePointerReorder.js", "utf8"); const reorderHook = await readFile("website/src/hooks/usePointerReorder.js", "utf8");
const groupedReorderHook = await readFile("website/src/hooks/useGroupedReorder.js", "utf8"); const groupedReorderHook = await readFile("website/src/hooks/useGroupedReorder.js", "utf8");
const inlineEditHook = await readFile("website/src/hooks/useInlineEdit.js", "utf8"); const inlineEditHook = await readFile("website/src/hooks/useInlineEdit.js", "utf8");
@ -34,6 +35,10 @@ test("toolbox storage, cards and pages are wired", async () => {
assert.match(toolboxActions, /export function useToolboxActions/); assert.match(toolboxActions, /export function useToolboxActions/);
assert.match(toolboxActions, /importAllToolboxesPayload/); assert.match(toolboxActions, /importAllToolboxesPayload/);
assert.match(toolboxActions, /async function addImageFiles/); assert.match(toolboxActions, /async function addImageFiles/);
assert.match(toolboxActions, /creationTemplate/);
assert.match(toolboxActions, /type: "notepad"/);
assert.match(toolboxActions, /type: "links"/);
assert.match(toolboxActions, /#\/library/);
assert.match(`${source}\n${toolboxActions}`, /features\/toolboxes\/storage\/toolboxStorage\.js/); assert.match(`${source}\n${toolboxActions}`, /features\/toolboxes\/storage\/toolboxStorage\.js/);
assert.match(toolboxStorage, /TOOLBOX_ICON_FILES/); assert.match(toolboxStorage, /TOOLBOX_ICON_FILES/);
assert.match(toolboxStorage, /export function normalizeToolboxIcon/); assert.match(toolboxStorage, /export function normalizeToolboxIcon/);
@ -85,6 +90,14 @@ test("toolbox storage, cards and pages are wired", async () => {
assert.match(toolboxPages, /moduleColumns/); assert.match(toolboxPages, /moduleColumns/);
assert.match(toolboxPages, /normalizeCombosData/); assert.match(toolboxPages, /normalizeCombosData/);
assert.match(toolboxPages, /function ToolboxView/); assert.match(toolboxPages, /function ToolboxView/);
assert.match(toolboxPages, /GuidedTooltipTour/);
assert.match(toolboxPages, /data-toolbox-tutorial-target/);
assert.match(guidedTooltipTour, /document\.querySelector/);
assert.match(guidedTooltipTour, /--tutorial-arrow-left/);
assert.match(guidedTooltipTour, /--tutorial-arrow-top/);
assert.match(guidedTooltipTour, /data-placement/);
assert.match(toolboxPages, /toolbox\.tutorial === true/);
assert.match(toolboxPages, /tutorial: false/);
assert.doesNotMatch(toolboxPages, /usePointerReorder/); assert.doesNotMatch(toolboxPages, /usePointerReorder/);
assert.match(storageQuota, /export function StorageQuota/); assert.match(storageQuota, /export function StorageQuota/);
assert.match(storageQuota, /role="progressbar"/); assert.match(storageQuota, /role="progressbar"/);

View file

@ -1186,6 +1186,7 @@
"eyebrow": "Prise en main", "eyebrow": "Prise en main",
"title": "Votre première toolbox", "title": "Votre première toolbox",
"description": "Une toolbox est votre espace personnel pour garder vos notes, listes, images, liens et outils utiles dans ce navigateur.", "description": "Une toolbox est votre espace personnel pour garder vos notes, listes, images, liens et outils utiles dans ce navigateur.",
"primaryAction": "Créer ma première toolbox",
"stepsTitle": "Les étapes", "stepsTitle": "Les étapes",
"sliderTitle": "Aperçu", "sliderTitle": "Aperçu",
"sliderLabel": "Aperçu de la prise en main", "sliderLabel": "Aperçu de la prise en main",
@ -1430,6 +1431,64 @@
"toastPositionLeft": "Afficher les alertes en bas à gauche", "toastPositionLeft": "Afficher les alertes en bas à gauche",
"toastPositionRight": "Afficher les alertes en bas à droite", "toastPositionRight": "Afficher les alertes en bas à droite",
"summary": "Créez des espaces modulaires pour regrouper vos outils de session : notes, suivis, références, captures et futurs outils restent accessibles rapidement, en local, par jeu ou par usage.", "summary": "Créez des espaces modulaires pour regrouper vos outils de session : notes, suivis, références, captures et futurs outils restent accessibles rapidement, en local, par jeu ou par usage.",
"creationTemplate": {
"notepad": {
"title": "Bienvenue",
"text": "Profite bien de ta nouvelle toolbox.",
"html": "<p>Profite bien de ta nouvelle toolbox.</p>"
},
"links": {
"moduleTitle": "Liens utiles",
"title": "Librairie des outils"
}
},
"tutorial": {
"eyebrow": "Premiers repères",
"title": "Votre toolbox est prête",
"text": "Avant d’ajouter du contenu, voici les actions principales à connaître.",
"dismissLabel": "Masquer les repères",
"nextLabel": "Suivant",
"doneLabel": "Terminer",
"items": [
{
"target": "icon",
"icon": "picture",
"title": "Changer l’icône",
"text": "Cliquez sur l’icône de la toolbox pour choisir une image plus reconnaissable."
},
{
"target": "title",
"icon": "edit",
"title": "Renommer la toolbox",
"text": "Cliquez sur son nom pour le modifier directement."
},
{
"target": "addTool",
"icon": "add",
"title": "Ajouter un outil",
"text": "Utilisez le bouton Ajouter un outil pour choisir ce dont vous avez besoin."
},
{
"target": "moduleTitle",
"icon": "edit",
"title": "Renommer un outil",
"text": "Cliquez sur le titre d’un outil, comme Bloc notes, pour l’adapter à votre usage."
},
{
"target": "deleteTool",
"icon": "trash",
"title": "Retirer un outil",
"text": "Chaque outil ajouté peut être retiré de la toolbox avec sa corbeille."
},
{
"target": "exportAll",
"placement": "right",
"icon": "export",
"title": "Sauvegarder régulièrement",
"text": "Pensez à exporter vos toolboxes avant un nettoyage du navigateur, un changement de machine ou une réinstallation."
}
]
},
"storageHelp": { "storageHelp": {
"title": "Sauvegardes locales", "title": "Sauvegardes locales",
"text": "Vos toolboxes restent dans l'IndexedDB de ce navigateur. Rien n’est envoyé sur un serveur.", "text": "Vos toolboxes restent dans l'IndexedDB de ce navigateur. Rien n’est envoyé sur un serveur.",

View file

@ -5,6 +5,12 @@ import { ConfirmModal, CreateToolboxModal, LinkToolboxModal, NotificationToast }
import { navigate } from "../router/hashRouter.js"; import { navigate } from "../router/hashRouter.js";
import { uid } from "../features/toolboxes/storage/toolboxStorage.js"; import { uid } from "../features/toolboxes/storage/toolboxStorage.js";
function scrollToTopAfterNavigation() {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => window.scrollTo({ top: 0, left: 0, behavior: "auto" }));
});
}
export function InlineNotice({ className = "", iconName = "link", message, tone = "warning" }) { export function InlineNotice({ className = "", iconName = "link", message, tone = "warning" }) {
if (!message) return null; if (!message) return null;
const classes = ["inline-notice", `is-${tone}`, className].filter(Boolean).join(" "); const classes = ["inline-notice", `is-${tone}`, className].filter(Boolean).join(" ");
@ -34,7 +40,8 @@ export function AppOverlays({
toastPosition, toastPosition,
getGame, getGame,
toolboxes, toolboxes,
links links,
setPendingTutorialToolboxId
}) { }) {
return ( return (
<> <>
@ -47,9 +54,13 @@ export function AppOverlays({
const gameId = createModal.gameId || ""; const gameId = createModal.gameId || "";
setCreateModal(null); setCreateModal(null);
if (!name) return; if (!name) return;
const toolbox = actions.createToolbox(name, gameId); const toolbox = actions.createToolbox(name, gameId, { tutorial: createModal.tutorial === true });
if (toolbox && createModal.tutorial === true) setPendingTutorialToolboxId?.(toolbox.id);
if (toolbox && gameId) setDrawerGameId(gameId); if (toolbox && gameId) setDrawerGameId(gameId);
else if (toolbox) navigate(`/toolbox/${toolbox.id}`); else if (toolbox) {
navigate(`/toolbox/${toolbox.id}`);
scrollToTopAfterNavigation();
}
}} />} }} />}
{linkModalGameId && <LinkToolboxModal gameId={linkModalGameId} toolboxes={toolboxes} selectedId={links[linkModalGameId] || ""} onClose={(toolboxId) => { {linkModalGameId && <LinkToolboxModal gameId={linkModalGameId} toolboxes={toolboxes} selectedId={links[linkModalGameId] || ""} onClose={(toolboxId) => {
const gameId = linkModalGameId; const gameId = linkModalGameId;

View file

@ -0,0 +1,109 @@
// Rôle : rend un tutoriel séquentiel sous forme de tooltips ciblés sur des éléments du DOM.
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "./Icon.jsx";
export function GuidedTooltipTour({ steps, labels = {}, targetAttribute = "data-guided-tooltip-target", onDismiss, onStarted }) {
const [stepIndex, setStepIndex] = useState(0);
const [style, setStyle] = useState(null);
const tooltipRef = useRef(null);
const startedRef = useRef(false);
const currentStep = steps?.[stepIndex];
const isLastStep = stepIndex >= (steps?.length || 0) - 1;
const placeTooltip = useCallback(() => {
if (!currentStep?.target) return;
const target = document.querySelector(`[${targetAttribute}="${currentStep.target}"]`);
if (!target) {
setStyle(null);
return;
}
let rect = target.getBoundingClientRect();
if (rect.top < 72 || rect.bottom > window.innerHeight - 72) {
target.scrollIntoView({ block: "center", inline: "nearest", behavior: "auto" });
rect = target.getBoundingClientRect();
}
const width = Math.min(320, window.innerWidth - 24);
if (currentStep.placement === "right") {
const availableRight = window.innerWidth - rect.right - 24;
const nextWidth = Math.min(300, Math.max(230, availableRight));
const left = Math.min(window.innerWidth - nextWidth - 12, rect.right + 12);
const top = Math.max(12, rect.top + rect.height / 2 - 75);
const arrowTop = Math.min(132, Math.max(18, rect.top + rect.height / 2 - top));
setStyle({ placement: "right", top: `${top}px`, left: `${left}px`, width: `${nextWidth}px`, "--tutorial-arrow-top": `${arrowTop}px` });
if (!startedRef.current) {
startedRef.current = true;
onStarted?.();
}
return;
}
const targetCenter = rect.left + rect.width / 2;
const left = Math.min(window.innerWidth - width - 12, Math.max(12, targetCenter - width / 2));
const arrowLeft = Math.min(width - 18, Math.max(18, targetCenter - left));
const below = rect.bottom + 12;
const placement = below + 170 > window.innerHeight ? "above" : "below";
const top = placement === "above" ? Math.max(12, rect.top - 158) : below;
setStyle({ placement, top: `${top}px`, left: `${left}px`, width: `${width}px`, "--tutorial-arrow-left": `${arrowLeft}px` });
if (!startedRef.current) {
startedRef.current = true;
onStarted?.();
}
}, [currentStep, onStarted, targetAttribute]);
useEffect(() => {
let frameId = 0;
let attempts = 0;
function placeWhenReady() {
attempts += 1;
placeTooltip();
if (attempts < 20 && currentStep?.target && !document.querySelector(`[${targetAttribute}="${currentStep.target}"]`)) {
frameId = window.requestAnimationFrame(placeWhenReady);
}
}
placeWhenReady();
window.addEventListener("resize", placeTooltip);
window.addEventListener("scroll", placeTooltip, true);
return () => {
window.cancelAnimationFrame(frameId);
window.removeEventListener("resize", placeTooltip);
window.removeEventListener("scroll", placeTooltip, true);
};
}, [currentStep, placeTooltip, targetAttribute]);
useLayoutEffect(() => {
const tooltip = tooltipRef.current;
if (!tooltip || !style) return;
const viewportHeight = window.visualViewport?.height || window.innerHeight;
const rect = tooltip.getBoundingClientRect();
const nextTop = Math.min(Math.max(12, rect.top), Math.max(12, viewportHeight - rect.height - 12));
if (Math.abs(nextTop - rect.top) < 1) return;
const arrowTop = style.placement === "right" && style["--tutorial-arrow-top"]
? `${Number.parseFloat(style["--tutorial-arrow-top"]) + rect.top - nextTop}px`
: style["--tutorial-arrow-top"];
setStyle((current) => current ? { ...current, top: `${nextTop}px`, "--tutorial-arrow-top": arrowTop } : current);
}, [style, currentStep]);
if (!steps?.length || !currentStep || !style) return null;
const { placement, ...tooltipStyle } = style;
return createPortal(
<section ref={tooltipRef} className="toolbox-tutorial-tooltip" aria-live="polite" aria-labelledby="toolbox-tutorial-title" data-placement={placement} role="dialog" style={tooltipStyle}>
<button className="toolbox-tutorial-close" type="button" onClick={onDismiss} aria-label={labels.dismiss || "Fermer le tutoriel"} title={labels.dismiss || "Fermer"}>
<Icon name="close" />
</button>
<span className="toolbox-tutorial-count">{stepIndex + 1}/{steps.length}</span>
<div className="toolbox-tutorial-heading">
<Icon name={currentStep.icon || "info"} />
<strong id="toolbox-tutorial-title">{currentStep.title}</strong>
</div>
<p>{currentStep.text}</p>
<footer>
<button className="primary" type="button" onClick={isLastStep ? onDismiss : () => setStepIndex((index) => Math.min(index + 1, steps.length - 1))}>
{isLastStep ? labels.done || "Terminer" : labels.next || "Suivant"}
</button>
</footer>
</section>,
document.body
);
}

View file

@ -32,7 +32,7 @@ export function Shell({ route, content, games, toolboxes, links, actions, childr
<span className="badge">{content.sidebar.badge}</span> <span className="badge">{content.sidebar.badge}</span>
<p>{content.sidebar.note}</p> <p>{content.sidebar.note}</p>
<div className="sidebar-storage-actions" aria-label="Actions globales de stockage"> <div className="sidebar-storage-actions" aria-label="Actions globales de stockage">
<button className="sidebar-action-button primary" onClick={actions.exportAllToolboxes} aria-label="Exporter toutes les toolboxes" title="Exporter tout"><Icon name="export" /></button> <button className="sidebar-action-button primary" onClick={actions.exportAllToolboxes} aria-label="Exporter toutes les toolboxes" data-toolbox-tutorial-target="exportAll" title="Exporter tout"><Icon name="export" /></button>
<ImportButton className="sidebar-action-button import-icon-button" title="Importer tout" ariaLabel="Importer toutes les toolboxes" onFile={actions.importAllToolboxes} icon="import" /> <ImportButton className="sidebar-action-button import-icon-button" title="Importer tout" ariaLabel="Importer toutes les toolboxes" onFile={actions.importAllToolboxes} icon="import" />
</div> </div>
</div> </div>

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); const icon = normalizeToolboxIcon(toolbox.icon);
return ( return (
@ -86,6 +86,7 @@ export function ToolboxIconPicker({ toolbox, onChange }) {
onClick={toggle} onClick={toggle}
aria-label="Changer l'icône de la toolbox" aria-label="Changer l'icône de la toolbox"
aria-expanded={open} aria-expanded={open}
data-toolbox-tutorial-target={tutorialTarget || undefined}
title="Changer l'icône" title="Changer l'icône"
> >
<img src={icon} alt="" aria-hidden="true" /> <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. // Rôle : affiche les pages toolbox, le panneau latéral et leurs contrôles.
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { GuidedTooltipTour } from "../../components/GuidedTooltipTour.jsx";
import { Icon } from "../../components/Icon.jsx"; import { Icon } from "../../components/Icon.jsx";
import { ImportButton } from "../../components/ImportButton.jsx"; import { ImportButton } from "../../components/ImportButton.jsx";
import { StorageQuota } from "../../components/StorageQuota.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} />; 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 moduleColumns = embedded || Number(toolbox.moduleColumns) === 1 ? 1 : 2;
const toolboxGameCover = getGameCardCover(toolboxGame); const toolboxGameCover = getGameCardCover(toolboxGame);
const moduleText = siteContent.toolboxes.modules; const moduleText = siteContent.toolboxes.modules;
const [showTutorial, setShowTutorial] = useState(() => shouldShowTutorial(toolbox, pendingTutorialToolboxId));
const currentToolboxIdRef = useRef(toolboxId);
const moduleContext = { const moduleContext = {
getModuleData, getModuleData,
moduleText, moduleText,
@ -202,6 +211,15 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
refresh: () => {} 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) { function addModule(type) {
if (!type) return; if (!type) return;
const module = { id: uid("mod"), type }; const module = { id: uid("mod"), type };
@ -231,10 +249,10 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
<div> <div>
<p className="eyebrow">Toolbox</p> <p className="eyebrow">Toolbox</p>
<div className="toolbox-title-row"> <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 && toolboxGameCover && <ToolboxGameIcon game={toolboxGame} />}
{embedded ? <h1>{toolbox.name}</h1> : ( {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> </div>
{toolboxGame && ( {toolboxGame && (
@ -252,6 +270,25 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
<AddToolControls onAdd={addModule} /> <AddToolControls onAdd={addModule} />
</div> </div>
</section> </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 && ( {!embedded && (
<section className="modules-toolbar" aria-label="Options d'affichage des outils"> <section className="modules-toolbar" aria-label="Options d'affichage des outils">
<div className="layout-switch" role="group" aria-label="Mode 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({ const inlineEdit = useInlineEdit({
value, value,
onCommit: (title) => onSave(title || fallback), onCommit: (title) => onSave(title || fallback),
@ -370,6 +407,7 @@ function EditableTitle({ value, fallback, onSave, className = "module-title" })
<h1 <h1
{...inlineEdit.getContentEditableProps({ {...inlineEdit.getContentEditableProps({
className, className,
"data-toolbox-tutorial-target": tutorialTarget || undefined,
title: "Cliquer pour renommer" title: "Cliquer pour renommer"
})} })}
>{value}</h1> >{value}</h1>

View file

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

View file

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

View file

@ -20,19 +20,36 @@ function downloadJson(payload, filename) {
URL.revokeObjectURL(link.href); URL.revokeObjectURL(link.href);
} }
export function useToolboxActions({ store, notify, setConfirmModal, setCreateModal, setLinkModalGameId, setDrawerGameId, setImage }) { function getLibraryUrl() {
function createToolbox(name, gameId = "") { 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 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 = { const toolbox = {
id: uid("tbx"), id: uid("tbx"),
name: name.trim() || "Nouvelle toolbox", name: name.trim() || "Nouvelle toolbox",
updatedAt: now, updatedAt: now,
modules: [ modules: [
{ id: uid("mod"), type: "notepad", title: "Notes rapides" }, { id: notepadModuleId, type: "notepad", title: creationTemplate?.notepad?.title || "Bienvenue" },
{ id: uid("mod"), type: "checklist" } { 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; 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 }); if (gameId) store.setLinks({ ...store.links, [gameId]: toolbox.id });
return toolbox; return toolbox;
} }

View file

@ -25,6 +25,7 @@ function App() {
const [linkModalGameId, setLinkModalGameId] = useState(""); const [linkModalGameId, setLinkModalGameId] = useState("");
const [drawerGameId, setDrawerGameId] = useState(""); const [drawerGameId, setDrawerGameId] = useState("");
const [image, setImage] = useState(null); const [image, setImage] = useState(null);
const [pendingTutorialToolboxId, setPendingTutorialToolboxId] = useState("");
const [notifications, setNotifications] = useState([]); const [notifications, setNotifications] = useState([]);
const [storageError, setStorageError] = useState(""); const [storageError, setStorageError] = useState("");
const handleStorageError = useCallback((message) => setStorageError(message), []); const handleStorageError = useCallback((message) => setStorageError(message), []);
@ -61,6 +62,7 @@ function App() {
const actions = useToolboxActions({ const actions = useToolboxActions({
store, store,
notify, notify,
creationTemplate: siteContent?.toolboxes?.creationTemplate,
setCreateModal, setCreateModal,
setConfirmModal, setConfirmModal,
setLinkModalGameId, setLinkModalGameId,
@ -114,6 +116,8 @@ function App() {
updateToolbox={actions.updateToolbox} updateToolbox={actions.updateToolbox}
updateModuleData={store.updateModuleData} updateModuleData={store.updateModuleData}
addImageFiles={actions.addImageFiles} addImageFiles={actions.addImageFiles}
pendingTutorialToolboxId={pendingTutorialToolboxId}
setPendingTutorialToolboxId={setPendingTutorialToolboxId}
/> />
{drawerGameId && ( {drawerGameId && (
<Suspense fallback={null}> <Suspense fallback={null}>
@ -151,6 +155,7 @@ function App() {
getGame={getGame} getGame={getGame}
toolboxes={store.toolboxes} toolboxes={store.toolboxes}
links={store.links} links={store.links}
setPendingTutorialToolboxId={setPendingTutorialToolboxId}
/> />
</Shell> </Shell>
); );

View file

@ -7,7 +7,7 @@ function prefersReducedMotion() {
return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
} }
export function HomeOnboardingSection({ content, className = "" }) { export function HomeOnboardingSection({ content, className = "", onCreateToolbox }) {
const slides = useMemo(() => content?.slides || [], [content]); const slides = useMemo(() => content?.slides || [], [content]);
const [activeSlide, setActiveSlide] = useState(0); const [activeSlide, setActiveSlide] = useState(0);
const [isPaused, setIsPaused] = useState(false); const [isPaused, setIsPaused] = useState(false);
@ -45,6 +45,13 @@ export function HomeOnboardingSection({ content, className = "" }) {
</li> </li>
))} ))}
</ol> </ol>
{onCreateToolbox && content.primaryAction && (
<div className="home-onboarding-steps-action">
<button className="primary home-onboarding-create-button" type="button" onClick={onCreateToolbox}>
{content.primaryAction}
</button>
</div>
)}
</div> </div>
<div className="home-onboarding-preview"> <div className="home-onboarding-preview">
<h3>{content.sliderTitle}</h3> <h3>{content.sliderTitle}</h3>

View file

@ -3,7 +3,7 @@ import { TOOLBOX_MODULES } from "../features/toolboxes/toolboxModuleRegistry.js"
import { RichText } from "../components/RichText.jsx"; import { RichText } from "../components/RichText.jsx";
import { HomeOnboardingSection } from "./HomeOnboardingSection.jsx"; import { HomeOnboardingSection } from "./HomeOnboardingSection.jsx";
export function HomePage({ siteContent, toolboxes }) { export function HomePage({ siteContent, toolboxes, actions }) {
const content = siteContent.home; const content = siteContent.home;
const toolCount = Object.keys(TOOLBOX_MODULES).length; const toolCount = Object.keys(TOOLBOX_MODULES).length;
const showOnboarding = import.meta.env.DEV || toolboxes.length === 0; const showOnboarding = import.meta.env.DEV || toolboxes.length === 0;
@ -24,7 +24,7 @@ export function HomePage({ siteContent, toolboxes }) {
<article><strong>{toolCount}</strong><span>{toolCount > 1 ? content.stats.toolPlural : content.stats.toolSingular}</span></article> <article><strong>{toolCount}</strong><span>{toolCount > 1 ? content.stats.toolPlural : content.stats.toolSingular}</span></article>
</div> </div>
</section> </section>
{showOnboarding && <HomeOnboardingSection content={content.onboarding} />} {showOnboarding && <HomeOnboardingSection content={content.onboarding} onCreateToolbox={() => actions.setCreateModal({ tutorial: true })} />}
<section className="origin-section nebula-panel" aria-labelledby="origin-title"> <section className="origin-section nebula-panel" aria-labelledby="origin-title">
<div className="origin-copy"> <div className="origin-copy">
<p className="eyebrow">{content.origin.eyebrow}</p> <p className="eyebrow">{content.origin.eyebrow}</p>

View file

@ -231,6 +231,18 @@ span {
font-size: var(--font-size-lg); font-size: var(--font-size-lg);
} }
.home-onboarding-create-button {
min-height: 40px;
padding-inline: var(--space-4);
}
.home-onboarding-steps-action {
display: flex;
justify-content: flex-start;
padding-top: var(--space-2);
padding-left: calc(28px + var(--space-3));
}
.home-onboarding-preview { .home-onboarding-preview {
display: grid; display: grid;
align-content: start; align-content: start;

View file

@ -62,6 +62,16 @@
-webkit-mask-image: url("/static/icons/link.svg"); -webkit-mask-image: url("/static/icons/link.svg");
} }
.ui-icon-picture {
mask-image: url("/static/icons/picture.svg");
-webkit-mask-image: url("/static/icons/picture.svg");
}
.ui-icon-toolbox {
mask-image: url("/static/icons/toolbox.svg");
-webkit-mask-image: url("/static/icons/toolbox.svg");
}
.ui-icon-settings { .ui-icon-settings {
mask-image: url("/static/icons/settings.svg"); mask-image: url("/static/icons/settings.svg");
-webkit-mask-image: url("/static/icons/settings.svg"); -webkit-mask-image: url("/static/icons/settings.svg");

View file

@ -83,6 +83,14 @@
width: 100%; width: 100%;
} }
.home-onboarding-steps-action {
padding-left: 0;
}
.home-onboarding-create-button {
width: 100%;
}
.hero, .hero,
.game-hero, .game-hero,
.page-hero, .page-hero,

View file

@ -178,6 +178,125 @@
outline-offset: 3px; outline-offset: 3px;
} }
.toolbox-tutorial-tooltip {
position: fixed;
z-index: 640;
display: grid;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid rgba(246, 196, 83, 0.34);
border-radius: var(--radius-md);
background:
radial-gradient(circle at 12% 0%, rgba(246, 196, 83, 0.12), transparent 36%),
rgba(7, 10, 24, 0.98);
box-shadow:
var(--shadow-lg),
0 0 24px rgba(139, 92, 246, 0.18);
}
.toolbox-tutorial-tooltip::before {
content: "";
position: absolute;
top: -6px;
left: var(--tutorial-arrow-left, 50%);
width: 10px;
height: 10px;
border-top: 1px solid rgba(246, 196, 83, 0.34);
border-left: 1px solid rgba(246, 196, 83, 0.34);
background: rgba(7, 10, 24, 0.98);
transform: translateX(-50%) rotate(45deg);
}
.toolbox-tutorial-tooltip[data-placement="above"]::before {
top: auto;
bottom: -6px;
border-top: 0;
border-left: 0;
border-right: 1px solid rgba(246, 196, 83, 0.34);
border-bottom: 1px solid rgba(246, 196, 83, 0.34);
}
.toolbox-tutorial-tooltip[data-placement="right"]::before {
top: var(--tutorial-arrow-top, 50%);
left: -6px;
border-top: 0;
border-left: 1px solid rgba(246, 196, 83, 0.34);
border-right: 0;
border-bottom: 1px solid rgba(246, 196, 83, 0.34);
transform: translateY(-50%) rotate(45deg);
}
.toolbox-tutorial-heading {
display: flex;
align-items: center;
gap: var(--space-2);
padding-right: var(--space-6);
}
.toolbox-tutorial-heading .ui-icon {
width: 20px;
height: 20px;
color: var(--color-accent-gold);
}
.toolbox-tutorial-heading strong {
color: var(--color-text-primary);
line-height: 1.25;
}
.toolbox-tutorial-tooltip p {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
line-height: 1.5;
}
.toolbox-tutorial-count {
width: fit-content;
padding: 2px 7px;
border: 1px solid rgba(246, 196, 83, 0.22);
border-radius: var(--radius-pill);
color: var(--color-accent-gold);
font-size: var(--font-size-xs);
font-weight: 900;
}
.toolbox-tutorial-close {
position: absolute;
top: var(--space-3);
right: var(--space-3);
width: 30px;
min-width: 30px;
min-height: 30px;
justify-content: center;
padding: 0;
border-color: transparent;
background: transparent;
color: var(--color-text-muted);
}
.toolbox-tutorial-close:hover {
border-color: rgba(150, 165, 205, 0.18);
background: rgba(21, 26, 48, 0.62);
color: var(--color-text-primary);
box-shadow: none;
}
.toolbox-tutorial-close .ui-icon {
width: 16px;
height: 16px;
}
.toolbox-tutorial-tooltip footer {
display: flex;
justify-content: flex-end;
}
.toolbox-tutorial-tooltip footer button {
min-height: 36px;
padding-inline: var(--space-3);
}
.tool-add-controls { .tool-add-controls {
position: relative; position: relative;
z-index: 280; z-index: 280;