Add guided onboarding tour for first toolbox creation
All checks were successful
Deploy Sokko G / deploy (push) Successful in 14s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 14s
This commit is contained in:
parent
3007aa1389
commit
4f73e0068b
20 changed files with 470 additions and 20 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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/);
|
||||||
|
|
|
||||||
|
|
@ -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"/);
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
109
website/src/components/GuidedTooltipTour.jsx
Normal file
109
website/src/components/GuidedTooltipTour.jsx
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue