structure refacto & file documentation
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
This commit is contained in:
parent
d7871736c8
commit
895fec2b40
84 changed files with 2629 additions and 2313 deletions
|
|
@ -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.
|
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
|
## Outils toolbox
|
||||||
|
|
||||||
Chaque outil toolbox doit rester dans son propre fichier dans :
|
Chaque outil toolbox doit rester dans son propre fichier dans :
|
||||||
|
|
|
||||||
|
|
@ -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/NotepadModule.jsx`
|
||||||
- `website/src/features/toolboxes/modules/ChecklistModule.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/LinksModule.jsx`
|
||||||
- `website/src/features/toolboxes/modules/CountersModule.jsx`
|
- `website/src/features/toolboxes/modules/CountersModule.jsx`
|
||||||
- `website/src/features/toolboxes/modules/CalculatorModule.jsx`
|
- `website/src/features/toolboxes/modules/CalculatorModule.jsx`
|
||||||
|
|
|
||||||
|
|
@ -142,13 +142,13 @@ Stockage compact :
|
||||||
|
|
||||||
## Outil Images
|
## Outil Images
|
||||||
|
|
||||||
Type : `screenshots`
|
Type : `images`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"shots": [
|
"images": [
|
||||||
{
|
{
|
||||||
"id": "shot1",
|
"id": "image1",
|
||||||
"label": "Map zone nord",
|
"label": "Map zone nord",
|
||||||
"dataUrl": "data:image/webp;base64,..."
|
"dataUrl": "data:image/webp;base64,..."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : lance les validations structurelles des données publiques du site.
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : centralise les règles de validation des JSON éditables du projet.
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
const SNAKE_CASE_RE = /^[a-z0-9]+(?:_[a-z0-9]+)*$/;
|
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.incrementLabel",
|
||||||
"toolboxes.modules.checklist.currentQuantityLabel",
|
"toolboxes.modules.checklist.currentQuantityLabel",
|
||||||
"toolboxes.modules.checklist.deleteTitle",
|
"toolboxes.modules.checklist.deleteTitle",
|
||||||
"toolboxes.modules.screenshots.addImages",
|
"toolboxes.modules.images.addImages",
|
||||||
"toolboxes.modules.screenshots.pastePlaceholder",
|
"toolboxes.modules.images.pastePlaceholder",
|
||||||
"toolboxes.modules.screenshots.pasteAriaLabel",
|
"toolboxes.modules.images.pasteAriaLabel",
|
||||||
"toolboxes.modules.screenshots.imageAlt",
|
"toolboxes.modules.images.imageAlt",
|
||||||
"toolboxes.modules.screenshots.labelPlaceholder",
|
"toolboxes.modules.images.labelPlaceholder",
|
||||||
"toolboxes.modules.screenshots.labelAriaLabel",
|
"toolboxes.modules.images.labelAriaLabel",
|
||||||
"toolboxes.modules.screenshots.previewAriaLabel",
|
"toolboxes.modules.images.previewAriaLabel",
|
||||||
"toolboxes.modules.screenshots.annotateAriaLabel",
|
"toolboxes.modules.images.annotateAriaLabel",
|
||||||
"toolboxes.modules.screenshots.annotateTitle",
|
"toolboxes.modules.images.annotateTitle",
|
||||||
"toolboxes.modules.screenshots.deleteAriaLabel",
|
"toolboxes.modules.images.deleteAriaLabel",
|
||||||
"toolboxes.modules.screenshots.deleteTitle",
|
"toolboxes.modules.images.deleteTitle",
|
||||||
"toolboxes.modules.links.titlePlaceholder",
|
"toolboxes.modules.links.titlePlaceholder",
|
||||||
"toolboxes.modules.links.urlPlaceholder",
|
"toolboxes.modules.links.urlPlaceholder",
|
||||||
"toolboxes.modules.links.addButton",
|
"toolboxes.modules.links.addButton",
|
||||||
|
|
|
||||||
121
tests/static-app.test.mjs
Normal file
121
tests/static-app.test.mjs
Normal file
|
|
@ -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 id="app"><\/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, /<br key=/);
|
||||||
|
assert.match(homePage, /export function HomePage/);
|
||||||
|
assert.match(homePage, /dragon\.png/);
|
||||||
|
assert.match(aboutPage, /export function AboutPage/);
|
||||||
|
assert.match(aboutPage, /module-icon-\$\{tool\.icon \|\| "notepad"\}/);
|
||||||
|
assert.match(bodyScrollLock, /modalLocks/);
|
||||||
|
assert.match(bodyScrollLock, /is-modal-open/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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("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");
|
||||||
|
});
|
||||||
89
tests/static-games.test.mjs
Normal file
89
tests/static-games.test.mjs
Normal file
|
|
@ -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)/);
|
||||||
|
});
|
||||||
|
|
@ -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 id="app"><\/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, /<br key=/);
|
|
||||||
assert.match(source, /getToolboxGame\(toolbox\)/);
|
|
||||||
assert.match(source, /Vers la page de jeu/);
|
|
||||||
assert.match(source, /Vers la toolbox complète/);
|
|
||||||
assert.match(source, /#\/games\/\$\{toolboxGame\.id\}/);
|
|
||||||
assert.match(source, /#\/toolbox\/\$\{toolbox\.id\}/);
|
|
||||||
assert.match(source, /setDrawerGameId\(""\)/);
|
|
||||||
assert.match(source, /toolbox-card-cover-link/);
|
|
||||||
assert.match(source, /toolbox-icon-cover/);
|
|
||||||
assert.match(source, /moduleColumns/);
|
|
||||||
assert.match(source, /function ToolboxView/);
|
|
||||||
assert.match(source, /usePointerReorder/);
|
|
||||||
assert.match(moduleRegistry, /onDragStart/);
|
|
||||||
assert.match(moduleRegistry, /onPointerDown/);
|
|
||||||
assert.match(moduleRegistry, /usePointerReorder/);
|
|
||||||
assert.match(reorderHook, /export function usePointerReorder/);
|
|
||||||
assert.match(reorderHook, /setPointerCapture/);
|
|
||||||
assert.match(reorderHook, /elementFromPoint/);
|
|
||||||
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, /<select value="" onChange=\{\(event\) => 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, /<Icon name="zoom"/);
|
|
||||||
assert.match(imageAnnotationModule, /<Icon name="zoom"/);
|
|
||||||
assert.match(styleBase, /themed-scrollbar/);
|
|
||||||
assert.match(styleBase, /body\.is-modal-open/);
|
|
||||||
assert.match(styleOverlays, /overscroll-behavior: contain/);
|
|
||||||
assert.match(source, /annotation-marker-list themed-scrollbar/);
|
|
||||||
assert.match(styleIcons, /ui-icon-map/);
|
|
||||||
assert.match(screenshotsModule, /dataTransfer\.files/);
|
|
||||||
assert.doesNotMatch(`${mhwildsListing}\n${diablo4Listing}\n${diablo4Overview}`, /mhwilds-(home-grid|home-card|heading|title-row|layout|filters|results|grid)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
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 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");
|
|
||||||
});
|
|
||||||
78
tests/static-styles.test.mjs
Normal file
78
tests/static-styles.test.mjs
Normal file
|
|
@ -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\)/);
|
||||||
|
});
|
||||||
178
tests/static-toolboxes.test.mjs
Normal file
178
tests/static-toolboxes.test.mjs
Normal file
|
|
@ -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, /<select value="" onChange=\{\(event\) => addModule/);
|
||||||
|
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.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(checklistModule, /Icon name="import"/);
|
||||||
|
assert.match(checklistModule, /editing &&/);
|
||||||
|
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(textImportModal, /createPortal/);
|
||||||
|
assert.match(textImportModal, /lockBodyScroll/);
|
||||||
|
assert.match(imagesModule, /export function ImagesModule/);
|
||||||
|
assert.match(imagesModule, /editing &&/);
|
||||||
|
assert.match(imagesModule, /clipboardData/);
|
||||||
|
assert.match(imagesModule, /createImageAnnotationModule/);
|
||||||
|
assert.match(imagesModule, /image-annotate-button/);
|
||||||
|
assert.match(imagesModule, /dataTransfer\.files/);
|
||||||
|
assert.match(imageAnnotationModule, /export function ImageAnnotationModule/);
|
||||||
|
assert.match(imageAnnotationModule, /annotation-marker/);
|
||||||
|
assert.match(imageAnnotationModule, /setImageFromFiles/);
|
||||||
|
assert.match(imageAnnotationModule, /context\.compressImageFile/);
|
||||||
|
assert.match(imageAnnotationModule, /context\.setImage/);
|
||||||
|
assert.match(imageAnnotationModule, /createMarkerId/);
|
||||||
|
assert.match(imageAnnotationModule, /<Icon name="zoom"/);
|
||||||
|
assert.match(linksModule, /export function LinksModule/);
|
||||||
|
assert.match(linksModule, /TextImportModal/);
|
||||||
|
assert.match(linksModule, /Icon name="import"/);
|
||||||
|
assert.match(linksModule, /editing &&/);
|
||||||
|
assert.match(linksModule, /normalizeUrl/);
|
||||||
|
assert.match(linksModule, /copyText/);
|
||||||
|
assert.match(linksModule, /parseColonImportLines/);
|
||||||
|
assert.match(linksModule, /context\.normalizeUrl/);
|
||||||
|
assert.match(countersModule, /export function CountersModule/);
|
||||||
|
assert.match(countersModule, /editing &&/);
|
||||||
|
assert.match(countersModule, /counter-actions/);
|
||||||
|
assert.match(textImport, /export function parseColonImportLines/);
|
||||||
|
assert.match(textImport, /line\.indexOf\(":\"\)/);
|
||||||
|
assert.match(imageViewer, /image-viewer-media/);
|
||||||
|
assert.match(imageViewer, /image-viewer-image-frame/);
|
||||||
|
assert.match(imageViewer, /image-viewer-marker/);
|
||||||
|
assert.match(imageViewer, /function openImageInNewTab/);
|
||||||
|
assert.match(imageViewer, /!hasMarkers &&/);
|
||||||
|
assert.match(imageViewer, /<Icon name="zoom"/);
|
||||||
|
assert.match(imageViewer, /annotation-marker-list themed-scrollbar/);
|
||||||
|
});
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : teste les parseurs d'import texte utilisés par les outils toolbox.
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js";
|
import { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js";
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@
|
||||||
{
|
{
|
||||||
"icon": "map",
|
"icon": "map",
|
||||||
"name": "Annotation d'images",
|
"name": "Annotation d'images",
|
||||||
"description": "Une image unique annotable avec des marqueurs, pratique pour une carte, un plan ou un screenshot important."
|
"description": "Une image unique annotable avec des marqueurs, pratique pour une carte, un plan ou un image important."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"icon": "link",
|
"icon": "link",
|
||||||
|
|
@ -219,7 +219,7 @@
|
||||||
"currentQuantityLabel": "Quantité actuelle",
|
"currentQuantityLabel": "Quantité actuelle",
|
||||||
"deleteTitle": "Supprimer"
|
"deleteTitle": "Supprimer"
|
||||||
},
|
},
|
||||||
"screenshots": {
|
"images": {
|
||||||
"addImages": "Ajouter des images",
|
"addImages": "Ajouter des images",
|
||||||
"pastePlaceholder": "Coller une image ici",
|
"pastePlaceholder": "Coller une image ici",
|
||||||
"pasteAriaLabel": "Coller une image depuis le presse-papiers",
|
"pasteAriaLabel": "Coller une image depuis le presse-papiers",
|
||||||
|
|
|
||||||
53
website/src/components/AppOverlays.jsx
Normal file
53
website/src/components/AppOverlays.jsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
// Rôle : regroupe les modales, notifications et viewers rendus au-dessus de l'application.
|
||||||
|
import React from "react";
|
||||||
|
import { ImageViewer } from "./ImageViewer.jsx";
|
||||||
|
import { ConfirmModal, CreateToolboxModal, LinkToolboxModal, NotificationToast } from "./ToolboxModals.jsx";
|
||||||
|
import { navigate } from "../router/hashRouter.js";
|
||||||
|
import { uid } from "../features/toolboxes/storage/toolboxStorage.js";
|
||||||
|
|
||||||
|
export function AppOverlays({
|
||||||
|
actions,
|
||||||
|
confirmModal,
|
||||||
|
setConfirmModal,
|
||||||
|
createModal,
|
||||||
|
setCreateModal,
|
||||||
|
linkModalGameId,
|
||||||
|
setLinkModalGameId,
|
||||||
|
setDrawerGameId,
|
||||||
|
storageError,
|
||||||
|
setStorageError,
|
||||||
|
image,
|
||||||
|
setImage,
|
||||||
|
notification,
|
||||||
|
getGame,
|
||||||
|
toolboxes,
|
||||||
|
links
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{confirmModal && <ConfirmModal {...confirmModal} onClose={(value) => {
|
||||||
|
const onResolve = confirmModal.onResolve;
|
||||||
|
setConfirmModal(null);
|
||||||
|
onResolve?.(value);
|
||||||
|
}} />}
|
||||||
|
{createModal && <CreateToolboxModal gameId={createModal.gameId || ""} initialName={getGame(createModal.gameId)?.title || ""} onClose={(name) => {
|
||||||
|
const gameId = createModal.gameId || "";
|
||||||
|
setCreateModal(null);
|
||||||
|
if (!name) return;
|
||||||
|
const toolbox = actions.createToolbox(name, gameId);
|
||||||
|
if (toolbox && gameId) setDrawerGameId(gameId);
|
||||||
|
else if (toolbox) navigate(`/toolbox/${toolbox.id}`);
|
||||||
|
}} />}
|
||||||
|
{linkModalGameId && <LinkToolboxModal gameId={linkModalGameId} toolboxes={toolboxes} selectedId={links[linkModalGameId] || ""} onClose={(toolboxId) => {
|
||||||
|
const gameId = linkModalGameId;
|
||||||
|
setLinkModalGameId("");
|
||||||
|
if (toolboxId == null) return;
|
||||||
|
actions.linkToolboxToGame(gameId, toolboxId);
|
||||||
|
setDrawerGameId(gameId);
|
||||||
|
}} />}
|
||||||
|
{storageError && <ConfirmModal title="Quota local atteint" message={storageError} confirmLabel="Compris" cancelLabel="Fermer" danger onClose={() => setStorageError("")} />}
|
||||||
|
{image && <ImageViewer image={image} onClose={() => setImage(null)} createMarkerId={() => uid("marker")} />}
|
||||||
|
{notification && <NotificationToast key={notification.id} message={notification.message} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : rend les icônes SVG de l'interface à partir d'un nom logique.
|
||||||
export function Icon({ name }) {
|
export function Icon({ name }) {
|
||||||
const className = name === "trash" ? "ui-icon-trash" : `ui-icon-${name}`;
|
const className = name === "trash" ? "ui-icon-trash" : `ui-icon-${name}`;
|
||||||
return <span className={`ui-icon ${className}`} aria-hidden="true" />;
|
return <span className={`ui-icon ${className}`} aria-hidden="true" />;
|
||||||
|
|
|
||||||
165
website/src/components/ImageViewer.jsx
Normal file
165
website/src/components/ImageViewer.jsx
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
// Rôle : affiche les images en modale, avec support des annotations.
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { lockBodyScroll } from "../utils/bodyScrollLock.js";
|
||||||
|
import { Icon } from "./Icon.jsx";
|
||||||
|
|
||||||
|
function clampPercent(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) return 0;
|
||||||
|
return Math.min(100, Math.max(0, parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.crypto?.randomUUID?.() || `marker-${Date.now()}` }) {
|
||||||
|
useEffect(() => lockBodyScroll(), []);
|
||||||
|
const canAnnotate = Boolean(image.canAnnotate && image.onChangeMarkers);
|
||||||
|
const [viewerMarkers, setViewerMarkers] = useState(() => Array.isArray(image.markers) ? image.markers : []);
|
||||||
|
const viewerRef = useRef(null);
|
||||||
|
const mediaRef = useRef(null);
|
||||||
|
const hasMarkers = viewerMarkers.length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setViewerMarkers(Array.isArray(image.markers) ? image.markers : []);
|
||||||
|
}, [image]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}, [image.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);
|
||||||
|
image.onChangeMarkers?.(nextMarkers);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addViewerMarker(event) {
|
||||||
|
if (!canAnnotate || !mediaRef.current) return;
|
||||||
|
const rect = mediaRef.current.getBoundingClientRect();
|
||||||
|
const nextMarkers = [
|
||||||
|
...viewerMarkers,
|
||||||
|
{
|
||||||
|
id: createMarkerId(),
|
||||||
|
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 (
|
||||||
|
<div className="image-viewer-root">
|
||||||
|
<div className="image-viewer-backdrop" onClick={onClose} />
|
||||||
|
<section ref={viewerRef} className="image-viewer" role="dialog" aria-modal="true" aria-label="Image">
|
||||||
|
<header>
|
||||||
|
{image.label && <strong className="image-viewer-title">{image.label}</strong>}
|
||||||
|
<div className="image-viewer-actions">
|
||||||
|
{!canAnnotate && !hasMarkers && (
|
||||||
|
<button className="image-viewer-button" onClick={() => openImageInNewTab(image.dataUrl)} aria-label="Ouvrir l'image en taille réelle" title="Taille réelle"><Icon name="zoom" /></button>
|
||||||
|
)}
|
||||||
|
<button className="image-viewer-button" onClick={onClose} aria-label="Fermer" title="Fermer"><Icon name="close" /></button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className={`image-viewer-body ${canAnnotate ? "has-annotation-side" : ""}`}>
|
||||||
|
<div className={`image-viewer-media ${canAnnotate ? "can-annotate" : ""}`}>
|
||||||
|
<div
|
||||||
|
ref={mediaRef}
|
||||||
|
className="image-viewer-image-frame"
|
||||||
|
onClick={addViewerMarker}
|
||||||
|
role={canAnnotate ? "button" : undefined}
|
||||||
|
tabIndex={canAnnotate ? 0 : undefined}
|
||||||
|
aria-label={canAnnotate ? image.addMarkerAriaLabel || "Ajouter un marqueur sur l'image" : undefined}
|
||||||
|
>
|
||||||
|
<img src={image.dataUrl} alt={image.alt || "Image"} />
|
||||||
|
{viewerMarkers.map((marker, index) => (
|
||||||
|
<span
|
||||||
|
className="annotation-marker image-viewer-marker"
|
||||||
|
key={marker.id || `${marker.x}-${marker.y}-${index}`}
|
||||||
|
style={{ "--marker-x": `${marker.x}%`, "--marker-y": `${marker.y}%` }}
|
||||||
|
title={marker.label || `${image.markerPrefix || "Marqueur"} ${index + 1}`}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canAnnotate && (
|
||||||
|
<aside className="image-viewer-side">
|
||||||
|
<strong>{viewerMarkers.length} {viewerMarkers.length > 1 ? "marqueurs" : "marqueur"}</strong>
|
||||||
|
{viewerMarkers.length ? (
|
||||||
|
<ol className="annotation-marker-list themed-scrollbar">
|
||||||
|
{viewerMarkers.map((marker, index) => (
|
||||||
|
<li key={marker.id}>
|
||||||
|
<span>{index + 1}</span>
|
||||||
|
<input
|
||||||
|
value={marker.label}
|
||||||
|
placeholder={image.markerPlaceholder || "Libellé du marqueur"}
|
||||||
|
onChange={(event) => updateViewerMarker(marker.id, (item) => ({ ...item, label: event.target.value }))}
|
||||||
|
aria-label={`Libellé du marqueur ${index + 1}`}
|
||||||
|
/>
|
||||||
|
<button className="calculator-action-button danger" type="button" onClick={() => removeViewerMarker(marker.id)} aria-label={`${image.deleteMarkerTitle || "Supprimer"} ${marker.label || `${image.markerPrefix || "Marqueur"} ${index + 1}`}`} title={image.deleteMarkerTitle || "Supprimer"}>
|
||||||
|
<Icon name="trash" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
) : (
|
||||||
|
<p className="muted">Cliquez sur l'image pour ajouter un marqueur.</p>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
website/src/components/ImportButton.jsx
Normal file
16
website/src/components/ImportButton.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<label className={className} aria-label={ariaLabel} title={title}>
|
||||||
|
{icon && <Icon name={icon} />}
|
||||||
|
{label}
|
||||||
|
<input type="file" accept="application/json" hidden onChange={async (event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) await onFile(file);
|
||||||
|
event.target.value = "";
|
||||||
|
}} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
website/src/components/RichText.jsx
Normal file
16
website/src/components/RichText.jsx
Normal file
|
|
@ -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>.*?<\/i>)/g).filter(Boolean);
|
||||||
|
return parts.flatMap((part, index) => {
|
||||||
|
const italic = part.match(/^<i>(.*?)<\/i>$/);
|
||||||
|
const value = italic ? italic[1] : part;
|
||||||
|
const lines = value.split("\n");
|
||||||
|
return lines.flatMap((line, lineIndex) => {
|
||||||
|
const key = `${index}-${lineIndex}`;
|
||||||
|
const node = italic ? <i key={key}>{line}</i> : <React.Fragment key={key}>{line}</React.Fragment>;
|
||||||
|
return lineIndex === lines.length - 1 ? [node] : [node, <br key={`${key}-br`} />];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
56
website/src/components/Shell.jsx
Normal file
56
website/src/components/Shell.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="app-shell">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<a className="brand" href="#/" aria-label={content.brand.homeAriaLabel}>
|
||||||
|
<span className="brand-mark" aria-hidden="true" />
|
||||||
|
<span className="brand-name"><span className="brand-name-main">Sokko</span> <span className="brand-name-accent">G</span></span>
|
||||||
|
</a>
|
||||||
|
<nav className="nav" aria-label="Navigation principale">
|
||||||
|
<a className={`nav-item ${route === "/" ? "active" : ""}`} href="#/"><span className="nav-icon nav-icon-home" aria-hidden="true" /><strong>{content.navigation.home}</strong></a>
|
||||||
|
<a className={`nav-item ${route.startsWith("/games") ? "active" : ""}`} href="#/games"><span className="nav-icon nav-icon-controller" aria-hidden="true" /><strong>{content.navigation.games}</strong></a>
|
||||||
|
<a className={`nav-item ${route.startsWith("/toolboxes") || route.startsWith("/toolbox") ? "active" : ""}`} href="#/toolboxes"><span className="nav-icon nav-icon-toolbox" aria-hidden="true" /><strong>{content.navigation.toolboxes}</strong></a>
|
||||||
|
</nav>
|
||||||
|
<a className={`sidebar-about-link ${route === "/about" ? "active" : ""}`} href="#/about"><span className="sidebar-about-icon" aria-hidden="true">?</span><strong>{content.navigation.about}</strong></a>
|
||||||
|
<div className="sidebar-note">
|
||||||
|
<span className="badge">{content.sidebar.badge}</span>
|
||||||
|
<p>{content.sidebar.note}</p>
|
||||||
|
<div className="sidebar-storage-actions" aria-label="Actions globales de stockage">
|
||||||
|
<button className="sidebar-action-button primary" onClick={actions.exportAllToolboxes} aria-label="Exporter toutes les toolboxes" title="Exporter tout"><Icon name="export" /></button>
|
||||||
|
<ImportButton className="sidebar-action-button import-icon-button" title="Importer tout" ariaLabel="Importer toutes les toolboxes" onFile={actions.importAllToolboxes} icon="import" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div className="content-shell">
|
||||||
|
<header className="topbar">
|
||||||
|
<div><strong>{topbarLabel}</strong></div>
|
||||||
|
{game && (
|
||||||
|
<div className="topbar-actions">
|
||||||
|
<button className="drawer-button toolbox-icon-button primary" onClick={() => actions.setDrawerGameId(game.id)} aria-label={toolboxLabel} title={toolboxLabel}>
|
||||||
|
<img src="/static/icons/toolbox.svg" alt="" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<main>{children}</main>
|
||||||
|
</div>
|
||||||
|
<nav className="mobile-nav" aria-label="Navigation mobile">
|
||||||
|
<a className={route === "/" ? "active" : ""} href="#/">{content.navigation.home}</a>
|
||||||
|
<a className={route.startsWith("/toolboxes") || route.startsWith("/toolbox") ? "active" : ""} href="#/toolboxes">{content.navigation.toolboxes}</a>
|
||||||
|
<button className="primary" onClick={() => actions.setCreateModal({})}>+</button>
|
||||||
|
<a className={route.startsWith("/games") ? "active" : ""} href="#/games">{content.navigation.mobileGames}</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
website/src/components/StorageQuota.jsx
Normal file
25
website/src/components/StorageQuota.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<section className={`storage-quota storage-quota-${state}`} aria-label="Budget recommandé de stockage local" title={`${browserQuota}. Le budget affiché est une limite de confort pour préserver les performances.`}>
|
||||||
|
<div><span>Stockage local recommandé</span><strong>{formatBytes(safeUsage.used)} / {formatBytes(APP_STORAGE_SOFT_LIMIT_BYTES)}</strong></div>
|
||||||
|
<div className="storage-quota-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow={percent} aria-label="Budget recommandé utilisé"><span style={{ width: `${percent}%` }} /></div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
website/src/components/ToolboxModals.jsx
Normal file
72
website/src/components/ToolboxModals.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="confirm-modal-root">
|
||||||
|
<div className="confirm-backdrop" onClick={() => onClose(false)} />
|
||||||
|
<section className="confirm-modal" role="dialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-message">
|
||||||
|
<header><h2 id="confirm-title">{title}</h2></header>
|
||||||
|
<p id="confirm-message">{message}</p>
|
||||||
|
<footer>
|
||||||
|
<button onClick={() => onClose(false)}>{cancelLabel}</button>
|
||||||
|
<button className={danger ? "danger confirm-danger" : "primary"} onClick={() => onClose(true)}>{confirmLabel}</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationToast({ message }) {
|
||||||
|
return (
|
||||||
|
<div className="notification-toast" role="status" aria-live="polite">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateToolboxModal({ gameId, initialName = "", onClose }) {
|
||||||
|
useModalScrollLock();
|
||||||
|
const [name, setName] = useState(initialName);
|
||||||
|
return (
|
||||||
|
<div className="confirm-modal-root">
|
||||||
|
<div className="confirm-backdrop" onClick={() => onClose("")} />
|
||||||
|
<section className="confirm-modal toolbox-create-modal" role="dialog" aria-modal="true" aria-labelledby="toolbox-create-title">
|
||||||
|
<header><h2 id="toolbox-create-title">{gameId ? "Créer et associer une toolbox" : "Nouvelle toolbox"}</h2></header>
|
||||||
|
<form onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (name.trim()) onClose(name.trim());
|
||||||
|
}}>
|
||||||
|
<label className="field"><span>Nom</span><input name="name" autoComplete="off" placeholder="Nom de la toolbox" required value={name} onChange={(event) => setName(event.target.value)} autoFocus /></label>
|
||||||
|
<footer><button type="button" onClick={() => onClose("")}>Annuler</button><button className="primary" type="submit">Créer</button></footer>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinkToolboxModal({ toolboxes, selectedId, onClose }) {
|
||||||
|
useModalScrollLock();
|
||||||
|
const [toolboxId, setToolboxId] = useState(selectedId);
|
||||||
|
return (
|
||||||
|
<div className="confirm-modal-root">
|
||||||
|
<div className="confirm-backdrop" onClick={() => onClose(null)} />
|
||||||
|
<section className="confirm-modal toolbox-link-modal" role="dialog" aria-modal="true" aria-labelledby="toolbox-link-title">
|
||||||
|
<header><h2 id="toolbox-link-title">Lier une toolbox</h2></header>
|
||||||
|
<form onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onClose(toolboxId);
|
||||||
|
}}>
|
||||||
|
<label className="field"><span>Toolbox associée</span><select value={toolboxId} onChange={(event) => setToolboxId(event.target.value)} autoFocus><option value="">Aucune</option>{toolboxes.map((toolbox) => <option key={toolbox.id} value={toolbox.id}>{toolbox.name}</option>)}</select></label>
|
||||||
|
<footer><button type="button" onClick={() => onClose(null)}>Annuler</button><button className="primary" type="submit">Valider</button></footer>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : copie des données filtrées au format compatible avec l'import checklist.
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Icon } from "../../components/Icon.jsx";
|
import { Icon } from "../../components/Icon.jsx";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : affiche le fil d'Ariane compact commun aux pages de jeu.
|
||||||
import { Icon } from "../../components/Icon.jsx";
|
import { Icon } from "../../components/Icon.jsx";
|
||||||
|
|
||||||
export function GameBreadcrumb({ game }) {
|
export function GameBreadcrumb({ game }) {
|
||||||
|
|
|
||||||
|
|
@ -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";
|
import { Icon } from "../../components/Icon.jsx";
|
||||||
|
|
||||||
export function GameFiltersPanel({
|
export function GameFiltersPanel({
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : affiche les listes de jeu transformables en checklist.
|
||||||
import { Icon } from "../../components/Icon.jsx";
|
import { Icon } from "../../components/Icon.jsx";
|
||||||
import { CopyChecklistItemsButton } from "./CopyChecklistItemsButton.jsx";
|
import { CopyChecklistItemsButton } from "./CopyChecklistItemsButton.jsx";
|
||||||
import { GameBreadcrumb } from "./GameBreadcrumb.jsx";
|
import { GameBreadcrumb } from "./GameBreadcrumb.jsx";
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : route vers les pages de jeux disponibles et leurs sous-pages.
|
||||||
import { GamesPage } from "./GamesPage.jsx";
|
import { GamesPage } from "./GamesPage.jsx";
|
||||||
import { Diablo4Page } from "./diablo4/Diablo4Page.jsx";
|
import { Diablo4Page } from "./diablo4/Diablo4Page.jsx";
|
||||||
import { MhwildsPage } from "./mhwilds/MhwildsPage.jsx";
|
import { MhwildsPage } from "./mhwilds/MhwildsPage.jsx";
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : liste les jeux déclarés dans le contenu éditable.
|
||||||
export function GamesPage({ siteContent, games, gamesError }) {
|
export function GamesPage({ siteContent, games, gamesError }) {
|
||||||
const content = siteContent.gamesPage;
|
const content = siteContent.gamesPage;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : affiche une carte d'affixe Diablo IV et ses catégories.
|
||||||
import { getCategoryIconStyle, getCategoryLabel } from "./utils.js";
|
import { getCategoryIconStyle, getCategoryLabel } from "./utils.js";
|
||||||
|
|
||||||
export function Diablo4AffixCard({ affix, categoryMap }) {
|
export function Diablo4AffixCard({ affix, categoryMap }) {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : configure les filtres applicables aux affixes Diablo IV.
|
||||||
import { GameFiltersPanel } from "../GameFiltersPanel.jsx";
|
import { GameFiltersPanel } from "../GameFiltersPanel.jsx";
|
||||||
import { getCategoryIconStyle, getCategoryId, getCategoryLabel } from "./utils.js";
|
import { getCategoryIconStyle, getCategoryId, getCategoryLabel } from "./utils.js";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : affiche et filtre la liste des affixes Diablo IV.
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx";
|
import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx";
|
||||||
import { GameBreadcrumb } from "../GameBreadcrumb.jsx";
|
import { GameBreadcrumb } from "../GameBreadcrumb.jsx";
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : affiche les raccourcis de la page d'accueil Diablo IV.
|
||||||
export function Diablo4Overview({ game }) {
|
export function Diablo4Overview({ game }) {
|
||||||
const heroStyle = game?.images?.heroBg ? { "--game-hero-image": `url("${game.images.heroBg}")` } : undefined;
|
const heroStyle = game?.images?.heroBg ? { "--game-hero-image": `url("${game.images.heroBg}")` } : undefined;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : assemble le hero et les sous-pages Diablo IV.
|
||||||
import { Diablo4Listing } from "./Diablo4Listing.jsx";
|
import { Diablo4Listing } from "./Diablo4Listing.jsx";
|
||||||
import { Diablo4Overview } from "./Diablo4Overview.jsx";
|
import { Diablo4Overview } from "./Diablo4Overview.jsx";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : regroupe les helpers d'affichage et de filtrage Diablo IV.
|
||||||
export function normalizeText(value) {
|
export function normalizeText(value) {
|
||||||
return String(value || "").trim().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/\s+/g, " ");
|
return String(value || "").trim().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/\s+/g, " ");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : charge et normalise les données publiques des jeux.
|
||||||
export const INITIAL_MHWILDS_STATE = {
|
export const INITIAL_MHWILDS_STATE = {
|
||||||
loaded: false,
|
loaded: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : configure les filtres Monster Hunter Wilds pour monstres et faune.
|
||||||
import { GameFiltersPanel } from "../GameFiltersPanel.jsx";
|
import { GameFiltersPanel } from "../GameFiltersPanel.jsx";
|
||||||
import { assetPath } from "./utils.js";
|
import { assetPath } from "./utils.js";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : affiche les listes filtrables de monstres et de faune Monster Hunter Wilds.
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { GameBreadcrumb } from "../GameBreadcrumb.jsx";
|
import { GameBreadcrumb } from "../GameBreadcrumb.jsx";
|
||||||
import { EndemicCard } from "./cards/EndemicCard.jsx";
|
import { EndemicCard } from "./cards/EndemicCard.jsx";
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : affiche les listes Monster Hunter Wilds exportables en checklist.
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { GameListsPage } from "../GameListsPage.jsx";
|
import { GameListsPage } from "../GameListsPage.jsx";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : affiche les raccourcis de la page d'accueil Monster Hunter Wilds.
|
||||||
import { assetPath } from "./utils.js";
|
import { assetPath } from "./utils.js";
|
||||||
|
|
||||||
export function MhwildsOverview({ game }) {
|
export function MhwildsOverview({ game }) {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : assemble le hero et les sous-pages Monster Hunter Wilds.
|
||||||
import { MhwildsListing } from "./MhwildsListing.jsx";
|
import { MhwildsListing } from "./MhwildsListing.jsx";
|
||||||
import { MhwildsLists } from "./MhwildsLists.jsx";
|
import { MhwildsLists } from "./MhwildsLists.jsx";
|
||||||
import { MhwildsOverview } from "./MhwildsOverview.jsx";
|
import { MhwildsOverview } from "./MhwildsOverview.jsx";
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : affiche les hitzones d'un monstre sous forme de tableau compact.
|
||||||
import { assetPath } from "../utils.js";
|
import { assetPath } from "../utils.js";
|
||||||
|
|
||||||
const PHYSICAL_COLUMNS = ["cut", "blunt", "ammo"];
|
const PHYSICAL_COLUMNS = ["cut", "blunt", "ammo"];
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : affiche une carte de faune endémique Monster Hunter Wilds.
|
||||||
import { IconRow } from "./IconRow.jsx";
|
import { IconRow } from "./IconRow.jsx";
|
||||||
import { assetPath, getConditionValues, normalizeText } from "../utils.js";
|
import { assetPath, getConditionValues, normalizeText } from "../utils.js";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
import { assetPath } from "../utils.js";
|
||||||
|
|
||||||
export function IconRow({ label, values, t }) {
|
export function IconRow({ label, values, t }) {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : affiche une carte monstre retournable avec faiblesses et hitzones.
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { DamageTable } from "./DamageTable.jsx";
|
import { DamageTable } from "./DamageTable.jsx";
|
||||||
import { IconRow } from "./IconRow.jsx";
|
import { IconRow } from "./IconRow.jsx";
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : regroupe les helpers d'assets, traduction et filtres Monster Hunter Wilds.
|
||||||
const MHWILDS_IMG_PATH = "/static/img/mhwilds";
|
const MHWILDS_IMG_PATH = "/static/img/mhwilds";
|
||||||
|
|
||||||
export function assetPath(name) {
|
export function assetPath(name) {
|
||||||
|
|
|
||||||
144
website/src/features/toolboxes/ToolboxCard.jsx
Normal file
144
website/src/features/toolboxes/ToolboxCard.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<article className={className} data-toolbox-id={toolbox.id}>
|
||||||
|
<button
|
||||||
|
className="toolbox-card-drag-handle"
|
||||||
|
type="button"
|
||||||
|
onPointerDown={(event) => onDragStart(event, toolbox.id)}
|
||||||
|
aria-label={`Déplacer ${toolbox.name}`}
|
||||||
|
title="Déplacer"
|
||||||
|
>
|
||||||
|
<Icon name="drag" />
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
className={`card-cover toolbox-card-cover-link ${gameCoverImage ? "toolbox-card-cover" : "toolbox-icon-cover"}`}
|
||||||
|
href={`#/toolbox/${toolbox.id}`}
|
||||||
|
aria-label={`Ouvrir ${toolbox.name}`}
|
||||||
|
title={`Ouvrir ${toolbox.name}`}
|
||||||
|
style={{ "--game-cover": getGameCardBackground(game), background: gameCoverImage ? "" : "var(--gradient-nebula)" }}
|
||||||
|
>
|
||||||
|
<img src={coverImage} alt={game ? game.title : `Icône de ${toolbox.name}`} loading="lazy" />
|
||||||
|
</a>
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="eyebrow">{game ? game.title : "Toolbox libre"}</p>
|
||||||
|
<h2>{toolbox.name}</h2>
|
||||||
|
<small>Modifiée le {formatDate(toolbox.updatedAt)}</small>
|
||||||
|
<div className="card-actions">
|
||||||
|
<a className="button card-icon-button" href={`#/toolbox/${toolbox.id}`} aria-label={`Ouvrir ${toolbox.name}`} title="Ouvrir"><Icon name="open" /></a>
|
||||||
|
<button className="card-icon-button" onClick={() => actions.exportToolbox(toolbox.id)} aria-label={`Exporter ${toolbox.name}`} title="Exporter"><Icon name="export" /></button>
|
||||||
|
<button className="card-icon-button danger" onClick={() => actions.setConfirmModal({
|
||||||
|
title: "Supprimer la toolbox",
|
||||||
|
message: `Supprimer "${toolbox.name}" et ses données locales ?`,
|
||||||
|
confirmLabel: "Supprimer",
|
||||||
|
danger: true,
|
||||||
|
onResolve: (confirmed) => confirmed && actions.deleteToolbox(toolbox.id)
|
||||||
|
})} aria-label={`Supprimer ${toolbox.name}`} title="Supprimer"><Icon name="trash" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="toolbox-icon-picker" ref={pickerRef}>
|
||||||
|
<button
|
||||||
|
className="toolbox-icon-picker-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
aria-label="Changer l'icône de la toolbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
title="Changer l'icône"
|
||||||
|
>
|
||||||
|
<img src={icon} alt="" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="toolbox-icon-picker-menu" aria-label="Icônes de toolbox">
|
||||||
|
{TOOLBOX_ICONS.map((path) => (
|
||||||
|
<button
|
||||||
|
key={path}
|
||||||
|
className={path === icon ? "active" : ""}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectIcon(path)}
|
||||||
|
aria-label={`Utiliser l'icône ${path.split("/").pop().replace(".png", "")}`}
|
||||||
|
aria-pressed={path === icon}
|
||||||
|
title={path.split("/").pop().replace(".png", "")}
|
||||||
|
>
|
||||||
|
<img src={path} alt="" aria-hidden="true" loading="lazy" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolboxGameIcon({ game }) {
|
||||||
|
const gameCoverImage = getGameCardCover(game);
|
||||||
|
return (
|
||||||
|
<div className="toolbox-game-icon" title={game.title} aria-label={`Jeu associé : ${game.title}`}>
|
||||||
|
<img src={gameCoverImage} alt="" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
326
website/src/features/toolboxes/ToolboxPages.jsx
Normal file
326
website/src/features/toolboxes/ToolboxPages.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="toolbox-page">
|
||||||
|
<section className="page-hero">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{content.eyebrow}</p>
|
||||||
|
<h1>{content.title}</h1>
|
||||||
|
<p>{content.summary}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="toolbar toolbox-actions-row">
|
||||||
|
<div>
|
||||||
|
<button className="primary" onClick={() => actions.setCreateModal({})}>{content.newButton}</button>
|
||||||
|
<ImportButton className="import-button" label={content.importOne} onFile={actions.importToolbox} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button className="primary" onClick={actions.exportAllToolboxes}>{content.exportAll}</button>
|
||||||
|
<ImportButton className="import-button" label={content.importAll} onFile={actions.importAllToolboxes} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="cards">
|
||||||
|
<StorageHelpCard help={content.storageHelp} />
|
||||||
|
{toolboxes.length ? toolboxes.map((toolbox) => (
|
||||||
|
<ToolboxCard
|
||||||
|
key={toolbox.id}
|
||||||
|
toolbox={toolbox}
|
||||||
|
game={getToolboxGame(toolbox)}
|
||||||
|
actions={actions}
|
||||||
|
draggingToolboxId={draggingToolboxId}
|
||||||
|
dropTarget={dropTarget}
|
||||||
|
onDragStart={startToolboxDrag}
|
||||||
|
/>
|
||||||
|
)) : (
|
||||||
|
<div className="empty"><h2>{content.emptyTitle}</h2><p>{content.emptyText}</p></div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<StorageQuota usage={storageUsage} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StorageHelpCard({ help }) {
|
||||||
|
return (
|
||||||
|
<article className="card storage-help-card" aria-labelledby="storage-help-title">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="storage-help-heading">
|
||||||
|
<span className="storage-help-mark" aria-hidden="true" />
|
||||||
|
<div><p className="eyebrow">IndexedDB</p><h2 id="storage-help-title">{help.title}</h2></div>
|
||||||
|
</div>
|
||||||
|
<p className="storage-help-intro">{help.text}</p>
|
||||||
|
<ul className="storage-help-list">
|
||||||
|
{help.items.map((item, index) => <li key={item}><span>{index + 1}</span><p>{item}</p></li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolboxPage(props) {
|
||||||
|
const { toolboxId, toolboxes, getToolboxGame } = props;
|
||||||
|
const toolbox = toolboxes.find((item) => item.id === toolboxId);
|
||||||
|
if (!toolbox) return <div className="empty"><h1>Toolbox introuvable</h1><a className="button" href="#/toolboxes">Retour</a></div>;
|
||||||
|
return <ToolboxView {...props} toolbox={toolbox} toolboxGame={getToolboxGame(toolbox)} embedded={false} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={embedded ? "toolbox-embedded" : "toolbox-page"}>
|
||||||
|
<section className={embedded ? "toolbox-head page-hero toolbox-head-embedded" : "toolbox-head page-hero"}>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Toolbox</p>
|
||||||
|
<div className="toolbox-title-row">
|
||||||
|
{!embedded && !toolboxGame && <ToolboxIconPicker toolbox={toolbox} onChange={(icon) => updateToolbox({ ...toolbox, icon })} />}
|
||||||
|
{!embedded && toolboxGameCover && <ToolboxGameIcon game={toolboxGame} />}
|
||||||
|
{embedded ? <h1>{toolbox.name}</h1> : (
|
||||||
|
<EditableTitle className="toolbox-title" value={toolbox.name} fallback="Nouvelle toolbox" onSave={(name) => name !== toolbox.name && updateToolbox({ ...toolbox, name })} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{toolboxGame && (
|
||||||
|
<a
|
||||||
|
className="button toolbox-hero-link"
|
||||||
|
href={embedded ? `#/toolbox/${toolbox.id}` : `#/games/${toolboxGame.id}`}
|
||||||
|
onClick={embedded ? () => actions.setDrawerGameId("") : undefined}
|
||||||
|
>
|
||||||
|
<Icon name="enter" />
|
||||||
|
<span>{embedded ? "Vers la toolbox complète" : "Vers la page de jeu"}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
<AddToolControls onAdd={addModule} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{!embedded && (
|
||||||
|
<section className="modules-toolbar" aria-label="Options d'affichage des outils">
|
||||||
|
<div className="layout-switch" role="group" aria-label="Mode d'affichage des outils">
|
||||||
|
<button className={moduleColumns === 1 ? "active" : ""} onClick={() => updateToolbox({ ...toolbox, moduleColumns: 1 })} aria-pressed={moduleColumns === 1} aria-label="Afficher en lignes" title="Afficher en lignes"><Icon name="rows" /></button>
|
||||||
|
<button className={moduleColumns === 2 ? "active" : ""} onClick={() => updateToolbox({ ...toolbox, moduleColumns: 2 })} aria-pressed={moduleColumns === 2} aria-label="Afficher en colonnes" title="Afficher en colonnes"><Icon name="columns" /></button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
<ToolboxModules
|
||||||
|
toolbox={toolbox}
|
||||||
|
moduleColumns={moduleColumns}
|
||||||
|
context={moduleContext}
|
||||||
|
onRename={(moduleId, title) => 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 && <StorageQuota usage={storageUsage} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<aside className="drawer" id="toolbox-drawer" aria-hidden="false" style={width ? { "--drawer-panel-width": `${width}px` } : undefined}>
|
||||||
|
<div className="drawer-backdrop" onClick={() => actions.setDrawerGameId("")} />
|
||||||
|
<span className="drawer-resize-handle" aria-hidden="true" onPointerDown={startResize} />
|
||||||
|
<section className="drawer-panel legacy-scrollbar" ref={panelRef} style={width ? { width } : undefined}>
|
||||||
|
<div className="drawer-content">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Toolbox liée</p>
|
||||||
|
<div className="drawer-toolbox-actions" aria-label="Actions toolbox liée">
|
||||||
|
<button className="drawer-action-button" onClick={() => actions.setLinkModalGameId(gameId)} aria-label="Lier une toolbox" title="Lier"><Icon name="link" /></button>
|
||||||
|
<ImportButton className="drawer-action-button import-icon-button" ariaLabel="Importer une toolbox" title="Importer" icon="import" onFile={(file) => actions.importToolbox(file, gameId)} />
|
||||||
|
<button className="drawer-action-button" onClick={() => actions.exportToolbox(selectedId)} disabled={!selectedId} aria-label="Exporter la toolbox liée" title="Exporter"><Icon name="export" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="drawer-close-button" onClick={() => actions.setDrawerGameId("")} aria-label="Fermer" title="Fermer"><Icon name="close" /></button>
|
||||||
|
</header>
|
||||||
|
{toolbox ? <ToolboxView siteContent={siteContent} toolbox={toolbox} toolboxGame={game} embedded actions={actions} storageUsage={storageUsage} getModuleData={getModuleData} updateToolbox={updateToolbox} updateModuleData={updateModuleData} addImageFiles={addImageFiles} /> : (
|
||||||
|
<div className="empty"><p>Aucune toolbox associée à cette page jeu.</p><button className="primary" onClick={() => actions.setCreateModal({ gameId })}>Créer et associer</button></div>
|
||||||
|
)}
|
||||||
|
<StorageQuota usage={storageUsage} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<h1
|
||||||
|
ref={ref}
|
||||||
|
className={className}
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
spellCheck="false"
|
||||||
|
title="Cliquer pour renommer"
|
||||||
|
onFocus={(event) => { 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}</h1>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.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 { useCallback, useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { TextImportModal } from "./TextImportModal.jsx";
|
import { TextImportModal } from "./TextImportModal.jsx";
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : fournit l'outil compteurs personnalisables.
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : fournit l'outil annotation d'images avec marqueurs éditables.
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
|
|
||||||
|
|
@ -84,7 +85,7 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
|
||||||
<button
|
<button
|
||||||
className="calculator-action-button"
|
className="calculator-action-button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => context.setScreenshot({
|
onClick={() => context.setImage({
|
||||||
dataUrl: data.image,
|
dataUrl: data.image,
|
||||||
markers,
|
markers,
|
||||||
alt: textContent.imageAlt || "Image annotée",
|
alt: textContent.imageAlt || "Image annotée",
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,22 @@
|
||||||
|
// Rôle : fournit l'outil images avec import, collage, libellés et annotation rapide.
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
|
|
||||||
export function ScreenshotsModule({ toolboxId, moduleId, context, editing }) {
|
export function ImagesModule({ toolboxId, moduleId, context, editing }) {
|
||||||
const data = context.getModuleData(toolboxId, moduleId, { shots: [] });
|
const data = context.getModuleData(toolboxId, moduleId, { images: [] });
|
||||||
const textContent = context.moduleText?.screenshots || {};
|
const textContent = context.moduleText?.images || {};
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const pastePlaceholder = textContent.pastePlaceholder || "Coller une image ici";
|
const pastePlaceholder = textContent.pastePlaceholder || "Coller une image ici";
|
||||||
|
|
||||||
async function addFiles(files) {
|
async function addFiles(files) {
|
||||||
if (await context.addScreenshotFiles(toolboxId, moduleId, files)) {
|
if (await context.addImageFiles(toolboxId, moduleId, files)) {
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateShot(shotId, patch) {
|
function updateImage(imageId, patch) {
|
||||||
context.setModuleData(toolboxId, moduleId, {
|
context.setModuleData(toolboxId, moduleId, {
|
||||||
shots: data.shots.map((shot) => shot.id === shotId ? { ...shot, ...patch } : shot)
|
images: data.images.map((image) => image.id === imageId ? { ...image, ...patch } : image)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,35 +67,35 @@ export function ScreenshotsModule({ toolboxId, moduleId, context, editing }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="shots">
|
<div className="images">
|
||||||
{data.shots.map((shot) => (
|
{data.images.map((image) => (
|
||||||
<figure key={shot.id}>
|
<figure key={image.id}>
|
||||||
<button className="shot-preview" onClick={() => context.setScreenshot(shot)} aria-label={textContent.previewAriaLabel || "Agrandir l'image"}>
|
<button className="image-preview" onClick={() => context.setImage(image)} aria-label={textContent.previewAriaLabel || "Agrandir l'image"}>
|
||||||
<img src={shot.dataUrl} alt={textContent.imageAlt || "Image"} />
|
<img src={image.dataUrl} alt={textContent.imageAlt || "Image"} />
|
||||||
</button>
|
</button>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<input
|
<input
|
||||||
className="shot-label-input"
|
className="image-label-input"
|
||||||
value={shot.label || ""}
|
value={image.label || ""}
|
||||||
placeholder={textContent.labelPlaceholder || "Libellé de l'image"}
|
placeholder={textContent.labelPlaceholder || "Libellé de l'image"}
|
||||||
onChange={(event) => updateShot(shot.id, { label: event.target.value })}
|
onChange={(event) => updateImage(image.id, { label: event.target.value })}
|
||||||
aria-label={textContent.labelAriaLabel || "Libellé de l'image"}
|
aria-label={textContent.labelAriaLabel || "Libellé de l'image"}
|
||||||
/>
|
/>
|
||||||
) : shot.label ? (
|
) : image.label ? (
|
||||||
<figcaption className="shot-label">{shot.label}</figcaption>
|
<figcaption className="image-label">{image.label}</figcaption>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="shot-actions">
|
<div className="image-actions">
|
||||||
<button
|
<button
|
||||||
className="shot-annotate-button"
|
className="image-annotate-button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => context.createImageAnnotationModule?.(shot.dataUrl)}
|
onClick={() => context.createImageAnnotationModule?.(image.dataUrl)}
|
||||||
aria-label={textContent.annotateAriaLabel || "Annoter l'image"}
|
aria-label={textContent.annotateAriaLabel || "Annoter l'image"}
|
||||||
title={textContent.annotateTitle || "Annoter"}
|
title={textContent.annotateTitle || "Annoter"}
|
||||||
>
|
>
|
||||||
<Icon name="map" />
|
<Icon name="map" />
|
||||||
</button>
|
</button>
|
||||||
<button className="shot-delete-button danger" type="button" onClick={() => {
|
<button className="image-delete-button danger" type="button" onClick={() => {
|
||||||
context.setModuleData(toolboxId, moduleId, { shots: data.shots.filter((item) => item.id !== shot.id) });
|
context.setModuleData(toolboxId, moduleId, { images: data.images.filter((item) => item.id !== image.id) });
|
||||||
}} aria-label={textContent.deleteAriaLabel || "Supprimer l'image"} title={textContent.deleteTitle || "Supprimer"}>
|
}} aria-label={textContent.deleteAriaLabel || "Supprimer l'image"} title={textContent.deleteTitle || "Supprimer"}>
|
||||||
<Icon name="trash" />
|
<Icon name="trash" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : fournit l'outil liens avec ajout manuel et import texte.
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { TextImportModal } from "./TextImportModal.jsx";
|
import { TextImportModal } from "./TextImportModal.jsx";
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : fournit l'outil bloc-notes libre.
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export function NotepadModule({ toolboxId, moduleId, context }) {
|
export function NotepadModule({ toolboxId, moduleId, context }) {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : affiche la modale d'import texte partagée par les outils compatibles.
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.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 { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
|
|
@ -9,12 +10,12 @@ import { CountersModule } from "./CountersModule.jsx";
|
||||||
import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
|
import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
|
||||||
import { LinksModule } from "./LinksModule.jsx";
|
import { LinksModule } from "./LinksModule.jsx";
|
||||||
import { NotepadModule } from "./NotepadModule.jsx";
|
import { NotepadModule } from "./NotepadModule.jsx";
|
||||||
import { ScreenshotsModule } from "./ScreenshotsModule.jsx";
|
import { ImagesModule } from "./ImagesModule.jsx";
|
||||||
|
|
||||||
const MODULE_COMPONENTS = {
|
const MODULE_COMPONENTS = {
|
||||||
notepad: { label: "Bloc notes", icon: "notepad", Component: NotepadModule, editable: false },
|
notepad: { label: "Bloc notes", icon: "notepad", Component: NotepadModule, editable: false },
|
||||||
checklist: { label: "Checklist", icon: "checklist", Component: ChecklistModule, editable: true, scrollable: true },
|
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 },
|
links: { label: "Liens", icon: "link", Component: LinksModule, editable: true, scrollable: true },
|
||||||
counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true, scrollable: true },
|
counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true, scrollable: true },
|
||||||
calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: false },
|
calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: false },
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : parse les imports texte en listes exploitables par les outils.
|
||||||
export function parseColonImportLines(text) {
|
export function parseColonImportLines(text) {
|
||||||
return String(text || "")
|
return String(text || "")
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
|
|
|
||||||
446
website/src/features/toolboxes/storage/toolboxStorage.js
Normal file
446
website/src/features/toolboxes/storage/toolboxStorage.js
Normal file
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
135
website/src/features/toolboxes/storage/useIndexedToolboxes.js
Normal file
135
website/src/features/toolboxes/storage/useIndexedToolboxes.js
Normal file
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
234
website/src/features/toolboxes/useToolboxActions.js
Normal file
234
website/src/features/toolboxes/useToolboxActions.js
Normal file
|
|
@ -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é.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
56
website/src/hooks/useAppData.js
Normal file
56
website/src/hooks/useAppData.js
Normal file
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
10
website/src/hooks/useGameFilters.js
Normal file
10
website/src/hooks/useGameFilters.js
Normal file
|
|
@ -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" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : fournit la réorganisation par pointeur pour listes et grilles locales.
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
function getDefaultPlacement(event, element) {
|
function getDefaultPlacement(event, element) {
|
||||||
|
|
|
||||||
1864
website/src/main.jsx
1864
website/src/main.jsx
File diff suppressed because it is too large
Load diff
102
website/src/pages/AboutPage.jsx
Normal file
102
website/src/pages/AboutPage.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="about-page">
|
||||||
|
<section className="page-hero">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{content.eyebrow}</p>
|
||||||
|
<h1>{content.title}</h1>
|
||||||
|
<p><RichText text={content.description} /></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="about-story-section nebula-panel" aria-labelledby="about-story-title">
|
||||||
|
<div className="about-section-heading">
|
||||||
|
<p className="eyebrow">Principes</p>
|
||||||
|
<h2 id="about-story-title">Comment ça fonctionne</h2>
|
||||||
|
</div>
|
||||||
|
<div className="about-story-list">
|
||||||
|
{content.sections.map((section, index) => (
|
||||||
|
<article className="about-story-item" key={section.title}>
|
||||||
|
<span>{String(index + 1).padStart(2, "0")}</span>
|
||||||
|
<div>
|
||||||
|
<h3>{section.title}</h3>
|
||||||
|
<p><RichText text={section.text} /></p>
|
||||||
|
{index === storageSectionIndex && limits.length > 0 && (
|
||||||
|
<div className="about-storage-reminders">
|
||||||
|
<p className="eyebrow">{content.limitsTitle}</p>
|
||||||
|
<ul className="about-reminders-list">
|
||||||
|
{limits.map((item, itemIndex) => {
|
||||||
|
const reminder = normalizeReminder(item, itemIndex);
|
||||||
|
return (
|
||||||
|
<li className="about-reminder-item" key={reminder.title || reminder.text}>
|
||||||
|
<span className="module-icon" aria-hidden="true">
|
||||||
|
<span className={`module-icon-svg module-icon-${reminder.icon || "toolbox"}`} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h4>{reminder.title}</h4>
|
||||||
|
<p><RichText text={reminder.text} /></p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="about-tools-section nebula-panel">
|
||||||
|
<div className="page-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Toolbox</p>
|
||||||
|
<h2>{content.toolsTitle}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="about-tools-list">
|
||||||
|
{content.tools.map((tool) => (
|
||||||
|
<article className="about-tool-item" key={tool.name}>
|
||||||
|
<span className="module-icon" aria-hidden="true">
|
||||||
|
<span className={`module-icon-svg module-icon-${tool.icon || "notepad"}`} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3>{tool.name}</h3>
|
||||||
|
<p><RichText text={tool.description} /></p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{content.contribute && (
|
||||||
|
<section className="about-contribute-section nebula-panel" id="contribuer" aria-labelledby="about-contribute-title">
|
||||||
|
<div className="about-section-heading">
|
||||||
|
<p className="eyebrow">{content.contribute.eyebrow}</p>
|
||||||
|
<h2 id="about-contribute-title">{content.contribute.title}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="about-contribute-content">
|
||||||
|
<p><RichText text={content.contribute.description} /></p>
|
||||||
|
<pre><code>{content.contribute.example}</code></pre>
|
||||||
|
<div className="about-prompt-box">
|
||||||
|
<h3>{content.contribute.promptTitle}</h3>
|
||||||
|
<p><RichText text={content.contribute.promptIntro} /></p>
|
||||||
|
<pre><code>{content.contribute.promptText}</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
website/src/pages/HomePage.jsx
Normal file
49
website/src/pages/HomePage.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<section className="hero">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{content.hero.eyebrow}</p>
|
||||||
|
<h1>{content.hero.title}</h1>
|
||||||
|
<p>{content.hero.description}</p>
|
||||||
|
<div className="actions">
|
||||||
|
<a className="button primary" href="#/toolboxes">{content.hero.primaryAction}</a>
|
||||||
|
<a className="button" href="#/games">{content.hero.secondaryAction}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="home-stats hero-stats" aria-label="Statistiques locales du site">
|
||||||
|
<article><strong>{toolboxes.length}</strong><span>{toolboxes.length > 1 ? content.stats.toolboxPlural : content.stats.toolboxSingular}</span></article>
|
||||||
|
<article><strong>{toolCount}</strong><span>{toolCount > 1 ? content.stats.toolPlural : content.stats.toolSingular}</span></article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="origin-section nebula-panel" aria-labelledby="origin-title">
|
||||||
|
<div className="origin-copy">
|
||||||
|
<p className="eyebrow">{content.origin.eyebrow}</p>
|
||||||
|
<h2 id="origin-title">{content.origin.title}</h2>
|
||||||
|
<div className="home-dialogue" aria-label={content.origin.dialogueAriaLabel}>
|
||||||
|
{content.origin.lines.map((line, index) => <DialogueLine key={index} line={line} />)}
|
||||||
|
<small className="dialogue-caption">{content.origin.caption}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="home-visual" aria-label="Illustration Sokko G">
|
||||||
|
<img src="/static/img/dragon.png" alt={content.origin.visualAlt} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<footer className="home-legal">
|
||||||
|
<p>{content.legal.copyright}</p>
|
||||||
|
<p>{content.legal.disclaimer}</p>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogueLine({ line }) {
|
||||||
|
return <p className={`dialogue-line dialogue-line-${line.speaker === "app" ? "app" : "user"}`}><RichText text={line.text || ""} /></p>;
|
||||||
|
}
|
||||||
20
website/src/router/RouteContent.jsx
Normal file
20
website/src/router/RouteContent.jsx
Normal file
|
|
@ -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 <HomePage {...props} />;
|
||||||
|
if (route === "/about") return <AboutPage siteContent={props.siteContent} />;
|
||||||
|
if (route === "/toolboxes") return <ToolboxesPage {...props} />;
|
||||||
|
if (route.startsWith("/toolbox/")) return <ToolboxPage {...props} toolboxId={route.split("/")[2]} />;
|
||||||
|
if (route === "/games") return <GamesPage {...props} />;
|
||||||
|
if (route.startsWith("/games/")) return <GameRoute {...props} gameId={route.split("/")[2]} category={route.split("/")[3] || ""} />;
|
||||||
|
navigate("/");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
27
website/src/router/hashRouter.js
Normal file
27
website/src/router/hashRouter.js
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : définit les bases globales HTML, body, typographie et contrôles natifs.
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
@ -251,11 +252,11 @@ body.is-modal-open {
|
||||||
.drawer-action-button,
|
.drawer-action-button,
|
||||||
.drawer-close-button,
|
.drawer-close-button,
|
||||||
.filter-reset-button,
|
.filter-reset-button,
|
||||||
.screenshot-viewer-button,
|
.image-viewer-button,
|
||||||
.module-edit-button,
|
.module-edit-button,
|
||||||
.module-delete-button,
|
.module-delete-button,
|
||||||
.shot-annotate-button,
|
.image-annotate-button,
|
||||||
.shot-delete-button,
|
.image-delete-button,
|
||||||
.checklist-delete-button,
|
.checklist-delete-button,
|
||||||
.tool-quick-add-button
|
.tool-quick-add-button
|
||||||
) {
|
) {
|
||||||
|
|
@ -288,11 +289,11 @@ body.is-modal-open {
|
||||||
.drawer-action-button,
|
.drawer-action-button,
|
||||||
.drawer-close-button,
|
.drawer-close-button,
|
||||||
.filter-reset-button,
|
.filter-reset-button,
|
||||||
.screenshot-viewer-button,
|
.image-viewer-button,
|
||||||
.module-edit-button,
|
.module-edit-button,
|
||||||
.module-delete-button,
|
.module-delete-button,
|
||||||
.shot-annotate-button,
|
.image-annotate-button,
|
||||||
.shot-delete-button,
|
.image-delete-button,
|
||||||
.checklist-delete-button,
|
.checklist-delete-button,
|
||||||
.tool-quick-add-button
|
.tool-quick-add-button
|
||||||
) .ui-icon {
|
) .ui-icon {
|
||||||
|
|
@ -309,11 +310,11 @@ body.is-modal-open {
|
||||||
.drawer-action-button,
|
.drawer-action-button,
|
||||||
.drawer-close-button,
|
.drawer-close-button,
|
||||||
.filter-reset-button,
|
.filter-reset-button,
|
||||||
.screenshot-viewer-button,
|
.image-viewer-button,
|
||||||
.module-edit-button,
|
.module-edit-button,
|
||||||
.module-delete-button,
|
.module-delete-button,
|
||||||
.shot-annotate-button,
|
.image-annotate-button,
|
||||||
.shot-delete-button,
|
.image-delete-button,
|
||||||
.checklist-delete-button,
|
.checklist-delete-button,
|
||||||
.tool-quick-add-button
|
.tool-quick-add-button
|
||||||
):hover {
|
):hover {
|
||||||
|
|
@ -335,11 +336,11 @@ body.is-modal-open {
|
||||||
.drawer-action-button,
|
.drawer-action-button,
|
||||||
.drawer-close-button,
|
.drawer-close-button,
|
||||||
.filter-reset-button,
|
.filter-reset-button,
|
||||||
.screenshot-viewer-button,
|
.image-viewer-button,
|
||||||
.module-edit-button,
|
.module-edit-button,
|
||||||
.module-delete-button,
|
.module-delete-button,
|
||||||
.shot-annotate-button,
|
.image-annotate-button,
|
||||||
.shot-delete-button,
|
.image-delete-button,
|
||||||
.tool-quick-add-button
|
.tool-quick-add-button
|
||||||
).primary {
|
).primary {
|
||||||
border-color: rgba(246, 196, 83, 0.34);
|
border-color: rgba(246, 196, 83, 0.34);
|
||||||
|
|
@ -360,11 +361,11 @@ body.is-modal-open {
|
||||||
.drawer-action-button,
|
.drawer-action-button,
|
||||||
.drawer-close-button,
|
.drawer-close-button,
|
||||||
.filter-reset-button,
|
.filter-reset-button,
|
||||||
.screenshot-viewer-button,
|
.image-viewer-button,
|
||||||
.module-edit-button,
|
.module-edit-button,
|
||||||
.module-delete-button,
|
.module-delete-button,
|
||||||
.shot-annotate-button,
|
.image-annotate-button,
|
||||||
.shot-delete-button,
|
.image-delete-button,
|
||||||
.tool-quick-add-button
|
.tool-quick-add-button
|
||||||
).primary:hover {
|
).primary:hover {
|
||||||
border-color: rgba(246, 196, 83, 0.72);
|
border-color: rgba(246, 196, 83, 0.72);
|
||||||
|
|
@ -387,11 +388,11 @@ body.is-modal-open {
|
||||||
.drawer-action-button,
|
.drawer-action-button,
|
||||||
.drawer-close-button,
|
.drawer-close-button,
|
||||||
.filter-reset-button,
|
.filter-reset-button,
|
||||||
.screenshot-viewer-button,
|
.image-viewer-button,
|
||||||
.module-edit-button,
|
.module-edit-button,
|
||||||
.module-delete-button,
|
.module-delete-button,
|
||||||
.shot-annotate-button,
|
.image-annotate-button,
|
||||||
.shot-delete-button,
|
.image-delete-button,
|
||||||
.checklist-delete-button,
|
.checklist-delete-button,
|
||||||
.tool-quick-add-button
|
.tool-quick-add-button
|
||||||
):hover .ui-icon {
|
):hover .ui-icon {
|
||||||
|
|
@ -399,12 +400,12 @@ body.is-modal-open {
|
||||||
filter: drop-shadow(0 0 6px rgba(196, 181, 253, 0.28));
|
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);
|
border-color: rgba(251, 113, 133, 0.2);
|
||||||
color: var(--color-danger);
|
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);
|
border-color: rgba(251, 113, 133, 0.56);
|
||||||
background:
|
background:
|
||||||
linear-gradient(135deg, rgba(80, 25, 42, 0.72), rgba(48, 18, 34, 0.82)) padding-box,
|
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);
|
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;
|
background-color: #fff;
|
||||||
filter: drop-shadow(0 0 6px rgba(251, 113, 133, 0.26));
|
filter: drop-shadow(0 0 6px rgba(251, 113, 133, 0.26));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : regroupe les styles de cards, panels et variantes compactes.
|
||||||
@use "mixins";
|
@use "mixins";
|
||||||
|
|
||||||
.section-grid,
|
.section-grid,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : contient les styles spécifiques aux affichages Diablo IV.
|
||||||
.game-card-placeholder {
|
.game-card-placeholder {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : regroupe les styles partagés des pages de jeux, filtres et listes.
|
||||||
@use "mixins";
|
@use "mixins";
|
||||||
|
|
||||||
.game-home-grid {
|
.game-home-grid {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : contient les styles spécifiques à la page d'accueil.
|
||||||
@use "mixins";
|
@use "mixins";
|
||||||
|
|
||||||
.hero,
|
.hero,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : définit le rendu commun des icônes SVG.
|
||||||
.ui-icon {
|
.ui-icon {
|
||||||
display: block;
|
display: block;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : contient les styles spécifiques aux pages Monster Hunter Wilds.
|
||||||
.monster-grid {
|
.monster-grid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : fournit les mixins Sass partagés du thème.
|
||||||
@mixin border-mask {
|
@mixin border-mask {
|
||||||
mask-image:
|
mask-image:
|
||||||
linear-gradient(#000 0 0),
|
linear-gradient(#000 0 0),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : génère le fond nebula et le champ d'étoiles global.
|
||||||
@use "sass:list";
|
@use "sass:list";
|
||||||
|
|
||||||
$bright-stars:
|
$bright-stars:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
.screenshot-viewer-root {
|
// Rôle : regroupe les styles des modales, viewers et notifications.
|
||||||
|
.image-viewer-root {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 110;
|
z-index: 110;
|
||||||
|
|
@ -8,14 +9,14 @@
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-viewer-backdrop {
|
.image-viewer-backdrop {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(2, 3, 9, 0.64);
|
background: rgba(2, 3, 9, 0.64);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-viewer {
|
.image-viewer {
|
||||||
--viewer-content-max-height: min(80vh, calc(100vh - 140px));
|
--viewer-content-max-height: min(80vh, calc(100vh - 140px));
|
||||||
--viewer-image-height: 400px;
|
--viewer-image-height: 400px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -30,14 +31,14 @@
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-viewer header {
|
.image-viewer header {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-viewer-title {
|
.image-viewer-title {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: var(--font-size-md);
|
font-size: var(--font-size-md);
|
||||||
|
|
@ -45,30 +46,30 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-viewer-actions {
|
.image-viewer-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-viewer-button {
|
.image-viewer-button {
|
||||||
flex: 0 0 40px;
|
flex: 0 0 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-viewer-body {
|
.image-viewer-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-viewer-body.has-annotation-side {
|
.image-viewer-body.has-annotation-side {
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(240px, 340px);
|
grid-template-columns: minmax(0, 1fr) minmax(240px, 340px);
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
justify-items: stretch;
|
justify-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-viewer-media {
|
.image-viewer-media {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
@ -77,11 +78,11 @@
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-viewer-media.can-annotate {
|
.image-viewer-media.can-annotate {
|
||||||
cursor: crosshair;
|
cursor: crosshair;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-viewer-image-frame {
|
.image-viewer-image-frame {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
|
|
@ -91,7 +92,7 @@
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-viewer-image-frame img {
|
.image-viewer-image-frame img {
|
||||||
display: block;
|
display: block;
|
||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|
@ -100,7 +101,7 @@
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-viewer-marker {
|
.image-viewer-marker {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
min-width: 30px;
|
min-width: 30px;
|
||||||
|
|
@ -123,7 +124,7 @@
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-viewer-side {
|
.image-viewer-side {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
|
@ -142,12 +143,12 @@
|
||||||
rgba(7, 10, 24, 0.38);
|
rgba(7, 10, 24, 0.38);
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-viewer-side strong {
|
.image-viewer-side strong {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-viewer-side .annotation-marker-list {
|
.image-viewer-side .annotation-marker-list {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : contient les adaptations responsive et préférences d'accessibilité.
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
|
|
@ -166,11 +167,11 @@
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-viewer-body.has-annotation-side {
|
.image-viewer-body.has-annotation-side {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-viewer-side {
|
.image-viewer-side {
|
||||||
height: min(280px, 32vh);
|
height: min(280px, 32vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : définit le layout principal, la sidebar et la navigation.
|
||||||
@use "mixins";
|
@use "mixins";
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : expose les tokens CSS du thème Sokko G.
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : regroupe les styles des toolboxes, outils et panneau latéral.
|
||||||
@use "mixins";
|
@use "mixins";
|
||||||
|
|
||||||
.toolbox-head {
|
.toolbox-head {
|
||||||
|
|
@ -1701,14 +1702,14 @@ textarea:focus {
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shots {
|
.images {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shots figure {
|
.images figure {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
@ -1719,7 +1720,7 @@ textarea:focus {
|
||||||
background: rgba(5, 7, 17, 0.38);
|
background: rgba(5, 7, 17, 0.38);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shot-preview {
|
.image-preview {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
@ -1729,18 +1730,18 @@ textarea:focus {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shot-preview:hover {
|
.image-preview:hover {
|
||||||
transform: none;
|
transform: none;
|
||||||
box-shadow: 0 0 0 2px rgba(246, 196, 83, 0.28);
|
box-shadow: 0 0 0 2px rgba(246, 196, 83, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shot-label,
|
.image-label,
|
||||||
.shot-label-input {
|
.image-label-input {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shot-label {
|
.image-label {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|
@ -1748,7 +1749,7 @@ textarea:focus {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shot-label-input {
|
.image-label-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
|
|
@ -1758,17 +1759,17 @@ textarea:focus {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shot-label-input::placeholder {
|
.image-label-input::placeholder {
|
||||||
color: rgba(185, 194, 215, 0.48);
|
color: rgba(185, 194, 215, 0.48);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shot-label-input:focus {
|
.image-label-input:focus {
|
||||||
border-color: rgba(246, 196, 83, 0.72);
|
border-color: rgba(246, 196, 83, 0.72);
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0 0 0 3px rgba(246, 196, 83, 0.14);
|
box-shadow: 0 0 0 3px rgba(246, 196, 83, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shots img {
|
.images img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
|
|
@ -1777,12 +1778,12 @@ textarea:focus {
|
||||||
background: var(--color-bg-deep);
|
background: var(--color-bg-deep);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shot-actions {
|
.image-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shot-actions .shot-annotate-button,
|
.image-actions .image-annotate-button,
|
||||||
.shot-actions .shot-delete-button {
|
.image-actions .image-delete-button {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : point d'entrée Sass qui assemble les modules de style.
|
||||||
@use "tokens";
|
@use "tokens";
|
||||||
@use "nebula";
|
@use "nebula";
|
||||||
@use "base";
|
@use "base";
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Rôle : bloque le scroll de page derrière les modales actives.
|
||||||
export function lockBodyScroll() {
|
export function lockBodyScroll() {
|
||||||
const currentLocks = Number(document.body.dataset.modalLocks || 0);
|
const currentLocks = Number(document.body.dataset.modalLocks || 0);
|
||||||
document.body.dataset.modalLocks = String(currentLocks + 1);
|
document.body.dataset.modalLocks = String(currentLocks + 1);
|
||||||
|
|
|
||||||
11
website/src/utils/imageCompression.js
Normal file
11
website/src/utils/imageCompression.js
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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_NAME = "sokkog";
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 1;
|
||||||
const KV_STORE = "kv";
|
const KV_STORE = "kv";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue