diff --git a/AGENTS.md b/AGENTS.md index 13fb52e..cf59dc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,14 @@ Le contenu éditorial du site doit rester dans : Ne pas réintroduire de fallback massif type `DEFAULT_SITE_CONTENT` dans le code React. `site.json` est la source de vérité et `npm run check` doit échouer si le contenu requis est invalide. +## En-têtes de fichiers + +Chaque nouveau fichier source ou test doit commencer par un commentaire court `Rôle : ...` décrivant ce qu'il gère. + +- mettre cet en-tête à jour si la responsabilité du fichier change ; +- garder l'intro concise, une ou deux lignes maximum ; +- utiliser `// Rôle : ...` dans les fichiers JS, JSX, MJS et SCSS. + ## Outils toolbox Chaque outil toolbox doit rester dans son propre fichier dans : diff --git a/README.md b/README.md index 1f60c09..dfdaa27 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Chaque outil possède son propre fichier de composant : - `website/src/features/toolboxes/modules/NotepadModule.jsx` - `website/src/features/toolboxes/modules/ChecklistModule.jsx` -- `website/src/features/toolboxes/modules/ScreenshotsModule.jsx` : outil Images, conservé sous ce nom technique historique. +- `website/src/features/toolboxes/modules/ImagesModule.jsx` - `website/src/features/toolboxes/modules/LinksModule.jsx` - `website/src/features/toolboxes/modules/CountersModule.jsx` - `website/src/features/toolboxes/modules/CalculatorModule.jsx` diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index c3156c1..53d7f9e 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -142,13 +142,13 @@ Stockage compact : ## Outil Images -Type : `screenshots` +Type : `images` ```json { - "shots": [ + "images": [ { - "id": "shot1", + "id": "image1", "label": "Map zone nord", "dataUrl": "data:image/webp;base64,..." } diff --git a/tests/data-validation.test.mjs b/tests/data-validation.test.mjs index abfe69c..8e78155 100644 --- a/tests/data-validation.test.mjs +++ b/tests/data-validation.test.mjs @@ -1,3 +1,4 @@ +// Rôle : lance les validations structurelles des données publiques du site. import { readFile } from "node:fs/promises"; import { test } from "node:test"; import assert from "node:assert/strict"; diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index 71802d0..287bb14 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -1,3 +1,4 @@ +// Rôle : centralise les règles de validation des JSON éditables du projet. import assert from "node:assert/strict"; const SNAKE_CASE_RE = /^[a-z0-9]+(?:_[a-z0-9]+)*$/; @@ -102,17 +103,17 @@ export function validateSiteContent(site) { "toolboxes.modules.checklist.incrementLabel", "toolboxes.modules.checklist.currentQuantityLabel", "toolboxes.modules.checklist.deleteTitle", - "toolboxes.modules.screenshots.addImages", - "toolboxes.modules.screenshots.pastePlaceholder", - "toolboxes.modules.screenshots.pasteAriaLabel", - "toolboxes.modules.screenshots.imageAlt", - "toolboxes.modules.screenshots.labelPlaceholder", - "toolboxes.modules.screenshots.labelAriaLabel", - "toolboxes.modules.screenshots.previewAriaLabel", - "toolboxes.modules.screenshots.annotateAriaLabel", - "toolboxes.modules.screenshots.annotateTitle", - "toolboxes.modules.screenshots.deleteAriaLabel", - "toolboxes.modules.screenshots.deleteTitle", + "toolboxes.modules.images.addImages", + "toolboxes.modules.images.pastePlaceholder", + "toolboxes.modules.images.pasteAriaLabel", + "toolboxes.modules.images.imageAlt", + "toolboxes.modules.images.labelPlaceholder", + "toolboxes.modules.images.labelAriaLabel", + "toolboxes.modules.images.previewAriaLabel", + "toolboxes.modules.images.annotateAriaLabel", + "toolboxes.modules.images.annotateTitle", + "toolboxes.modules.images.deleteAriaLabel", + "toolboxes.modules.images.deleteTitle", "toolboxes.modules.links.titlePlaceholder", "toolboxes.modules.links.urlPlaceholder", "toolboxes.modules.links.addButton", diff --git a/tests/static-app.test.mjs b/tests/static-app.test.mjs new file mode 100644 index 0000000..7d11c13 --- /dev/null +++ b/tests/static-app.test.mjs @@ -0,0 +1,121 @@ +// Rôle : vérifie les invariants statiques du shell, du routing et des overlays. +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; +import assert from "node:assert/strict"; + +test("vite entrypoint and app shell are wired", async () => { + const html = await readFile("website/index.html", "utf8"); + const source = await readFile("website/src/main.jsx", "utf8"); + const appDataHook = await readFile("website/src/hooks/useAppData.js", "utf8"); + const gameFiltersHook = await readFile("website/src/hooks/useGameFilters.js", "utf8"); + const shell = await readFile("website/src/components/Shell.jsx", "utf8"); + const overlays = await readFile("website/src/components/AppOverlays.jsx", "utf8"); + const modals = await readFile("website/src/components/ToolboxModals.jsx", "utf8"); + const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8"); + const richText = await readFile("website/src/components/RichText.jsx", "utf8"); + const homePage = await readFile("website/src/pages/HomePage.jsx", "utf8"); + const aboutPage = await readFile("website/src/pages/AboutPage.jsx", "utf8"); + const hashRouter = await readFile("website/src/router/hashRouter.js", "utf8"); + const routeContent = await readFile("website/src/router/RouteContent.jsx", "utf8"); + const bodyScrollLock = await readFile("website/src/utils/bodyScrollLock.js", "utf8"); + + assert.match(html, /
<\/div>/); + assert.match(html, /\/favicon\.ico/); + assert.match(html, /\/src\/main\.jsx/); + assert.match(source, /styles\/main\.scss/); + assert.match(source, /components\/Shell\.jsx/); + assert.match(source, /components\/AppOverlays\.jsx/); + assert.match(source, /router\/RouteContent\.jsx/); + assert.match(source, /hooks\/useAppData\.js/); + assert.match(source, /hooks\/useGameFilters\.js/); + assert.doesNotMatch(source, /DEFAULT_SITE_CONTENT/); + assert.match(appDataHook, /export function useAppData/); + assert.match(appDataHook, /\/data\/site\.json/); + assert.match(appDataHook, /setSiteContentError/); + assert.match(gameFiltersHook, /export function useGameFilters/); + assert.match(hashRouter, /export function useHashRoute/); + assert.match(hashRouter, /export function navigate/); + assert.match(routeContent, /export function RouteContent/); + assert.match(routeContent, /pages\/HomePage\.jsx/); + assert.match(routeContent, /pages\/AboutPage\.jsx/); + assert.match(routeContent, /features\/games\/GameRoute\.jsx/); + assert.match(shell, /export function Shell/); + assert.match(shell, /sidebar-about-link/); + assert.match(shell, /ImportButton/); + assert.match(overlays, /export function AppOverlays/); + assert.match(overlays, /\.\/ToolboxModals\.jsx/); + assert.match(overlays, /\.\/ImageViewer\.jsx/); + assert.match(overlays, /uid\("marker"\)/); + assert.match(modals, /export function ConfirmModal/); + assert.match(modals, /export function CreateToolboxModal/); + assert.match(modals, /export function LinkToolboxModal/); + assert.match(modals, /export function NotificationToast/); + assert.match(modals, /useModalScrollLock/); + assert.match(imageViewer, /export function ImageViewer/); + assert.match(imageViewer, /function openImageInNewTab/); + assert.match(richText, /value\.split\("\\n"\)/); + assert.match(richText, /
{ + const storageSchema = await readFile("docs/STORAGE_SCHEMA.md", "utf8"); + const featureChecklist = await readFile("docs/FEATURE_CHECKLIST.md", "utf8"); + + assert.match(storageSchema, /IndexedDB/); + assert.match(storageSchema, /Outil Checklist/); + assert.match(storageSchema, /Outil Images/); + assert.match(storageSchema, /Outil Annotation d'images/); + assert.match(storageSchema, /Outil Calculateur/); + assert.match(featureChecklist, /Mettre à jour `docs\/STORAGE_SCHEMA\.md`/); + assert.match(featureChecklist, /npm run check/); +}); + +test("server and vite support local env configuration", async () => { + const server = await readFile("server.mjs", "utf8"); + const viteConfig = await readFile("vite.config.js", "utf8"); + const envExample = await readFile(".env.example", "utf8"); + + assert.match(server, /function loadEnvFile/); + assert.match(server, /process\.env\.PORT/); + assert.match(viteConfig, /function loadEnvFile/); + assert.match(viteConfig, /process\.env\.PORT/); + assert.match(envExample, /PORT=5173/); +}); + +test("legacy icons and primary images are available", async () => { + const manifest = JSON.parse(await readFile("website/public/data/toolbox-icons.json", "utf8")); + + assert.ok(manifest.icons.length >= 4); + await readFile("website/public/static/icons/home.svg", "utf8"); + await readFile("website/public/static/icons/add.svg", "utf8"); + await readFile("website/public/static/icons/checklist.svg", "utf8"); + await readFile("website/public/static/icons/close.svg", "utf8"); + await readFile("website/public/static/icons/columns.svg", "utf8"); + await readFile("website/public/static/icons/copy.svg", "utf8"); + await readFile("website/public/static/icons/abacus.svg", "utf8"); + await readFile("website/public/static/icons/calculator.svg", "utf8"); + await readFile("website/public/static/icons/map.svg", "utf8"); + await readFile("website/public/static/icons/enter.svg", "utf8"); + await readFile("website/public/static/icons/export.svg", "utf8"); + await readFile("website/public/static/icons/hide.svg", "utf8"); + await readFile("website/public/static/icons/chevron-down.svg", "utf8"); + await readFile("website/public/static/icons/chevron-up.svg", "utf8"); + await readFile("website/public/static/icons/scrollable.svg", "utf8"); + await readFile("website/public/static/icons/notepad.svg", "utf8"); + await readFile("website/public/static/icons/picture.svg", "utf8"); + await readFile("website/public/static/icons/rows.svg", "utf8"); + await readFile("website/public/static/icons/rubber.svg", "utf8"); + await readFile("website/public/static/icons/trashcan.svg", "utf8"); + await readFile("website/public/static/icons/zoom.svg", "utf8"); + await readFile("website/public/favicon.ico"); + await readFile("website/public/static/img/dragon.png"); + await readFile("website/public/static/img/toolbox-icons/toolbox.png"); + await readFile("website/public/static/img/toolbox-icons/sword.png"); + await readFile("website/public/static/img/toolbox-icons/puzzle.png"); +}); diff --git a/tests/static-games.test.mjs b/tests/static-games.test.mjs new file mode 100644 index 0000000..4617fae --- /dev/null +++ b/tests/static-games.test.mjs @@ -0,0 +1,89 @@ +// Rôle : vérifie les invariants statiques des pages et données de jeux. +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; +import assert from "node:assert/strict"; + +test("generic game routes and shared game components are wired", async () => { + const routeContent = await readFile("website/src/router/RouteContent.jsx", "utf8"); + const gamesPage = await readFile("website/src/features/games/GamesPage.jsx", "utf8"); + const gameRoute = await readFile("website/src/features/games/GameRoute.jsx", "utf8"); + const gameLoaders = await readFile("website/src/features/games/loaders.js", "utf8"); + const gameFiltersPanel = await readFile("website/src/features/games/GameFiltersPanel.jsx", "utf8"); + const checklistCopyButton = await readFile("website/src/features/games/CopyChecklistItemsButton.jsx", "utf8"); + const gameListsPage = await readFile("website/src/features/games/GameListsPage.jsx", "utf8"); + + assert.match(routeContent, /features\/games\/GamesPage\.jsx/); + assert.match(routeContent, /features\/games\/GameRoute\.jsx/); + assert.match(gamesPage, /export function GamesPage/); + assert.match(gamesPage, /siteContent\.gamesPage/); + assert.match(gamesPage, /game-card-cover-link/); + assert.match(gamesPage, /game-card-placeholder/); + assert.match(gameRoute, /export function GameRoute/); + assert.match(gameRoute, /MhwildsPage/); + assert.match(gameRoute, /Diablo4Page/); + assert.match(gameLoaders, /export async function loadMhwildsData/); + assert.match(gameLoaders, /\/data\/mhwilds\/lists\/index\.json/); + assert.match(gameLoaders, /loadGameLists/); + assert.match(gameLoaders, /normalizeGameListFile/); + assert.match(gameLoaders, /export async function loadDiablo4Data/); + assert.match(gameLoaders, /\/data\/diablo4\/affixes_types\.json/); + assert.match(gameLoaders, /filterOptionKeys/); + assert.match(gameFiltersPanel, /export function GameFiltersPanel/); + assert.match(gameFiltersPanel, /filter-reset-button/); + assert.match(checklistCopyButton, /formatChecklistImportItems/); + assert.match(checklistCopyButton, /formatChecklistImportSections/); + assert.match(checklistCopyButton, /\$\{entry\.label\}:\$\{entry\.quantity\}/); + assert.match(checklistCopyButton, /# \$\{title\}/); + assert.match(checklistCopyButton, /navigator\.clipboard\.writeText/); + assert.match(checklistCopyButton, /results-copy-button/); + assert.match(gameListsPage, /export function GameListsPage/); + assert.match(gameListsPage, /game-list-selector/); + assert.match(gameListsPage, /game-list-create-button/); + assert.match(gameListsPage, /module-icon-checklist/); +}); + +test("mhwilds and diablo pages expose expected route pieces", async () => { + const diablo4Page = await readFile("website/src/features/games/diablo4/Diablo4Page.jsx", "utf8"); + const diablo4Overview = await readFile("website/src/features/games/diablo4/Diablo4Overview.jsx", "utf8"); + const diablo4Listing = await readFile("website/src/features/games/diablo4/Diablo4Listing.jsx", "utf8"); + const diablo4Filters = await readFile("website/src/features/games/diablo4/Diablo4Filters.jsx", "utf8"); + const diablo4AffixCard = await readFile("website/src/features/games/diablo4/Diablo4AffixCard.jsx", "utf8"); + const diablo4Utils = await readFile("website/src/features/games/diablo4/utils.js", "utf8"); + const mhwildsPage = await readFile("website/src/features/games/mhwilds/MhwildsPage.jsx", "utf8"); + const mhwildsOverview = await readFile("website/src/features/games/mhwilds/MhwildsOverview.jsx", "utf8"); + const mhwildsListing = await readFile("website/src/features/games/mhwilds/MhwildsListing.jsx", "utf8"); + const mhwildsLists = await readFile("website/src/features/games/mhwilds/MhwildsLists.jsx", "utf8"); + const mhwildsFilters = await readFile("website/src/features/games/mhwilds/MhwildsFilters.jsx", "utf8"); + const monsterCard = await readFile("website/src/features/games/mhwilds/cards/MonsterCard.jsx", "utf8"); + const endemicCard = await readFile("website/src/features/games/mhwilds/cards/EndemicCard.jsx", "utf8"); + const damageTable = await readFile("website/src/features/games/mhwilds/cards/DamageTable.jsx", "utf8"); + + assert.match(diablo4Page, /export function Diablo4Page/); + assert.match(diablo4Overview, /export function Diablo4Overview/); + assert.match(diablo4Listing, /export function Diablo4Listing/); + assert.match(diablo4Listing, /CopyChecklistItemsButton/); + assert.match(diablo4Filters, /export function Diablo4Filters/); + assert.match(diablo4Filters, /GameFiltersPanel/); + assert.match(diablo4AffixCard, /export function Diablo4AffixCard/); + assert.match(diablo4AffixCard, /categoryMap/); + assert.match(diablo4Utils, /getFilteredDiablo4Affixes/); + assert.match(diablo4Utils, /getCategoryIconStyle/); + assert.match(mhwildsPage, /export function MhwildsPage/); + assert.match(mhwildsPage, /MhwildsLists/); + assert.match(mhwildsPage, /linkedToolboxId/); + assert.match(mhwildsOverview, /#\/games\/mhwilds\/lists/); + assert.match(mhwildsListing, /export function MhwildsListing/); + assert.match(mhwildsListing, /GameBreadcrumb/); + assert.match(mhwildsListing, /CopyChecklistItemsButton/); + assert.match(mhwildsListing, /endemic_life/); + assert.match(mhwildsLists, /export function MhwildsLists/); + assert.match(mhwildsLists, /GameListsPage/); + assert.match(mhwildsLists, /selectedListId/); + assert.match(mhwildsFilters, /export function MhwildsFilters/); + assert.match(mhwildsFilters, /GameFiltersPanel/); + assert.match(monsterCard, /export function MonsterCard/); + assert.match(monsterCard, /ui-icon-flip/); + assert.match(endemicCard, /export function EndemicCard/); + assert.match(damageTable, /rowIndex/); + assert.doesNotMatch(`${mhwildsListing}\n${diablo4Listing}\n${diablo4Overview}`, /mhwilds-(home-grid|home-card|heading|title-row|layout|filters|results|grid)/); +}); diff --git a/tests/static-smoke.test.mjs b/tests/static-smoke.test.mjs deleted file mode 100644 index b8a6c99..0000000 --- a/tests/static-smoke.test.mjs +++ /dev/null @@ -1,394 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { test } from "node:test"; -import assert from "node:assert/strict"; - -test("vite entrypoint loads the react application", async () => { - const html = await readFile("website/index.html", "utf8"); - - assert.match(html, /
<\/div>/); - assert.match(html, /\/favicon\.ico/); - assert.match(html, /\/src\/main\.jsx/); -}); - -test("project documentation tracks storage and feature updates", async () => { - const storageSchema = await readFile("docs/STORAGE_SCHEMA.md", "utf8"); - const featureChecklist = await readFile("docs/FEATURE_CHECKLIST.md", "utf8"); - - assert.match(storageSchema, /IndexedDB/); - assert.match(storageSchema, /Outil Checklist/); - assert.match(storageSchema, /Outil Images/); - assert.match(storageSchema, /Outil Annotation d'images/); - assert.match(storageSchema, /Outil Calculateur/); - assert.match(featureChecklist, /Mettre à jour `docs\/STORAGE_SCHEMA\.md`/); - assert.match(featureChecklist, /npm run check/); -}); - -test("react application defines the expected local toolbox primitives", async () => { - const source = await readFile("website/src/main.jsx", "utf8"); - const styles = await readFile("website/src/styles/main.scss", "utf8"); - const styleNebula = await readFile("website/src/styles/_nebula.scss", "utf8"); - const styleTokens = await readFile("website/src/styles/_tokens.scss", "utf8"); - const styleBase = await readFile("website/src/styles/_base.scss", "utf8"); - const styleMixins = await readFile("website/src/styles/_mixins.scss", "utf8"); - const styleShell = await readFile("website/src/styles/_shell.scss", "utf8"); - const styleCards = await readFile("website/src/styles/_cards.scss", "utf8"); - const styleHome = await readFile("website/src/styles/_home.scss", "utf8"); - const styleToolboxes = await readFile("website/src/styles/_toolboxes.scss", "utf8"); - const styleOverlays = await readFile("website/src/styles/_overlays.scss", "utf8"); - const styleIcons = await readFile("website/src/styles/_icons.scss", "utf8"); - const styleGames = await readFile("website/src/styles/_games.scss", "utf8"); - const styleMhwilds = await readFile("website/src/styles/_mhwilds.scss", "utf8"); - const styleResponsive = await readFile("website/src/styles/_responsive.scss", "utf8"); - const iconComponent = await readFile("website/src/components/Icon.jsx", "utf8"); - const gamesPage = await readFile("website/src/features/games/GamesPage.jsx", "utf8"); - const gameRoute = await readFile("website/src/features/games/GameRoute.jsx", "utf8"); - const checklistCopyButton = await readFile("website/src/features/games/CopyChecklistItemsButton.jsx", "utf8"); - const gameListsPage = await readFile("website/src/features/games/GameListsPage.jsx", "utf8"); - const gameLoaders = await readFile("website/src/features/games/loaders.js", "utf8"); - const gameFiltersPanel = await readFile("website/src/features/games/GameFiltersPanel.jsx", "utf8"); - const diablo4Page = await readFile("website/src/features/games/diablo4/Diablo4Page.jsx", "utf8"); - const diablo4Overview = await readFile("website/src/features/games/diablo4/Diablo4Overview.jsx", "utf8"); - const diablo4Listing = await readFile("website/src/features/games/diablo4/Diablo4Listing.jsx", "utf8"); - const diablo4Filters = await readFile("website/src/features/games/diablo4/Diablo4Filters.jsx", "utf8"); - const diablo4AffixCard = await readFile("website/src/features/games/diablo4/Diablo4AffixCard.jsx", "utf8"); - const diablo4Utils = await readFile("website/src/features/games/diablo4/utils.js", "utf8"); - const mhwildsPage = await readFile("website/src/features/games/mhwilds/MhwildsPage.jsx", "utf8"); - const mhwildsOverview = await readFile("website/src/features/games/mhwilds/MhwildsOverview.jsx", "utf8"); - const mhwildsListing = await readFile("website/src/features/games/mhwilds/MhwildsListing.jsx", "utf8"); - const mhwildsLists = await readFile("website/src/features/games/mhwilds/MhwildsLists.jsx", "utf8"); - const mhwildsFilters = await readFile("website/src/features/games/mhwilds/MhwildsFilters.jsx", "utf8"); - const monsterCard = await readFile("website/src/features/games/mhwilds/cards/MonsterCard.jsx", "utf8"); - const endemicCard = await readFile("website/src/features/games/mhwilds/cards/EndemicCard.jsx", "utf8"); - const damageTable = await readFile("website/src/features/games/mhwilds/cards/DamageTable.jsx", "utf8"); - const moduleRegistry = await readFile("website/src/features/toolboxes/modules/index.jsx", "utf8"); - const notepadModule = await readFile("website/src/features/toolboxes/modules/NotepadModule.jsx", "utf8"); - const calculatorModule = await readFile("website/src/features/toolboxes/modules/CalculatorModule.jsx", "utf8"); - const checklistModule = await readFile("website/src/features/toolboxes/modules/ChecklistModule.jsx", "utf8"); - const screenshotsModule = await readFile("website/src/features/toolboxes/modules/ScreenshotsModule.jsx", "utf8"); - const imageAnnotationModule = await readFile("website/src/features/toolboxes/modules/ImageAnnotationModule.jsx", "utf8"); - const linksModule = await readFile("website/src/features/toolboxes/modules/LinksModule.jsx", "utf8"); - const textImportModal = await readFile("website/src/features/toolboxes/modules/TextImportModal.jsx", "utf8"); - const countersModule = await readFile("website/src/features/toolboxes/modules/CountersModule.jsx", "utf8"); - const textImport = await readFile("website/src/features/toolboxes/modules/textImport.js", "utf8"); - const reorderHook = await readFile("website/src/hooks/usePointerReorder.js", "utf8"); - const bodyScrollLock = await readFile("website/src/utils/bodyScrollLock.js", "utf8"); - const indexedDbStorage = await readFile("website/src/utils/indexedDbStorage.js", "utf8"); - - assert.match(source, /indexedDbStorage\.js/); - assert.match(indexedDbStorage, /const DB_NAME = "sokkog"/); - assert.match(indexedDbStorage, /const KV_STORE = "kv"/); - assert.match(indexedDbStorage, /const MODULE_STORE = "modules"/); - assert.match(source, /styles\/main\.scss/); - assert.match(styles, /@use "tokens"/); - assert.match(styles, /@use "nebula"/); - assert.match(styles, /@use "toolboxes"/); - assert.match(styles, /@use "mhwilds"/); - assert.match(styles, /@use "diablo4"/); - assert.match(styleTokens, /--gradient-brand/); - assert.match(styleBase, /:is\(/); - assert.match(styleBase, /\.card-icon-button/); - assert.match(styleBase, /\.drawer-action-button/); - assert.match(styleNebula, /@function star-field/); - assert.match(styleNebula, /body::after/); - assert.match(styleBase, /\.import-icon-button/); - assert.match(styleHome, /\.dialogue-line i/); - assert.match(styleHome, /font-style: italic/); - assert.match(styleHome, /\.about-page/); - assert.match(styleHome, /\.nebula-panel/); - assert.match(styleMixins, /@mixin gold-panel-frame/); - assert.match(styleHome, /\.about-story-section/); - assert.match(styleHome, /\.about-tools-list/); - assert.match(styleHome, /\.about-tool-item \.module-icon/); - assert.match(styleShell, /\.sidebar-about-link/); - assert.match(styleToolboxes, /\.module-icon-abacus/); - assert.match(styleToolboxes, /\.module-icon-calculator/); - assert.match(styleToolboxes, /\.calculator-module/); - assert.match(styleToolboxes, /\.toolbox-icon-picker/); - assert.match(styleToolboxes, /\.toolbox-game-icon/); - assert.match(styleToolboxes, /\.toolbox-hero-link/); - assert.match(styleToolboxes, /\.toolbox-actions-row/); - assert.match(styleToolboxes, /\.text-import-actions/); - assert.match(styleIcons, /ui-icon-drag/); - assert.match(styleIcons, /ui-icon-dropdown/); - assert.match(styleIcons, /ui-icon-enter/); - assert.match(styleIcons, /ui-icon-calculator/); - assert.match(styleIcons, /ui-icon-scrollable/); - assert.match(styleIcons, /ui-icon-hide/); - assert.match(styleIcons, /ui-icon-eye-open/); - assert.match(styleIcons, /ui-icon-eye-closed/); - assert.match(styleIcons, /ui-icon-chevron-down/); - assert.match(styleIcons, /ui-icon-chevron-up/); - assert.match(styleIcons, /ui-icon-flip/); - assert.match(styleIcons, /ui-icon-add/); - assert.match(styleMhwilds, /\.mhwilds-card/); - assert.match(styleMhwilds, /\.mhwilds-flip-indicator/); - assert.match(styleGames, /\.game-home-card/); - assert.match(styleGames, /\.game-layout/); - assert.match(styleGames, /\.game-grid/); - assert.match(styleGames, /\.filter-panel/); - assert.doesNotMatch(styleMhwilds, /\.game-(home-card|layout|grid)/); - assert.match(styleCards, /compact-data-card/); - assert.match(styleResponsive, /@media \(max-width: 760px\)/); - assert.match(source, /features\/toolboxes\/modules\/index\.jsx/); - assert.match(source, /features\/games\/GamesPage\.jsx/); - assert.match(source, /features\/games\/GameRoute\.jsx/); - assert.match(iconComponent, /export function Icon/); - assert.match(gamesPage, /export function GamesPage/); - assert.match(gamesPage, /siteContent\.gamesPage/); - assert.match(gamesPage, /game-card-cover-link/); - assert.match(gamesPage, /game-card-placeholder/); - assert.match(gameRoute, /export function GameRoute/); - assert.match(gameRoute, /MhwildsPage/); - assert.match(gameRoute, /Diablo4Page/); - assert.match(checklistCopyButton, /formatChecklistImportItems/); - assert.match(checklistCopyButton, /formatChecklistImportSections/); - assert.match(checklistCopyButton, /\$\{entry\.label\}:\$\{entry\.quantity\}/); - assert.match(checklistCopyButton, /# \$\{title\}/); - assert.match(checklistCopyButton, /navigator\.clipboard\.writeText/); - assert.match(checklistCopyButton, /results-copy-button/); - assert.match(gameListsPage, /export function GameListsPage/); - assert.match(gameListsPage, /game-list-selector/); - assert.match(gameListsPage, /game-list-create-button/); - assert.match(gameListsPage, /module-icon-checklist/); - assert.match(gameLoaders, /export async function loadMhwildsData/); - assert.match(gameLoaders, /\/data\/mhwilds\/lists\/index\.json/); - assert.match(gameLoaders, /loadGameLists/); - assert.match(gameLoaders, /normalizeGameListFile/); - assert.match(gameLoaders, /export async function loadDiablo4Data/); - assert.match(gameLoaders, /\/data\/diablo4\/affixes_types\.json/); - assert.match(gameLoaders, /filterOptionKeys/); - assert.match(gameFiltersPanel, /export function GameFiltersPanel/); - assert.match(gameFiltersPanel, /filter-reset-button/); - assert.match(diablo4Page, /export function Diablo4Page/); - assert.match(diablo4Overview, /export function Diablo4Overview/); - assert.match(diablo4Listing, /export function Diablo4Listing/); - assert.match(diablo4Listing, /CopyChecklistItemsButton/); - assert.match(diablo4Filters, /export function Diablo4Filters/); - assert.match(diablo4Filters, /GameFiltersPanel/); - assert.match(diablo4AffixCard, /export function Diablo4AffixCard/); - assert.match(diablo4AffixCard, /categoryMap/); - assert.match(diablo4Utils, /getFilteredDiablo4Affixes/); - assert.match(diablo4Utils, /getCategoryIconStyle/); - assert.match(mhwildsPage, /export function MhwildsPage/); - assert.match(mhwildsPage, /MhwildsLists/); - assert.match(mhwildsPage, /linkedToolboxId/); - assert.match(mhwildsOverview, /#\/games\/mhwilds\/lists/); - assert.match(mhwildsListing, /export function MhwildsListing/); - assert.match(mhwildsListing, /GameBreadcrumb/); - assert.match(mhwildsLists, /export function MhwildsLists/); - assert.match(mhwildsLists, /GameListsPage/); - assert.match(mhwildsLists, /selectedListId/); - assert.match(mhwildsListing, /CopyChecklistItemsButton/); - assert.match(mhwildsListing, /endemic_life/); - assert.match(mhwildsFilters, /export function MhwildsFilters/); - assert.match(mhwildsFilters, /GameFiltersPanel/); - assert.match(monsterCard, /export function MonsterCard/); - assert.match(monsterCard, /ui-icon-flip/); - assert.match(endemicCard, /export function EndemicCard/); - assert.match(damageTable, /rowIndex/); - assert.match(moduleRegistry, /export const TOOLBOX_MODULES/); - assert.match(moduleRegistry, /export function AddToolControls/); - assert.match(moduleRegistry, /export function ToolboxModules/); - assert.match(moduleRegistry, /MODULE_COMPONENTS/); - assert.match(moduleRegistry, /notepad:/); - assert.match(moduleRegistry, /checklist:/); - assert.match(moduleRegistry, /screenshots:/); - assert.match(moduleRegistry, /links:/); - assert.match(moduleRegistry, /counters:/); - assert.match(moduleRegistry, /calculator:/); - assert.match(moduleRegistry, /imageAnnotation:/); - assert.match(moduleRegistry, /Annotation d'images/); - assert.match(moduleRegistry, /editable: false/); - assert.match(moduleRegistry, /module-edit-button/); - assert.match(notepadModule, /export function NotepadModule/); - assert.match(calculatorModule, /export function CalculatorModule/); - assert.match(calculatorModule, /calculateExpression/); - assert.match(calculatorModule, /activeParentId/); - assert.match(calculatorModule, /parentId/); - assert.match(calculatorModule, /scrollResults/); - assert.match(calculatorModule, /calculator-scroll-toggle/); - assert.match(calculatorModule, /EditableCalculatorLabel/); - assert.match(calculatorModule, /calculator-entry-label/); - assert.match(calculatorModule, /copyChecklistImport/); - assert.match(calculatorModule, /resetCalculator/); - assert.match(calculatorModule, /Quantité non modifiable/); - assert.doesNotMatch(styleToolboxes, /lecture seule/); - assert.match(calculatorModule, /entry\.label \|\| formatResult\(entry\.value\)/); - assert.match(calculatorModule, /ResizeObserver/); - assert.match(calculatorModule, /--calculator-scroll-height/); - assert.match(calculatorModule, /CalculatorEntries/); - assert.match(checklistModule, /export function ChecklistModule/); - assert.match(checklistModule, /TextImportModal/); - assert.match(textImportModal, /createPortal/); - assert.match(textImportModal, /lockBodyScroll/); - assert.match(checklistModule, /Icon name="import"/); - assert.match(styleToolboxes, /\.text-import-modal-dialog/); - assert.match(linksModule, /TextImportModal/); - assert.match(linksModule, /Icon name="import"/); - assert.match(screenshotsModule, /export function ScreenshotsModule/); - assert.match(imageAnnotationModule, /export function ImageAnnotationModule/); - assert.match(linksModule, /export function LinksModule/); - assert.match(countersModule, /export function CountersModule/); - assert.match(textImport, /export function parseColonImportLines/); - assert.match(textImport, /line\.indexOf\(":\"\)/); - assert.match(checklistModule, /editing &&/); - assert.match(screenshotsModule, /editing &&/); - assert.match(linksModule, /editing &&/); - assert.match(countersModule, /editing &&/); - assert.match(countersModule, /counter-actions/); - assert.match(linksModule, /normalizeUrl/); - assert.match(linksModule, /copyText/); - assert.doesNotMatch(source, /DEFAULT_SITE_CONTENT/); - assert.match(source, /\/data\/site\.json/); - assert.match(source, /setSiteContentError/); - assert.match(source, /useIndexedToolboxes/); - assert.match(source, /getStorageEstimate/); - assert.match(source, /DEFAULT_TOOLBOX_ICON/); - assert.match(source, /TOOLBOX_ICON_FILES/); - assert.match(source, /function normalizeToolboxIcon/); - assert.match(source, /function ToolboxIconPicker/); - assert.match(source, /function ToolboxGameIcon/); - assert.match(source, /updateToolboxOrder/); - assert.match(source, /draggingToolboxId/); - assert.match(source, /toolbox-card-drag-handle/); - assert.match(source, /function StorageQuota/); - assert.match(source, /function AboutPage/); - assert.match(source, /route === "\/about"/); - assert.match(source, /sidebar-about-link/); - assert.match(source, /module-icon-\$\{tool\.icon \|\| "notepad"\}/); - assert.match(source, /role="progressbar"/); - assert.match(source, /function createGlobalExportPayload/); - assert.match(source, /async function importAllToolboxes/); - assert.match(source, /import-icon-button/); - assert.match(source, /function LinkToolboxModal/); - assert.doesNotMatch(source, /description: _description/); - assert.match(source, /function CreateToolboxModal/); - assert.match(source, /const getToolboxGameId/); - assert.match(source, /dragon\.png/); - assert.match(source, /value\.split\("\\n"\)/); - assert.match(source, /
addModule/); - assert.match(source, /qtyTarget/); - assert.match(source, /qtyCurrent/); - assert.match(source, /hideCompletedSections/); - assert.match(source, /hideCompletedSectionsFully/); - assert.match(source, /hideWhenComplete/); - assert.match(source, /collapsed/); - assert.match(source, /module\.scrollable/); - assert.match(source, /normalizeLinksData/); - assert.match(source, /normalizeCountersData/); - assert.match(source, /normalizeCalculatorData/); - assert.match(source, /normalizeImageAnnotationData/); - assert.match(source, /compactModuleDataForStorage\(type, value\)/); - assert.match(source, /scrollResults/); - assert.match(checklistModule, /ChecklistItem/); - assert.match(checklistModule, /ChecklistSection/); - assert.match(checklistModule, /parseChecklistImport/); - assert.match(checklistModule, /parseColonImportLines/); - assert.match(checklistModule, /startsWith\("#"\)/); - assert.match(checklistModule, /chevron-down/); - assert.match(checklistModule, /chevron-up/); - assert.match(checklistModule, /Number\.parseInt/); - assert.match(checklistModule, /checklist-qty-current/); - assert.match(checklistModule, /checklist-delete-button danger/); - assert.match(linksModule, /parseColonImportLines/); - assert.match(linksModule, /context\.normalizeUrl/); - assert.match(screenshotsModule, /clipboardData/); - assert.match(screenshotsModule, /createImageAnnotationModule/); - assert.match(screenshotsModule, /shot-annotate-button/); - assert.match(imageAnnotationModule, /annotation-marker/); - assert.match(imageAnnotationModule, /setImageFromFiles/); - assert.match(imageAnnotationModule, /context\.compressImageFile/); - assert.match(imageAnnotationModule, /context\.setScreenshot/); - assert.match(imageAnnotationModule, /createMarkerId/); - assert.match(source, /uid\("marker"\)/); - assert.match(source, /async function addScreenshotFiles/); - assert.match(source, /function ScreenshotViewer/); - assert.match(source, /useModalScrollLock/); - assert.match(bodyScrollLock, /modalLocks/); - assert.match(bodyScrollLock, /is-modal-open/); - assert.match(moduleRegistry, /lockBodyScroll/); - assert.match(source, /screenshot-viewer-media/); - assert.match(source, /screenshot-viewer-image-frame/); - assert.match(source, /screenshot-viewer-marker/); - assert.match(source, /function openImageInNewTab/); - assert.match(source, /!hasMarkers &&/); - assert.match(source, / { - const server = await readFile("server.mjs", "utf8"); - const viteConfig = await readFile("vite.config.js", "utf8"); - const envExample = await readFile(".env.example", "utf8"); - - assert.match(server, /function loadEnvFile/); - assert.match(server, /process\.env\.PORT/); - assert.match(viteConfig, /function loadEnvFile/); - assert.match(viteConfig, /process\.env\.PORT/); - assert.match(envExample, /PORT=5173/); -}); - -test("legacy svg icons are available for reuse", async () => { - const manifest = JSON.parse(await readFile("website/public/data/toolbox-icons.json", "utf8")); - - assert.ok(manifest.icons.length >= 4); - await readFile("website/public/static/icons/home.svg", "utf8"); - await readFile("website/public/static/icons/add.svg", "utf8"); - await readFile("website/public/static/icons/checklist.svg", "utf8"); - await readFile("website/public/static/icons/close.svg", "utf8"); - await readFile("website/public/static/icons/columns.svg", "utf8"); - await readFile("website/public/static/icons/copy.svg", "utf8"); - await readFile("website/public/static/icons/abacus.svg", "utf8"); - await readFile("website/public/static/icons/calculator.svg", "utf8"); - await readFile("website/public/static/icons/map.svg", "utf8"); - await readFile("website/public/static/icons/enter.svg", "utf8"); - await readFile("website/public/static/icons/export.svg", "utf8"); - await readFile("website/public/static/icons/hide.svg", "utf8"); - await readFile("website/public/static/icons/chevron-down.svg", "utf8"); - await readFile("website/public/static/icons/chevron-up.svg", "utf8"); - await readFile("website/public/static/icons/scrollable.svg", "utf8"); - await readFile("website/public/static/icons/notepad.svg", "utf8"); - await readFile("website/public/static/icons/picture.svg", "utf8"); - await readFile("website/public/static/icons/rows.svg", "utf8"); - await readFile("website/public/static/icons/rubber.svg", "utf8"); - await readFile("website/public/static/icons/trashcan.svg", "utf8"); - await readFile("website/public/static/icons/zoom.svg", "utf8"); - await readFile("website/public/favicon.ico"); - await readFile("website/public/static/img/dragon.png"); - await readFile("website/public/static/img/toolbox-icons/toolbox.png"); - await readFile("website/public/static/img/toolbox-icons/sword.png"); - await readFile("website/public/static/img/toolbox-icons/puzzle.png"); -}); diff --git a/tests/static-styles.test.mjs b/tests/static-styles.test.mjs new file mode 100644 index 0000000..b718d20 --- /dev/null +++ b/tests/static-styles.test.mjs @@ -0,0 +1,78 @@ +// Rôle : vérifie les conventions statiques des styles Sass. +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; +import assert from "node:assert/strict"; + +test("style entrypoint and theme layers are wired", async () => { + const styles = await readFile("website/src/styles/main.scss", "utf8"); + const styleNebula = await readFile("website/src/styles/_nebula.scss", "utf8"); + const styleTokens = await readFile("website/src/styles/_tokens.scss", "utf8"); + const styleBase = await readFile("website/src/styles/_base.scss", "utf8"); + const styleMixins = await readFile("website/src/styles/_mixins.scss", "utf8"); + const styleShell = await readFile("website/src/styles/_shell.scss", "utf8"); + const styleCards = await readFile("website/src/styles/_cards.scss", "utf8"); + const styleHome = await readFile("website/src/styles/_home.scss", "utf8"); + const styleToolboxes = await readFile("website/src/styles/_toolboxes.scss", "utf8"); + const styleOverlays = await readFile("website/src/styles/_overlays.scss", "utf8"); + const styleIcons = await readFile("website/src/styles/_icons.scss", "utf8"); + const styleGames = await readFile("website/src/styles/_games.scss", "utf8"); + const styleMhwilds = await readFile("website/src/styles/_mhwilds.scss", "utf8"); + const styleResponsive = await readFile("website/src/styles/_responsive.scss", "utf8"); + + assert.match(styles, /@use "tokens"/); + assert.match(styles, /@use "nebula"/); + assert.match(styles, /@use "toolboxes"/); + assert.match(styles, /@use "mhwilds"/); + assert.match(styles, /@use "diablo4"/); + assert.match(styleTokens, /--gradient-brand/); + assert.match(styleBase, /:is\(/); + assert.match(styleBase, /\.card-icon-button/); + assert.match(styleBase, /\.drawer-action-button/); + assert.match(styleBase, /\.import-icon-button/); + assert.match(styleBase, /themed-scrollbar/); + assert.match(styleBase, /body\.is-modal-open/); + assert.match(styleNebula, /@function star-field/); + assert.match(styleNebula, /body::after/); + assert.match(styleHome, /\.dialogue-line i/); + assert.match(styleHome, /font-style: italic/); + assert.match(styleHome, /\.about-page/); + assert.match(styleHome, /\.nebula-panel/); + assert.match(styleHome, /\.about-story-section/); + assert.match(styleHome, /\.about-tools-list/); + assert.match(styleHome, /\.about-tool-item \.module-icon/); + assert.match(styleMixins, /@mixin gold-panel-frame/); + assert.match(styleShell, /\.sidebar-about-link/); + assert.match(styleToolboxes, /\.module-icon-abacus/); + assert.match(styleToolboxes, /\.module-icon-calculator/); + assert.match(styleToolboxes, /\.calculator-module/); + assert.match(styleToolboxes, /\.toolbox-icon-picker/); + assert.match(styleToolboxes, /\.toolbox-game-icon/); + assert.match(styleToolboxes, /\.toolbox-hero-link/); + assert.match(styleToolboxes, /\.toolbox-actions-row/); + assert.match(styleToolboxes, /\.text-import-actions/); + assert.match(styleToolboxes, /\.text-import-modal-dialog/); + assert.doesNotMatch(styleToolboxes, /lecture seule/); + assert.match(styleIcons, /ui-icon-drag/); + assert.match(styleIcons, /ui-icon-dropdown/); + assert.match(styleIcons, /ui-icon-enter/); + assert.match(styleIcons, /ui-icon-calculator/); + assert.match(styleIcons, /ui-icon-scrollable/); + assert.match(styleIcons, /ui-icon-hide/); + assert.match(styleIcons, /ui-icon-eye-open/); + assert.match(styleIcons, /ui-icon-eye-closed/); + assert.match(styleIcons, /ui-icon-chevron-down/); + assert.match(styleIcons, /ui-icon-chevron-up/); + assert.match(styleIcons, /ui-icon-flip/); + assert.match(styleIcons, /ui-icon-add/); + assert.match(styleIcons, /ui-icon-map/); + assert.match(styleGames, /\.game-home-card/); + assert.match(styleGames, /\.game-layout/); + assert.match(styleGames, /\.game-grid/); + assert.match(styleGames, /\.filter-panel/); + assert.match(styleMhwilds, /\.mhwilds-card/); + assert.match(styleMhwilds, /\.mhwilds-flip-indicator/); + assert.doesNotMatch(styleMhwilds, /\.game-(home-card|layout|grid)/); + assert.match(styleCards, /compact-data-card/); + assert.match(styleOverlays, /overscroll-behavior: contain/); + assert.match(styleResponsive, /@media \(max-width: 760px\)/); +}); diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs new file mode 100644 index 0000000..a06db87 --- /dev/null +++ b/tests/static-toolboxes.test.mjs @@ -0,0 +1,178 @@ +// Rôle : vérifie les invariants statiques des toolboxes et de leurs outils. +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; +import assert from "node:assert/strict"; + +test("toolbox storage, cards and pages are wired", async () => { + const source = await readFile("website/src/main.jsx", "utf8"); + const toolboxCard = await readFile("website/src/features/toolboxes/ToolboxCard.jsx", "utf8"); + const toolboxPages = await readFile("website/src/features/toolboxes/ToolboxPages.jsx", "utf8"); + const toolboxActions = await readFile("website/src/features/toolboxes/useToolboxActions.js", "utf8"); + const toolboxStorage = await readFile("website/src/features/toolboxes/storage/toolboxStorage.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 importButton = await readFile("website/src/components/ImportButton.jsx", "utf8"); + const reorderHook = await readFile("website/src/hooks/usePointerReorder.js", "utf8"); + + assert.match(`${indexedToolboxesHook}\n${toolboxPages}`, /indexedDbStorage\.js/); + assert.match(source, /features\/toolboxes\/storage\/useIndexedToolboxes\.js/); + assert.match(source, /features\/toolboxes\/useToolboxActions\.js/); + assert.match(indexedToolboxesHook, /export function useIndexedToolboxes/); + assert.match(indexedToolboxesHook, /getStorageEstimate/); + assert.match(toolboxActions, /export function useToolboxActions/); + assert.match(toolboxActions, /importAllToolboxesPayload/); + assert.match(toolboxActions, /async function addImageFiles/); + assert.match(`${source}\n${toolboxActions}`, /features\/toolboxes\/storage\/toolboxStorage\.js/); + assert.match(toolboxStorage, /TOOLBOX_ICON_FILES/); + assert.match(toolboxStorage, /export function normalizeToolboxIcon/); + assert.match(toolboxStorage, /export function createGlobalExportPayload/); + assert.match(toolboxStorage, /qtyTarget/); + assert.match(toolboxStorage, /qtyCurrent/); + assert.match(toolboxStorage, /hideCompletedSections/); + assert.match(toolboxStorage, /hideCompletedSectionsFully/); + assert.match(toolboxStorage, /hideWhenComplete/); + assert.match(toolboxStorage, /collapsed/); + assert.match(toolboxStorage, /module\.scrollable/); + assert.match(toolboxStorage, /export function normalizeLinksData/); + assert.match(toolboxStorage, /export function normalizeCountersData/); + assert.match(toolboxStorage, /export function normalizeCalculatorData/); + assert.match(toolboxStorage, /export function normalizeImageAnnotationData/); + assert.match(toolboxStorage, /export function compactModuleDataForStorage\(type, value\)/); + assert.match(toolboxStorage, /scrollResults/); + assert.match(toolboxCard, /DEFAULT_TOOLBOX_ICON/); + assert.match(toolboxCard, /export function ToolboxCard/); + assert.match(toolboxCard, /export function ToolboxIconPicker/); + assert.match(toolboxCard, /export function ToolboxGameIcon/); + assert.match(toolboxCard, /toolbox-card-drag-handle/); + assert.match(toolboxCard, /toolbox-card-cover-link/); + assert.match(toolboxCard, /toolbox-icon-cover/); + assert.match(toolboxPages, /\.\/ToolboxCard\.jsx/); + assert.match(toolboxPages, /updateToolboxOrder/); + assert.match(toolboxPages, /draggingToolboxId/); + assert.match(toolboxPages, /components\/StorageQuota\.jsx/); + assert.match(toolboxPages, /components\/ImportButton\.jsx/); + assert.match(toolboxPages, /import-icon-button/); + assert.match(toolboxPages, /getToolboxGame\(toolbox\)/); + assert.match(toolboxPages, /Vers la page de jeu/); + assert.match(toolboxPages, /Vers la toolbox complète/); + assert.match(toolboxPages, /#\/games\/\$\{toolboxGame\.id\}/); + assert.match(toolboxPages, /#\/toolbox\/\$\{toolbox\.id\}/); + assert.match(toolboxPages, /setDrawerGameId\(""\)/); + assert.match(toolboxPages, /moduleColumns/); + assert.match(toolboxPages, /function ToolboxView/); + assert.match(toolboxPages, /usePointerReorder/); + assert.match(storageQuota, /export function StorageQuota/); + assert.match(storageQuota, /role="progressbar"/); + assert.match(importButton, /export function ImportButton/); + assert.match(importButton, /accept="application\/json"/); + assert.match(reorderHook, /export function usePointerReorder/); + assert.match(reorderHook, /setPointerCapture/); + assert.match(reorderHook, /elementFromPoint/); +}); + +test("toolbox module registry and modules expose expected behavior", async () => { + const source = await readFile("website/src/main.jsx", "utf8"); + const moduleRegistry = await readFile("website/src/features/toolboxes/modules/index.jsx", "utf8"); + const notepadModule = await readFile("website/src/features/toolboxes/modules/NotepadModule.jsx", "utf8"); + const calculatorModule = await readFile("website/src/features/toolboxes/modules/CalculatorModule.jsx", "utf8"); + const checklistModule = await readFile("website/src/features/toolboxes/modules/ChecklistModule.jsx", "utf8"); + const imagesModule = await readFile("website/src/features/toolboxes/modules/ImagesModule.jsx", "utf8"); + const imageAnnotationModule = await readFile("website/src/features/toolboxes/modules/ImageAnnotationModule.jsx", "utf8"); + const linksModule = await readFile("website/src/features/toolboxes/modules/LinksModule.jsx", "utf8"); + const textImportModal = await readFile("website/src/features/toolboxes/modules/TextImportModal.jsx", "utf8"); + const countersModule = await readFile("website/src/features/toolboxes/modules/CountersModule.jsx", "utf8"); + const textImport = await readFile("website/src/features/toolboxes/modules/textImport.js", "utf8"); + const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8"); + + assert.match(moduleRegistry, /export const TOOLBOX_MODULES/); + assert.match(moduleRegistry, /export function AddToolControls/); + assert.match(moduleRegistry, /export function ToolboxModules/); + assert.match(moduleRegistry, /MODULE_COMPONENTS/); + assert.match(moduleRegistry, /notepad:/); + assert.match(moduleRegistry, /checklist:/); + assert.match(moduleRegistry, /images:/); + assert.match(moduleRegistry, /links:/); + assert.match(moduleRegistry, /counters:/); + assert.match(moduleRegistry, /calculator:/); + assert.match(moduleRegistry, /imageAnnotation:/); + assert.match(moduleRegistry, /Annotation d'images/); + assert.match(moduleRegistry, /editable: false/); + assert.match(moduleRegistry, /module-edit-button/); + assert.match(moduleRegistry, /onDragStart/); + assert.match(moduleRegistry, /onPointerDown/); + assert.match(moduleRegistry, /usePointerReorder/); + assert.match(moduleRegistry, /tool-add-card/); + assert.match(moduleRegistry, /tool-quick-add-button/); + assert.match(moduleRegistry, /tool-add-quick-toggle/); + assert.match(moduleRegistry, /scrollable: true/); + assert.match(moduleRegistry, /module-content/); + assert.match(moduleRegistry, /module-scroll-button/); + assert.match(moduleRegistry, /Icon name="dropdown"/); + assert.match(moduleRegistry, /aria-expanded=\{quickOpen\}/); + assert.doesNotMatch(source, / updateViewerMarker(marker.id, (item) => ({ ...item, label: event.target.value }))} + aria-label={`Libellé du marqueur ${index + 1}`} + /> + + + ))} + + ) : ( +

Cliquez sur l'image pour ajouter un marqueur.

+ )} + + )} +
+ +
+ ); +} diff --git a/website/src/components/ImportButton.jsx b/website/src/components/ImportButton.jsx new file mode 100644 index 0000000..bce380b --- /dev/null +++ b/website/src/components/ImportButton.jsx @@ -0,0 +1,16 @@ +// Rôle : encapsule l'input fichier JSON derrière un bouton d'import réutilisable. +import { Icon } from "./Icon.jsx"; + +export function ImportButton({ className, label, title, ariaLabel, icon, onFile }) { + return ( + + ); +} diff --git a/website/src/components/RichText.jsx b/website/src/components/RichText.jsx new file mode 100644 index 0000000..061cf6c --- /dev/null +++ b/website/src/components/RichText.jsx @@ -0,0 +1,16 @@ +// Rôle : rend les textes éditoriaux avec retours ligne et italique contrôlés. +import React from "react"; + +export function RichText({ text }) { + const parts = String(text).split(/(.*?<\/i>)/g).filter(Boolean); + return parts.flatMap((part, index) => { + const italic = part.match(/^(.*?)<\/i>$/); + const value = italic ? italic[1] : part; + const lines = value.split("\n"); + return lines.flatMap((line, lineIndex) => { + const key = `${index}-${lineIndex}`; + const node = italic ? {line} : {line}; + return lineIndex === lines.length - 1 ? [node] : [node,
]; + }); + }); +} diff --git a/website/src/components/Shell.jsx b/website/src/components/Shell.jsx new file mode 100644 index 0000000..72625cf --- /dev/null +++ b/website/src/components/Shell.jsx @@ -0,0 +1,56 @@ +// Rôle : structure le layout global avec sidebar, topbar, navigation et contenu. +import React from "react"; +import { Icon } from "./Icon.jsx"; +import { ImportButton } from "./ImportButton.jsx"; + +export function Shell({ route, content, games, toolboxes, links, actions, children }) { + const gameId = route.split("/")[1] === "games" ? route.split("/")[2] || "" : ""; + const game = games.find((item) => item.id === gameId); + const linkedToolbox = game ? toolboxes.find((item) => item.id === links[game.id]) : null; + const topbarLabel = route.startsWith("/toolbox") ? content.topbar.toolbox : route.startsWith("/games") ? content.topbar.games : content.topbar.dashboard; + const toolboxLabel = linkedToolbox ? `Ouvrir la toolbox ${linkedToolbox.name}` : "Associer une toolbox"; + + return ( +
+ +
+
+
{topbarLabel}
+ {game && ( +
+ +
+ )} +
+
{children}
+
+ +
+ ); +} diff --git a/website/src/components/StorageQuota.jsx b/website/src/components/StorageQuota.jsx new file mode 100644 index 0000000..b5c37ca --- /dev/null +++ b/website/src/components/StorageQuota.jsx @@ -0,0 +1,25 @@ +// Rôle : affiche le budget de stockage local sous forme de barre de progression. +const APP_STORAGE_WARNING_RATIO = 0.85; +const APP_STORAGE_SOFT_LIMIT_BYTES = 250 * 1024 * 1024; + +function formatBytes(bytes) { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 o"; + if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1).replace(".", ",")} Mio`; + if (bytes >= 1024) return `${Math.ceil(bytes / 1024)} Kio`; + return `${bytes} o`; +} + +export function StorageQuota({ usage }) { + const safeUsage = usage || { used: 0, limit: 0, ratio: 0 }; + const softRatio = Math.min(1, safeUsage.used / APP_STORAGE_SOFT_LIMIT_BYTES); + const percent = Math.round(softRatio * 100); + const state = safeUsage.used >= APP_STORAGE_SOFT_LIMIT_BYTES ? "danger" : softRatio >= APP_STORAGE_WARNING_RATIO ? "warning" : "ok"; + const browserQuota = safeUsage.limit ? `Quota navigateur estimé : ${formatBytes(safeUsage.limit)}` : "Quota navigateur estimé indisponible"; + + return ( +
+
Stockage local recommandé{formatBytes(safeUsage.used)} / {formatBytes(APP_STORAGE_SOFT_LIMIT_BYTES)}
+
+
+ ); +} diff --git a/website/src/components/ToolboxModals.jsx b/website/src/components/ToolboxModals.jsx new file mode 100644 index 0000000..e699f72 --- /dev/null +++ b/website/src/components/ToolboxModals.jsx @@ -0,0 +1,72 @@ +// Rôle : fournit les modales globales liées aux toolboxes et confirmations. +import { useEffect, useState } from "react"; +import { lockBodyScroll } from "../utils/bodyScrollLock.js"; + +function useModalScrollLock() { + useEffect(() => lockBodyScroll(), []); +} + +export function ConfirmModal({ title, message, confirmLabel = "Confirmer", cancelLabel = "Annuler", danger = false, onClose }) { + useModalScrollLock(); + return ( +
+
onClose(false)} /> +
+

{title}

+

{message}

+
+ + +
+
+
+ ); +} + +export function NotificationToast({ message }) { + return ( +
+ {message} +
+ ); +} + +export function CreateToolboxModal({ gameId, initialName = "", onClose }) { + useModalScrollLock(); + const [name, setName] = useState(initialName); + return ( +
+
onClose("")} /> +
+

{gameId ? "Créer et associer une toolbox" : "Nouvelle toolbox"}

+
{ + event.preventDefault(); + if (name.trim()) onClose(name.trim()); + }}> + +
+
+
+
+ ); +} + +export function LinkToolboxModal({ toolboxes, selectedId, onClose }) { + useModalScrollLock(); + const [toolboxId, setToolboxId] = useState(selectedId); + return ( +
+
onClose(null)} /> +
+
+
{ + event.preventDefault(); + onClose(toolboxId); + }}> + +
+
+
+
+ ); +} diff --git a/website/src/features/games/CopyChecklistItemsButton.jsx b/website/src/features/games/CopyChecklistItemsButton.jsx index 953a4d9..70855e8 100644 --- a/website/src/features/games/CopyChecklistItemsButton.jsx +++ b/website/src/features/games/CopyChecklistItemsButton.jsx @@ -1,3 +1,4 @@ +// Rôle : copie des données filtrées au format compatible avec l'import checklist. import { useState } from "react"; import { Icon } from "../../components/Icon.jsx"; diff --git a/website/src/features/games/GameBreadcrumb.jsx b/website/src/features/games/GameBreadcrumb.jsx index c5bce1b..6ac9fcc 100644 --- a/website/src/features/games/GameBreadcrumb.jsx +++ b/website/src/features/games/GameBreadcrumb.jsx @@ -1,3 +1,4 @@ +// Rôle : affiche le fil d'Ariane compact commun aux pages de jeu. import { Icon } from "../../components/Icon.jsx"; export function GameBreadcrumb({ game }) { diff --git a/website/src/features/games/GameFiltersPanel.jsx b/website/src/features/games/GameFiltersPanel.jsx index b117b4a..3c14c61 100644 --- a/website/src/features/games/GameFiltersPanel.jsx +++ b/website/src/features/games/GameFiltersPanel.jsx @@ -1,3 +1,4 @@ +// Rôle : fournit le panneau de filtres générique des pages de données de jeu. import { Icon } from "../../components/Icon.jsx"; export function GameFiltersPanel({ diff --git a/website/src/features/games/GameListsPage.jsx b/website/src/features/games/GameListsPage.jsx index 0bad047..e699220 100644 --- a/website/src/features/games/GameListsPage.jsx +++ b/website/src/features/games/GameListsPage.jsx @@ -1,3 +1,4 @@ +// Rôle : affiche les listes de jeu transformables en checklist. import { Icon } from "../../components/Icon.jsx"; import { CopyChecklistItemsButton } from "./CopyChecklistItemsButton.jsx"; import { GameBreadcrumb } from "./GameBreadcrumb.jsx"; diff --git a/website/src/features/games/GameRoute.jsx b/website/src/features/games/GameRoute.jsx index b8f1b6d..6574fcd 100644 --- a/website/src/features/games/GameRoute.jsx +++ b/website/src/features/games/GameRoute.jsx @@ -1,3 +1,4 @@ +// Rôle : route vers les pages de jeux disponibles et leurs sous-pages. import { GamesPage } from "./GamesPage.jsx"; import { Diablo4Page } from "./diablo4/Diablo4Page.jsx"; import { MhwildsPage } from "./mhwilds/MhwildsPage.jsx"; diff --git a/website/src/features/games/GamesPage.jsx b/website/src/features/games/GamesPage.jsx index 17c61ba..81d7f3c 100644 --- a/website/src/features/games/GamesPage.jsx +++ b/website/src/features/games/GamesPage.jsx @@ -1,3 +1,4 @@ +// Rôle : liste les jeux déclarés dans le contenu éditable. export function GamesPage({ siteContent, games, gamesError }) { const content = siteContent.gamesPage; diff --git a/website/src/features/games/diablo4/Diablo4AffixCard.jsx b/website/src/features/games/diablo4/Diablo4AffixCard.jsx index 08c68de..2962f79 100644 --- a/website/src/features/games/diablo4/Diablo4AffixCard.jsx +++ b/website/src/features/games/diablo4/Diablo4AffixCard.jsx @@ -1,3 +1,4 @@ +// Rôle : affiche une carte d'affixe Diablo IV et ses catégories. import { getCategoryIconStyle, getCategoryLabel } from "./utils.js"; export function Diablo4AffixCard({ affix, categoryMap }) { diff --git a/website/src/features/games/diablo4/Diablo4Filters.jsx b/website/src/features/games/diablo4/Diablo4Filters.jsx index dcf2406..ce5dbdb 100644 --- a/website/src/features/games/diablo4/Diablo4Filters.jsx +++ b/website/src/features/games/diablo4/Diablo4Filters.jsx @@ -1,3 +1,4 @@ +// Rôle : configure les filtres applicables aux affixes Diablo IV. import { GameFiltersPanel } from "../GameFiltersPanel.jsx"; import { getCategoryIconStyle, getCategoryId, getCategoryLabel } from "./utils.js"; diff --git a/website/src/features/games/diablo4/Diablo4Listing.jsx b/website/src/features/games/diablo4/Diablo4Listing.jsx index 1de7848..9c87745 100644 --- a/website/src/features/games/diablo4/Diablo4Listing.jsx +++ b/website/src/features/games/diablo4/Diablo4Listing.jsx @@ -1,3 +1,4 @@ +// Rôle : affiche et filtre la liste des affixes Diablo IV. import { useMemo } from "react"; import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx"; import { GameBreadcrumb } from "../GameBreadcrumb.jsx"; diff --git a/website/src/features/games/diablo4/Diablo4Overview.jsx b/website/src/features/games/diablo4/Diablo4Overview.jsx index 5f053d1..d7e8e5d 100644 --- a/website/src/features/games/diablo4/Diablo4Overview.jsx +++ b/website/src/features/games/diablo4/Diablo4Overview.jsx @@ -1,3 +1,4 @@ +// Rôle : affiche les raccourcis de la page d'accueil Diablo IV. export function Diablo4Overview({ game }) { const heroStyle = game?.images?.heroBg ? { "--game-hero-image": `url("${game.images.heroBg}")` } : undefined; diff --git a/website/src/features/games/diablo4/Diablo4Page.jsx b/website/src/features/games/diablo4/Diablo4Page.jsx index 15b2005..fc00c5f 100644 --- a/website/src/features/games/diablo4/Diablo4Page.jsx +++ b/website/src/features/games/diablo4/Diablo4Page.jsx @@ -1,3 +1,4 @@ +// Rôle : assemble le hero et les sous-pages Diablo IV. import { Diablo4Listing } from "./Diablo4Listing.jsx"; import { Diablo4Overview } from "./Diablo4Overview.jsx"; diff --git a/website/src/features/games/diablo4/utils.js b/website/src/features/games/diablo4/utils.js index 91fe5e8..f92e6fa 100644 --- a/website/src/features/games/diablo4/utils.js +++ b/website/src/features/games/diablo4/utils.js @@ -1,3 +1,4 @@ +// Rôle : regroupe les helpers d'affichage et de filtrage Diablo IV. export function normalizeText(value) { return String(value || "").trim().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/\s+/g, " "); } diff --git a/website/src/features/games/loaders.js b/website/src/features/games/loaders.js index a3c7d45..2e5faf9 100644 --- a/website/src/features/games/loaders.js +++ b/website/src/features/games/loaders.js @@ -1,3 +1,4 @@ +// Rôle : charge et normalise les données publiques des jeux. export const INITIAL_MHWILDS_STATE = { loaded: false, loading: false, diff --git a/website/src/features/games/mhwilds/MhwildsFilters.jsx b/website/src/features/games/mhwilds/MhwildsFilters.jsx index 9587fe1..ae50f86 100644 --- a/website/src/features/games/mhwilds/MhwildsFilters.jsx +++ b/website/src/features/games/mhwilds/MhwildsFilters.jsx @@ -1,3 +1,4 @@ +// Rôle : configure les filtres Monster Hunter Wilds pour monstres et faune. import { GameFiltersPanel } from "../GameFiltersPanel.jsx"; import { assetPath } from "./utils.js"; diff --git a/website/src/features/games/mhwilds/MhwildsListing.jsx b/website/src/features/games/mhwilds/MhwildsListing.jsx index 433296f..21f529c 100644 --- a/website/src/features/games/mhwilds/MhwildsListing.jsx +++ b/website/src/features/games/mhwilds/MhwildsListing.jsx @@ -1,3 +1,4 @@ +// Rôle : affiche les listes filtrables de monstres et de faune Monster Hunter Wilds. import { useMemo } from "react"; import { GameBreadcrumb } from "../GameBreadcrumb.jsx"; import { EndemicCard } from "./cards/EndemicCard.jsx"; diff --git a/website/src/features/games/mhwilds/MhwildsLists.jsx b/website/src/features/games/mhwilds/MhwildsLists.jsx index 3d51f8e..e7109b0 100644 --- a/website/src/features/games/mhwilds/MhwildsLists.jsx +++ b/website/src/features/games/mhwilds/MhwildsLists.jsx @@ -1,3 +1,4 @@ +// Rôle : affiche les listes Monster Hunter Wilds exportables en checklist. import { useMemo, useState } from "react"; import { GameListsPage } from "../GameListsPage.jsx"; diff --git a/website/src/features/games/mhwilds/MhwildsOverview.jsx b/website/src/features/games/mhwilds/MhwildsOverview.jsx index f3cad98..f1d8769 100644 --- a/website/src/features/games/mhwilds/MhwildsOverview.jsx +++ b/website/src/features/games/mhwilds/MhwildsOverview.jsx @@ -1,3 +1,4 @@ +// Rôle : affiche les raccourcis de la page d'accueil Monster Hunter Wilds. import { assetPath } from "./utils.js"; export function MhwildsOverview({ game }) { diff --git a/website/src/features/games/mhwilds/MhwildsPage.jsx b/website/src/features/games/mhwilds/MhwildsPage.jsx index d4a1dc3..e8cf1c9 100644 --- a/website/src/features/games/mhwilds/MhwildsPage.jsx +++ b/website/src/features/games/mhwilds/MhwildsPage.jsx @@ -1,3 +1,4 @@ +// Rôle : assemble le hero et les sous-pages Monster Hunter Wilds. import { MhwildsListing } from "./MhwildsListing.jsx"; import { MhwildsLists } from "./MhwildsLists.jsx"; import { MhwildsOverview } from "./MhwildsOverview.jsx"; diff --git a/website/src/features/games/mhwilds/cards/DamageTable.jsx b/website/src/features/games/mhwilds/cards/DamageTable.jsx index f6c703b..daee7f4 100644 --- a/website/src/features/games/mhwilds/cards/DamageTable.jsx +++ b/website/src/features/games/mhwilds/cards/DamageTable.jsx @@ -1,3 +1,4 @@ +// Rôle : affiche les hitzones d'un monstre sous forme de tableau compact. import { assetPath } from "../utils.js"; const PHYSICAL_COLUMNS = ["cut", "blunt", "ammo"]; diff --git a/website/src/features/games/mhwilds/cards/EndemicCard.jsx b/website/src/features/games/mhwilds/cards/EndemicCard.jsx index 7fb62f9..5cca3e3 100644 --- a/website/src/features/games/mhwilds/cards/EndemicCard.jsx +++ b/website/src/features/games/mhwilds/cards/EndemicCard.jsx @@ -1,3 +1,4 @@ +// Rôle : affiche une carte de faune endémique Monster Hunter Wilds. import { IconRow } from "./IconRow.jsx"; import { assetPath, getConditionValues, normalizeText } from "../utils.js"; diff --git a/website/src/features/games/mhwilds/cards/IconRow.jsx b/website/src/features/games/mhwilds/cards/IconRow.jsx index de1e258..6ab7716 100644 --- a/website/src/features/games/mhwilds/cards/IconRow.jsx +++ b/website/src/features/games/mhwilds/cards/IconRow.jsx @@ -1,3 +1,4 @@ +// Rôle : rend une ligne d'icônes localisées pour les cartes Monster Hunter Wilds. import { assetPath } from "../utils.js"; export function IconRow({ label, values, t }) { diff --git a/website/src/features/games/mhwilds/cards/MonsterCard.jsx b/website/src/features/games/mhwilds/cards/MonsterCard.jsx index 2257b61..48be028 100644 --- a/website/src/features/games/mhwilds/cards/MonsterCard.jsx +++ b/website/src/features/games/mhwilds/cards/MonsterCard.jsx @@ -1,3 +1,4 @@ +// Rôle : affiche une carte monstre retournable avec faiblesses et hitzones. import { useState } from "react"; import { DamageTable } from "./DamageTable.jsx"; import { IconRow } from "./IconRow.jsx"; diff --git a/website/src/features/games/mhwilds/utils.js b/website/src/features/games/mhwilds/utils.js index ec3ebb8..60f015a 100644 --- a/website/src/features/games/mhwilds/utils.js +++ b/website/src/features/games/mhwilds/utils.js @@ -1,3 +1,4 @@ +// Rôle : regroupe les helpers d'assets, traduction et filtres Monster Hunter Wilds. const MHWILDS_IMG_PATH = "/static/img/mhwilds"; export function assetPath(name) { diff --git a/website/src/features/toolboxes/ToolboxCard.jsx b/website/src/features/toolboxes/ToolboxCard.jsx new file mode 100644 index 0000000..8370d2d --- /dev/null +++ b/website/src/features/toolboxes/ToolboxCard.jsx @@ -0,0 +1,144 @@ +// Rôle : affiche une toolbox dans la liste, avec actions rapides et icône personnalisable. +import React, { useEffect, useRef, useState } from "react"; +import { Icon } from "../../components/Icon.jsx"; +import { + DEFAULT_TOOLBOX_ICON, + normalizeToolboxIcon, + TOOLBOX_ICONS +} from "./storage/toolboxStorage.js"; + +export function getGameCardCover(game) { + return game?.images?.cardCover || game?.image || ""; +} + +export function getGameCardBackground(game) { + return game?.cover || "var(--gradient-nebula)"; +} + +export function formatDate(value) { + return new Intl.DateTimeFormat("fr-FR", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)); +} + +export function ToolboxCard({ toolbox, game, actions, draggingToolboxId, dropTarget, onDragStart }) { + const gameCoverImage = getGameCardCover(game); + const coverImage = gameCoverImage || toolbox.icon || DEFAULT_TOOLBOX_ICON; + const isDragging = draggingToolboxId === toolbox.id; + const isDropTarget = dropTarget.id === toolbox.id; + const className = [ + "card", + "toolbox-card", + isDragging ? "is-dragging" : "", + isDropTarget ? "is-drop-target" : "", + isDropTarget && dropTarget.placement === "after" ? "drop-after" : "" + ].filter(Boolean).join(" "); + + return ( +
+ + + {game + +
+

{game ? game.title : "Toolbox libre"}

+

{toolbox.name}

+ Modifiée le {formatDate(toolbox.updatedAt)} +
+ + + +
+
+
+ ); +} + +export function ToolboxIconPicker({ toolbox, onChange }) { + const [open, setOpen] = useState(false); + const pickerRef = useRef(null); + const icon = normalizeToolboxIcon(toolbox.icon); + + useEffect(() => { + if (!open) return undefined; + + function handlePointerDown(event) { + if (!pickerRef.current?.contains(event.target)) setOpen(false); + } + + function handleKeyDown(event) { + if (event.key === "Escape") setOpen(false); + } + + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + function selectIcon(nextIcon) { + onChange(nextIcon); + setOpen(false); + } + + return ( +
+ + {open && ( +
+ {TOOLBOX_ICONS.map((path) => ( + + ))} +
+ )} +
+ ); +} + +export function ToolboxGameIcon({ game }) { + const gameCoverImage = getGameCardCover(game); + return ( +
+ +
+ ); +} diff --git a/website/src/features/toolboxes/ToolboxPages.jsx b/website/src/features/toolboxes/ToolboxPages.jsx new file mode 100644 index 0000000..c42d0e5 --- /dev/null +++ b/website/src/features/toolboxes/ToolboxPages.jsx @@ -0,0 +1,326 @@ +// Rôle : affiche les pages toolbox, le panneau latéral et leurs contrôles. +import React, { useEffect, useRef, useState } from "react"; +import { Icon } from "../../components/Icon.jsx"; +import { ImportButton } from "../../components/ImportButton.jsx"; +import { StorageQuota } from "../../components/StorageQuota.jsx"; +import { usePointerReorder } from "../../hooks/usePointerReorder.js"; +import { compressImage } from "../../utils/imageCompression.js"; +import { getSetting as dbGetSetting, setSetting as dbSetSetting } from "../../utils/indexedDbStorage.js"; +import { getGameCardCover, ToolboxCard, ToolboxGameIcon, ToolboxIconPicker } from "./ToolboxCard.jsx"; +import { AddToolControls, ToolboxModules } from "./modules/index.jsx"; +import { + clampQty, + hostnameFromUrl, + normalizeCalculatorData, + normalizeChecklistData, + normalizeCountersData, + normalizeImageAnnotationData, + normalizeLinksData, + normalizeUrl, + uid +} from "./storage/toolboxStorage.js"; + +const DRAWER_WIDTH_SETTING = "drawerWidth"; + +async function copyText(value) { + try { + await navigator.clipboard.writeText(value); + return true; + } catch { + return false; + } +} + +export function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions, storageUsage }) { + const content = siteContent.toolboxes; + const { + draggingId: draggingToolboxId, + dropTarget, + startDrag: startToolboxDrag + } = usePointerReorder({ + targetSelector: ".toolbox-card", + getTargetId: (target) => target.dataset.toolboxId, + getPlacement: (event, target) => { + const rect = target.getBoundingClientRect(); + return event.clientY > rect.top + rect.height / 2 || event.clientX > rect.left + rect.width / 2 ? "after" : "before"; + }, + onMove: (draggingId, targetId, placement) => { + const nextIds = toolboxes.map((toolbox) => toolbox.id).filter((id) => id !== draggingId); + const targetIndex = nextIds.indexOf(targetId); + nextIds.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, draggingId); + actions.updateToolboxOrder(nextIds); + } + }); + + return ( +
+
+
+

{content.eyebrow}

+

{content.title}

+

{content.summary}

+
+
+
+
+ + +
+
+ + +
+
+
+ + {toolboxes.length ? toolboxes.map((toolbox) => ( + + )) : ( +

{content.emptyTitle}

{content.emptyText}

+ )} +
+ +
+ ); +} + +function StorageHelpCard({ help }) { + return ( +
+
+
+
+

{help.text}

+
    + {help.items.map((item, index) =>
  • {index + 1}

    {item}

  • )} +
+
+
+ ); +} + +export function ToolboxPage(props) { + const { toolboxId, toolboxes, getToolboxGame } = props; + const toolbox = toolboxes.find((item) => item.id === toolboxId); + if (!toolbox) return

Toolbox introuvable

Retour
; + return ; +} + +function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, getModuleData, updateToolbox, updateModuleData, addImageFiles }) { + const moduleColumns = Number(toolbox.moduleColumns) === 1 ? 1 : 2; + const toolboxGameCover = getGameCardCover(toolboxGame); + const moduleText = siteContent.toolboxes.modules; + const moduleContext = { + getModuleData, + moduleText, + normalizeChecklistData, + normalizeLinksData, + normalizeCountersData, + normalizeCalculatorData, + normalizeImageAnnotationData, + normalizeUrl, + hostnameFromUrl, + copyText, + notify: actions.notify, + compressImageFile: compressImage, + clampQty, + uid, + setModuleData: updateModuleData, + addImageFiles, + createImageAnnotationModule, + setImage: actions.setImage, + refresh: () => {} + }; + + function addModule(type) { + if (!type) return; + updateToolbox({ ...toolbox, modules: [...toolbox.modules, { id: uid("mod"), type }] }); + } + + function createImageAnnotationModule(dataUrl) { + if (!dataUrl) return; + const moduleId = uid("mod"); + updateToolbox({ ...toolbox, modules: [...toolbox.modules, { id: moduleId, type: "imageAnnotation" }] }); + updateModuleData(toolbox.id, moduleId, { image: dataUrl, markers: [] }, "imageAnnotation"); + } + + function moveModule(fromModuleId, toModuleId, placement = "before") { + if (!fromModuleId || !toModuleId || fromModuleId === toModuleId) return; + const modules = [...toolbox.modules]; + const fromIndex = modules.findIndex((module) => module.id === fromModuleId); + const toIndex = modules.findIndex((module) => module.id === toModuleId); + if (fromIndex < 0 || toIndex < 0) return; + const [moved] = modules.splice(fromIndex, 1); + const targetIndex = modules.findIndex((module) => module.id === toModuleId); + modules.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, moved); + updateToolbox({ ...toolbox, modules }); + } + + return ( +
+
+
+

Toolbox

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

{toolbox.name}

: ( + name !== toolbox.name && updateToolbox({ ...toolbox, name })} /> + )} +
+ {toolboxGame && ( + actions.setDrawerGameId("") : undefined} + > + + {embedded ? "Vers la toolbox complète" : "Vers la page de jeu"} + + )} +
+
+ +
+
+ {!embedded && ( +
+
+ + +
+
+ )} + updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? { ...module, title } : module) })} + onUpdateModule={(moduleId, updater) => updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? updater(module) : module) })} + onDelete={(moduleId) => actions.setConfirmModal({ + title: "Retirer l'outil", + message: "Retirer cet outil de la toolbox ?", + confirmLabel: "Retirer", + danger: true, + onResolve: (confirmed) => { + if (!confirmed) return; + actions.removeModuleData(toolbox.id, moduleId); + updateToolbox({ ...toolbox, modules: toolbox.modules.filter((module) => module.id !== moduleId) }); + } + })} + onMove={moveModule} + /> + {!embedded && } +
+ ); +} + +export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, storageUsage, getModuleData, updateToolbox, updateModuleData, addImageFiles }) { + const selectedId = links[gameId] || ""; + const toolbox = toolboxes.find((item) => item.id === selectedId); + const [width, setWidth] = useState(""); + const panelRef = useRef(null); + + useEffect(() => { + let cancelled = false; + dbGetSetting(DRAWER_WIDTH_SETTING, "") + .then((storedWidth) => { + if (cancelled) return; + const value = Number(storedWidth); + if (Number.isFinite(value) && value > 0) setWidth(Math.min(Math.max(value, 360), Math.floor(window.innerWidth * 0.94))); + }) + .catch(() => {}); + return () => { cancelled = true; }; + }, []); + + function startResize(event) { + const panel = panelRef.current; + if (!panel) return; + event.preventDefault(); + const startX = event.clientX; + const startWidth = panel.getBoundingClientRect().width; + const minWidth = 360; + const maxWidth = Math.floor(window.innerWidth * 0.94); + document.body.classList.add("is-resizing-drawer"); + const onMove = (moveEvent) => { + const nextWidth = Math.min(Math.max(startWidth + startX - moveEvent.clientX, minWidth), maxWidth); + setWidth(nextWidth); + dbSetSetting(DRAWER_WIDTH_SETTING, Math.round(nextWidth)).catch(() => {}); + }; + const stop = () => { + document.body.classList.remove("is-resizing-drawer"); + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", stop); + window.removeEventListener("pointercancel", stop); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", stop); + window.addEventListener("pointercancel", stop); + } + + return ( + + ); +} + +function EditableTitle({ value, fallback, onSave, className = "module-title" }) { + const ref = useRef(null); + useEffect(() => { + if (ref.current && document.activeElement !== ref.current) ref.current.textContent = value; + }, [value]); + return ( +

{ event.currentTarget.dataset.previousTitle = event.currentTarget.textContent.trim(); }} + onBlur={(event) => onSave(event.currentTarget.textContent.trim() || fallback)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + event.currentTarget.blur(); + } + if (event.key === "Escape") { + event.preventDefault(); + event.currentTarget.textContent = event.currentTarget.dataset.previousTitle || value; + event.currentTarget.blur(); + } + }} + >{value}

+ ); +} diff --git a/website/src/features/toolboxes/modules/CalculatorModule.jsx b/website/src/features/toolboxes/modules/CalculatorModule.jsx index 0aa9c4f..1e3cef9 100644 --- a/website/src/features/toolboxes/modules/CalculatorModule.jsx +++ b/website/src/features/toolboxes/modules/CalculatorModule.jsx @@ -1,3 +1,4 @@ +// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence. import { useLayoutEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; diff --git a/website/src/features/toolboxes/modules/ChecklistModule.jsx b/website/src/features/toolboxes/modules/ChecklistModule.jsx index 12dcb0a..8fd9514 100644 --- a/website/src/features/toolboxes/modules/ChecklistModule.jsx +++ b/website/src/features/toolboxes/modules/ChecklistModule.jsx @@ -1,3 +1,4 @@ +// Rôle : fournit l'outil checklist avec quantités, catégories et imports texte. import { useCallback, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; import { TextImportModal } from "./TextImportModal.jsx"; diff --git a/website/src/features/toolboxes/modules/CountersModule.jsx b/website/src/features/toolboxes/modules/CountersModule.jsx index 82aface..e283d93 100644 --- a/website/src/features/toolboxes/modules/CountersModule.jsx +++ b/website/src/features/toolboxes/modules/CountersModule.jsx @@ -1,3 +1,4 @@ +// Rôle : fournit l'outil compteurs personnalisables. import { useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; diff --git a/website/src/features/toolboxes/modules/ImageAnnotationModule.jsx b/website/src/features/toolboxes/modules/ImageAnnotationModule.jsx index d5b53ae..4f65020 100644 --- a/website/src/features/toolboxes/modules/ImageAnnotationModule.jsx +++ b/website/src/features/toolboxes/modules/ImageAnnotationModule.jsx @@ -1,3 +1,4 @@ +// Rôle : fournit l'outil annotation d'images avec marqueurs éditables. import { useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; @@ -84,7 +85,7 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
)} -
- {data.shots.map((shot) => ( -
- {editing ? ( updateShot(shot.id, { label: event.target.value })} + onChange={(event) => updateImage(image.id, { label: event.target.value })} aria-label={textContent.labelAriaLabel || "Libellé de l'image"} /> - ) : shot.label ? ( -
{shot.label}
+ ) : image.label ? ( +
{image.label}
) : null} -
+
- diff --git a/website/src/features/toolboxes/modules/LinksModule.jsx b/website/src/features/toolboxes/modules/LinksModule.jsx index a6a8289..cf6a931 100644 --- a/website/src/features/toolboxes/modules/LinksModule.jsx +++ b/website/src/features/toolboxes/modules/LinksModule.jsx @@ -1,3 +1,4 @@ +// Rôle : fournit l'outil liens avec ajout manuel et import texte. import { useCallback, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; import { TextImportModal } from "./TextImportModal.jsx"; diff --git a/website/src/features/toolboxes/modules/NotepadModule.jsx b/website/src/features/toolboxes/modules/NotepadModule.jsx index d7b65a8..8961f05 100644 --- a/website/src/features/toolboxes/modules/NotepadModule.jsx +++ b/website/src/features/toolboxes/modules/NotepadModule.jsx @@ -1,3 +1,4 @@ +// Rôle : fournit l'outil bloc-notes libre. import { useState } from "react"; export function NotepadModule({ toolboxId, moduleId, context }) { diff --git a/website/src/features/toolboxes/modules/TextImportModal.jsx b/website/src/features/toolboxes/modules/TextImportModal.jsx index 3251ba5..12975f4 100644 --- a/website/src/features/toolboxes/modules/TextImportModal.jsx +++ b/website/src/features/toolboxes/modules/TextImportModal.jsx @@ -1,3 +1,4 @@ +// Rôle : affiche la modale d'import texte partagée par les outils compatibles. import { useEffect } from "react"; import { createPortal } from "react-dom"; import { Icon } from "../../../components/Icon.jsx"; diff --git a/website/src/features/toolboxes/modules/index.jsx b/website/src/features/toolboxes/modules/index.jsx index 3d73f92..6a8ef04 100644 --- a/website/src/features/toolboxes/modules/index.jsx +++ b/website/src/features/toolboxes/modules/index.jsx @@ -1,3 +1,4 @@ +// Rôle : registre des outils toolbox, rendu commun et contrôles d'ajout/réorganisation. import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Icon } from "../../../components/Icon.jsx"; @@ -9,12 +10,12 @@ import { CountersModule } from "./CountersModule.jsx"; import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx"; import { LinksModule } from "./LinksModule.jsx"; import { NotepadModule } from "./NotepadModule.jsx"; -import { ScreenshotsModule } from "./ScreenshotsModule.jsx"; +import { ImagesModule } from "./ImagesModule.jsx"; const MODULE_COMPONENTS = { notepad: { label: "Bloc notes", icon: "notepad", Component: NotepadModule, editable: false }, checklist: { label: "Checklist", icon: "checklist", Component: ChecklistModule, editable: true, scrollable: true }, - screenshots: { label: "Images", icon: "picture", Component: ScreenshotsModule, editable: true, scrollable: true }, + images: { label: "Images", icon: "picture", Component: ImagesModule, editable: true, scrollable: true }, links: { label: "Liens", icon: "link", Component: LinksModule, editable: true, scrollable: true }, counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true, scrollable: true }, calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: false }, diff --git a/website/src/features/toolboxes/modules/textImport.js b/website/src/features/toolboxes/modules/textImport.js index 3905f73..86dea7c 100644 --- a/website/src/features/toolboxes/modules/textImport.js +++ b/website/src/features/toolboxes/modules/textImport.js @@ -1,3 +1,4 @@ +// Rôle : parse les imports texte en listes exploitables par les outils. export function parseColonImportLines(text) { return String(text || "") .split(/\r?\n/) diff --git a/website/src/features/toolboxes/storage/toolboxStorage.js b/website/src/features/toolboxes/storage/toolboxStorage.js new file mode 100644 index 0000000..241db1d --- /dev/null +++ b/website/src/features/toolboxes/storage/toolboxStorage.js @@ -0,0 +1,446 @@ +// Rôle : normalise, compacte et sérialise les données toolbox persistées. +const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", image: "s", link: "l", counter: "c", calc: "r", marker: "k" }; +const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; +const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/"; +const TOOLBOX_ICON_FILES = [ + "toolbox.png", + "ball.png", + "capture.png", + "card.png", + "city.png", + "compass.png", + "crash.png", + "detective.png", + "fire.png", + "horror.png", + "jump.png", + "mining.png", + "parachute.png", + "puzzle.png", + "shield.png", + "sword.png", + "target.png", + "tower.png", + "wheel.png" +]; +const DEFAULT_MODULE_TITLES = { + notepad: "Bloc notes", + checklist: "Checklist", + images: "Images", + links: "Liens", + counters: "Compteurs", + calculator: "Calculateur", + imageAnnotation: "Annotation d'images" +}; + +export const TOOLBOX_ICONS = TOOLBOX_ICON_FILES.map((file) => `${TOOLBOX_ICON_BASE}${file}`); +export const DEFAULT_TOOLBOX_ICON = `${TOOLBOX_ICON_BASE}toolbox.png`; + +function randomToken(length = 6) { + const bytes = new Uint8Array(length); + if (globalThis.crypto?.getRandomValues) { + globalThis.crypto.getRandomValues(bytes); + return [...bytes].map((byte) => ID_ALPHABET[byte % ID_ALPHABET.length]).join(""); + } + return Math.random().toString(36).slice(2, 2 + length).padEnd(length, "0"); +} + +export function uid(prefix) { + return `${ID_PREFIXES[prefix] || "x"}${randomToken()}`; +} + +function getDefaultModuleTitle(type) { + return DEFAULT_MODULE_TITLES[type] || "Outil"; +} + +export function normalizeToolboxIcon(icon) { + const value = String(icon || "").trim(); + const file = value.replace(TOOLBOX_ICON_BASE, "").split("/").pop(); + return TOOLBOX_ICON_FILES.includes(file) ? `${TOOLBOX_ICON_BASE}${file}` : DEFAULT_TOOLBOX_ICON; +} + +function normalizeToolboxModule(module) { + if (!module || typeof module !== "object" || !module.type) return null; + const title = String(module.title || "").trim(); + const normalized = { id: module.id || uid("mod"), type: module.type }; + if (title && title !== getDefaultModuleTitle(module.type)) normalized.title = title; + if (module.scrollable === true) normalized.scrollable = true; + return normalized; +} + +export function normalizeToolbox(toolbox) { + if (!toolbox || typeof toolbox !== "object") return null; + return { + id: toolbox.id || uid("tbx"), + name: String(toolbox.name || "Nouvelle toolbox").trim() || "Nouvelle toolbox", + icon: normalizeToolboxIcon(toolbox.icon), + moduleColumns: Number(toolbox.moduleColumns) === 1 ? 1 : 2, + modules: (Array.isArray(toolbox.modules) ? toolbox.modules : []).map(normalizeToolboxModule).filter(Boolean), + updatedAt: toolbox.updatedAt || new Date().toISOString() + }; +} + +export function compactToolboxForStorage(toolbox) { + const normalized = normalizeToolbox(toolbox); + if (!normalized) return null; + const compact = { + id: normalized.id, + name: normalized.name, + modules: normalized.modules, + updatedAt: normalized.updatedAt + }; + if (normalized.moduleColumns === 1) compact.moduleColumns = 1; + if (normalized.icon !== DEFAULT_TOOLBOX_ICON) compact.icon = normalized.icon; + return compact; +} + +export function compactToolboxesForStorage(toolboxes) { + return (Array.isArray(toolboxes) ? toolboxes : []).map(compactToolboxForStorage).filter(Boolean); +} + +export function moduleStorageKey(toolboxId, moduleId) { + return `${toolboxId}:${moduleId}`; +} + +export function globalModuleKey(toolboxId, moduleId) { + return `${toolboxId}:${moduleId}`; +} + +function parsePositiveInt(value, fallback = 1) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +export function clampQty(value, target) { + const parsed = Number.parseInt(value, 10); + const safeValue = Number.isFinite(parsed) ? parsed : 0; + return Math.min(safeValue, Math.max(1, target)); +} + +function normalizeChecklistItem(item) { + const qtyTarget = Math.max(1, parsePositiveInt(item?.qtyTarget, 1)); + const qtyCurrent = clampQty(item?.qtyCurrent, qtyTarget); + return { + id: item?.id || uid("item"), + label: String(item?.label || "").trim(), + qtyTarget, + qtyCurrent + }; +} + +function normalizeChecklistSection(section, fallbackTitle = "") { + const normalized = { + id: section?.id || uid("section"), + title: String(section?.title ?? fallbackTitle).trim(), + items: (section?.items || []).map(normalizeChecklistItem).filter((item) => item.label) + }; + if (typeof section?.hideWhenComplete === "boolean") normalized.hideWhenComplete = section.hideWhenComplete; + if (section?.collapsed === true) normalized.collapsed = true; + return normalized; +} + +export function normalizeChecklistData(data) { + const hideCompletedSections = Boolean(data?.hideCompletedSections); + const hideCompletedSectionsFully = Boolean(data?.hideCompletedSectionsFully); + const sections = Array.isArray(data?.sections) + ? data.sections.map((section) => normalizeChecklistSection(section)).filter((section) => section.items.length || section.title) + : []; + const legacyItems = (data?.items || []).map(normalizeChecklistItem).filter((item) => item.label); + if (!sections.length && legacyItems.length) return { hideCompletedSections, hideCompletedSectionsFully, sections: [{ id: uid("section"), title: "", items: legacyItems }], items: legacyItems }; + const items = sections.flatMap((section) => section.items); + return { hideCompletedSections, hideCompletedSectionsFully, sections, items }; +} + +export function normalizeUrl(value) { + const cleanValue = String(value || "").trim(); + if (!cleanValue) return ""; + + try { + const url = new URL(cleanValue.includes("://") ? cleanValue : `https://${cleanValue}`); + if (url.protocol !== "http:" && url.protocol !== "https:") return ""; + return url.href; + } catch { + return ""; + } +} + +export function hostnameFromUrl(value) { + try { + return new URL(value).hostname.replace(/^www\./, ""); + } catch { + return value; + } +} + +export function normalizeLinksData(data) { + return { + links: (data?.links || []) + .map((link) => { + const url = normalizeUrl(link?.url); + if (!url) return null; + return { + id: link?.id || uid("link"), + title: String(link?.title || "").trim(), + url + }; + }) + .filter(Boolean) + }; +} + +function normalizeCounterValue(value) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : 0; +} + +export function normalizeCountersData(data) { + return { + counters: (data?.counters || []) + .map((counter) => ({ + id: counter?.id || uid("counter"), + label: String(counter?.label || "").trim(), + value: normalizeCounterValue(counter?.value) + })) + .filter((counter) => counter.label) + }; +} + +function normalizeCalculatorValue(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +export function labelFromFileName(name) { + if (!name || name === "image.png") return ""; + return name.replace(/\.[^.]+$/, "").trim(); +} + +export function normalizeCalculatorData(data) { + const entries = (data?.entries || []) + .map((entry) => ({ + id: entry?.id || uid("calc"), + parentId: String(entry?.parentId || ""), + label: String(entry?.label || "").trim(), + value: normalizeCalculatorValue(entry?.value) + })); + const entryIds = new Set(entries.map((entry) => entry.id)); + return { + scrollResults: data?.scrollResults === true, + entries: entries.map((entry) => entryIds.has(entry.parentId) ? entry : { ...entry, parentId: "" }) + }; +} + +function clampPercent(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.min(100, Math.max(0, parsed)); +} + +export function normalizeImageAnnotationData(data) { + const image = String(data?.image || data?.dataUrl || ""); + const markers = (data?.markers || []) + .map((marker) => ({ + id: marker?.id || uid("marker"), + x: clampPercent(marker?.x), + y: clampPercent(marker?.y), + label: String(marker?.label || "").trim() + })); + return { image, markers }; +} + +function compactChecklistItemForStorage(item) { + const normalized = normalizeChecklistItem(item); + const compact = { id: normalized.id, label: normalized.label }; + if (normalized.qtyTarget !== 1) compact.qtyTarget = normalized.qtyTarget; + if (normalized.qtyCurrent !== 0) compact.qtyCurrent = normalized.qtyCurrent; + return compact; +} + +function compactChecklistSectionForStorage(section) { + const normalized = normalizeChecklistSection(section); + const compact = { + id: normalized.id, + items: normalized.items.map(compactChecklistItemForStorage) + }; + if (normalized.title) compact.title = normalized.title; + if (typeof normalized.hideWhenComplete === "boolean") compact.hideWhenComplete = normalized.hideWhenComplete; + if (normalized.collapsed === true) compact.collapsed = true; + return compact; +} + +export function compactModuleDataForStorage(type, value) { + if (type === "notepad") { + const text = String(value?.text || ""); + return text ? { text } : null; + } + if (type === "checklist") { + const normalized = normalizeChecklistData(value); + const sections = normalized.sections.map(compactChecklistSectionForStorage).filter((section) => section.items.length || section.title); + if (!sections.length) return null; + const settings = { + ...(normalized.hideCompletedSections ? { hideCompletedSections: true } : {}), + ...(normalized.hideCompletedSectionsFully ? { hideCompletedSectionsFully: true } : {}) + }; + if (sections.length === 1 && !sections[0].title && typeof sections[0].hideWhenComplete !== "boolean") return { ...settings, items: sections[0].items }; + return { ...settings, sections }; + } + if (type === "images") { + const images = (Array.isArray(value?.images) ? value.images : []) + .filter((image) => image?.dataUrl) + .map((image) => { + const compact = { id: image.id || uid("image"), dataUrl: image.dataUrl }; + if (image.label) compact.label = image.label; + return compact; + }); + return images.length ? { images } : null; + } + if (type === "imageAnnotation") { + const normalized = normalizeImageAnnotationData(value); + if (!normalized.image) return null; + return { + image: normalized.image, + markers: normalized.markers.map((marker) => { + const compact = { id: marker.id, x: marker.x, y: marker.y }; + if (marker.label) compact.label = marker.label; + return compact; + }) + }; + } + if (type === "links") { + const links = normalizeLinksData(value).links.map((link) => { + const compact = { + id: link.id, + url: link.url + }; + if (link.title) compact.title = link.title; + return compact; + }); + return links.length ? { links } : null; + } + if (type === "counters") { + const counters = normalizeCountersData(value).counters.map((counter) => ({ + id: counter.id, + label: counter.label, + value: counter.value + })); + return counters.length ? { counters } : null; + } + if (type === "calculator") { + const normalized = normalizeCalculatorData(value); + const entries = normalized.entries.map((entry) => { + const compact = { + id: entry.id, + label: entry.label, + value: entry.value + }; + if (entry.parentId) compact.parentId = entry.parentId; + return compact; + }); + if (!entries.length && !normalized.scrollResults) return null; + return normalized.scrollResults ? { entries, scrollResults: true } : { entries }; + } + return value; +} + +export function prepareModuleDataForStorage(toolboxes, toolboxId, moduleId, value, moduleType = "") { + const module = toolboxes.find((toolbox) => toolbox.id === toolboxId)?.modules.find((item) => item.id === moduleId); + return compactModuleDataForStorage(moduleType || module?.type, value); +} + +function createExportIdFactory() { + const counts = {}; + return (prefix) => { + counts[prefix] = (counts[prefix] || 0) + 1; + return `${ID_PREFIXES[prefix] || "x"}${counts[prefix].toString(36)}`; + }; +} + +function remapModuleDataForExport(type, data, nextId) { + const compact = compactModuleDataForStorage(type, data); + if (!compact) return null; + if (type === "checklist") { + if (compact.sections) { + return { + sections: compact.sections.map((section) => ({ + ...section, + id: nextId("section"), + items: section.items.map((item) => ({ ...item, id: nextId("item") })) + })) + }; + } + return { items: compact.items.map((item) => ({ ...item, id: nextId("item") })) }; + } + if (type === "images") { + return { images: compact.images.map((image) => ({ ...image, id: nextId("image") })) }; + } + + if (type === "imageAnnotation") { + return { + ...compact, + markers: compact.markers.map((marker) => ({ ...marker, id: nextId("marker") })) + }; + } + + if (type === "links") { + return { links: compact.links.map((link) => ({ ...link, id: nextId("link") })) }; + } + + if (type === "counters") { + return { counters: compact.counters.map((counter) => ({ ...counter, id: nextId("counter") })) }; + } + + if (type === "calculator") { + const entryIdMap = new Map(); + compact.entries.forEach((entry) => entryIdMap.set(entry.id, nextId("calc"))); + const remapped = { + entries: compact.entries.map((entry) => { + const remapped = { ...entry, id: entryIdMap.get(entry.id) }; + if (entry.parentId) remapped.parentId = entryIdMap.get(entry.parentId) || ""; + return remapped; + }) + }; + if (compact.scrollResults) remapped.scrollResults = true; + return remapped; + } + + return compact; +} + +export function createToolboxExportPayload(toolbox, moduleData) { + const source = normalizeToolbox(toolbox); + const nextId = createExportIdFactory(); + const moduleIdMap = new Map(); + const exportedToolbox = compactToolboxForStorage({ + ...source, + id: nextId("tbx"), + modules: source.modules.map((module) => { + const id = nextId("mod"); + moduleIdMap.set(module.id, id); + return { ...module, id }; + }) + }); + const modules = Object.fromEntries(source.modules + .map((module) => [ + moduleIdMap.get(module.id), + remapModuleDataForExport(module.type, moduleData[moduleStorageKey(source.id, module.id)] || null, nextId) + ]) + .filter(([, data]) => data != null)); + return { toolbox: exportedToolbox, modules }; +} + +export function createGlobalExportPayload(toolboxes, links, moduleData) { + const modules = {}; + toolboxes.forEach((toolbox) => { + toolbox.modules.forEach((module) => { + const data = compactModuleDataForStorage(module.type, moduleData[moduleStorageKey(toolbox.id, module.id)] || null); + if (data) modules[globalModuleKey(toolbox.id, module.id)] = data; + }); + }); + return { + version: 1, + exportedAt: new Date().toISOString(), + toolboxes: compactToolboxesForStorage(toolboxes), + modules, + links + }; +} diff --git a/website/src/features/toolboxes/storage/useIndexedToolboxes.js b/website/src/features/toolboxes/storage/useIndexedToolboxes.js new file mode 100644 index 0000000..558f8eb --- /dev/null +++ b/website/src/features/toolboxes/storage/useIndexedToolboxes.js @@ -0,0 +1,135 @@ +// Rôle : synchronise les toolboxes, données d'outils, liens de jeux et quota via IndexedDB. +import { useEffect, useState } from "react"; +import { + getAllModuleData as dbGetAllModuleData, + getLinks as dbGetLinks, + getStorageEstimate, + getToolboxes as dbGetToolboxes, + removeModuleData as dbRemoveModuleData, + removeModuleDataKeys as dbRemoveModuleDataKeys, + setLinks as dbSetLinks, + setModuleData as dbSetModuleData, + setToolboxes as dbSetToolboxes +} from "../../../utils/indexedDbStorage.js"; +import { + compactToolboxesForStorage, + moduleStorageKey, + normalizeToolbox, + prepareModuleDataForStorage +} from "./toolboxStorage.js"; + +export function useIndexedToolboxes(onError) { + const [ready, setReady] = useState(false); + const [toolboxes, setToolboxesState] = useState([]); + const [links, setLinksState] = useState({}); + const [moduleData, setModuleDataState] = useState({}); + const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 }); + + async function refreshQuota() { + try { + const estimate = await getStorageEstimate(); + const used = estimate.usage || 0; + const limit = estimate.quota || 0; + setStorageUsage({ used, limit, ratio: limit ? Math.min(1, used / limit) : 0 }); + } catch { + setStorageUsage({ used: 0, limit: 0, ratio: 0 }); + } + } + + useEffect(() => { + let cancelled = false; + async function loadStore() { + try { + const [storedToolboxes, storedLinks, storedModules] = await Promise.all([ + dbGetToolboxes(), + dbGetLinks(), + dbGetAllModuleData() + ]); + if (cancelled) return; + setToolboxesState((Array.isArray(storedToolboxes) ? storedToolboxes : []).map(normalizeToolbox).filter(Boolean)); + setLinksState(storedLinks && typeof storedLinks === "object" ? storedLinks : {}); + setModuleDataState(Object.fromEntries((storedModules || []).map((entry) => [entry.key, entry.data]))); + setReady(true); + refreshQuota(); + } catch (error) { + if (!cancelled) { + setReady(true); + onError(error.message || "Stockage IndexedDB indisponible."); + } + } + } + loadStore(); + return () => { cancelled = true; }; + }, []); + + function persistToolboxes(nextToolboxes) { + const normalized = compactToolboxesForStorage(nextToolboxes).map(normalizeToolbox).filter(Boolean); + setToolboxesState(normalized); + dbSetToolboxes(compactToolboxesForStorage(normalized)) + .then(refreshQuota) + .catch((error) => onError(error.message)); + return true; + } + + function persistLinks(nextLinks) { + setLinksState(nextLinks); + dbSetLinks(nextLinks) + .then(refreshQuota) + .catch((error) => onError(error.message)); + return true; + } + + function getModuleData(toolboxId, moduleId, fallback) { + const value = moduleData[moduleStorageKey(toolboxId, moduleId)]; + return value == null ? fallback : value; + } + + function updateModuleData(toolboxId, moduleId, value, moduleType = "") { + const compact = prepareModuleDataForStorage(toolboxes, toolboxId, moduleId, value, moduleType); + const key = moduleStorageKey(toolboxId, moduleId); + setModuleDataState((state) => { + const nextState = { ...state }; + if (compact == null) delete nextState[key]; + else nextState[key] = compact; + return nextState; + }); + const operation = compact == null ? dbRemoveModuleData(key) : dbSetModuleData(key, compact); + operation.then(refreshQuota).catch((error) => onError(error.message)); + return true; + } + + function removeModuleData(toolboxId, moduleId) { + const key = moduleStorageKey(toolboxId, moduleId); + setModuleDataState((state) => { + const nextState = { ...state }; + delete nextState[key]; + return nextState; + }); + dbRemoveModuleData(key).then(refreshQuota).catch((error) => onError(error.message)); + } + + function removeToolboxModuleData(toolbox) { + const keys = (toolbox?.modules || []).map((module) => moduleStorageKey(toolbox.id, module.id)); + setModuleDataState((state) => { + const nextState = { ...state }; + keys.forEach((key) => delete nextState[key]); + return nextState; + }); + dbRemoveModuleDataKeys(keys).then(refreshQuota).catch((error) => onError(error.message)); + } + + return { + ready, + toolboxes, + links, + moduleData, + storageUsage, + setToolboxes: persistToolboxes, + setLinks: persistLinks, + getModuleData, + updateModuleData, + removeModuleData, + removeToolboxModuleData, + refreshQuota + }; +} diff --git a/website/src/features/toolboxes/useToolboxActions.js b/website/src/features/toolboxes/useToolboxActions.js new file mode 100644 index 0000000..cc55944 --- /dev/null +++ b/website/src/features/toolboxes/useToolboxActions.js @@ -0,0 +1,234 @@ +// Rôle : regroupe les actions métier de création, import/export, lien et édition toolbox. +import { + compactModuleDataForStorage, + createGlobalExportPayload, + createToolboxExportPayload, + globalModuleKey, + labelFromFileName, + normalizeToolbox, + uid +} from "./storage/toolboxStorage.js"; +import { compressImage } from "../../utils/imageCompression.js"; + +function downloadJson(payload, filename) { + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = filename; + link.click(); + URL.revokeObjectURL(link.href); +} + +export function useToolboxActions({ store, notify, setConfirmModal, setCreateModal, setLinkModalGameId, setDrawerGameId, setImage }) { + function createToolbox(name, gameId = "") { + const now = new Date().toISOString(); + 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" } + ] + }; + if (!store.setToolboxes([toolbox, ...store.toolboxes])) return null; + if (gameId) store.setLinks({ ...store.links, [gameId]: toolbox.id }); + return toolbox; + } + + function updateToolbox(nextToolbox) { + store.setToolboxes(store.toolboxes.map((toolbox) => toolbox.id === nextToolbox.id + ? { ...nextToolbox, updatedAt: new Date().toISOString() } + : toolbox)); + } + + function deleteToolbox(id) { + const toolbox = store.toolboxes.find((item) => item.id === id); + store.removeToolboxModuleData(toolbox); + store.setToolboxes(store.toolboxes.filter((item) => item.id !== id)); + const nextLinks = { ...store.links }; + Object.entries(nextLinks).forEach(([gameId, toolboxId]) => { + if (toolboxId === id) delete nextLinks[gameId]; + }); + store.setLinks(nextLinks); + } + + function linkToolboxToGame(gameId, toolboxId) { + const previousToolboxId = store.links[gameId] || ""; + const nextLinks = { ...store.links }; + if (toolboxId) nextLinks[gameId] = toolboxId; + else delete nextLinks[gameId]; + store.setLinks(nextLinks); + const touched = new Set([previousToolboxId, toolboxId].filter(Boolean)); + store.setToolboxes(store.toolboxes.map((toolbox) => touched.has(toolbox.id) + ? { ...toolbox, updatedAt: new Date().toISOString() } + : toolbox)); + } + + async function addImageFiles(toolboxId, moduleId, files) { + const imageFiles = [...files].filter((file) => file?.type?.startsWith("image/")); + if (!imageFiles.length) return false; + const data = store.getModuleData(toolboxId, moduleId, { images: [] }); + for (const file of imageFiles) { + const image = { id: uid("image"), dataUrl: await compressImage(file) }; + const label = labelFromFileName(file.name); + if (label) image.label = label; + data.images.unshift(image); + } + return store.updateModuleData(toolboxId, moduleId, data); + } + + function createChecklistFromList(gameId, list) { + const toolboxId = store.links[gameId] || ""; + const toolbox = store.toolboxes.find((item) => item.id === toolboxId); + if (!toolbox || !list) return false; + + const moduleId = uid("mod"); + const sections = (list.categories || []).map((category) => ({ + id: uid("section"), + title: category.title || "", + items: (category.items || []).map((item) => ({ + id: uid("item"), + label: item.name, + qtyTarget: Math.max(1, Number.parseInt(item.quantity, 10) || 1), + qtyCurrent: 0 + })).filter((item) => item.label) + })).filter((section) => section.items.length); + + if (!sections.length) return false; + + setConfirmModal({ + title: "Créer une checklist", + message: `Créer la checklist "${list.title || "Checklist"}" dans la toolbox "${toolbox.name}" ?`, + confirmLabel: "Créer", + onResolve: (confirmed) => { + if (!confirmed) return; + store.setToolboxes(store.toolboxes.map((item) => item.id === toolboxId + ? { ...item, modules: [...item.modules, { id: moduleId, type: "checklist", title: list.title || "Checklist" }], updatedAt: new Date().toISOString() } + : item)); + store.updateModuleData(toolboxId, moduleId, { sections }, "checklist"); + notify("Checklist créée dans la toolbox associée."); + } + }); + return true; + } + + async function importToolboxPayload(file, gameId = "") { + const payload = JSON.parse(await file.text()); + if (!payload.toolbox?.modules) throw new Error("Format d'outils invalide"); + const imported = normalizeToolbox({ ...payload.toolbox, id: uid("tbx"), updatedAt: new Date().toISOString() }); + const moduleIdMap = new Map(); + imported.modules = imported.modules.map((module) => { + const nextId = uid("mod"); + moduleIdMap.set(module.id, nextId); + return { ...module, id: nextId }; + }); + if (!store.setToolboxes([imported, ...store.toolboxes])) return null; + Object.entries(payload.modules || {}).forEach(([oldId, data]) => { + const nextId = moduleIdMap.get(oldId); + const module = imported.modules.find((item) => item.id === nextId); + if (nextId) store.updateModuleData(imported.id, nextId, data, module?.type); + }); + if (gameId) linkToolboxToGame(gameId, imported.id); + return imported; + } + + async function importAllToolboxesPayload(file) { + const payload = JSON.parse(await file.text()); + if (!Array.isArray(payload.toolboxes) || !payload.modules || typeof payload.modules !== "object") { + throw new Error("Format d'import global invalide"); + } + const toolboxIdMap = new Map(); + const moduleIdMap = new Map(); + const importedToolboxes = payload.toolboxes.map((toolbox) => { + const nextToolboxId = uid("tbx"); + toolboxIdMap.set(toolbox.id, nextToolboxId); + const modules = (toolbox.modules || []).map((module) => { + const nextModuleId = uid("mod"); + moduleIdMap.set(globalModuleKey(toolbox.id, module.id), nextModuleId); + return { ...module, id: nextModuleId }; + }); + return normalizeToolbox({ ...toolbox, id: nextToolboxId, name: `${toolbox.name || "Toolbox"} (import)`, modules, updatedAt: new Date().toISOString() }); + }); + const nextLinks = { ...store.links }; + Object.entries(payload.links || {}).forEach(([gameId, oldToolboxId]) => { + const nextToolboxId = toolboxIdMap.get(oldToolboxId); + if (nextToolboxId) nextLinks[gameId] = nextToolboxId; + }); + Object.entries(payload.modules).forEach(([key, data]) => { + const [oldToolboxId] = key.split(":"); + const nextToolboxId = toolboxIdMap.get(oldToolboxId); + const nextModuleId = moduleIdMap.get(key); + const toolbox = importedToolboxes.find((item) => item.id === nextToolboxId); + const module = toolbox?.modules.find((item) => item.id === nextModuleId); + const compact = compactModuleDataForStorage(module?.type, data); + if (nextToolboxId && nextModuleId && compact) store.updateModuleData(nextToolboxId, nextModuleId, compact, module?.type); + }); + store.setToolboxes([...importedToolboxes, ...store.toolboxes]); + store.setLinks(nextLinks); + store.refreshQuota(); + } + + return { + createToolbox, + updateToolbox, + deleteToolbox, + linkToolboxToGame, + setCreateModal, + setConfirmModal, + setLinkModalGameId, + setDrawerGameId, + setImage, + createChecklistFromList, + addImageFiles, + notify, + removeModuleData: store.removeModuleData, + updateModuleData: store.updateModuleData, + updateToolboxOrder: (orderedIds) => { + const order = new Map(orderedIds.map((id, index) => [id, index])); + store.setToolboxes([...store.toolboxes].sort((a, b) => (order.get(a.id) ?? 9999) - (order.get(b.id) ?? 9999))); + }, + importToolbox: async (file, gameId = "") => { + try { + const toolbox = await importToolboxPayload(file, gameId); + if (toolbox) notify("Toolbox importée."); + return toolbox; + } catch (error) { + setConfirmModal({ + title: "Import impossible", + message: error.message, + confirmLabel: "Compris", + cancelLabel: "Fermer", + danger: true + }); + return null; + } + }, + importAllToolboxes: async (file) => { + try { + await importAllToolboxesPayload(file); + notify("Import global terminé."); + return true; + } catch (error) { + setConfirmModal({ + title: "Import global impossible", + message: error.message, + confirmLabel: "Compris", + cancelLabel: "Fermer", + danger: true + }); + return false; + } + }, + exportToolbox: (id) => { + const toolbox = normalizeToolbox(store.toolboxes.find((item) => item.id === id)); + if (!toolbox) return; + downloadJson(createToolboxExportPayload(toolbox, store.moduleData), `${toolbox.name.toLowerCase().replace(/[^a-z0-9]+/gi, "-")}.sokkog.json`); + notify("Export de la toolbox lancé."); + }, + exportAllToolboxes: () => { + downloadJson(createGlobalExportPayload(store.toolboxes, store.links, store.moduleData), `sokkog-global-${new Date().toISOString().slice(0, 10)}.json`); + notify("Export global lancé."); + } + }; +} diff --git a/website/src/hooks/useAppData.js b/website/src/hooks/useAppData.js new file mode 100644 index 0000000..0b66985 --- /dev/null +++ b/website/src/hooks/useAppData.js @@ -0,0 +1,56 @@ +// Rôle : charge le contenu éditorial et les données publiques nécessaires à l'application. +import { useEffect, useState } from "react"; +import { INITIAL_DIABLO4_STATE, INITIAL_MHWILDS_STATE, loadDiablo4Data, loadMhwildsData } from "../features/games/loaders.js"; + +export function useAppData(route) { + const [siteContent, setSiteContent] = useState(null); + const [siteContentError, setSiteContentError] = useState(""); + const [games, setGames] = useState([]); + const [gamesError, setGamesError] = useState(""); + const [mhwilds, setMhwilds] = useState(INITIAL_MHWILDS_STATE); + const [diablo4, setDiablo4] = useState(INITIAL_DIABLO4_STATE); + + useEffect(() => { + Promise.all([ + fetch("/data/site.json").then((response) => { + if (!response.ok) throw new Error("Impossible de charger le contenu du site."); + return response.json(); + }).catch((error) => { + setSiteContentError(error.message); + return null; + }), + fetch("/data/games.json").then((response) => response.ok ? response.json() : { games: [] }).catch((error) => { + setGamesError(error.message); + return { games: [] }; + }) + ]).then(([content, gamesPayload]) => { + if (content) setSiteContent(content); + setGames(Array.isArray(gamesPayload.games) ? gamesPayload.games : []); + }); + }, []); + + useEffect(() => { + if (!route.startsWith("/games/mhwilds") || mhwilds.loaded || mhwilds.loading) return; + setMhwilds((state) => ({ ...state, loading: true, error: "" })); + loadMhwildsData() + .then(setMhwilds) + .catch((error) => setMhwilds({ ...INITIAL_MHWILDS_STATE, loaded: true, error: error.message })); + }, [route, mhwilds.loaded, mhwilds.loading]); + + useEffect(() => { + if (!route.startsWith("/games/diablo4") || diablo4.loaded || diablo4.loading) return; + setDiablo4((state) => ({ ...state, loading: true, error: "" })); + loadDiablo4Data() + .then(setDiablo4) + .catch((error) => setDiablo4({ ...INITIAL_DIABLO4_STATE, loaded: true, error: error.message })); + }, [route, diablo4.loaded, diablo4.loading]); + + return { + siteContent, + siteContentError, + games, + gamesError, + mhwilds, + diablo4 + }; +} diff --git a/website/src/hooks/useGameFilters.js b/website/src/hooks/useGameFilters.js new file mode 100644 index 0000000..fb527cf --- /dev/null +++ b/website/src/hooks/useGameFilters.js @@ -0,0 +1,10 @@ +// Rôle : gère l'état des filtres actifs pour les pages de jeu. +import { useState } from "react"; + +export function useGameFilters() { + return useState({ + monsters: { name: "", weaknesses: [], logic: "and" }, + endemic: { name: "", locations: [], logic: "and" }, + diablo4: { name: "", categories: [], logic: "and" } + }); +} diff --git a/website/src/hooks/usePointerReorder.js b/website/src/hooks/usePointerReorder.js index 196f4fd..34354d9 100644 --- a/website/src/hooks/usePointerReorder.js +++ b/website/src/hooks/usePointerReorder.js @@ -1,3 +1,4 @@ +// Rôle : fournit la réorganisation par pointeur pour listes et grilles locales. import { useEffect, useRef, useState } from "react"; function getDefaultPlacement(event, element) { diff --git a/website/src/main.jsx b/website/src/main.jsx index 32250a8..1c366a7 100644 --- a/website/src/main.jsx +++ b/website/src/main.jsx @@ -1,696 +1,27 @@ -import React, { useEffect, useRef, useState } from "react"; +// Rôle : point d'entrée React, assemble données, routes, shell et overlays. +import React, { useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import "./styles/main.scss"; -import { Icon } from "./components/Icon.jsx"; -import { GameRoute } from "./features/games/GameRoute.jsx"; -import { GamesPage } from "./features/games/GamesPage.jsx"; -import { INITIAL_DIABLO4_STATE, INITIAL_MHWILDS_STATE, loadDiablo4Data, loadMhwildsData } from "./features/games/loaders.js"; -import { AddToolControls, ToolboxModules, TOOLBOX_MODULES } from "./features/toolboxes/modules/index.jsx"; -import { usePointerReorder } from "./hooks/usePointerReorder.js"; -import { lockBodyScroll } from "./utils/bodyScrollLock.js"; -import { - getAllModuleData as dbGetAllModuleData, - getLinks as dbGetLinks, - getSetting as dbGetSetting, - getStorageEstimate, - getToolboxes as dbGetToolboxes, - removeModuleData as dbRemoveModuleData, - removeModuleDataKeys as dbRemoveModuleDataKeys, - setLinks as dbSetLinks, - setModuleData as dbSetModuleData, - setSetting as dbSetSetting, - setToolboxes as dbSetToolboxes -} from "./utils/indexedDbStorage.js"; - -const APP_STORAGE_WARNING_RATIO = 0.85; -const APP_STORAGE_SOFT_LIMIT_BYTES = 250 * 1024 * 1024; -const DRAWER_WIDTH_SETTING = "drawerWidth"; -const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", shot: "s", link: "l", counter: "c", calc: "r", marker: "k" }; -const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; -const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/"; -const TOOLBOX_ICON_FILES = [ - "toolbox.png", - "ball.png", - "capture.png", - "card.png", - "city.png", - "compass.png", - "crash.png", - "detective.png", - "fire.png", - "horror.png", - "jump.png", - "mining.png", - "parachute.png", - "puzzle.png", - "shield.png", - "sword.png", - "target.png", - "tower.png", - "wheel.png" -]; -const TOOLBOX_ICONS = TOOLBOX_ICON_FILES.map((file) => `${TOOLBOX_ICON_BASE}${file}`); -const DEFAULT_TOOLBOX_ICON = `${TOOLBOX_ICON_BASE}toolbox.png`; - -function randomToken(length = 6) { - const bytes = new Uint8Array(length); - if (globalThis.crypto?.getRandomValues) { - globalThis.crypto.getRandomValues(bytes); - return [...bytes].map((byte) => ID_ALPHABET[byte % ID_ALPHABET.length]).join(""); - } - return Math.random().toString(36).slice(2, 2 + length).padEnd(length, "0"); -} - -function uid(prefix) { - return `${ID_PREFIXES[prefix] || "x"}${randomToken()}`; -} - -function formatBytes(bytes) { - if (!Number.isFinite(bytes) || bytes <= 0) return "0 o"; - if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1).replace(".", ",")} Mio`; - if (bytes >= 1024) return `${Math.ceil(bytes / 1024)} Kio`; - return `${bytes} o`; -} - -function getDefaultModuleTitle(type) { - return TOOLBOX_MODULES[type]?.label || "Outil"; -} - -function normalizeToolboxIcon(icon) { - const value = String(icon || "").trim(); - const file = value.replace(TOOLBOX_ICON_BASE, "").split("/").pop(); - return TOOLBOX_ICON_FILES.includes(file) ? `${TOOLBOX_ICON_BASE}${file}` : DEFAULT_TOOLBOX_ICON; -} - -function getGameCardCover(game) { - return game?.images?.cardCover || game?.image || ""; -} - -function getGameCardBackground(game) { - return game?.cover || "var(--gradient-nebula)"; -} - -function normalizeToolboxModule(module) { - if (!module || typeof module !== "object" || !module.type) return null; - const title = String(module.title || "").trim(); - const normalized = { id: module.id || uid("mod"), type: module.type }; - if (title && title !== getDefaultModuleTitle(module.type)) normalized.title = title; - if (module.scrollable === true) normalized.scrollable = true; - return normalized; -} - -function normalizeToolbox(toolbox) { - if (!toolbox || typeof toolbox !== "object") return null; - return { - id: toolbox.id || uid("tbx"), - name: String(toolbox.name || "Nouvelle toolbox").trim() || "Nouvelle toolbox", - icon: normalizeToolboxIcon(toolbox.icon), - moduleColumns: Number(toolbox.moduleColumns) === 1 ? 1 : 2, - modules: (Array.isArray(toolbox.modules) ? toolbox.modules : []).map(normalizeToolboxModule).filter(Boolean), - updatedAt: toolbox.updatedAt || new Date().toISOString() - }; -} - -function compactToolboxForStorage(toolbox) { - const normalized = normalizeToolbox(toolbox); - if (!normalized) return null; - const compact = { - id: normalized.id, - name: normalized.name, - modules: normalized.modules, - updatedAt: normalized.updatedAt - }; - if (normalized.moduleColumns === 1) compact.moduleColumns = 1; - if (normalized.icon !== DEFAULT_TOOLBOX_ICON) compact.icon = normalized.icon; - return compact; -} - -function compactToolboxesForStorage(toolboxes) { - return (Array.isArray(toolboxes) ? toolboxes : []).map(compactToolboxForStorage).filter(Boolean); -} - -function moduleStorageKey(toolboxId, moduleId) { - return `${toolboxId}:${moduleId}`; -} - -function globalModuleKey(toolboxId, moduleId) { - return `${toolboxId}:${moduleId}`; -} - -function parsePositiveInt(value, fallback = 1) { - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -} - -function clampQty(value, target) { - const parsed = Number.parseInt(value, 10); - const safeValue = Number.isFinite(parsed) ? parsed : 0; - return Math.min(safeValue, Math.max(1, target)); -} - -function normalizeChecklistItem(item) { - const qtyTarget = Math.max(1, parsePositiveInt(item?.qtyTarget, 1)); - const qtyCurrent = clampQty(item?.qtyCurrent, qtyTarget); - return { - id: item?.id || uid("item"), - label: String(item?.label || "").trim(), - qtyTarget, - qtyCurrent - }; -} - -function normalizeChecklistSection(section, fallbackTitle = "") { - const normalized = { - id: section?.id || uid("section"), - title: String(section?.title ?? fallbackTitle).trim(), - items: (section?.items || []).map(normalizeChecklistItem).filter((item) => item.label) - }; - if (typeof section?.hideWhenComplete === "boolean") normalized.hideWhenComplete = section.hideWhenComplete; - if (section?.collapsed === true) normalized.collapsed = true; - return normalized; -} - -function normalizeChecklistData(data) { - const hideCompletedSections = Boolean(data?.hideCompletedSections); - const hideCompletedSectionsFully = Boolean(data?.hideCompletedSectionsFully); - const sections = Array.isArray(data?.sections) - ? data.sections.map((section) => normalizeChecklistSection(section)).filter((section) => section.items.length || section.title) - : []; - const legacyItems = (data?.items || []).map(normalizeChecklistItem).filter((item) => item.label); - if (!sections.length && legacyItems.length) return { hideCompletedSections, hideCompletedSectionsFully, sections: [{ id: uid("section"), title: "", items: legacyItems }], items: legacyItems }; - const items = sections.flatMap((section) => section.items); - return { hideCompletedSections, hideCompletedSectionsFully, sections, items }; -} - -function normalizeUrl(value) { - const cleanValue = String(value || "").trim(); - if (!cleanValue) return ""; - - try { - const url = new URL(cleanValue.includes("://") ? cleanValue : `https://${cleanValue}`); - if (url.protocol !== "http:" && url.protocol !== "https:") return ""; - return url.href; - } catch { - return ""; - } -} - -function hostnameFromUrl(value) { - try { - return new URL(value).hostname.replace(/^www\./, ""); - } catch { - return value; - } -} - -function normalizeLinksData(data) { - return { - links: (data?.links || []) - .map((link) => { - const url = normalizeUrl(link?.url); - if (!url) return null; - return { - id: link?.id || uid("link"), - title: String(link?.title || "").trim(), - url - }; - }) - .filter(Boolean) - }; -} - -function normalizeCounterValue(value) { - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) ? parsed : 0; -} - -function normalizeCountersData(data) { - return { - counters: (data?.counters || []) - .map((counter) => ({ - id: counter?.id || uid("counter"), - label: String(counter?.label || "").trim(), - value: normalizeCounterValue(counter?.value) - })) - .filter((counter) => counter.label) - }; -} - -function normalizeCalculatorValue(value) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : 0; -} - -function labelFromFileName(name) { - if (!name || name === "image.png") return ""; - return name.replace(/\.[^.]+$/, "").trim(); -} - -function normalizeCalculatorData(data) { - const entries = (data?.entries || []) - .map((entry) => ({ - id: entry?.id || uid("calc"), - parentId: String(entry?.parentId || ""), - label: String(entry?.label || "").trim(), - value: normalizeCalculatorValue(entry?.value) - })); - const entryIds = new Set(entries.map((entry) => entry.id)); - return { - scrollResults: data?.scrollResults === true, - entries: entries.map((entry) => entryIds.has(entry.parentId) ? entry : { ...entry, parentId: "" }) - }; -} - -function clampPercent(value) { - const parsed = Number(value); - if (!Number.isFinite(parsed)) return 0; - return Math.min(100, Math.max(0, parsed)); -} - -function normalizeImageAnnotationData(data) { - const image = String(data?.image || data?.dataUrl || ""); - const markers = (data?.markers || []) - .map((marker) => ({ - id: marker?.id || uid("marker"), - x: clampPercent(marker?.x), - y: clampPercent(marker?.y), - label: String(marker?.label || "").trim() - })); - return { image, markers }; -} - -function compactChecklistItemForStorage(item) { - const normalized = normalizeChecklistItem(item); - const compact = { id: normalized.id, label: normalized.label }; - if (normalized.qtyTarget !== 1) compact.qtyTarget = normalized.qtyTarget; - if (normalized.qtyCurrent !== 0) compact.qtyCurrent = normalized.qtyCurrent; - return compact; -} - -function compactChecklistSectionForStorage(section) { - const normalized = normalizeChecklistSection(section); - const compact = { - id: normalized.id, - items: normalized.items.map(compactChecklistItemForStorage) - }; - if (normalized.title) compact.title = normalized.title; - if (typeof normalized.hideWhenComplete === "boolean") compact.hideWhenComplete = normalized.hideWhenComplete; - if (normalized.collapsed === true) compact.collapsed = true; - return compact; -} - -function compactModuleDataForStorage(type, value) { - if (type === "notepad") { - const text = String(value?.text || ""); - return text ? { text } : null; - } - if (type === "checklist") { - const normalized = normalizeChecklistData(value); - const sections = normalized.sections.map(compactChecklistSectionForStorage).filter((section) => section.items.length || section.title); - if (!sections.length) return null; - const settings = { - ...(normalized.hideCompletedSections ? { hideCompletedSections: true } : {}), - ...(normalized.hideCompletedSectionsFully ? { hideCompletedSectionsFully: true } : {}) - }; - if (sections.length === 1 && !sections[0].title && typeof sections[0].hideWhenComplete !== "boolean") return { ...settings, items: sections[0].items }; - return { ...settings, sections }; - } - if (type === "screenshots") { - const shots = (Array.isArray(value?.shots) ? value.shots : []) - .filter((shot) => shot?.dataUrl) - .map((shot) => { - const compact = { id: shot.id || uid("shot"), dataUrl: shot.dataUrl }; - if (shot.label) compact.label = shot.label; - return compact; - }); - return shots.length ? { shots } : null; - } - if (type === "imageAnnotation") { - const normalized = normalizeImageAnnotationData(value); - if (!normalized.image) return null; - return { - image: normalized.image, - markers: normalized.markers.map((marker) => { - const compact = { id: marker.id, x: marker.x, y: marker.y }; - if (marker.label) compact.label = marker.label; - return compact; - }) - }; - } - if (type === "links") { - const links = normalizeLinksData(value).links.map((link) => { - const compact = { - id: link.id, - url: link.url - }; - if (link.title) compact.title = link.title; - return compact; - }); - return links.length ? { links } : null; - } - if (type === "counters") { - const counters = normalizeCountersData(value).counters.map((counter) => ({ - id: counter.id, - label: counter.label, - value: counter.value - })); - return counters.length ? { counters } : null; - } - if (type === "calculator") { - const normalized = normalizeCalculatorData(value); - const entries = normalized.entries.map((entry) => { - const compact = { - id: entry.id, - label: entry.label, - value: entry.value - }; - if (entry.parentId) compact.parentId = entry.parentId; - return compact; - }); - if (!entries.length && !normalized.scrollResults) return null; - return normalized.scrollResults ? { entries, scrollResults: true } : { entries }; - } - return value; -} - -function prepareModuleDataForStorage(toolboxes, toolboxId, moduleId, value, moduleType = "") { - const module = toolboxes.find((toolbox) => toolbox.id === toolboxId)?.modules.find((item) => item.id === moduleId); - return compactModuleDataForStorage(moduleType || module?.type, value); -} - -function createExportIdFactory() { - const counts = {}; - return (prefix) => { - counts[prefix] = (counts[prefix] || 0) + 1; - return `${ID_PREFIXES[prefix] || "x"}${counts[prefix].toString(36)}`; - }; -} - -function remapModuleDataForExport(type, data, nextId) { - const compact = compactModuleDataForStorage(type, data); - if (!compact) return null; - if (type === "checklist") { - if (compact.sections) { - return { - sections: compact.sections.map((section) => ({ - ...section, - id: nextId("section"), - items: section.items.map((item) => ({ ...item, id: nextId("item") })) - })) - }; - } - return { items: compact.items.map((item) => ({ ...item, id: nextId("item") })) }; - } - if (type === "screenshots") { - return { shots: compact.shots.map((shot) => ({ ...shot, id: nextId("shot") })) }; - } - - if (type === "imageAnnotation") { - return { - ...compact, - markers: compact.markers.map((marker) => ({ ...marker, id: nextId("marker") })) - }; - } - - if (type === "links") { - return { links: compact.links.map((link) => ({ ...link, id: nextId("link") })) }; - } - - if (type === "counters") { - return { counters: compact.counters.map((counter) => ({ ...counter, id: nextId("counter") })) }; - } - - if (type === "calculator") { - const entryIdMap = new Map(); - compact.entries.forEach((entry) => entryIdMap.set(entry.id, nextId("calc"))); - const remapped = { - entries: compact.entries.map((entry) => { - const remapped = { ...entry, id: entryIdMap.get(entry.id) }; - if (entry.parentId) remapped.parentId = entryIdMap.get(entry.parentId) || ""; - return remapped; - }) - }; - if (compact.scrollResults) remapped.scrollResults = true; - return remapped; - } - - return compact; -} - -function createToolboxExportPayload(toolbox, moduleData) { - const source = normalizeToolbox(toolbox); - const nextId = createExportIdFactory(); - const moduleIdMap = new Map(); - const exportedToolbox = compactToolboxForStorage({ - ...source, - id: nextId("tbx"), - modules: source.modules.map((module) => { - const id = nextId("mod"); - moduleIdMap.set(module.id, id); - return { ...module, id }; - }) - }); - const modules = Object.fromEntries(source.modules - .map((module) => [ - moduleIdMap.get(module.id), - remapModuleDataForExport(module.type, moduleData[moduleStorageKey(source.id, module.id)] || null, nextId) - ]) - .filter(([, data]) => data != null)); - return { toolbox: exportedToolbox, modules }; -} - -function createGlobalExportPayload(toolboxes, links, moduleData) { - const modules = {}; - toolboxes.forEach((toolbox) => { - toolbox.modules.forEach((module) => { - const data = compactModuleDataForStorage(module.type, moduleData[moduleStorageKey(toolbox.id, module.id)] || null); - if (data) modules[globalModuleKey(toolbox.id, module.id)] = data; - }); - }); - return { - version: 1, - exportedAt: new Date().toISOString(), - toolboxes: compactToolboxesForStorage(toolboxes), - modules, - links - }; -} - -function downloadJson(payload, filename) { - const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = filename; - link.click(); - URL.revokeObjectURL(link.href); -} - -function formatDate(value) { - return new Intl.DateTimeFormat("fr-FR", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)); -} - -function currentRoute() { - const hashRoute = location.hash.replace(/^#/, ""); - if (hashRoute) return hashRoute.replace(/#.+$/, ""); - const path = location.pathname.replace(/\/+$/, "") || "/"; - if (path === "/mhwilds") return "/games/mhwilds"; - if (path === "/mhwilds/monsters") return "/games/mhwilds/monsters"; - if (path === "/mhwilds/endemic") return "/games/mhwilds/endemic"; - if (path === "/mhwilds/lists") return "/games/mhwilds/lists"; - return path; -} - -function navigate(path) { - location.hash = path; -} - -async function compressImage(file) { - const bitmap = await createImageBitmap(file); - const maxSide = 1400; - const ratio = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height)); - const canvas = document.createElement("canvas"); - canvas.width = Math.round(bitmap.width * ratio); - canvas.height = Math.round(bitmap.height * ratio); - canvas.getContext("2d").drawImage(bitmap, 0, 0, canvas.width, canvas.height); - return canvas.toDataURL("image/jpeg", 0.78); -} - -function dataUrlToBlob(dataUrl) { - const [meta, payload] = String(dataUrl).split(","); - const mime = meta.match(/^data:([^;]+);base64$/)?.[1] || "image/png"; - const binary = atob(payload || ""); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); - return new Blob([bytes], { type: mime }); -} - -function openImageInNewTab(dataUrl) { - try { - const url = URL.createObjectURL(dataUrlToBlob(dataUrl)); - window.open(url, "_blank", "noopener,noreferrer"); - setTimeout(() => URL.revokeObjectURL(url), 60000); - } catch { - window.open(dataUrl, "_blank", "noopener,noreferrer"); - } -} - -async function copyText(value) { - try { - await navigator.clipboard.writeText(value); - return true; - } catch { - return false; - } -} - -function useHashRoute() { - const [route, setRoute] = useState(currentRoute); - useEffect(() => { - const onHashChange = () => setRoute(currentRoute()); - window.addEventListener("hashchange", onHashChange); - return () => window.removeEventListener("hashchange", onHashChange); - }, []); - return route; -} - -function useModalScrollLock() { - useEffect(() => lockBodyScroll(), []); -} - -function useIndexedToolboxes(onError) { - const [ready, setReady] = useState(false); - const [toolboxes, setToolboxesState] = useState([]); - const [links, setLinksState] = useState({}); - const [moduleData, setModuleDataState] = useState({}); - const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 }); - - async function refreshQuota() { - try { - const estimate = await getStorageEstimate(); - const used = estimate.usage || 0; - const limit = estimate.quota || 0; - setStorageUsage({ used, limit, ratio: limit ? Math.min(1, used / limit) : 0 }); - } catch { - setStorageUsage({ used: 0, limit: 0, ratio: 0 }); - } - } - - useEffect(() => { - let cancelled = false; - async function loadStore() { - try { - const [storedToolboxes, storedLinks, storedModules] = await Promise.all([ - dbGetToolboxes(), - dbGetLinks(), - dbGetAllModuleData() - ]); - if (cancelled) return; - setToolboxesState((Array.isArray(storedToolboxes) ? storedToolboxes : []).map(normalizeToolbox).filter(Boolean)); - setLinksState(storedLinks && typeof storedLinks === "object" ? storedLinks : {}); - setModuleDataState(Object.fromEntries((storedModules || []).map((entry) => [entry.key, entry.data]))); - setReady(true); - refreshQuota(); - } catch (error) { - if (!cancelled) { - setReady(true); - onError(error.message || "Stockage IndexedDB indisponible."); - } - } - } - loadStore(); - return () => { cancelled = true; }; - }, []); - - function persistToolboxes(nextToolboxes) { - const normalized = compactToolboxesForStorage(nextToolboxes).map(normalizeToolbox).filter(Boolean); - setToolboxesState(normalized); - dbSetToolboxes(compactToolboxesForStorage(normalized)) - .then(refreshQuota) - .catch((error) => onError(error.message)); - return true; - } - - function persistLinks(nextLinks) { - setLinksState(nextLinks); - dbSetLinks(nextLinks) - .then(refreshQuota) - .catch((error) => onError(error.message)); - return true; - } - - function getModuleData(toolboxId, moduleId, fallback) { - const value = moduleData[moduleStorageKey(toolboxId, moduleId)]; - return value == null ? fallback : value; - } - - function updateModuleData(toolboxId, moduleId, value, moduleType = "") { - const compact = prepareModuleDataForStorage(toolboxes, toolboxId, moduleId, value, moduleType); - const key = moduleStorageKey(toolboxId, moduleId); - setModuleDataState((state) => { - const nextState = { ...state }; - if (compact == null) delete nextState[key]; - else nextState[key] = compact; - return nextState; - }); - const operation = compact == null ? dbRemoveModuleData(key) : dbSetModuleData(key, compact); - operation.then(refreshQuota).catch((error) => onError(error.message)); - return true; - } - - function removeModuleData(toolboxId, moduleId) { - const key = moduleStorageKey(toolboxId, moduleId); - setModuleDataState((state) => { - const nextState = { ...state }; - delete nextState[key]; - return nextState; - }); - dbRemoveModuleData(key).then(refreshQuota).catch((error) => onError(error.message)); - } - - function removeToolboxModuleData(toolbox) { - const keys = (toolbox?.modules || []).map((module) => moduleStorageKey(toolbox.id, module.id)); - setModuleDataState((state) => { - const nextState = { ...state }; - keys.forEach((key) => delete nextState[key]); - return nextState; - }); - dbRemoveModuleDataKeys(keys).then(refreshQuota).catch((error) => onError(error.message)); - } - - return { - ready, - toolboxes, - links, - moduleData, - storageUsage, - setToolboxes: persistToolboxes, - setLinks: persistLinks, - getModuleData, - updateModuleData, - removeModuleData, - removeToolboxModuleData, - refreshQuota - }; -} +import { AppOverlays } from "./components/AppOverlays.jsx"; +import { Shell } from "./components/Shell.jsx"; +import { ToolboxDrawer } from "./features/toolboxes/ToolboxPages.jsx"; +import { useIndexedToolboxes } from "./features/toolboxes/storage/useIndexedToolboxes.js"; +import { useToolboxActions } from "./features/toolboxes/useToolboxActions.js"; +import { useAppData } from "./hooks/useAppData.js"; +import { useGameFilters } from "./hooks/useGameFilters.js"; +import { RouteContent } from "./router/RouteContent.jsx"; +import { useHashRoute } from "./router/hashRouter.js"; +import { moduleStorageKey } from "./features/toolboxes/storage/toolboxStorage.js"; function App() { const route = useHashRoute(); - const [siteContent, setSiteContent] = useState(null); - const [siteContentError, setSiteContentError] = useState(""); - const [games, setGames] = useState([]); - const [gamesError, setGamesError] = useState(""); - const [mhwilds, setMhwilds] = useState(INITIAL_MHWILDS_STATE); - const [diablo4, setDiablo4] = useState(INITIAL_DIABLO4_STATE); - const [filters, setFilters] = useState({ - monsters: { name: "", weaknesses: [], logic: "and" }, - endemic: { name: "", locations: [], logic: "and" }, - diablo4: { name: "", categories: [], logic: "and" } - }); + const { siteContent, siteContentError, games, gamesError, mhwilds, diablo4 } = useAppData(route); + const [filters, setFilters] = useGameFilters(); const [confirmModal, setConfirmModal] = useState(null); const [createModal, setCreateModal] = useState(null); const [linkModalGameId, setLinkModalGameId] = useState(""); const [drawerGameId, setDrawerGameId] = useState(""); - const [screenshot, setScreenshot] = useState(null); + const [image, setImage] = useState(null); const [notification, setNotification] = useState(null); const [storageError, setStorageError] = useState(""); const store = useIndexedToolboxes((message) => setStorageError(message)); @@ -714,41 +45,6 @@ function App() { return () => window.clearTimeout(timeoutId); }, [notification]); - useEffect(() => { - Promise.all([ - fetch("/data/site.json").then((response) => { - if (!response.ok) throw new Error("Impossible de charger le contenu du site."); - return response.json(); - }).catch((error) => { - setSiteContentError(error.message); - return null; - }), - fetch("/data/games.json").then((response) => response.ok ? response.json() : { games: [] }).catch((error) => { - setGamesError(error.message); - return { games: [] }; - }) - ]).then(([content, gamesPayload]) => { - if (content) setSiteContent(content); - setGames(Array.isArray(gamesPayload.games) ? gamesPayload.games : []); - }); - }, []); - - useEffect(() => { - if (!route.startsWith("/games/mhwilds") || mhwilds.loaded || mhwilds.loading) return; - setMhwilds((state) => ({ ...state, loading: true, error: "" })); - loadMhwildsData() - .then(setMhwilds) - .catch((error) => setMhwilds({ ...INITIAL_MHWILDS_STATE, loaded: true, error: error.message })); - }, [route, mhwilds.loaded, mhwilds.loading]); - - useEffect(() => { - if (!route.startsWith("/games/diablo4") || diablo4.loaded || diablo4.loading) return; - setDiablo4((state) => ({ ...state, loading: true, error: "" })); - loadDiablo4Data() - .then(setDiablo4) - .catch((error) => setDiablo4({ ...INITIAL_DIABLO4_STATE, loaded: true, error: error.message })); - }, [route, diablo4.loaded, diablo4.loading]); - const t = (key, { capitalize = false } = {}) => { const value = mhwilds.translations[key] || String(key || "").replace(/_/g, " "); return capitalize ? value.charAt(0).toUpperCase() + value.slice(1) : value; @@ -757,217 +53,15 @@ function App() { const getToolboxGameId = (toolbox) => Object.entries(store.links).find(([, toolboxId]) => toolboxId === toolbox?.id)?.[0] || ""; const getToolboxGame = (toolbox) => getGame(getToolboxGameId(toolbox)); - function createToolbox(name, gameId = "") { - const now = new Date().toISOString(); - 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" } - ] - }; - if (!store.setToolboxes([toolbox, ...store.toolboxes])) return null; - if (gameId) store.setLinks({ ...store.links, [gameId]: toolbox.id }); - return toolbox; - } - - function updateToolbox(nextToolbox) { - store.setToolboxes(store.toolboxes.map((toolbox) => toolbox.id === nextToolbox.id - ? { ...nextToolbox, updatedAt: new Date().toISOString() } - : toolbox)); - } - - function deleteToolbox(id) { - const toolbox = store.toolboxes.find((item) => item.id === id); - store.removeToolboxModuleData(toolbox); - store.setToolboxes(store.toolboxes.filter((item) => item.id !== id)); - const nextLinks = { ...store.links }; - Object.entries(nextLinks).forEach(([gameId, toolboxId]) => { - if (toolboxId === id) delete nextLinks[gameId]; - }); - store.setLinks(nextLinks); - } - - function linkToolboxToGame(gameId, toolboxId) { - const previousToolboxId = store.links[gameId] || ""; - const nextLinks = { ...store.links }; - if (toolboxId) nextLinks[gameId] = toolboxId; - else delete nextLinks[gameId]; - store.setLinks(nextLinks); - const touched = new Set([previousToolboxId, toolboxId].filter(Boolean)); - store.setToolboxes(store.toolboxes.map((toolbox) => touched.has(toolbox.id) - ? { ...toolbox, updatedAt: new Date().toISOString() } - : toolbox)); - } - - async function addScreenshotFiles(toolboxId, moduleId, files) { - const imageFiles = [...files].filter((file) => file?.type?.startsWith("image/")); - if (!imageFiles.length) return false; - const data = store.getModuleData(toolboxId, moduleId, { shots: [] }); - for (const file of imageFiles) { - const shot = { id: uid("shot"), dataUrl: await compressImage(file) }; - const label = labelFromFileName(file.name); - if (label) shot.label = label; - data.shots.unshift(shot); - } - return store.updateModuleData(toolboxId, moduleId, data); - } - - function createChecklistFromList(gameId, list) { - const toolboxId = store.links[gameId] || ""; - const toolbox = store.toolboxes.find((item) => item.id === toolboxId); - if (!toolbox || !list) return false; - - const moduleId = uid("mod"); - const sections = (list.categories || []).map((category) => ({ - id: uid("section"), - title: category.title || "", - items: (category.items || []).map((item) => ({ - id: uid("item"), - label: item.name, - qtyTarget: Math.max(1, Number.parseInt(item.quantity, 10) || 1), - qtyCurrent: 0 - })).filter((item) => item.label) - })).filter((section) => section.items.length); - - if (!sections.length) return false; - - setConfirmModal({ - title: "Créer une checklist", - message: `Créer la checklist "${list.title || "Checklist"}" dans la toolbox "${toolbox.name}" ?`, - confirmLabel: "Créer", - onResolve: (confirmed) => { - if (!confirmed) return; - store.setToolboxes(store.toolboxes.map((item) => item.id === toolboxId - ? { ...item, modules: [...item.modules, { id: moduleId, type: "checklist", title: list.title || "Checklist" }], updatedAt: new Date().toISOString() } - : item)); - store.updateModuleData(toolboxId, moduleId, { sections }, "checklist"); - notify("Checklist créée dans la toolbox associée."); - } - }); - return true; - } - - async function importToolbox(file, gameId = "") { - const payload = JSON.parse(await file.text()); - if (!payload.toolbox?.modules) throw new Error("Format d'outils invalide"); - const imported = normalizeToolbox({ ...payload.toolbox, id: uid("tbx"), updatedAt: new Date().toISOString() }); - const moduleIdMap = new Map(); - imported.modules = imported.modules.map((module) => { - const nextId = uid("mod"); - moduleIdMap.set(module.id, nextId); - return { ...module, id: nextId }; - }); - if (!store.setToolboxes([imported, ...store.toolboxes])) return null; - Object.entries(payload.modules || {}).forEach(([oldId, data]) => { - const nextId = moduleIdMap.get(oldId); - const module = imported.modules.find((item) => item.id === nextId); - if (nextId) store.updateModuleData(imported.id, nextId, data, module?.type); - }); - if (gameId) linkToolboxToGame(gameId, imported.id); - return imported; - } - - async function importAllToolboxes(file) { - const payload = JSON.parse(await file.text()); - if (!Array.isArray(payload.toolboxes) || !payload.modules || typeof payload.modules !== "object") { - throw new Error("Format d'import global invalide"); - } - const toolboxIdMap = new Map(); - const moduleIdMap = new Map(); - const importedToolboxes = payload.toolboxes.map((toolbox) => { - const nextToolboxId = uid("tbx"); - toolboxIdMap.set(toolbox.id, nextToolboxId); - const modules = (toolbox.modules || []).map((module) => { - const nextModuleId = uid("mod"); - moduleIdMap.set(globalModuleKey(toolbox.id, module.id), nextModuleId); - return { ...module, id: nextModuleId }; - }); - return normalizeToolbox({ ...toolbox, id: nextToolboxId, name: `${toolbox.name || "Toolbox"} (import)`, modules, updatedAt: new Date().toISOString() }); - }); - const nextLinks = { ...store.links }; - Object.entries(payload.links || {}).forEach(([gameId, oldToolboxId]) => { - const nextToolboxId = toolboxIdMap.get(oldToolboxId); - if (nextToolboxId) nextLinks[gameId] = nextToolboxId; - }); - Object.entries(payload.modules).forEach(([key, data]) => { - const [oldToolboxId] = key.split(":"); - const nextToolboxId = toolboxIdMap.get(oldToolboxId); - const nextModuleId = moduleIdMap.get(key); - const toolbox = importedToolboxes.find((item) => item.id === nextToolboxId); - const module = toolbox?.modules.find((item) => item.id === nextModuleId); - const compact = compactModuleDataForStorage(module?.type, data); - if (nextToolboxId && nextModuleId && compact) store.updateModuleData(nextToolboxId, nextModuleId, compact, module?.type); - }); - store.setToolboxes([...importedToolboxes, ...store.toolboxes]); - store.setLinks(nextLinks); - store.refreshQuota(); - } - - const actions = { - createToolbox, - updateToolbox, - deleteToolbox, - linkToolboxToGame, + const actions = useToolboxActions({ + store, + notify, setCreateModal, setConfirmModal, setLinkModalGameId, setDrawerGameId, - setScreenshot, - createChecklistFromList, - addScreenshotFiles, - notify, - removeModuleData: store.removeModuleData, - updateModuleData: store.updateModuleData, - updateToolboxOrder: (orderedIds) => { - const order = new Map(orderedIds.map((id, index) => [id, index])); - store.setToolboxes([...store.toolboxes].sort((a, b) => (order.get(a.id) ?? 9999) - (order.get(b.id) ?? 9999))); - }, - importToolbox: async (file, gameId = "") => { - try { - const toolbox = await importToolbox(file, gameId); - if (toolbox) notify("Toolbox importée."); - return toolbox; - } catch (error) { - setConfirmModal({ - title: "Import impossible", - message: error.message, - confirmLabel: "Compris", - cancelLabel: "Fermer", - danger: true - }); - return null; - } - }, - importAllToolboxes: async (file) => { - try { - await importAllToolboxes(file); - notify("Import global terminé."); - return true; - } catch (error) { - setConfirmModal({ - title: "Import global impossible", - message: error.message, - confirmLabel: "Compris", - cancelLabel: "Fermer", - danger: true - }); - return false; - } - }, - exportToolbox: (id) => { - const toolbox = normalizeToolbox(store.toolboxes.find((item) => item.id === id)); - if (!toolbox) return; - downloadJson(createToolboxExportPayload(toolbox, store.moduleData), `${toolbox.name.toLowerCase().replace(/[^a-z0-9]+/gi, "-")}.sokkog.json`); - notify("Export de la toolbox lancé."); - }, - exportAllToolboxes: () => { - downloadJson(createGlobalExportPayload(store.toolboxes, store.links, store.moduleData), `sokkog-global-${new Date().toISOString().slice(0, 10)}.json`); - notify("Export global lancé."); - } - }; + setImage + }); if (!siteContent) { return ( @@ -999,9 +93,9 @@ function App() { actions={actions} storageUsage={store.storageUsage} getModuleData={store.getModuleData} - updateToolbox={updateToolbox} + updateToolbox={actions.updateToolbox} updateModuleData={store.updateModuleData} - addScreenshotFiles={addScreenshotFiles} + addImageFiles={actions.addImageFiles} /> {drawerGameId && ( )} - {confirmModal && { - const onResolve = confirmModal.onResolve; - setConfirmModal(null); - onResolve?.(value); - }} />} - {createModal && { - const gameId = createModal.gameId || ""; - setCreateModal(null); - if (!name) return; - const toolbox = createToolbox(name, gameId); - if (toolbox && gameId) setDrawerGameId(gameId); - else if (toolbox) navigate(`/toolbox/${toolbox.id}`); - }} />} - {linkModalGameId && { - const gameId = linkModalGameId; - setLinkModalGameId(""); - if (toolboxId == null) return; - linkToolboxToGame(gameId, toolboxId); - setDrawerGameId(gameId); - }} />} - {storageError && setStorageError("")} />} - {screenshot && setScreenshot(null)} />} - {notification && } + ); } -function Shell({ route, content, games, toolboxes, links, actions, children }) { - const gameId = route.split("/")[1] === "games" ? route.split("/")[2] || "" : ""; - const game = games.find((item) => item.id === gameId); - const linkedToolbox = game ? toolboxes.find((item) => item.id === links[game.id]) : null; - const topbarLabel = route.startsWith("/toolbox") ? content.topbar.toolbox : route.startsWith("/games") ? content.topbar.games : content.topbar.dashboard; - const toolboxLabel = linkedToolbox ? `Ouvrir la toolbox ${linkedToolbox.name}` : "Associer une toolbox"; - - return ( -
- -
-
-
{topbarLabel}
- {game && ( -
- -
- )} -
-
{children}
-
- -
- ); -} - -function RouteContent(props) { - const { route } = props; - if (route === "/") return ; - if (route === "/about") return ; - if (route === "/toolboxes") return ; - if (route.startsWith("/toolbox/")) return ; - if (route === "/games") return ; - if (route.startsWith("/games/")) return ; - navigate("/"); - return null; -} - -function HomePage({ siteContent, toolboxes }) { - const content = siteContent.home; - const toolCount = Object.keys(TOOLBOX_MODULES).length; - return ( - <> -
-
-

{content.hero.eyebrow}

-

{content.hero.title}

-

{content.hero.description}

- -
-
-
{toolboxes.length}{toolboxes.length > 1 ? content.stats.toolboxPlural : content.stats.toolboxSingular}
-
{toolCount}{toolCount > 1 ? content.stats.toolPlural : content.stats.toolSingular}
-
-
-
-
-

{content.origin.eyebrow}

-

{content.origin.title}

-
- {content.origin.lines.map((line, index) => )} - {content.origin.caption} -
-
-
- {content.origin.visualAlt} -
-
-
-

{content.legal.copyright}

-

{content.legal.disclaimer}

-
- - ); -} - -function AboutPage({ siteContent }) { - const content = siteContent.about; - const limits = Array.isArray(content.limits) ? content.limits : []; - const storageSectionIndex = content.sections.findIndex((section) => section.title.toLowerCase().includes("stockage")); - const normalizeReminder = (item, index) => typeof item === "string" ? { title: `Point ${index + 1}`, text: item, icon: "toolbox" } : item; - useEffect(() => { - if (!location.hash.includes("#contribuer")) return; - requestAnimationFrame(() => document.querySelector("#contribuer")?.scrollIntoView({ behavior: "smooth", block: "start" })); - }, []); - return ( -
-
-
-

{content.eyebrow}

-

{content.title}

-

-
-
-
-
-

Principes

-

Comment ça fonctionne

-
-
- {content.sections.map((section, index) => ( -
- {String(index + 1).padStart(2, "0")} -
-

{section.title}

-

- {index === storageSectionIndex && limits.length > 0 && ( -
-

{content.limitsTitle}

-
    - {limits.map((item, itemIndex) => { - const reminder = normalizeReminder(item, itemIndex); - return ( -
  • -
  • - ); - })} -
-
- )} -
-
- ))} -
-
-
-
-
-

Toolbox

-

{content.toolsTitle}

-
-
-
- {content.tools.map((tool) => ( -
-
- ))} -
-
- {content.contribute && ( -
-
-

{content.contribute.eyebrow}

-

{content.contribute.title}

-
-
-

-
{content.contribute.example}
-
-

{content.contribute.promptTitle}

-

-
{content.contribute.promptText}
-
-
-
- )} -
- ); -} - -function DialogueLine({ line }) { - return

; -} - -function RichText({ text }) { - const parts = String(text).split(/(.*?<\/i>)/g).filter(Boolean); - return parts.flatMap((part, index) => { - const italic = part.match(/^(.*?)<\/i>$/); - const value = italic ? italic[1] : part; - const lines = value.split("\n"); - return lines.flatMap((line, lineIndex) => { - const key = `${index}-${lineIndex}`; - const node = italic ? {line} : {line}; - return lineIndex === lines.length - 1 ? [node] : [node,
]; - }); - }); -} - -function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions, storageUsage }) { - const content = siteContent.toolboxes; - const updateToolboxOrder = actions.updateToolboxOrder; - const { - draggingId: draggingToolboxId, - dropTarget, - startDrag: startToolboxDrag - } = usePointerReorder({ - targetSelector: ".toolbox-card", - getTargetId: (target) => target.dataset.toolboxId, - getPlacement: (event, target) => { - const rect = target.getBoundingClientRect(); - return event.clientY > rect.top + rect.height / 2 || event.clientX > rect.left + rect.width / 2 ? "after" : "before"; - }, - onMove: (draggingId, targetId, placement) => { - const nextIds = toolboxes.map((toolbox) => toolbox.id).filter((id) => id !== draggingId); - const targetIndex = nextIds.indexOf(targetId); - nextIds.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, draggingId); - updateToolboxOrder(nextIds); - } - }); - - return ( -
-
-
-

{content.eyebrow}

-

{content.title}

-

{content.summary}

-
-
-
-
- - -
-
- - -
-
-
- - {toolboxes.length ? toolboxes.map((toolbox) => ( - - )) : ( -

{content.emptyTitle}

{content.emptyText}

- )} -
- -
- ); -} - -function StorageHelpCard({ help }) { - return ( -
-
-
-
-

{help.text}

-
    - {help.items.map((item, index) =>
  • {index + 1}

    {item}

  • )} -
-
-
- ); -} - -function ToolboxCard({ toolbox, game, actions, draggingToolboxId, dropTarget, onDragStart }) { - const gameCoverImage = getGameCardCover(game); - const coverImage = gameCoverImage || toolbox.icon || DEFAULT_TOOLBOX_ICON; - const isDragging = draggingToolboxId === toolbox.id; - const isDropTarget = dropTarget.id === toolbox.id; - const className = [ - "card", - "toolbox-card", - isDragging ? "is-dragging" : "", - isDropTarget ? "is-drop-target" : "", - isDropTarget && dropTarget.placement === "after" ? "drop-after" : "" - ].filter(Boolean).join(" "); - - return ( -
- - - {game - -
-

{game ? game.title : "Toolbox libre"}

-

{toolbox.name}

- Modifiée le {formatDate(toolbox.updatedAt)} -
- - - -
-
-
- ); -} - -function ToolboxPage(props) { - const { toolboxId, toolboxes, getToolboxGame } = props; - const toolbox = toolboxes.find((item) => item.id === toolboxId); - if (!toolbox) return

Toolbox introuvable

Retour
; - return ; -} - -function ToolboxIconPicker({ toolbox, onChange }) { - const [open, setOpen] = useState(false); - const pickerRef = useRef(null); - const icon = normalizeToolboxIcon(toolbox.icon); - - useEffect(() => { - if (!open) return undefined; - - function handlePointerDown(event) { - if (!pickerRef.current?.contains(event.target)) setOpen(false); - } - - function handleKeyDown(event) { - if (event.key === "Escape") setOpen(false); - } - - document.addEventListener("pointerdown", handlePointerDown); - document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("keydown", handleKeyDown); - }; - }, [open]); - - function selectIcon(nextIcon) { - onChange(nextIcon); - setOpen(false); - } - - return ( -
- - {open && ( -
- {TOOLBOX_ICONS.map((path) => ( - - ))} -
- )} -
- ); -} - -function ToolboxGameIcon({ game }) { - const gameCoverImage = getGameCardCover(game); - return ( -
- -
- ); -} - -function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, getModuleData, updateToolbox, updateModuleData, addScreenshotFiles }) { - const moduleColumns = Number(toolbox.moduleColumns) === 1 ? 1 : 2; - const toolboxGameCover = getGameCardCover(toolboxGame); - const moduleText = siteContent.toolboxes.modules; - const moduleContext = { - getModuleData, - moduleText, - normalizeChecklistData, - normalizeLinksData, - normalizeCountersData, - normalizeCalculatorData, - normalizeImageAnnotationData, - normalizeUrl, - hostnameFromUrl, - copyText, - notify: actions.notify, - compressImageFile: compressImage, - clampQty, - uid, - setModuleData: updateModuleData, - addScreenshotFiles, - createImageAnnotationModule, - setScreenshot: actions.setScreenshot, - refresh: () => {} - }; - - function addModule(type) { - if (!type) return; - updateToolbox({ ...toolbox, modules: [...toolbox.modules, { id: uid("mod"), type }] }); - } - - function createImageAnnotationModule(dataUrl) { - if (!dataUrl) return; - const moduleId = uid("mod"); - updateToolbox({ ...toolbox, modules: [...toolbox.modules, { id: moduleId, type: "imageAnnotation" }] }); - updateModuleData(toolbox.id, moduleId, { image: dataUrl, markers: [] }, "imageAnnotation"); - } - - function moveModule(fromModuleId, toModuleId, placement = "before") { - if (!fromModuleId || !toModuleId || fromModuleId === toModuleId) return; - const modules = [...toolbox.modules]; - const fromIndex = modules.findIndex((module) => module.id === fromModuleId); - const toIndex = modules.findIndex((module) => module.id === toModuleId); - if (fromIndex < 0 || toIndex < 0) return; - const [moved] = modules.splice(fromIndex, 1); - const targetIndex = modules.findIndex((module) => module.id === toModuleId); - modules.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, moved); - updateToolbox({ ...toolbox, modules }); - } - - return ( -
-
-
-

Toolbox

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

{toolbox.name}

: ( - name !== toolbox.name && updateToolbox({ ...toolbox, name })} /> - )} -
- {toolboxGame && ( - actions.setDrawerGameId("") : undefined} - > - - {embedded ? "Vers la toolbox complète" : "Vers la page de jeu"} - - )} -
-
- -
-
- {!embedded && ( -
-
- - -
-
- )} - updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? { ...module, title } : module) })} - onUpdateModule={(moduleId, updater) => updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? updater(module) : module) })} - onDelete={(moduleId) => actions.setConfirmModal({ - title: "Retirer l'outil", - message: "Retirer cet outil de la toolbox ?", - confirmLabel: "Retirer", - danger: true, - onResolve: (confirmed) => { - if (!confirmed) return; - actions.removeModuleData(toolbox.id, moduleId); - updateToolbox({ ...toolbox, modules: toolbox.modules.filter((module) => module.id !== moduleId) }); - } - })} - onMove={moveModule} - /> - {!embedded && } -
- ); -} - -function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, storageUsage, getModuleData, updateToolbox, updateModuleData, addScreenshotFiles }) { - const selectedId = links[gameId] || ""; - const toolbox = toolboxes.find((item) => item.id === selectedId); - const [width, setWidth] = useState(""); - const panelRef = useRef(null); - - useEffect(() => { - let cancelled = false; - dbGetSetting(DRAWER_WIDTH_SETTING, "") - .then((storedWidth) => { - if (cancelled) return; - const value = Number(storedWidth); - if (Number.isFinite(value) && value > 0) setWidth(Math.min(Math.max(value, 360), Math.floor(window.innerWidth * 0.94))); - }) - .catch(() => {}); - return () => { cancelled = true; }; - }, []); - - function startResize(event) { - const panel = panelRef.current; - if (!panel) return; - event.preventDefault(); - const startX = event.clientX; - const startWidth = panel.getBoundingClientRect().width; - const minWidth = 360; - const maxWidth = Math.floor(window.innerWidth * 0.94); - document.body.classList.add("is-resizing-drawer"); - const onMove = (moveEvent) => { - const nextWidth = Math.min(Math.max(startWidth + startX - moveEvent.clientX, minWidth), maxWidth); - setWidth(nextWidth); - dbSetSetting(DRAWER_WIDTH_SETTING, Math.round(nextWidth)).catch(() => {}); - }; - const stop = () => { - document.body.classList.remove("is-resizing-drawer"); - window.removeEventListener("pointermove", onMove); - window.removeEventListener("pointerup", stop); - window.removeEventListener("pointercancel", stop); - }; - window.addEventListener("pointermove", onMove); - window.addEventListener("pointerup", stop); - window.addEventListener("pointercancel", stop); - } - - return ( - - ); -} - -function EditableTitle({ value, fallback, onSave, className = "module-title" }) { - const ref = useRef(null); - useEffect(() => { - if (ref.current && document.activeElement !== ref.current) ref.current.textContent = value; - }, [value]); - return ( -

{ event.currentTarget.dataset.previousTitle = event.currentTarget.textContent.trim(); }} - onBlur={(event) => onSave(event.currentTarget.textContent.trim() || fallback)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - event.currentTarget.blur(); - } - if (event.key === "Escape") { - event.preventDefault(); - event.currentTarget.textContent = event.currentTarget.dataset.previousTitle || value; - event.currentTarget.blur(); - } - }} - >{value}

- ); -} - -function ConfirmModal({ title, message, confirmLabel = "Confirmer", cancelLabel = "Annuler", danger = false, onClose }) { - useModalScrollLock(); - return ( -
-
onClose(false)} /> -
-

{title}

-

{message}

-
- - -
-
-
- ); -} - -function NotificationToast({ message }) { - return ( -
- {message} -
- ); -} - -function CreateToolboxModal({ gameId, initialName = "", onClose }) { - useModalScrollLock(); - const [name, setName] = useState(initialName); - return ( -
-
onClose("")} /> -
-

{gameId ? "Créer et associer une toolbox" : "Nouvelle toolbox"}

-
{ - event.preventDefault(); - if (name.trim()) onClose(name.trim()); - }}> - -
-
-
-
- ); -} - -function LinkToolboxModal({ toolboxes, selectedId, onClose }) { - useModalScrollLock(); - const [toolboxId, setToolboxId] = useState(selectedId); - return ( -
-
onClose(null)} /> -
-
-
{ - event.preventDefault(); - onClose(toolboxId); - }}> - -
-
-
-
- ); -} - -function ScreenshotViewer({ shot, onClose }) { - useModalScrollLock(); - const canAnnotate = Boolean(shot.canAnnotate && shot.onChangeMarkers); - const [viewerMarkers, setViewerMarkers] = useState(() => Array.isArray(shot.markers) ? shot.markers : []); - const viewerRef = useRef(null); - const mediaRef = useRef(null); - const hasMarkers = viewerMarkers.length > 0; - - useEffect(() => { - setViewerMarkers(Array.isArray(shot.markers) ? shot.markers : []); - }, [shot]); - - useEffect(() => { - if (!viewerRef.current || !mediaRef.current) return undefined; - const viewer = viewerRef.current; - const frame = mediaRef.current; - - function syncImageHeight() { - const maxHeight = Math.max(400, window.innerHeight * 0.8); - const height = Math.min(maxHeight, Math.max(400, Math.round(frame.getBoundingClientRect().height))); - viewer.style.setProperty("--viewer-image-height", `${height}px`); - } - - syncImageHeight(); - const observer = new ResizeObserver(syncImageHeight); - observer.observe(frame); - window.addEventListener("resize", syncImageHeight); - return () => { - observer.disconnect(); - window.removeEventListener("resize", syncImageHeight); - }; - }, [shot.dataUrl, canAnnotate]); - - useEffect(() => { - const onKeyDown = (event) => { - if (event.key === "Escape") onClose(); - }; - document.addEventListener("keydown", onKeyDown); - return () => document.removeEventListener("keydown", onKeyDown); - }, [onClose]); - - function updateViewerMarkers(nextMarkers) { - setViewerMarkers(nextMarkers); - shot.onChangeMarkers?.(nextMarkers); - } - - function addViewerMarker(event) { - if (!canAnnotate || !mediaRef.current) return; - const rect = mediaRef.current.getBoundingClientRect(); - const nextMarkers = [ - ...viewerMarkers, - { - id: shot.createMarkerId?.() || uid("marker"), - label: "", - x: clampPercent(((event.clientX - rect.left) / rect.width) * 100), - y: clampPercent(((event.clientY - rect.top) / rect.height) * 100) - } - ]; - updateViewerMarkers(nextMarkers); - } - - function updateViewerMarker(markerId, updater) { - updateViewerMarkers(viewerMarkers.map((marker) => marker.id === markerId ? updater(marker) : marker)); - } - - function removeViewerMarker(markerId) { - updateViewerMarkers(viewerMarkers.filter((marker) => marker.id !== markerId)); - } - - return ( -
-
-
-
- {shot.label && {shot.label}} -
- {!canAnnotate && !hasMarkers && ( - - )} - -
-
-
-
-
- {shot.alt - {viewerMarkers.map((marker, index) => ( - - {index + 1} - - ))} -
-
- {canAnnotate && ( - - )} -
-
-
- ); -} - -function StorageQuota({ usage }) { - const safeUsage = usage || { used: 0, limit: 0, ratio: 0 }; - const softRatio = Math.min(1, safeUsage.used / APP_STORAGE_SOFT_LIMIT_BYTES); - const percent = Math.round(softRatio * 100); - const state = safeUsage.used >= APP_STORAGE_SOFT_LIMIT_BYTES ? "danger" : softRatio >= APP_STORAGE_WARNING_RATIO ? "warning" : "ok"; - const browserQuota = safeUsage.limit ? `Quota navigateur estimé : ${formatBytes(safeUsage.limit)}` : "Quota navigateur estimé indisponible"; - return ( -
-
Stockage local recommandé{formatBytes(safeUsage.used)} / {formatBytes(APP_STORAGE_SOFT_LIMIT_BYTES)}
-
-
- ); -} - -function ImportButton({ className, label, title, ariaLabel, icon, onFile }) { - return ( - - ); -} - createRoot(document.querySelector("#app")).render(); diff --git a/website/src/pages/AboutPage.jsx b/website/src/pages/AboutPage.jsx new file mode 100644 index 0000000..5c51946 --- /dev/null +++ b/website/src/pages/AboutPage.jsx @@ -0,0 +1,102 @@ +// Rôle : présente le projet, son fonctionnement local et les repères de contribution. +import React, { useEffect } from "react"; +import { RichText } from "../components/RichText.jsx"; + +export function AboutPage({ siteContent }) { + const content = siteContent.about; + const limits = Array.isArray(content.limits) ? content.limits : []; + const storageSectionIndex = content.sections.findIndex((section) => section.title.toLowerCase().includes("stockage")); + const normalizeReminder = (item, index) => typeof item === "string" ? { title: `Point ${index + 1}`, text: item, icon: "toolbox" } : item; + useEffect(() => { + if (!location.hash.includes("#contribuer")) return; + requestAnimationFrame(() => document.querySelector("#contribuer")?.scrollIntoView({ behavior: "smooth", block: "start" })); + }, []); + + return ( +
+
+
+

{content.eyebrow}

+

{content.title}

+

+
+
+
+
+

Principes

+

Comment ça fonctionne

+
+
+ {content.sections.map((section, index) => ( +
+ {String(index + 1).padStart(2, "0")} +
+

{section.title}

+

+ {index === storageSectionIndex && limits.length > 0 && ( +
+

{content.limitsTitle}

+
    + {limits.map((item, itemIndex) => { + const reminder = normalizeReminder(item, itemIndex); + return ( +
  • +
  • + ); + })} +
+
+ )} +
+
+ ))} +
+
+
+
+
+

Toolbox

+

{content.toolsTitle}

+
+
+
+ {content.tools.map((tool) => ( +
+
+ ))} +
+
+ {content.contribute && ( +
+
+

{content.contribute.eyebrow}

+

{content.contribute.title}

+
+
+

+
{content.contribute.example}
+
+

{content.contribute.promptTitle}

+

+
{content.contribute.promptText}
+
+
+
+ )} +
+ ); +} diff --git a/website/src/pages/HomePage.jsx b/website/src/pages/HomePage.jsx new file mode 100644 index 0000000..30864ef --- /dev/null +++ b/website/src/pages/HomePage.jsx @@ -0,0 +1,49 @@ +// Rôle : affiche la page d'accueil et les contenus éditoriaux principaux. +import React from "react"; +import { TOOLBOX_MODULES } from "../features/toolboxes/modules/index.jsx"; +import { RichText } from "../components/RichText.jsx"; + +export function HomePage({ siteContent, toolboxes }) { + const content = siteContent.home; + const toolCount = Object.keys(TOOLBOX_MODULES).length; + return ( + <> +
+
+

{content.hero.eyebrow}

+

{content.hero.title}

+

{content.hero.description}

+ +
+
+
{toolboxes.length}{toolboxes.length > 1 ? content.stats.toolboxPlural : content.stats.toolboxSingular}
+
{toolCount}{toolCount > 1 ? content.stats.toolPlural : content.stats.toolSingular}
+
+
+
+
+

{content.origin.eyebrow}

+

{content.origin.title}

+
+ {content.origin.lines.map((line, index) => )} + {content.origin.caption} +
+
+
+ {content.origin.visualAlt} +
+
+
+

{content.legal.copyright}

+

{content.legal.disclaimer}

+
+ + ); +} + +function DialogueLine({ line }) { + return

; +} diff --git a/website/src/router/RouteContent.jsx b/website/src/router/RouteContent.jsx new file mode 100644 index 0000000..f2506b2 --- /dev/null +++ b/website/src/router/RouteContent.jsx @@ -0,0 +1,20 @@ +// Rôle : choisit la page à rendre selon la route active. +import React from "react"; +import { GameRoute } from "../features/games/GameRoute.jsx"; +import { GamesPage } from "../features/games/GamesPage.jsx"; +import { ToolboxesPage, ToolboxPage } from "../features/toolboxes/ToolboxPages.jsx"; +import { AboutPage } from "../pages/AboutPage.jsx"; +import { HomePage } from "../pages/HomePage.jsx"; +import { navigate } from "./hashRouter.js"; + +export function RouteContent(props) { + const { route } = props; + if (route === "/") return ; + if (route === "/about") return ; + if (route === "/toolboxes") return ; + if (route.startsWith("/toolbox/")) return ; + if (route === "/games") return ; + if (route.startsWith("/games/")) return ; + navigate("/"); + return null; +} diff --git a/website/src/router/hashRouter.js b/website/src/router/hashRouter.js new file mode 100644 index 0000000..4759bb8 --- /dev/null +++ b/website/src/router/hashRouter.js @@ -0,0 +1,27 @@ +// Rôle : synchronise la navigation hash avec l'état React. +import { useEffect, useState } from "react"; + +function currentRoute() { + const hashRoute = location.hash.replace(/^#/, ""); + if (hashRoute) return hashRoute.replace(/#.+$/, ""); + const path = location.pathname.replace(/\/+$/, "") || "/"; + if (path === "/mhwilds") return "/games/mhwilds"; + if (path === "/mhwilds/monsters") return "/games/mhwilds/monsters"; + if (path === "/mhwilds/endemic") return "/games/mhwilds/endemic"; + if (path === "/mhwilds/lists") return "/games/mhwilds/lists"; + return path; +} + +export function navigate(path) { + location.hash = path; +} + +export function useHashRoute() { + const [route, setRoute] = useState(currentRoute); + useEffect(() => { + const onHashChange = () => setRoute(currentRoute()); + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, []); + return route; +} diff --git a/website/src/styles/_base.scss b/website/src/styles/_base.scss index c40bd15..7bac819 100644 --- a/website/src/styles/_base.scss +++ b/website/src/styles/_base.scss @@ -1,3 +1,4 @@ +// Rôle : définit les bases globales HTML, body, typographie et contrôles natifs. * { box-sizing: border-box; } @@ -251,11 +252,11 @@ body.is-modal-open { .drawer-action-button, .drawer-close-button, .filter-reset-button, - .screenshot-viewer-button, + .image-viewer-button, .module-edit-button, .module-delete-button, - .shot-annotate-button, - .shot-delete-button, + .image-annotate-button, + .image-delete-button, .checklist-delete-button, .tool-quick-add-button ) { @@ -288,11 +289,11 @@ body.is-modal-open { .drawer-action-button, .drawer-close-button, .filter-reset-button, - .screenshot-viewer-button, + .image-viewer-button, .module-edit-button, .module-delete-button, - .shot-annotate-button, - .shot-delete-button, + .image-annotate-button, + .image-delete-button, .checklist-delete-button, .tool-quick-add-button ) .ui-icon { @@ -309,11 +310,11 @@ body.is-modal-open { .drawer-action-button, .drawer-close-button, .filter-reset-button, - .screenshot-viewer-button, + .image-viewer-button, .module-edit-button, .module-delete-button, - .shot-annotate-button, - .shot-delete-button, + .image-annotate-button, + .image-delete-button, .checklist-delete-button, .tool-quick-add-button ):hover { @@ -335,11 +336,11 @@ body.is-modal-open { .drawer-action-button, .drawer-close-button, .filter-reset-button, - .screenshot-viewer-button, + .image-viewer-button, .module-edit-button, .module-delete-button, - .shot-annotate-button, - .shot-delete-button, + .image-annotate-button, + .image-delete-button, .tool-quick-add-button ).primary { border-color: rgba(246, 196, 83, 0.34); @@ -360,11 +361,11 @@ body.is-modal-open { .drawer-action-button, .drawer-close-button, .filter-reset-button, - .screenshot-viewer-button, + .image-viewer-button, .module-edit-button, .module-delete-button, - .shot-annotate-button, - .shot-delete-button, + .image-annotate-button, + .image-delete-button, .tool-quick-add-button ).primary:hover { border-color: rgba(246, 196, 83, 0.72); @@ -387,11 +388,11 @@ body.is-modal-open { .drawer-action-button, .drawer-close-button, .filter-reset-button, - .screenshot-viewer-button, + .image-viewer-button, .module-edit-button, .module-delete-button, - .shot-annotate-button, - .shot-delete-button, + .image-annotate-button, + .image-delete-button, .checklist-delete-button, .tool-quick-add-button ):hover .ui-icon { @@ -399,12 +400,12 @@ body.is-modal-open { filter: drop-shadow(0 0 6px rgba(196, 181, 253, 0.28)); } -:is(.card-icon-button, .module-delete-button, .shot-delete-button, .checklist-delete-button).danger { +:is(.card-icon-button, .module-delete-button, .image-delete-button, .checklist-delete-button).danger { border-color: rgba(251, 113, 133, 0.2); color: var(--color-danger); } -:is(.card-icon-button, .module-delete-button, .shot-delete-button, .checklist-delete-button).danger:hover { +:is(.card-icon-button, .module-delete-button, .image-delete-button, .checklist-delete-button).danger:hover { border-color: rgba(251, 113, 133, 0.56); background: linear-gradient(135deg, rgba(80, 25, 42, 0.72), rgba(48, 18, 34, 0.82)) padding-box, @@ -415,7 +416,7 @@ body.is-modal-open { inset 0 1px 0 rgba(255, 255, 255, 0.06); } -:is(.card-icon-button, .module-delete-button, .shot-delete-button, .checklist-delete-button).danger:hover .ui-icon { +:is(.card-icon-button, .module-delete-button, .image-delete-button, .checklist-delete-button).danger:hover .ui-icon { background-color: #fff; filter: drop-shadow(0 0 6px rgba(251, 113, 133, 0.26)); } diff --git a/website/src/styles/_cards.scss b/website/src/styles/_cards.scss index 0c8762a..01f131c 100644 --- a/website/src/styles/_cards.scss +++ b/website/src/styles/_cards.scss @@ -1,3 +1,4 @@ +// Rôle : regroupe les styles de cards, panels et variantes compactes. @use "mixins"; .section-grid, diff --git a/website/src/styles/_diablo4.scss b/website/src/styles/_diablo4.scss index 27190e9..958bcd8 100644 --- a/website/src/styles/_diablo4.scss +++ b/website/src/styles/_diablo4.scss @@ -1,3 +1,4 @@ +// Rôle : contient les styles spécifiques aux affichages Diablo IV. .game-card-placeholder { display: grid; width: 100%; diff --git a/website/src/styles/_games.scss b/website/src/styles/_games.scss index ded251d..97ac124 100644 --- a/website/src/styles/_games.scss +++ b/website/src/styles/_games.scss @@ -1,3 +1,4 @@ +// Rôle : regroupe les styles partagés des pages de jeux, filtres et listes. @use "mixins"; .game-home-grid { diff --git a/website/src/styles/_home.scss b/website/src/styles/_home.scss index c5b1aeb..7527a6e 100644 --- a/website/src/styles/_home.scss +++ b/website/src/styles/_home.scss @@ -1,3 +1,4 @@ +// Rôle : contient les styles spécifiques à la page d'accueil. @use "mixins"; .hero, diff --git a/website/src/styles/_icons.scss b/website/src/styles/_icons.scss index e4ed102..0024f7f 100644 --- a/website/src/styles/_icons.scss +++ b/website/src/styles/_icons.scss @@ -1,3 +1,4 @@ +// Rôle : définit le rendu commun des icônes SVG. .ui-icon { display: block; width: 20px; diff --git a/website/src/styles/_mhwilds.scss b/website/src/styles/_mhwilds.scss index c00fc39..bf714a5 100644 --- a/website/src/styles/_mhwilds.scss +++ b/website/src/styles/_mhwilds.scss @@ -1,3 +1,4 @@ +// Rôle : contient les styles spécifiques aux pages Monster Hunter Wilds. .monster-grid { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); } diff --git a/website/src/styles/_mixins.scss b/website/src/styles/_mixins.scss index 72882b7..d8a15a4 100644 --- a/website/src/styles/_mixins.scss +++ b/website/src/styles/_mixins.scss @@ -1,3 +1,4 @@ +// Rôle : fournit les mixins Sass partagés du thème. @mixin border-mask { mask-image: linear-gradient(#000 0 0), diff --git a/website/src/styles/_nebula.scss b/website/src/styles/_nebula.scss index b73e436..d8cb1b9 100644 --- a/website/src/styles/_nebula.scss +++ b/website/src/styles/_nebula.scss @@ -1,3 +1,4 @@ +// Rôle : génère le fond nebula et le champ d'étoiles global. @use "sass:list"; $bright-stars: diff --git a/website/src/styles/_overlays.scss b/website/src/styles/_overlays.scss index 7aabeb8..dc91036 100644 --- a/website/src/styles/_overlays.scss +++ b/website/src/styles/_overlays.scss @@ -1,4 +1,5 @@ -.screenshot-viewer-root { +// Rôle : regroupe les styles des modales, viewers et notifications. +.image-viewer-root { position: fixed; inset: 0; z-index: 110; @@ -8,14 +9,14 @@ overscroll-behavior: contain; } -.screenshot-viewer-backdrop { +.image-viewer-backdrop { position: absolute; inset: 0; background: rgba(2, 3, 9, 0.64); backdrop-filter: blur(4px); } -.screenshot-viewer { +.image-viewer { --viewer-content-max-height: min(80vh, calc(100vh - 140px)); --viewer-image-height: 400px; position: relative; @@ -30,14 +31,14 @@ box-shadow: var(--shadow-lg); } -.screenshot-viewer header { +.image-viewer header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--space-2); } -.screenshot-viewer-title { +.image-viewer-title { overflow: hidden; color: var(--color-text); font-size: var(--font-size-md); @@ -45,30 +46,30 @@ white-space: nowrap; } -.screenshot-viewer-actions { +.image-viewer-actions { display: flex; justify-content: flex-end; gap: var(--space-2); } -.screenshot-viewer-button { +.image-viewer-button { flex: 0 0 40px; } -.screenshot-viewer-body { +.image-viewer-body { display: grid; min-width: 0; justify-items: center; } -.screenshot-viewer-body.has-annotation-side { +.image-viewer-body.has-annotation-side { grid-template-columns: minmax(0, 1fr) minmax(240px, 340px); gap: var(--space-4); align-items: start; justify-items: stretch; } -.screenshot-viewer-media { +.image-viewer-media { display: flex; min-width: 0; max-width: 100%; @@ -77,11 +78,11 @@ align-items: start; } -.screenshot-viewer-media.can-annotate { +.image-viewer-media.can-annotate { cursor: crosshair; } -.screenshot-viewer-image-frame { +.image-viewer-image-frame { position: relative; display: block; width: fit-content; @@ -91,7 +92,7 @@ line-height: 0; } -.screenshot-viewer-image-frame img { +.image-viewer-image-frame img { display: block; width: auto; height: auto; @@ -100,7 +101,7 @@ object-fit: contain; } -.screenshot-viewer-marker { +.image-viewer-marker { display: inline-grid; width: 30px; min-width: 30px; @@ -123,7 +124,7 @@ pointer-events: none; } -.screenshot-viewer-side { +.image-viewer-side { display: grid; align-content: start; grid-template-rows: auto minmax(0, 1fr); @@ -142,12 +143,12 @@ rgba(7, 10, 24, 0.38); } -.screenshot-viewer-side strong { +.image-viewer-side strong { color: var(--color-text-secondary); font-size: var(--font-size-sm); } -.screenshot-viewer-side .annotation-marker-list { +.image-viewer-side .annotation-marker-list { min-width: 0; max-height: 100%; overflow-y: auto; diff --git a/website/src/styles/_responsive.scss b/website/src/styles/_responsive.scss index 266db3e..1741c31 100644 --- a/website/src/styles/_responsive.scss +++ b/website/src/styles/_responsive.scss @@ -1,3 +1,4 @@ +// Rôle : contient les adaptations responsive et préférences d'accessibilité. @media (prefers-reduced-motion: reduce) { *, *::before, @@ -166,11 +167,11 @@ grid-template-columns: 1fr; } - .screenshot-viewer-body.has-annotation-side { + .image-viewer-body.has-annotation-side { grid-template-columns: 1fr; } - .screenshot-viewer-side { + .image-viewer-side { height: min(280px, 32vh); } diff --git a/website/src/styles/_shell.scss b/website/src/styles/_shell.scss index beaf434..6d17f44 100644 --- a/website/src/styles/_shell.scss +++ b/website/src/styles/_shell.scss @@ -1,3 +1,4 @@ +// Rôle : définit le layout principal, la sidebar et la navigation. @use "mixins"; .app-shell { diff --git a/website/src/styles/_tokens.scss b/website/src/styles/_tokens.scss index 148187a..1e64382 100644 --- a/website/src/styles/_tokens.scss +++ b/website/src/styles/_tokens.scss @@ -1,3 +1,4 @@ +// Rôle : expose les tokens CSS du thème Sokko G. :root { color-scheme: dark; diff --git a/website/src/styles/_toolboxes.scss b/website/src/styles/_toolboxes.scss index d966c3c..a19e456 100644 --- a/website/src/styles/_toolboxes.scss +++ b/website/src/styles/_toolboxes.scss @@ -1,3 +1,4 @@ +// Rôle : regroupe les styles des toolboxes, outils et panneau latéral. @use "mixins"; .toolbox-head { @@ -1701,14 +1702,14 @@ textarea:focus { padding: var(--space-4); } -.shots { +.images { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: var(--space-3); padding: var(--space-4); } -.shots figure { +.images figure { position: relative; display: grid; gap: 8px; @@ -1719,7 +1720,7 @@ textarea:focus { background: rgba(5, 7, 17, 0.38); } -.shot-preview { +.image-preview { display: block; width: 100%; min-height: 0; @@ -1729,18 +1730,18 @@ textarea:focus { background: transparent; } -.shot-preview:hover { +.image-preview:hover { transform: none; box-shadow: 0 0 0 2px rgba(246, 196, 83, 0.28); } -.shot-label, -.shot-label-input { +.image-label, +.image-label-input { min-width: 0; font-size: var(--font-size-sm); } -.shot-label { +.image-label { overflow: hidden; color: var(--color-text-secondary); font-weight: 700; @@ -1748,7 +1749,7 @@ textarea:focus { white-space: nowrap; } -.shot-label-input { +.image-label-input { width: 100%; min-height: 36px; padding: 0 10px; @@ -1758,17 +1759,17 @@ textarea:focus { color: var(--color-text); } -.shot-label-input::placeholder { +.image-label-input::placeholder { color: rgba(185, 194, 215, 0.48); } -.shot-label-input:focus { +.image-label-input:focus { border-color: rgba(246, 196, 83, 0.72); outline: none; box-shadow: 0 0 0 3px rgba(246, 196, 83, 0.14); } -.shots img { +.images img { display: block; width: 100%; aspect-ratio: 16 / 9; @@ -1777,12 +1778,12 @@ textarea:focus { background: var(--color-bg-deep); } -.shot-actions { +.image-actions { display: flex; gap: var(--space-2); } -.shot-actions .shot-annotate-button, -.shot-actions .shot-delete-button { +.image-actions .image-annotate-button, +.image-actions .image-delete-button { margin-top: 0; } diff --git a/website/src/styles/main.scss b/website/src/styles/main.scss index 02fa8b3..fe38483 100644 --- a/website/src/styles/main.scss +++ b/website/src/styles/main.scss @@ -1,3 +1,4 @@ +// Rôle : point d'entrée Sass qui assemble les modules de style. @use "tokens"; @use "nebula"; @use "base"; diff --git a/website/src/utils/bodyScrollLock.js b/website/src/utils/bodyScrollLock.js index bbe01c6..e90c56b 100644 --- a/website/src/utils/bodyScrollLock.js +++ b/website/src/utils/bodyScrollLock.js @@ -1,3 +1,4 @@ +// Rôle : bloque le scroll de page derrière les modales actives. export function lockBodyScroll() { const currentLocks = Number(document.body.dataset.modalLocks || 0); document.body.dataset.modalLocks = String(currentLocks + 1); diff --git a/website/src/utils/imageCompression.js b/website/src/utils/imageCompression.js new file mode 100644 index 0000000..dc5ba1b --- /dev/null +++ b/website/src/utils/imageCompression.js @@ -0,0 +1,11 @@ +// Rôle : compresse les images utilisateur avant stockage IndexedDB. +export async function compressImage(file) { + const bitmap = await createImageBitmap(file); + const maxSide = 1400; + const ratio = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height)); + const canvas = document.createElement("canvas"); + canvas.width = Math.round(bitmap.width * ratio); + canvas.height = Math.round(bitmap.height * ratio); + canvas.getContext("2d").drawImage(bitmap, 0, 0, canvas.width, canvas.height); + return canvas.toDataURL("image/jpeg", 0.78); +} diff --git a/website/src/utils/indexedDbStorage.js b/website/src/utils/indexedDbStorage.js index b1f60d9..49fa21a 100644 --- a/website/src/utils/indexedDbStorage.js +++ b/website/src/utils/indexedDbStorage.js @@ -1,3 +1,4 @@ +// Rôle : encapsule les opérations IndexedDB génériques utilisées par les toolboxes. const DB_NAME = "sokkog"; const DB_VERSION = 1; const KV_STORE = "kv";