From 4f73e0068b7b57677056cb48eb7f39f24e6ae84a Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Fri, 14 Aug 2026 13:20:47 +0200 Subject: [PATCH] Add guided onboarding tour for first toolbox creation --- docs/STORAGE_SCHEMA.md | 2 + tests/helpers/data-validation.mjs | 31 +++++ tests/static-app.test.mjs | 3 + tests/static-toolboxes.test.mjs | 13 ++ website/public/data/site.json | 59 +++++++++ website/src/components/AppOverlays.jsx | 17 ++- website/src/components/GuidedTooltipTour.jsx | 109 ++++++++++++++++ website/src/components/Shell.jsx | 2 +- .../src/features/toolboxes/ToolboxCard.jsx | 3 +- .../src/features/toolboxes/ToolboxPages.jsx | 46 ++++++- .../src/features/toolboxes/modules/index.jsx | 8 +- .../toolboxes/storage/toolboxStorage.js | 5 +- .../features/toolboxes/useToolboxActions.js | 25 +++- website/src/main.jsx | 5 + website/src/pages/HomeOnboardingSection.jsx | 9 +- website/src/pages/HomePage.jsx | 4 +- website/src/styles/_home.scss | 12 ++ website/src/styles/_icons.scss | 10 ++ website/src/styles/_responsive.scss | 8 ++ website/src/styles/toolboxes/_layout.scss | 119 ++++++++++++++++++ 20 files changed, 470 insertions(+), 20 deletions(-) create mode 100644 website/src/components/GuidedTooltipTour.jsx diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index 122fa3e..7b78f20 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -55,6 +55,7 @@ Les toolboxes sont stockées dans `kv/toolboxes`. "one": ["mod1"], "two": [["mod1"], []] }, + "tutorial": true, "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`. - 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`. +- `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 diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index bdf0db2..9584ced 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -596,6 +596,8 @@ export function validateSiteContent(site) { } }); + assertNonEmptyString(site, "home.onboarding.primaryAction"); + const onboardingCards = valueAt(site, "home.onboarding.cards"); 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"); @@ -629,6 +631,35 @@ export function validateSiteContent(site) { 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"); 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"); diff --git a/tests/static-app.test.mjs b/tests/static-app.test.mjs index 9796ff5..a3f6004 100644 --- a/tests/static-app.test.mjs +++ b/tests/static-app.test.mjs @@ -64,9 +64,12 @@ test("vite entrypoint and app shell are wired", async () => { assert.match(homePage, /export function HomePage/); assert.match(homePage, /HomeOnboardingSection/); assert.match(homePage, /import\.meta\.env\.DEV \|\| toolboxes\.length === 0/); + assert.match(homePage, /setCreateModal\(\{ tutorial: true \}\)/); assert.match(homePage, /dragon-home\.png/); assert.match(homeOnboarding, /export function HomeOnboardingSection/); assert.match(homeOnboarding, /prefers-reduced-motion: reduce/); + assert.match(homeOnboarding, /onCreateToolbox/); + assert.match(homeOnboarding, /content\.primaryAction/); assert.match(homeOnboarding, /content\.sliderTitle/); assert.match(homeOnboarding, /home-onboarding-dots/); assert.match(aboutPage, /export function AboutPage/); diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 543207e..9a8d414 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -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 storageQuota = await readFile("website/src/components/StorageQuota.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 groupedReorderHook = await readFile("website/src/hooks/useGroupedReorder.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, /importAllToolboxesPayload/); 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(toolboxStorage, /TOOLBOX_ICON_FILES/); 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, /normalizeCombosData/); 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.match(storageQuota, /export function StorageQuota/); assert.match(storageQuota, /role="progressbar"/); diff --git a/website/public/data/site.json b/website/public/data/site.json index f3bbbd3..4cccfe7 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -1186,6 +1186,7 @@ "eyebrow": "Prise en main", "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.", + "primaryAction": "Créer ma première toolbox", "stepsTitle": "Les étapes", "sliderTitle": "Aperçu", "sliderLabel": "Aperçu de la prise en main", @@ -1430,6 +1431,64 @@ "toastPositionLeft": "Afficher les alertes en bas à gauche", "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.", + "creationTemplate": { + "notepad": { + "title": "Bienvenue", + "text": "Profite bien de ta nouvelle toolbox.", + "html": "

Profite bien de ta nouvelle toolbox.

" + }, + "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": { "title": "Sauvegardes locales", "text": "Vos toolboxes restent dans l'IndexedDB de ce navigateur. Rien n’est envoyé sur un serveur.", diff --git a/website/src/components/AppOverlays.jsx b/website/src/components/AppOverlays.jsx index d8d623b..4f804e7 100644 --- a/website/src/components/AppOverlays.jsx +++ b/website/src/components/AppOverlays.jsx @@ -5,6 +5,12 @@ import { ConfirmModal, CreateToolboxModal, LinkToolboxModal, NotificationToast } import { navigate } from "../router/hashRouter.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" }) { if (!message) return null; const classes = ["inline-notice", `is-${tone}`, className].filter(Boolean).join(" "); @@ -34,7 +40,8 @@ export function AppOverlays({ toastPosition, getGame, toolboxes, - links + links, + setPendingTutorialToolboxId }) { return ( <> @@ -47,9 +54,13 @@ export function AppOverlays({ const gameId = createModal.gameId || ""; setCreateModal(null); 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); - else if (toolbox) navigate(`/toolbox/${toolbox.id}`); + else if (toolbox) { + navigate(`/toolbox/${toolbox.id}`); + scrollToTopAfterNavigation(); + } }} />} {linkModalGameId && { const gameId = linkModalGameId; diff --git a/website/src/components/GuidedTooltipTour.jsx b/website/src/components/GuidedTooltipTour.jsx new file mode 100644 index 0000000..7f8ad92 --- /dev/null +++ b/website/src/components/GuidedTooltipTour.jsx @@ -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( +
+ + {stepIndex + 1}/{steps.length} +
+ + {currentStep.title} +
+

{currentStep.text}

+
+ +
+
, + document.body + ); +} diff --git a/website/src/components/Shell.jsx b/website/src/components/Shell.jsx index d2ebe50..da7b1b8 100644 --- a/website/src/components/Shell.jsx +++ b/website/src/components/Shell.jsx @@ -32,7 +32,7 @@ export function Shell({ route, content, games, toolboxes, links, actions, childr {content.sidebar.badge}

{content.sidebar.note}

- +
diff --git a/website/src/features/toolboxes/ToolboxCard.jsx b/website/src/features/toolboxes/ToolboxCard.jsx index 35e841b..84b9de8 100644 --- a/website/src/features/toolboxes/ToolboxCard.jsx +++ b/website/src/features/toolboxes/ToolboxCard.jsx @@ -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" > diff --git a/website/src/features/toolboxes/ToolboxPages.jsx b/website/src/features/toolboxes/ToolboxPages.jsx index 008ba94..b6242f0 100644 --- a/website/src/features/toolboxes/ToolboxPages.jsx +++ b/website/src/features/toolboxes/ToolboxPages.jsx @@ -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 ; } -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

Toolbox

- {!embedded && !toolboxGame && updateToolbox({ ...toolbox, icon })} />} + {!embedded && !toolboxGame && updateToolbox({ ...toolbox, icon })} tutorialTarget="icon" />} {!embedded && toolboxGameCover && } {embedded ?

{toolbox.name}

: ( - name !== toolbox.name && updateToolbox({ ...toolbox, name })} /> + name !== toolbox.name && updateToolbox({ ...toolbox, name })} /> )}
{toolboxGame && ( @@ -252,6 +270,25 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
+ {!embedded && showTutorial && ( + { + if (pendingTutorialToolboxId === toolbox.id) setPendingTutorialToolboxId?.(""); + setShowTutorial(false); + }} + onStarted={() => { + if (pendingTutorialToolboxId === toolbox.id) setPendingTutorialToolboxId?.(""); + if (toolbox.tutorial === true) updateToolbox({ ...toolbox, tutorial: false }); + }} + /> + )} {!embedded && (
@@ -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" })

{value}

diff --git a/website/src/features/toolboxes/modules/index.jsx b/website/src/features/toolboxes/modules/index.jsx index 752b101..4b926ab 100644 --- a/website/src/features/toolboxes/modules/index.jsx +++ b/website/src/features/toolboxes/modules/index.jsx @@ -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 @@ -311,7 +312,7 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
{definition.scrollable && ( @@ -345,7 +346,7 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena )} -
@@ -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 }) {

diff --git a/website/src/features/toolboxes/storage/toolboxStorage.js b/website/src/features/toolboxes/storage/toolboxStorage.js index d78d01f..8d25b2c 100644 --- a/website/src/features/toolboxes/storage/toolboxStorage.js +++ b/website/src/features/toolboxes/storage/toolboxStorage.js @@ -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; } diff --git a/website/src/features/toolboxes/useToolboxActions.js b/website/src/features/toolboxes/useToolboxActions.js index fc418de..f360f04 100644 --- a/website/src/features/toolboxes/useToolboxActions.js +++ b/website/src/features/toolboxes/useToolboxActions.js @@ -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 || `

${welcomeText}

`; + 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; } diff --git a/website/src/main.jsx b/website/src/main.jsx index 60852a6..f2b46f3 100644 --- a/website/src/main.jsx +++ b/website/src/main.jsx @@ -25,6 +25,7 @@ function App() { const [linkModalGameId, setLinkModalGameId] = useState(""); const [drawerGameId, setDrawerGameId] = useState(""); const [image, setImage] = useState(null); + const [pendingTutorialToolboxId, setPendingTutorialToolboxId] = useState(""); const [notifications, setNotifications] = useState([]); const [storageError, setStorageError] = useState(""); const handleStorageError = useCallback((message) => setStorageError(message), []); @@ -61,6 +62,7 @@ function App() { const actions = useToolboxActions({ store, notify, + creationTemplate: siteContent?.toolboxes?.creationTemplate, setCreateModal, setConfirmModal, setLinkModalGameId, @@ -114,6 +116,8 @@ function App() { updateToolbox={actions.updateToolbox} updateModuleData={store.updateModuleData} addImageFiles={actions.addImageFiles} + pendingTutorialToolboxId={pendingTutorialToolboxId} + setPendingTutorialToolboxId={setPendingTutorialToolboxId} /> {drawerGameId && ( @@ -151,6 +155,7 @@ function App() { getGame={getGame} toolboxes={store.toolboxes} links={store.links} + setPendingTutorialToolboxId={setPendingTutorialToolboxId} /> ); diff --git a/website/src/pages/HomeOnboardingSection.jsx b/website/src/pages/HomeOnboardingSection.jsx index cf26e14..0ff592b 100644 --- a/website/src/pages/HomeOnboardingSection.jsx +++ b/website/src/pages/HomeOnboardingSection.jsx @@ -7,7 +7,7 @@ function prefersReducedMotion() { 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 [activeSlide, setActiveSlide] = useState(0); const [isPaused, setIsPaused] = useState(false); @@ -45,6 +45,13 @@ export function HomeOnboardingSection({ content, className = "" }) { ))} + {onCreateToolbox && content.primaryAction && ( +
+ +
+ )}

{content.sliderTitle}

diff --git a/website/src/pages/HomePage.jsx b/website/src/pages/HomePage.jsx index 7b5d6aa..30b860a 100644 --- a/website/src/pages/HomePage.jsx +++ b/website/src/pages/HomePage.jsx @@ -3,7 +3,7 @@ import { TOOLBOX_MODULES } from "../features/toolboxes/toolboxModuleRegistry.js" import { RichText } from "../components/RichText.jsx"; import { HomeOnboardingSection } from "./HomeOnboardingSection.jsx"; -export function HomePage({ siteContent, toolboxes }) { +export function HomePage({ siteContent, toolboxes, actions }) { const content = siteContent.home; const toolCount = Object.keys(TOOLBOX_MODULES).length; const showOnboarding = import.meta.env.DEV || toolboxes.length === 0; @@ -24,7 +24,7 @@ export function HomePage({ siteContent, toolboxes }) {
{toolCount}{toolCount > 1 ? content.stats.toolPlural : content.stats.toolSingular}

- {showOnboarding && } + {showOnboarding && actions.setCreateModal({ tutorial: true })} />}

{content.origin.eyebrow}

diff --git a/website/src/styles/_home.scss b/website/src/styles/_home.scss index d741947..0189b44 100644 --- a/website/src/styles/_home.scss +++ b/website/src/styles/_home.scss @@ -231,6 +231,18 @@ span { 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 { display: grid; align-content: start; diff --git a/website/src/styles/_icons.scss b/website/src/styles/_icons.scss index 8ba4455..607f048 100644 --- a/website/src/styles/_icons.scss +++ b/website/src/styles/_icons.scss @@ -62,6 +62,16 @@ -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 { mask-image: url("/static/icons/settings.svg"); -webkit-mask-image: url("/static/icons/settings.svg"); diff --git a/website/src/styles/_responsive.scss b/website/src/styles/_responsive.scss index 846b3c7..669dd31 100644 --- a/website/src/styles/_responsive.scss +++ b/website/src/styles/_responsive.scss @@ -83,6 +83,14 @@ width: 100%; } + .home-onboarding-steps-action { + padding-left: 0; + } + + .home-onboarding-create-button { + width: 100%; + } + .hero, .game-hero, .page-hero, diff --git a/website/src/styles/toolboxes/_layout.scss b/website/src/styles/toolboxes/_layout.scss index 47b02c9..d192ca1 100644 --- a/website/src/styles/toolboxes/_layout.scss +++ b/website/src/styles/toolboxes/_layout.scss @@ -178,6 +178,125 @@ 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 { position: relative; z-index: 280;