move to indexedb and add about page
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
a8809afdfd
commit
772b9951de
12 changed files with 855 additions and 249 deletions
|
|
@ -139,7 +139,19 @@ Style courant :
|
||||||
|
|
||||||
Le mot visible cote utilisateur est **outil**, pas **module**. Le terme module reste acceptable uniquement dans le code.
|
Le mot visible cote utilisateur est **outil**, pas **module**. Le terme module reste acceptable uniquement dans le code.
|
||||||
|
|
||||||
## 8. Boutons
|
## 8. Panneaux De Section
|
||||||
|
|
||||||
|
Les grandes sections non repetitives, comme le dialogue d'origine et les sections de presentation, utilisent `.nebula-panel`.
|
||||||
|
|
||||||
|
Style courant :
|
||||||
|
|
||||||
|
- fond semi-transparent `rgba(16, 20, 38, 0.42)` ;
|
||||||
|
- bordure statique identique aux outils en etat normal ;
|
||||||
|
- accent dore leger en bas/droite ;
|
||||||
|
- pas de hover sur les panneaux de lecture ;
|
||||||
|
- implementation via le mixin `gold-panel-frame` dans `website/src/styles/_mixins.scss`.
|
||||||
|
|
||||||
|
## 9. Boutons
|
||||||
|
|
||||||
### Primary
|
### Primary
|
||||||
|
|
||||||
|
|
@ -189,7 +201,7 @@ Les icones utilisent `currentColor` via `.ui-icon`.
|
||||||
- hover danger : icone blanche + glow rouge discret ;
|
- hover danger : icone blanche + glow rouge discret ;
|
||||||
- utiliser les SVG de `website/public/static/icons`.
|
- utiliser les SVG de `website/public/static/icons`.
|
||||||
|
|
||||||
## 9. Formulaires Et Checkbox
|
## 10. Formulaires Et Checkbox
|
||||||
|
|
||||||
Inputs et textareas :
|
Inputs et textareas :
|
||||||
|
|
||||||
|
|
@ -207,7 +219,7 @@ Checkbox :
|
||||||
- etat coche avec gradient dore/violet et coche blanche ;
|
- etat coche avec gradient dore/violet et coche blanche ;
|
||||||
- spacing adapte dans les filtres.
|
- spacing adapte dans les filtres.
|
||||||
|
|
||||||
## 10. Navigation
|
## 11. Navigation
|
||||||
|
|
||||||
Sidebar :
|
Sidebar :
|
||||||
|
|
||||||
|
|
@ -218,7 +230,7 @@ Sidebar :
|
||||||
|
|
||||||
Le nom de marque affiche `Sokko` en blanc et `G` en dore. Au hover, `Sokko` anime vers le dore de droite a gauche puis revient au hover out.
|
Le nom de marque affiche `Sokko` en blanc et `G` en dore. Au hover, `Sokko` anime vers le dore de droite a gauche puis revient au hover out.
|
||||||
|
|
||||||
## 11. Filtres Et Tableaux
|
## 12. Filtres Et Tableaux
|
||||||
|
|
||||||
Filtres :
|
Filtres :
|
||||||
|
|
||||||
|
|
@ -234,7 +246,7 @@ Tableaux :
|
||||||
- bordures fines ;
|
- bordures fines ;
|
||||||
- lisibilite prioritaire sur la decoration.
|
- lisibilite prioritaire sur la decoration.
|
||||||
|
|
||||||
## 12. Toolboxes
|
## 13. Toolboxes
|
||||||
|
|
||||||
Les toolboxes sont locales et stockees dans le navigateur.
|
Les toolboxes sont locales et stockees dans le navigateur.
|
||||||
|
|
||||||
|
|
@ -244,12 +256,12 @@ Regles UI :
|
||||||
- image de jeu si associee a un jeu ;
|
- image de jeu si associee a un jeu ;
|
||||||
- icone editable si toolbox libre ;
|
- icone editable si toolbox libre ;
|
||||||
- action importer/exporter clairement identifiee ;
|
- action importer/exporter clairement identifiee ;
|
||||||
- quota localStorage visible en bas de page/panneau ;
|
- budget IndexedDB recommande visible en bas de page/panneau, avec quota navigateur reel seulement informatif ;
|
||||||
- import/export global disponibles.
|
- import/export global disponibles.
|
||||||
|
|
||||||
Les cards toolbox doivent garder une taille compacte. L'image/icone de cover est cliquable.
|
Les cards toolbox doivent garder une taille compacte. L'image/icone de cover est cliquable.
|
||||||
|
|
||||||
## 13. Donnees Jeux
|
## 14. Donnees Jeux
|
||||||
|
|
||||||
Le contenu editorial editable doit rester dans :
|
Le contenu editorial editable doit rester dans :
|
||||||
|
|
||||||
|
|
@ -259,7 +271,7 @@ Le contenu editorial editable doit rester dans :
|
||||||
|
|
||||||
Les retours a la ligne `\n` du JSON doivent etre rendus correctement. Le JSX ne doit pas contenir de contenu editorial long si une entree JSON existe deja.
|
Les retours a la ligne `\n` du JSON doivent etre rendus correctement. Le JSX ne doit pas contenir de contenu editorial long si une entree JSON existe deja.
|
||||||
|
|
||||||
## 14. Modales Et Drawer
|
## 15. Modales Et Drawer
|
||||||
|
|
||||||
Modales :
|
Modales :
|
||||||
|
|
||||||
|
|
@ -274,7 +286,7 @@ Drawer toolbox :
|
||||||
- overlay floute leger ;
|
- overlay floute leger ;
|
||||||
- les actions internes ne doivent pas fermer le drawer sauf action explicite.
|
- les actions internes ne doivent pas fermer le drawer sauf action explicite.
|
||||||
|
|
||||||
## 15. Scrollbars
|
## 16. Scrollbars
|
||||||
|
|
||||||
Les scrollbars reprennent l'ancien rendu :
|
Les scrollbars reprennent l'ancien rendu :
|
||||||
|
|
||||||
|
|
@ -304,7 +316,7 @@ Les scrollbars reprennent l'ancien rendu :
|
||||||
|
|
||||||
Ne pas ajouter de rail noir visible ni de double bordure pres des panneaux.
|
Ne pas ajouter de rail noir visible ni de double bordure pres des panneaux.
|
||||||
|
|
||||||
## 16. Images Et Assets
|
## 17. Images Et Assets
|
||||||
|
|
||||||
Regles :
|
Regles :
|
||||||
|
|
||||||
|
|
@ -317,7 +329,7 @@ Regles :
|
||||||
- les categories de jeu utilisent des images representatives, entieres et bien cadrees ;
|
- les categories de jeu utilisent des images representatives, entieres et bien cadrees ;
|
||||||
- eviter les images trop grandes dans les cards.
|
- eviter les images trop grandes dans les cards.
|
||||||
|
|
||||||
## 17. Accessibilite
|
## 18. Accessibilite
|
||||||
|
|
||||||
Minimum attendu :
|
Minimum attendu :
|
||||||
|
|
||||||
|
|
@ -329,7 +341,7 @@ Minimum attendu :
|
||||||
- modales avec `role="dialog"` et `aria-modal="true"` ;
|
- modales avec `role="dialog"` et `aria-modal="true"` ;
|
||||||
- ne pas transmettre une information uniquement par couleur.
|
- ne pas transmettre une information uniquement par couleur.
|
||||||
|
|
||||||
## 18. A Eviter
|
## 19. A Eviter
|
||||||
|
|
||||||
- accents dores partout ;
|
- accents dores partout ;
|
||||||
- hover trop lumineux sur tous les elements ;
|
- hover trop lumineux sur tous les elements ;
|
||||||
|
|
|
||||||
22
README.md
22
README.md
|
|
@ -6,13 +6,13 @@ Sokko G propose des toolboxes modulaires stockées dans le navigateur, ainsi que
|
||||||
|
|
||||||
## Fonctionnalités
|
## Fonctionnalités
|
||||||
|
|
||||||
- Toolboxes locales stockées dans `localStorage`.
|
- Toolboxes locales stockées dans IndexedDB.
|
||||||
- Outils disponibles : bloc notes, checklist avec quantités, images, annotation d’images, liens, compteurs et calculateur.
|
- Outils disponibles : bloc notes, checklist avec quantités, images, annotation d’images, liens, compteurs et calculateur.
|
||||||
- Import/export d’une toolbox.
|
- Import/export d’une toolbox.
|
||||||
- Import/export global de toutes les toolboxes.
|
- Import/export global de toutes les toolboxes.
|
||||||
- Association locale entre une page jeu et une toolbox.
|
- Association locale entre une page jeu et une toolbox.
|
||||||
- Panneau latéral redimensionnable sur les pages jeux.
|
- Panneau latéral redimensionnable sur les pages jeux.
|
||||||
- Suivi et contrôle du quota localStorage.
|
- Suivi d’un budget de stockage recommandé, avec quota navigateur en information.
|
||||||
- Pages de guides de jeu éditables via JSON.
|
- Pages de guides de jeu éditables via JSON.
|
||||||
|
|
||||||
## Démarrage
|
## Démarrage
|
||||||
|
|
@ -64,7 +64,7 @@ Les contenus maintenus à la main sont regroupés dans `website/public/data`.
|
||||||
|
|
||||||
| Fichier | Rôle |
|
| Fichier | Rôle |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `website/public/data/site.json` | Textes généraux du site : home, navigation, sidebar, page toolboxes. |
|
| `website/public/data/site.json` | Textes généraux du site : home, about, navigation, sidebar, page toolboxes. |
|
||||||
| `website/public/data/games.json` | Liste des jeux affichés sur `/games` et contenus des cards jeux. |
|
| `website/public/data/games.json` | Liste des jeux affichés sur `/games` et contenus des cards jeux. |
|
||||||
| `website/public/data/mhwilds/monsters.json` | Données des monstres Monster Hunter Wilds. |
|
| `website/public/data/mhwilds/monsters.json` | Données des monstres Monster Hunter Wilds. |
|
||||||
| `website/public/data/mhwilds/endemic_life.json` | Données de la faune Monster Hunter Wilds. |
|
| `website/public/data/mhwilds/endemic_life.json` | Données de la faune Monster Hunter Wilds. |
|
||||||
|
|
@ -97,7 +97,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 pour le stockage existant.
|
- `website/src/features/toolboxes/modules/ScreenshotsModule.jsx` : outil Images, conservé sous ce nom technique historique.
|
||||||
- `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`
|
||||||
|
|
@ -107,7 +107,7 @@ Pour ajouter ou maintenir un outil :
|
||||||
|
|
||||||
- créer son fichier dans `website/src/features/toolboxes/modules/` ;
|
- créer son fichier dans `website/src/features/toolboxes/modules/` ;
|
||||||
- l’ajouter au registre `MODULE_COMPONENTS` dans `website/src/features/toolboxes/modules/index.jsx` ;
|
- l’ajouter au registre `MODULE_COMPONENTS` dans `website/src/features/toolboxes/modules/index.jsx` ;
|
||||||
- garder les données persistées via les helpers de `website/src/main.jsx` tant que le stockage reste en localStorage.
|
- garder les données persistées via le service IndexedDB `website/src/utils/indexedDbStorage.js`.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
|
|
@ -152,11 +152,11 @@ Pour ajouter ou maintenir un outil :
|
||||||
|
|
||||||
## Stockage local
|
## Stockage local
|
||||||
|
|
||||||
Clés principales :
|
Les données utilisateur sont stockées dans IndexedDB, dans la base `sokkog`.
|
||||||
|
|
||||||
- `sokkog:toolboxes` : registre local des toolboxes.
|
Espaces principaux :
|
||||||
- `sokkog:game-toolbox-links` : associations locales jeu -> toolbox.
|
|
||||||
- `sokkog:toolbox:{toolboxId}:module:{moduleId}` : données de chaque outil.
|
|
||||||
- `sokkog:drawer-width` : largeur du panneau latéral.
|
|
||||||
|
|
||||||
Le quota applicatif est contrôlé côté navigateur avant écriture. Les imports globaux simulent l’espace nécessaire avant de modifier les données locales.
|
- `kv` : registre des toolboxes, associations jeu -> toolbox et réglages locaux.
|
||||||
|
- `modules` : données de chaque outil, indexées par `{toolboxId}:{moduleId}`.
|
||||||
|
|
||||||
|
La barre affichée utilise un budget recommandé de confort pour l’app. Le quota navigateur réel vient de `navigator.storage.estimate()` et peut être beaucoup plus élevé selon l’espace disque disponible. Les images et annotations restent locales et peuvent occuper beaucoup d’espace ; les exports globaux restent recommandés pour sauvegarder les données.
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ export function validateSiteContent(site) {
|
||||||
"navigation.home",
|
"navigation.home",
|
||||||
"navigation.toolboxes",
|
"navigation.toolboxes",
|
||||||
"navigation.games",
|
"navigation.games",
|
||||||
|
"navigation.about",
|
||||||
"navigation.mobileGames",
|
"navigation.mobileGames",
|
||||||
"sidebar.badge",
|
"sidebar.badge",
|
||||||
"sidebar.note",
|
"sidebar.note",
|
||||||
|
|
@ -56,8 +57,14 @@ export function validateSiteContent(site) {
|
||||||
"home.origin.visualAlt",
|
"home.origin.visualAlt",
|
||||||
"home.origin.dialogueAriaLabel",
|
"home.origin.dialogueAriaLabel",
|
||||||
"home.origin.caption",
|
"home.origin.caption",
|
||||||
|
"home.aboutLink",
|
||||||
"home.legal.copyright",
|
"home.legal.copyright",
|
||||||
"home.legal.disclaimer",
|
"home.legal.disclaimer",
|
||||||
|
"about.eyebrow",
|
||||||
|
"about.title",
|
||||||
|
"about.description",
|
||||||
|
"about.toolsTitle",
|
||||||
|
"about.limitsTitle",
|
||||||
"toolboxes.eyebrow",
|
"toolboxes.eyebrow",
|
||||||
"toolboxes.title",
|
"toolboxes.title",
|
||||||
"toolboxes.newButton",
|
"toolboxes.newButton",
|
||||||
|
|
@ -139,6 +146,37 @@ export function validateSiteContent(site) {
|
||||||
|
|
||||||
assertStringArray(site, "toolboxes.storageHelp.items", 3);
|
assertStringArray(site, "toolboxes.storageHelp.items", 3);
|
||||||
|
|
||||||
|
const aboutLimits = valueAt(site, "about.limits");
|
||||||
|
assert.ok(Array.isArray(aboutLimits), "about.limits must be an array");
|
||||||
|
assert.ok(aboutLimits.length >= 3, "about.limits must contain at least 3 items");
|
||||||
|
aboutLimits.forEach((limit, index) => {
|
||||||
|
if (typeof limit === "string") {
|
||||||
|
assert.ok(limit.trim(), `about.limits[${index}] must not be empty`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert.equal(typeof limit, "object", `about.limits[${index}] must be a string or object`);
|
||||||
|
assertNonEmptyString({ limit }, "limit.icon");
|
||||||
|
assertNonEmptyString({ limit }, "limit.title");
|
||||||
|
assertNonEmptyString({ limit }, "limit.text");
|
||||||
|
});
|
||||||
|
|
||||||
|
const aboutSections = valueAt(site, "about.sections");
|
||||||
|
assert.ok(Array.isArray(aboutSections), "about.sections must be an array");
|
||||||
|
assert.ok(aboutSections.length >= 2, "about.sections must contain at least 2 items");
|
||||||
|
aboutSections.forEach((section) => {
|
||||||
|
assertNonEmptyString({ section }, "section.title");
|
||||||
|
assertNonEmptyString({ section }, "section.text");
|
||||||
|
});
|
||||||
|
|
||||||
|
const aboutTools = valueAt(site, "about.tools");
|
||||||
|
assert.ok(Array.isArray(aboutTools), "about.tools must be an array");
|
||||||
|
assert.ok(aboutTools.length >= 1, "about.tools must contain at least 1 item");
|
||||||
|
aboutTools.forEach((tool) => {
|
||||||
|
assertNonEmptyString({ tool }, "tool.icon");
|
||||||
|
assertNonEmptyString({ tool }, "tool.name");
|
||||||
|
assertNonEmptyString({ tool }, "tool.description");
|
||||||
|
});
|
||||||
|
|
||||||
const lines = valueAt(site, "home.origin.lines");
|
const lines = valueAt(site, "home.origin.lines");
|
||||||
assert.ok(Array.isArray(lines), "home.origin.lines must be an array");
|
assert.ok(Array.isArray(lines), "home.origin.lines must be an array");
|
||||||
assert.ok(lines.length >= 2, "home.origin.lines must contain at least 2 items");
|
assert.ok(lines.length >= 2, "home.origin.lines must contain at least 2 items");
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ test("react application defines the expected local toolbox primitives", async ()
|
||||||
const styleNebula = await readFile("website/src/styles/_nebula.scss", "utf8");
|
const styleNebula = await readFile("website/src/styles/_nebula.scss", "utf8");
|
||||||
const styleTokens = await readFile("website/src/styles/_tokens.scss", "utf8");
|
const styleTokens = await readFile("website/src/styles/_tokens.scss", "utf8");
|
||||||
const styleBase = await readFile("website/src/styles/_base.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 styleCards = await readFile("website/src/styles/_cards.scss", "utf8");
|
||||||
const styleHome = await readFile("website/src/styles/_home.scss", "utf8");
|
const styleHome = await readFile("website/src/styles/_home.scss", "utf8");
|
||||||
const styleToolboxes = await readFile("website/src/styles/_toolboxes.scss", "utf8");
|
const styleToolboxes = await readFile("website/src/styles/_toolboxes.scss", "utf8");
|
||||||
|
|
@ -53,9 +55,12 @@ test("react application defines the expected local toolbox primitives", async ()
|
||||||
const textImport = await readFile("website/src/features/toolboxes/modules/textImport.js", "utf8");
|
const textImport = await readFile("website/src/features/toolboxes/modules/textImport.js", "utf8");
|
||||||
const reorderHook = await readFile("website/src/hooks/usePointerReorder.js", "utf8");
|
const reorderHook = await readFile("website/src/hooks/usePointerReorder.js", "utf8");
|
||||||
const bodyScrollLock = await readFile("website/src/utils/bodyScrollLock.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, /sokkog:toolboxes/);
|
assert.match(source, /indexedDbStorage\.js/);
|
||||||
assert.match(source, /sokkog:game-toolbox-links/);
|
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(source, /styles\/main\.scss/);
|
||||||
assert.match(styles, /@use "tokens"/);
|
assert.match(styles, /@use "tokens"/);
|
||||||
assert.match(styles, /@use "nebula"/);
|
assert.match(styles, /@use "nebula"/);
|
||||||
|
|
@ -71,6 +76,13 @@ test("react application defines the expected local toolbox primitives", async ()
|
||||||
assert.match(styleBase, /\.import-icon-button/);
|
assert.match(styleBase, /\.import-icon-button/);
|
||||||
assert.match(styleHome, /\.dialogue-line i/);
|
assert.match(styleHome, /\.dialogue-line i/);
|
||||||
assert.match(styleHome, /font-style: italic/);
|
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-abacus/);
|
||||||
assert.match(styleToolboxes, /\.module-icon-calculator/);
|
assert.match(styleToolboxes, /\.module-icon-calculator/);
|
||||||
assert.match(styleToolboxes, /\.calculator-module/);
|
assert.match(styleToolboxes, /\.calculator-module/);
|
||||||
|
|
@ -183,7 +195,8 @@ test("react application defines the expected local toolbox primitives", async ()
|
||||||
assert.match(linksModule, /copyText/);
|
assert.match(linksModule, /copyText/);
|
||||||
assert.match(source, /DEFAULT_SITE_CONTENT/);
|
assert.match(source, /DEFAULT_SITE_CONTENT/);
|
||||||
assert.match(source, /\/data\/site\.json/);
|
assert.match(source, /\/data\/site\.json/);
|
||||||
assert.match(source, /APP_STORAGE_LIMIT_BYTES/);
|
assert.match(source, /useIndexedToolboxes/);
|
||||||
|
assert.match(source, /getStorageEstimate/);
|
||||||
assert.match(source, /DEFAULT_TOOLBOX_ICON/);
|
assert.match(source, /DEFAULT_TOOLBOX_ICON/);
|
||||||
assert.match(source, /TOOLBOX_ICON_FILES/);
|
assert.match(source, /TOOLBOX_ICON_FILES/);
|
||||||
assert.match(source, /function normalizeToolboxIcon/);
|
assert.match(source, /function normalizeToolboxIcon/);
|
||||||
|
|
@ -193,7 +206,10 @@ test("react application defines the expected local toolbox primitives", async ()
|
||||||
assert.match(source, /draggingToolboxId/);
|
assert.match(source, /draggingToolboxId/);
|
||||||
assert.match(source, /toolbox-card-drag-handle/);
|
assert.match(source, /toolbox-card-drag-handle/);
|
||||||
assert.match(source, /function StorageQuota/);
|
assert.match(source, /function StorageQuota/);
|
||||||
assert.match(source, /function writeStorageValue/);
|
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, /role="progressbar"/);
|
||||||
assert.match(source, /function createGlobalExportPayload/);
|
assert.match(source, /function createGlobalExportPayload/);
|
||||||
assert.match(source, /async function importAllToolboxes/);
|
assert.match(source, /async function importAllToolboxes/);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"home": "Accueil",
|
"home": "Accueil",
|
||||||
"toolboxes": "Toolboxes",
|
"toolboxes": "Toolboxes",
|
||||||
"games": "Jeux",
|
"games": "Jeux",
|
||||||
|
"about": "C'est quoi Sokko G ?",
|
||||||
"mobileGames": "Infos"
|
"mobileGames": "Infos"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
|
|
@ -76,11 +77,96 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"aboutLink": "Comprendre Sokko G",
|
||||||
"legal": {
|
"legal": {
|
||||||
"copyright": "© 2026 Shinuwa / Sokko G. Tous droits réservés.",
|
"copyright": "© 2026 Shinuwa / Sokko G. Tous droits réservés.",
|
||||||
"disclaimer": "Sokko G est un projet non officiel. Les noms, logos, images, illustrations et contenus liés aux jeux présentés appartiennent à leurs éditeurs et ayants droit respectifs. Les visuels officiels utilisés sur le site restent la propriété de leurs détenteurs."
|
"disclaimer": "Sokko G est un projet non officiel. Les noms, logos, images, illustrations et contenus liés aux jeux présentés appartiennent à leurs éditeurs et ayants droit respectifs. Les visuels officiels utilisés sur le site restent la propriété de leurs détenteurs."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"about": {
|
||||||
|
"eyebrow": "À propos",
|
||||||
|
"title": "C'est quoi Sokko G ?",
|
||||||
|
"description": "Un espace second écran pour garder vos outils, repères et infos de jeu sous la main pendant une session.",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"title": "Le but du site",
|
||||||
|
"text": "Sokko G sert à regrouper les outils utiles pendant une session : notes, suivis, images, liens et repères de jeu. L’objectif est de garder les informations importantes accessibles rapidement sur un second écran."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Fonctionnement",
|
||||||
|
"text": "Chaque toolbox est composée d’outils que vous ajoutez selon vos besoins. Elle peut rester indépendante ou être associée à une page jeu pour passer rapidement du guide à vos notes personnelles."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Stockage local",
|
||||||
|
"text": "Aucun compte, aucune synchronisation cloud et aucune donnée utilisateur envoyée au serveur. Les toolboxes sont enregistrées localement dans le navigateur que vous utilisez."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "IndexedDB simplement",
|
||||||
|
"text": "IndexedDB est un espace de stockage intégré au navigateur. Il permet de conserver des données locales plus riches, comme des images ou des annotations. Ces données restent liées au navigateur et au profil utilisé : un nettoyage, un changement d’appareil ou une réinitialisation peut les supprimer."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"toolsTitle": "Outils disponibles",
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"icon": "notepad",
|
||||||
|
"name": "Bloc notes",
|
||||||
|
"description": "Un espace texte simple pour garder des notes rapides, idées ou rappels pendant une session."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "checklist",
|
||||||
|
"name": "Checklist",
|
||||||
|
"description": "Une liste d’objectifs avec quantités pour suivre crafts, ressources, tâches ou préparatifs."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "picture",
|
||||||
|
"name": "Images",
|
||||||
|
"description": "Un espace pour conserver des images utiles, ajoutées par fichier ou collage depuis le presse-papiers."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "map",
|
||||||
|
"name": "Annotation d'images",
|
||||||
|
"description": "Une image unique annotable avec des marqueurs, pratique pour une carte, un plan ou un screenshot important."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "link",
|
||||||
|
"name": "Liens",
|
||||||
|
"description": "Une liste de raccourcis vers des guides, builds, cartes interactives ou ressources externes."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "abacus",
|
||||||
|
"name": "Compteurs",
|
||||||
|
"description": "Des compteurs personnalisables pour suivre scores, ressources, victoires, essais ou résultats."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "calculator",
|
||||||
|
"name": "Calculateur",
|
||||||
|
"description": "Un calculateur avec résultats enregistrables et arborescence pour préparer des chaînes de craft ou de ressources."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"limitsTitle": "À retenir",
|
||||||
|
"limits": [
|
||||||
|
{
|
||||||
|
"icon": "toolbox",
|
||||||
|
"title": "Données locales",
|
||||||
|
"text": "Vos toolboxes restent dans le navigateur et le profil utilisés. Elles ne sont pas synchronisées automatiquement entre plusieurs appareils."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "export",
|
||||||
|
"title": "Sauvegarde utile",
|
||||||
|
"text": "Un export global régulier est recommandé, surtout avant un nettoyage du navigateur, un changement de machine ou une réinstallation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "abacus",
|
||||||
|
"title": "Budget conseillé",
|
||||||
|
"text": "Le budget affiché dans l’app sert de repère de confort. Ce n’est pas une limite serveur, mais un seuil au-delà duquel l’expérience peut devenir moins fluide."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "picture",
|
||||||
|
"title": "Médias lourds",
|
||||||
|
"text": "Les images et annotations prennent plus de place que du texte. Elles sont pratiques, mais elles doivent être surveillées si vous en ajoutez beaucoup."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"toolboxes": {
|
"toolboxes": {
|
||||||
"eyebrow": "Données locales",
|
"eyebrow": "Données locales",
|
||||||
"title": "Toolboxes",
|
"title": "Toolboxes",
|
||||||
|
|
@ -91,10 +177,10 @@
|
||||||
"summary": "Créez des espaces modulaires pour regrouper vos outils de session : notes, suivis, références, captures et futurs outils restent accessibles rapidement, en local, par jeu ou par usage.",
|
"summary": "Créez des espaces modulaires pour regrouper vos outils de session : notes, suivis, références, captures et futurs outils restent accessibles rapidement, en local, par jeu ou par usage.",
|
||||||
"storageHelp": {
|
"storageHelp": {
|
||||||
"title": "Sauvegardes locales",
|
"title": "Sauvegardes locales",
|
||||||
"text": "Vos toolboxes restent sur cet appareil, dans ce navigateur. Rien n’est envoyé sur un serveur.",
|
"text": "Vos toolboxes restent dans l'IndexedDB de ce navigateur. Rien n’est envoyé sur un serveur.",
|
||||||
"items": [
|
"items": [
|
||||||
"Un nettoyage du navigateur ou un changement d’appareil peut supprimer les données.",
|
"Un nettoyage du navigateur ou un changement d’appareil peut supprimer les données.",
|
||||||
"Surveillez le quota, surtout si vous ajoutez des images.",
|
"Surveillez le budget recommandé : au-delà, le navigateur peut encore stocker des données, mais l’app peut devenir moins fluide.",
|
||||||
"Faites un export global régulier pour garder une sauvegarde."
|
"Faites un export global régulier pour garder une sauvegarde."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,23 @@ import { INITIAL_DIABLO4_STATE, INITIAL_MHWILDS_STATE, loadDiablo4Data, loadMhwi
|
||||||
import { AddToolControls, ToolboxModules, TOOLBOX_MODULES } from "./features/toolboxes/modules/index.jsx";
|
import { AddToolControls, ToolboxModules, TOOLBOX_MODULES } from "./features/toolboxes/modules/index.jsx";
|
||||||
import { usePointerReorder } from "./hooks/usePointerReorder.js";
|
import { usePointerReorder } from "./hooks/usePointerReorder.js";
|
||||||
import { lockBodyScroll } from "./utils/bodyScrollLock.js";
|
import { lockBodyScroll } from "./utils/bodyScrollLock.js";
|
||||||
|
import {
|
||||||
|
getAllModuleData as dbGetAllModuleData,
|
||||||
|
getLinks as dbGetLinks,
|
||||||
|
getSetting as dbGetSetting,
|
||||||
|
getStorageEstimate,
|
||||||
|
getToolboxes as dbGetToolboxes,
|
||||||
|
removeModuleData as dbRemoveModuleData,
|
||||||
|
removeModuleDataKeys as dbRemoveModuleDataKeys,
|
||||||
|
setLinks as dbSetLinks,
|
||||||
|
setModuleData as dbSetModuleData,
|
||||||
|
setSetting as dbSetSetting,
|
||||||
|
setToolboxes as dbSetToolboxes
|
||||||
|
} from "./utils/indexedDbStorage.js";
|
||||||
|
|
||||||
const STORAGE_KEYS = {
|
|
||||||
registry: "sokkog:toolboxes",
|
|
||||||
links: "sokkog:game-toolbox-links",
|
|
||||||
drawerWidth: "sokkog:drawer-width"
|
|
||||||
};
|
|
||||||
const APP_STORAGE_PREFIX = "sokkog:";
|
|
||||||
const APP_STORAGE_LIMIT_BYTES = 5 * 1024 * 1024;
|
|
||||||
const APP_STORAGE_WARNING_RATIO = 0.85;
|
const APP_STORAGE_WARNING_RATIO = 0.85;
|
||||||
|
const APP_STORAGE_SOFT_LIMIT_BYTES = 250 * 1024 * 1024;
|
||||||
|
const DRAWER_WIDTH_SETTING = "drawerWidth";
|
||||||
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", shot: "s", link: "l", counter: "c", calc: "r", marker: "k" };
|
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", shot: "s", link: "l", counter: "c", calc: "r", marker: "k" };
|
||||||
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/";
|
const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/";
|
||||||
|
|
@ -46,7 +54,7 @@ const DEFAULT_TOOLBOX_ICON = `${TOOLBOX_ICON_BASE}toolbox.png`;
|
||||||
|
|
||||||
const DEFAULT_SITE_CONTENT = {
|
const DEFAULT_SITE_CONTENT = {
|
||||||
brand: { name: "Sokko G", homeAriaLabel: "Accueil Sokko G" },
|
brand: { name: "Sokko G", homeAriaLabel: "Accueil Sokko G" },
|
||||||
navigation: { home: "Accueil", toolboxes: "Toolboxes", games: "Jeux", mobileGames: "Infos" },
|
navigation: { home: "Accueil", toolboxes: "Toolboxes", games: "Jeux", about: "C'est quoi Sokko G ?", mobileGames: "Infos" },
|
||||||
sidebar: { badge: "", note: "" },
|
sidebar: { badge: "", note: "" },
|
||||||
topbar: { dashboard: "", toolbox: "", games: "" },
|
topbar: { dashboard: "", toolbox: "", games: "" },
|
||||||
gamesPage: {
|
gamesPage: {
|
||||||
|
|
@ -78,11 +86,30 @@ const DEFAULT_SITE_CONTENT = {
|
||||||
caption: "",
|
caption: "",
|
||||||
lines: []
|
lines: []
|
||||||
},
|
},
|
||||||
|
aboutLink: "Comprendre Sokko G",
|
||||||
legal: {
|
legal: {
|
||||||
copyright: "",
|
copyright: "",
|
||||||
disclaimer: ""
|
disclaimer: ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
about: {
|
||||||
|
eyebrow: "À propos",
|
||||||
|
title: "C'est quoi Sokko G ?",
|
||||||
|
description: "Sokko G est une webapp locale pensée pour garder les bons outils et les bonnes informations à portée de main pendant une session gaming.",
|
||||||
|
sections: [],
|
||||||
|
toolsTitle: "Outils disponibles",
|
||||||
|
tools: [
|
||||||
|
{ icon: "notepad", name: "Bloc notes", description: "Notes rapides pendant une session." },
|
||||||
|
{ icon: "checklist", name: "Checklist", description: "Objectifs et quantités à suivre." },
|
||||||
|
{ icon: "picture", name: "Images", description: "Images ajoutées par fichier ou collage." },
|
||||||
|
{ icon: "map", name: "Annotation d'images", description: "Marqueurs sur une carte ou un plan." },
|
||||||
|
{ icon: "link", name: "Liens", description: "Raccourcis vers guides, builds ou cartes." },
|
||||||
|
{ icon: "abacus", name: "Compteurs", description: "Scores, ressources, essais ou résultats." },
|
||||||
|
{ icon: "calculator", name: "Calculateur", description: "Calculs enregistrés et chaînes de craft." }
|
||||||
|
],
|
||||||
|
limitsTitle: "À retenir",
|
||||||
|
limits: []
|
||||||
|
},
|
||||||
toolboxes: {
|
toolboxes: {
|
||||||
eyebrow: "",
|
eyebrow: "",
|
||||||
title: "Toolboxes",
|
title: "Toolboxes",
|
||||||
|
|
@ -194,51 +221,13 @@ function uid(prefix) {
|
||||||
return `${ID_PREFIXES[prefix] || "x"}${randomToken()}`;
|
return `${ID_PREFIXES[prefix] || "x"}${randomToken()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readJson(key, fallback) {
|
|
||||||
try {
|
|
||||||
const value = localStorage.getItem(key);
|
|
||||||
return value ? JSON.parse(value) : fallback;
|
|
||||||
} catch {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringStorageBytes(value) {
|
|
||||||
return String(value || "").length * 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAppStorageUsage(projected = {}) {
|
|
||||||
let used = 0;
|
|
||||||
for (let index = 0; index < localStorage.length; index += 1) {
|
|
||||||
const key = localStorage.key(index);
|
|
||||||
if (!key?.startsWith(APP_STORAGE_PREFIX) || Object.prototype.hasOwnProperty.call(projected, key)) continue;
|
|
||||||
used += stringStorageBytes(key) + stringStorageBytes(localStorage.getItem(key));
|
|
||||||
}
|
|
||||||
Object.entries(projected).forEach(([key, value]) => {
|
|
||||||
if (!key.startsWith(APP_STORAGE_PREFIX) || value == null) return;
|
|
||||||
used += stringStorageBytes(key) + stringStorageBytes(value);
|
|
||||||
});
|
|
||||||
return { used, limit: APP_STORAGE_LIMIT_BYTES, ratio: Math.min(1, used / APP_STORAGE_LIMIT_BYTES) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
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 * 1024) return `${(bytes / 1024 / 1024).toFixed(1).replace(".", ",")} Mio`;
|
||||||
if (bytes >= 1024) return `${Math.ceil(bytes / 1024)} Kio`;
|
if (bytes >= 1024) return `${Math.ceil(bytes / 1024)} Kio`;
|
||||||
return `${bytes} o`;
|
return `${bytes} o`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeStorageValue(key, value) {
|
|
||||||
const projectedUsage = getAppStorageUsage({ [key]: value });
|
|
||||||
if (projectedUsage.used > APP_STORAGE_LIMIT_BYTES) {
|
|
||||||
throw new Error(`Quota local atteint (${formatBytes(projectedUsage.used)} / ${formatBytes(projectedUsage.limit)}).`);
|
|
||||||
}
|
|
||||||
localStorage.setItem(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeJson(key, value) {
|
|
||||||
writeStorageValue(key, JSON.stringify(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultModuleTitle(type) {
|
function getDefaultModuleTitle(type) {
|
||||||
return TOOLBOX_MODULES[type]?.label || "Outil";
|
return TOOLBOX_MODULES[type]?.label || "Outil";
|
||||||
}
|
}
|
||||||
|
|
@ -295,37 +284,14 @@ function compactToolboxesForStorage(toolboxes) {
|
||||||
return (Array.isArray(toolboxes) ? toolboxes : []).map(compactToolboxForStorage).filter(Boolean);
|
return (Array.isArray(toolboxes) ? toolboxes : []).map(compactToolboxForStorage).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadToolboxes() {
|
|
||||||
return (Array.isArray(readJson(STORAGE_KEYS.registry, [])) ? readJson(STORAGE_KEYS.registry, []) : [])
|
|
||||||
.map(normalizeToolbox)
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveToolboxes(toolboxes) {
|
|
||||||
writeJson(STORAGE_KEYS.registry, compactToolboxesForStorage(toolboxes));
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadLinks() {
|
|
||||||
return readJson(STORAGE_KEYS.links, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveLinks(links) {
|
|
||||||
writeJson(STORAGE_KEYS.links, links);
|
|
||||||
}
|
|
||||||
|
|
||||||
function moduleStorageKey(toolboxId, moduleId) {
|
function moduleStorageKey(toolboxId, moduleId) {
|
||||||
return `sokkog:toolbox:${toolboxId}:module:${moduleId}`;
|
return `${toolboxId}:${moduleId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function globalModuleKey(toolboxId, moduleId) {
|
function globalModuleKey(toolboxId, moduleId) {
|
||||||
return `${toolboxId}:${moduleId}`;
|
return `${toolboxId}:${moduleId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModuleData(toolboxId, moduleId, fallback) {
|
|
||||||
const value = readJson(moduleStorageKey(toolboxId, moduleId), undefined);
|
|
||||||
return value == null ? fallback : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePositiveInt(value, fallback = 1) {
|
function parsePositiveInt(value, fallback = 1) {
|
||||||
const parsed = Number.parseInt(value, 10);
|
const parsed = Number.parseInt(value, 10);
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
|
@ -517,15 +483,9 @@ function compactModuleDataForStorage(type, value) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setModuleData(toolboxes, toolboxId, moduleId, value, moduleType = "") {
|
function prepareModuleDataForStorage(toolboxes, toolboxId, moduleId, value, moduleType = "") {
|
||||||
const module = toolboxes.find((toolbox) => toolbox.id === toolboxId)?.modules.find((item) => item.id === moduleId);
|
const module = toolboxes.find((toolbox) => toolbox.id === toolboxId)?.modules.find((item) => item.id === moduleId);
|
||||||
const compact = compactModuleDataForStorage(moduleType || module?.type, value);
|
return compactModuleDataForStorage(moduleType || module?.type, value);
|
||||||
const key = moduleStorageKey(toolboxId, moduleId);
|
|
||||||
if (compact == null) {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
writeJson(key, compact);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createExportIdFactory() {
|
function createExportIdFactory() {
|
||||||
|
|
@ -578,7 +538,7 @@ function remapModuleDataForExport(type, data, nextId) {
|
||||||
return compact;
|
return compact;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createToolboxExportPayload(toolbox) {
|
function createToolboxExportPayload(toolbox, moduleData) {
|
||||||
const source = normalizeToolbox(toolbox);
|
const source = normalizeToolbox(toolbox);
|
||||||
const nextId = createExportIdFactory();
|
const nextId = createExportIdFactory();
|
||||||
const moduleIdMap = new Map();
|
const moduleIdMap = new Map();
|
||||||
|
|
@ -594,17 +554,17 @@ function createToolboxExportPayload(toolbox) {
|
||||||
const modules = Object.fromEntries(source.modules
|
const modules = Object.fromEntries(source.modules
|
||||||
.map((module) => [
|
.map((module) => [
|
||||||
moduleIdMap.get(module.id),
|
moduleIdMap.get(module.id),
|
||||||
remapModuleDataForExport(module.type, getModuleData(source.id, module.id, null), nextId)
|
remapModuleDataForExport(module.type, moduleData[moduleStorageKey(source.id, module.id)] || null, nextId)
|
||||||
])
|
])
|
||||||
.filter(([, data]) => data != null));
|
.filter(([, data]) => data != null));
|
||||||
return { toolbox: exportedToolbox, modules };
|
return { toolbox: exportedToolbox, modules };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createGlobalExportPayload(toolboxes, links) {
|
function createGlobalExportPayload(toolboxes, links, moduleData) {
|
||||||
const modules = {};
|
const modules = {};
|
||||||
toolboxes.forEach((toolbox) => {
|
toolboxes.forEach((toolbox) => {
|
||||||
toolbox.modules.forEach((module) => {
|
toolbox.modules.forEach((module) => {
|
||||||
const data = compactModuleDataForStorage(module.type, getModuleData(toolbox.id, module.id, null));
|
const data = compactModuleDataForStorage(module.type, moduleData[moduleStorageKey(toolbox.id, module.id)] || null);
|
||||||
if (data) modules[globalModuleKey(toolbox.id, module.id)] = data;
|
if (data) modules[globalModuleKey(toolbox.id, module.id)] = data;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -707,56 +667,118 @@ function useModalScrollLock() {
|
||||||
useEffect(() => lockBodyScroll(), []);
|
useEffect(() => lockBodyScroll(), []);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useLocalToolboxes(onError) {
|
function useIndexedToolboxes(onError) {
|
||||||
const [toolboxes, setToolboxesState] = useState(loadToolboxes);
|
const [ready, setReady] = useState(false);
|
||||||
const [links, setLinksState] = useState(loadLinks);
|
const [toolboxes, setToolboxesState] = useState([]);
|
||||||
const [, setQuotaTick] = useState(0);
|
const [links, setLinksState] = useState({});
|
||||||
|
const [moduleData, setModuleDataState] = useState({});
|
||||||
|
const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 });
|
||||||
|
|
||||||
function refreshQuota() {
|
async function refreshQuota() {
|
||||||
setQuotaTick((value) => value + 1);
|
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) {
|
function persistToolboxes(nextToolboxes) {
|
||||||
try {
|
const normalized = compactToolboxesForStorage(nextToolboxes).map(normalizeToolbox).filter(Boolean);
|
||||||
saveToolboxes(nextToolboxes);
|
setToolboxesState(normalized);
|
||||||
setToolboxesState(loadToolboxes());
|
dbSetToolboxes(compactToolboxesForStorage(normalized))
|
||||||
refreshQuota();
|
.then(refreshQuota)
|
||||||
|
.catch((error) => onError(error.message));
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
|
||||||
onError(error.message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistLinks(nextLinks) {
|
function persistLinks(nextLinks) {
|
||||||
try {
|
setLinksState(nextLinks);
|
||||||
saveLinks(nextLinks);
|
dbSetLinks(nextLinks)
|
||||||
setLinksState(loadLinks());
|
.then(refreshQuota)
|
||||||
refreshQuota();
|
.catch((error) => onError(error.message));
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
|
||||||
onError(error.message);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getModuleData(toolboxId, moduleId, fallback) {
|
||||||
|
const value = moduleData[moduleStorageKey(toolboxId, moduleId)];
|
||||||
|
return value == null ? fallback : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateModuleData(toolboxId, moduleId, value, moduleType = "") {
|
function updateModuleData(toolboxId, moduleId, value, moduleType = "") {
|
||||||
try {
|
const compact = prepareModuleDataForStorage(toolboxes, toolboxId, moduleId, value, moduleType);
|
||||||
setModuleData(toolboxes, toolboxId, moduleId, value, moduleType);
|
const key = moduleStorageKey(toolboxId, moduleId);
|
||||||
refreshQuota();
|
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;
|
return true;
|
||||||
} catch (error) {
|
|
||||||
onError(error.message);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
|
ready,
|
||||||
toolboxes,
|
toolboxes,
|
||||||
links,
|
links,
|
||||||
|
moduleData,
|
||||||
|
storageUsage,
|
||||||
setToolboxes: persistToolboxes,
|
setToolboxes: persistToolboxes,
|
||||||
setLinks: persistLinks,
|
setLinks: persistLinks,
|
||||||
|
getModuleData,
|
||||||
updateModuleData,
|
updateModuleData,
|
||||||
|
removeModuleData,
|
||||||
|
removeToolboxModuleData,
|
||||||
refreshQuota
|
refreshQuota
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -779,7 +801,7 @@ function App() {
|
||||||
const [drawerGameId, setDrawerGameId] = useState("");
|
const [drawerGameId, setDrawerGameId] = useState("");
|
||||||
const [screenshot, setScreenshot] = useState(null);
|
const [screenshot, setScreenshot] = useState(null);
|
||||||
const [storageError, setStorageError] = useState("");
|
const [storageError, setStorageError] = useState("");
|
||||||
const store = useLocalToolboxes((message) => setStorageError(message));
|
const store = useIndexedToolboxes((message) => setStorageError(message));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
|
|
@ -842,7 +864,7 @@ function App() {
|
||||||
|
|
||||||
function deleteToolbox(id) {
|
function deleteToolbox(id) {
|
||||||
const toolbox = store.toolboxes.find((item) => item.id === id);
|
const toolbox = store.toolboxes.find((item) => item.id === id);
|
||||||
toolbox?.modules.forEach((module) => localStorage.removeItem(moduleStorageKey(id, module.id)));
|
store.removeToolboxModuleData(toolbox);
|
||||||
store.setToolboxes(store.toolboxes.filter((item) => item.id !== id));
|
store.setToolboxes(store.toolboxes.filter((item) => item.id !== id));
|
||||||
const nextLinks = { ...store.links };
|
const nextLinks = { ...store.links };
|
||||||
Object.entries(nextLinks).forEach(([gameId, toolboxId]) => {
|
Object.entries(nextLinks).forEach(([gameId, toolboxId]) => {
|
||||||
|
|
@ -866,7 +888,7 @@ function App() {
|
||||||
async function addScreenshotFiles(toolboxId, moduleId, files) {
|
async function addScreenshotFiles(toolboxId, moduleId, files) {
|
||||||
const imageFiles = [...files].filter((file) => file?.type?.startsWith("image/"));
|
const imageFiles = [...files].filter((file) => file?.type?.startsWith("image/"));
|
||||||
if (!imageFiles.length) return false;
|
if (!imageFiles.length) return false;
|
||||||
const data = getModuleData(toolboxId, moduleId, { shots: [] });
|
const data = store.getModuleData(toolboxId, moduleId, { shots: [] });
|
||||||
for (const file of imageFiles) {
|
for (const file of imageFiles) {
|
||||||
data.shots.unshift({ id: uid("shot"), dataUrl: await compressImage(file) });
|
data.shots.unshift({ id: uid("shot"), dataUrl: await compressImage(file) });
|
||||||
}
|
}
|
||||||
|
|
@ -886,7 +908,8 @@ function App() {
|
||||||
if (!store.setToolboxes([imported, ...store.toolboxes])) return null;
|
if (!store.setToolboxes([imported, ...store.toolboxes])) return null;
|
||||||
Object.entries(payload.modules || {}).forEach(([oldId, data]) => {
|
Object.entries(payload.modules || {}).forEach(([oldId, data]) => {
|
||||||
const nextId = moduleIdMap.get(oldId);
|
const nextId = moduleIdMap.get(oldId);
|
||||||
if (nextId) store.updateModuleData(imported.id, nextId, data);
|
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);
|
if (gameId) linkToolboxToGame(gameId, imported.id);
|
||||||
return imported;
|
return imported;
|
||||||
|
|
@ -914,10 +937,6 @@ function App() {
|
||||||
const nextToolboxId = toolboxIdMap.get(oldToolboxId);
|
const nextToolboxId = toolboxIdMap.get(oldToolboxId);
|
||||||
if (nextToolboxId) nextLinks[gameId] = nextToolboxId;
|
if (nextToolboxId) nextLinks[gameId] = nextToolboxId;
|
||||||
});
|
});
|
||||||
const projected = {
|
|
||||||
[STORAGE_KEYS.registry]: JSON.stringify(compactToolboxesForStorage([...importedToolboxes, ...store.toolboxes])),
|
|
||||||
[STORAGE_KEYS.links]: JSON.stringify(nextLinks)
|
|
||||||
};
|
|
||||||
Object.entries(payload.modules).forEach(([key, data]) => {
|
Object.entries(payload.modules).forEach(([key, data]) => {
|
||||||
const [oldToolboxId] = key.split(":");
|
const [oldToolboxId] = key.split(":");
|
||||||
const nextToolboxId = toolboxIdMap.get(oldToolboxId);
|
const nextToolboxId = toolboxIdMap.get(oldToolboxId);
|
||||||
|
|
@ -925,13 +944,10 @@ function App() {
|
||||||
const toolbox = importedToolboxes.find((item) => item.id === nextToolboxId);
|
const toolbox = importedToolboxes.find((item) => item.id === nextToolboxId);
|
||||||
const module = toolbox?.modules.find((item) => item.id === nextModuleId);
|
const module = toolbox?.modules.find((item) => item.id === nextModuleId);
|
||||||
const compact = compactModuleDataForStorage(module?.type, data);
|
const compact = compactModuleDataForStorage(module?.type, data);
|
||||||
if (nextToolboxId && nextModuleId && compact) projected[moduleStorageKey(nextToolboxId, nextModuleId)] = JSON.stringify(compact);
|
if (nextToolboxId && nextModuleId && compact) store.updateModuleData(nextToolboxId, nextModuleId, compact, module?.type);
|
||||||
});
|
});
|
||||||
const projectedUsage = getAppStorageUsage(projected);
|
store.setToolboxes([...importedToolboxes, ...store.toolboxes]);
|
||||||
if (projectedUsage.used > APP_STORAGE_LIMIT_BYTES) throw new Error(`Quota local atteint (${formatBytes(projectedUsage.used)} / ${formatBytes(projectedUsage.limit)}).`);
|
store.setLinks(nextLinks);
|
||||||
Object.entries(projected).forEach(([key, value]) => writeStorageValue(key, value));
|
|
||||||
store.setToolboxes(loadToolboxes());
|
|
||||||
store.setLinks(loadLinks());
|
|
||||||
store.refreshQuota();
|
store.refreshQuota();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -946,6 +962,7 @@ function App() {
|
||||||
setDrawerGameId,
|
setDrawerGameId,
|
||||||
setScreenshot,
|
setScreenshot,
|
||||||
addScreenshotFiles,
|
addScreenshotFiles,
|
||||||
|
removeModuleData: store.removeModuleData,
|
||||||
updateModuleData: store.updateModuleData,
|
updateModuleData: store.updateModuleData,
|
||||||
updateToolboxOrder: (orderedIds) => {
|
updateToolboxOrder: (orderedIds) => {
|
||||||
const order = new Map(orderedIds.map((id, index) => [id, index]));
|
const order = new Map(orderedIds.map((id, index) => [id, index]));
|
||||||
|
|
@ -983,9 +1000,9 @@ function App() {
|
||||||
exportToolbox: (id) => {
|
exportToolbox: (id) => {
|
||||||
const toolbox = normalizeToolbox(store.toolboxes.find((item) => item.id === id));
|
const toolbox = normalizeToolbox(store.toolboxes.find((item) => item.id === id));
|
||||||
if (!toolbox) return;
|
if (!toolbox) return;
|
||||||
downloadJson(createToolboxExportPayload(toolbox), `${toolbox.name.toLowerCase().replace(/[^a-z0-9]+/gi, "-")}.sokkog.json`);
|
downloadJson(createToolboxExportPayload(toolbox, store.moduleData), `${toolbox.name.toLowerCase().replace(/[^a-z0-9]+/gi, "-")}.sokkog.json`);
|
||||||
},
|
},
|
||||||
exportAllToolboxes: () => downloadJson(createGlobalExportPayload(store.toolboxes, store.links), `sokkog-global-${new Date().toISOString().slice(0, 10)}.json`)
|
exportAllToolboxes: () => downloadJson(createGlobalExportPayload(store.toolboxes, store.links, store.moduleData), `sokkog-global-${new Date().toISOString().slice(0, 10)}.json`)
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1005,6 +1022,8 @@ function App() {
|
||||||
getGame={getGame}
|
getGame={getGame}
|
||||||
getToolboxGame={getToolboxGame}
|
getToolboxGame={getToolboxGame}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
|
storageUsage={store.storageUsage}
|
||||||
|
getModuleData={store.getModuleData}
|
||||||
updateToolbox={updateToolbox}
|
updateToolbox={updateToolbox}
|
||||||
updateModuleData={store.updateModuleData}
|
updateModuleData={store.updateModuleData}
|
||||||
addScreenshotFiles={addScreenshotFiles}
|
addScreenshotFiles={addScreenshotFiles}
|
||||||
|
|
@ -1017,6 +1036,8 @@ function App() {
|
||||||
toolboxes={store.toolboxes}
|
toolboxes={store.toolboxes}
|
||||||
links={store.links}
|
links={store.links}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
|
storageUsage={store.storageUsage}
|
||||||
|
getModuleData={store.getModuleData}
|
||||||
updateToolbox={updateToolbox}
|
updateToolbox={updateToolbox}
|
||||||
updateModuleData={store.updateModuleData}
|
updateModuleData={store.updateModuleData}
|
||||||
addScreenshotFiles={addScreenshotFiles}
|
addScreenshotFiles={addScreenshotFiles}
|
||||||
|
|
@ -1027,7 +1048,7 @@ function App() {
|
||||||
setConfirmModal(null);
|
setConfirmModal(null);
|
||||||
onResolve?.(value);
|
onResolve?.(value);
|
||||||
}} />}
|
}} />}
|
||||||
{createModal && <CreateToolboxModal gameId={createModal.gameId || ""} onClose={(name) => {
|
{createModal && <CreateToolboxModal gameId={createModal.gameId || ""} initialName={getGame(createModal.gameId)?.title || ""} onClose={(name) => {
|
||||||
const gameId = createModal.gameId || "";
|
const gameId = createModal.gameId || "";
|
||||||
setCreateModal(null);
|
setCreateModal(null);
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|
@ -1067,6 +1088,7 @@ function Shell({ route, content, games, toolboxes, links, actions, children }) {
|
||||||
<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("/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>
|
<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>
|
</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">
|
<div className="sidebar-note">
|
||||||
<span className="badge">{content.sidebar.badge}</span>
|
<span className="badge">{content.sidebar.badge}</span>
|
||||||
<p>{content.sidebar.note}</p>
|
<p>{content.sidebar.note}</p>
|
||||||
|
|
@ -1102,6 +1124,7 @@ function Shell({ route, content, games, toolboxes, links, actions, children }) {
|
||||||
function RouteContent(props) {
|
function RouteContent(props) {
|
||||||
const { route } = props;
|
const { route } = props;
|
||||||
if (route === "/") return <HomePage {...props} />;
|
if (route === "/") return <HomePage {...props} />;
|
||||||
|
if (route === "/about") return <AboutPage siteContent={props.siteContent} />;
|
||||||
if (route === "/toolboxes") return <ToolboxesPage {...props} />;
|
if (route === "/toolboxes") return <ToolboxesPage {...props} />;
|
||||||
if (route.startsWith("/toolbox/")) return <ToolboxPage {...props} toolboxId={route.split("/")[2]} />;
|
if (route.startsWith("/toolbox/")) return <ToolboxPage {...props} toolboxId={route.split("/")[2]} />;
|
||||||
if (route === "/games") return <GamesPage {...props} />;
|
if (route === "/games") return <GamesPage {...props} />;
|
||||||
|
|
@ -1130,7 +1153,7 @@ function HomePage({ siteContent, toolboxes }) {
|
||||||
<article><strong>{toolCount}</strong><span>{toolCount > 1 ? content.stats.toolPlural : content.stats.toolSingular}</span></article>
|
<article><strong>{toolCount}</strong><span>{toolCount > 1 ? content.stats.toolPlural : content.stats.toolSingular}</span></article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="origin-section" aria-labelledby="origin-title">
|
<section className="origin-section nebula-panel" aria-labelledby="origin-title">
|
||||||
<div className="origin-copy">
|
<div className="origin-copy">
|
||||||
<p className="eyebrow">{content.origin.eyebrow}</p>
|
<p className="eyebrow">{content.origin.eyebrow}</p>
|
||||||
<h2 id="origin-title">{content.origin.title}</h2>
|
<h2 id="origin-title">{content.origin.title}</h2>
|
||||||
|
|
@ -1151,6 +1174,83 @@ function HomePage({ siteContent, toolboxes }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AboutPage({ siteContent }) {
|
||||||
|
const content = siteContent.about || DEFAULT_SITE_CONTENT.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;
|
||||||
|
return (
|
||||||
|
<div className="about-page">
|
||||||
|
<section className="page-hero">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{content.eyebrow}</p>
|
||||||
|
<h1>{content.title}</h1>
|
||||||
|
<p>{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>{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>{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>{tool.description}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DialogueLine({ line }) {
|
function DialogueLine({ line }) {
|
||||||
return <p className={`dialogue-line dialogue-line-${line.speaker === "app" ? "app" : "user"}`}><RichText text={line.text || ""} /></p>;
|
return <p className={`dialogue-line dialogue-line-${line.speaker === "app" ? "app" : "user"}`}><RichText text={line.text || ""} /></p>;
|
||||||
}
|
}
|
||||||
|
|
@ -1169,7 +1269,7 @@ function RichText({ text }) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions }) {
|
function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions, storageUsage }) {
|
||||||
const content = siteContent.toolboxes;
|
const content = siteContent.toolboxes;
|
||||||
const updateToolboxOrder = actions.updateToolboxOrder;
|
const updateToolboxOrder = actions.updateToolboxOrder;
|
||||||
const {
|
const {
|
||||||
|
|
@ -1226,7 +1326,7 @@ function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions }) {
|
||||||
<div className="empty"><h2>{content.emptyTitle}</h2><p>{content.emptyText}</p></div>
|
<div className="empty"><h2>{content.emptyTitle}</h2><p>{content.emptyText}</p></div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
<StorageQuota />
|
<StorageQuota usage={storageUsage} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1237,7 +1337,7 @@ function StorageHelpCard({ help }) {
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="storage-help-heading">
|
<div className="storage-help-heading">
|
||||||
<span className="storage-help-mark" aria-hidden="true" />
|
<span className="storage-help-mark" aria-hidden="true" />
|
||||||
<div><p className="eyebrow">LocalStorage</p><h2 id="storage-help-title">{help.title}</h2></div>
|
<div><p className="eyebrow">IndexedDB</p><h2 id="storage-help-title">{help.title}</h2></div>
|
||||||
</div>
|
</div>
|
||||||
<p className="storage-help-intro">{help.text}</p>
|
<p className="storage-help-intro">{help.text}</p>
|
||||||
<ul className="storage-help-list">
|
<ul className="storage-help-list">
|
||||||
|
|
@ -1379,7 +1479,7 @@ function ToolboxGameIcon({ game }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, updateToolbox, updateModuleData, addScreenshotFiles }) {
|
function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, getModuleData, updateToolbox, updateModuleData, addScreenshotFiles }) {
|
||||||
const moduleColumns = Number(toolbox.moduleColumns) === 1 ? 1 : 2;
|
const moduleColumns = Number(toolbox.moduleColumns) === 1 ? 1 : 2;
|
||||||
const toolboxGameCover = getGameCardCover(toolboxGame);
|
const toolboxGameCover = getGameCardCover(toolboxGame);
|
||||||
const moduleText = siteContent?.toolboxes?.modules || DEFAULT_SITE_CONTENT.toolboxes.modules;
|
const moduleText = siteContent?.toolboxes?.modules || DEFAULT_SITE_CONTENT.toolboxes.modules;
|
||||||
|
|
@ -1475,27 +1575,35 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, upd
|
||||||
danger: true,
|
danger: true,
|
||||||
onResolve: (confirmed) => {
|
onResolve: (confirmed) => {
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
localStorage.removeItem(moduleStorageKey(toolbox.id, moduleId));
|
actions.removeModuleData(toolbox.id, moduleId);
|
||||||
updateToolbox({ ...toolbox, modules: toolbox.modules.filter((module) => module.id !== moduleId) });
|
updateToolbox({ ...toolbox, modules: toolbox.modules.filter((module) => module.id !== moduleId) });
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
onMove={moveModule}
|
onMove={moveModule}
|
||||||
/>
|
/>
|
||||||
{!embedded && <StorageQuota />}
|
{!embedded && <StorageQuota usage={storageUsage} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, updateToolbox, updateModuleData, addScreenshotFiles }) {
|
function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, storageUsage, getModuleData, updateToolbox, updateModuleData, addScreenshotFiles }) {
|
||||||
const selectedId = links[gameId] || "";
|
const selectedId = links[gameId] || "";
|
||||||
const toolbox = toolboxes.find((item) => item.id === selectedId);
|
const toolbox = toolboxes.find((item) => item.id === selectedId);
|
||||||
const [width, setWidth] = useState(() => {
|
const [width, setWidth] = useState("");
|
||||||
const value = Number(localStorage.getItem(STORAGE_KEYS.drawerWidth));
|
|
||||||
if (!Number.isFinite(value) || value <= 0) return "";
|
|
||||||
return Math.min(Math.max(value, 360), Math.floor(window.innerWidth * 0.94));
|
|
||||||
});
|
|
||||||
const panelRef = useRef(null);
|
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) {
|
function startResize(event) {
|
||||||
const panel = panelRef.current;
|
const panel = panelRef.current;
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
|
|
@ -1508,7 +1616,7 @@ function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, u
|
||||||
const onMove = (moveEvent) => {
|
const onMove = (moveEvent) => {
|
||||||
const nextWidth = Math.min(Math.max(startWidth + startX - moveEvent.clientX, minWidth), maxWidth);
|
const nextWidth = Math.min(Math.max(startWidth + startX - moveEvent.clientX, minWidth), maxWidth);
|
||||||
setWidth(nextWidth);
|
setWidth(nextWidth);
|
||||||
writeStorageValue(STORAGE_KEYS.drawerWidth, String(Math.round(nextWidth)));
|
dbSetSetting(DRAWER_WIDTH_SETTING, Math.round(nextWidth)).catch(() => {});
|
||||||
};
|
};
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
document.body.classList.remove("is-resizing-drawer");
|
document.body.classList.remove("is-resizing-drawer");
|
||||||
|
|
@ -1538,10 +1646,10 @@ function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, u
|
||||||
</div>
|
</div>
|
||||||
<button className="drawer-close-button" onClick={() => actions.setDrawerGameId("")} aria-label="Fermer" title="Fermer"><Icon name="close" /></button>
|
<button className="drawer-close-button" onClick={() => actions.setDrawerGameId("")} aria-label="Fermer" title="Fermer"><Icon name="close" /></button>
|
||||||
</header>
|
</header>
|
||||||
{toolbox ? <ToolboxView siteContent={siteContent} toolbox={toolbox} toolboxGame={game} embedded actions={actions} updateToolbox={updateToolbox} updateModuleData={updateModuleData} addScreenshotFiles={addScreenshotFiles} /> : (
|
{toolbox ? <ToolboxView siteContent={siteContent} toolbox={toolbox} toolboxGame={game} embedded actions={actions} storageUsage={storageUsage} getModuleData={getModuleData} updateToolbox={updateToolbox} updateModuleData={updateModuleData} addScreenshotFiles={addScreenshotFiles} /> : (
|
||||||
<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>
|
<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 />
|
<StorageQuota usage={storageUsage} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -1595,9 +1703,9 @@ function ConfirmModal({ title, message, confirmLabel = "Confirmer", cancelLabel
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateToolboxModal({ gameId, onClose }) {
|
function CreateToolboxModal({ gameId, initialName = "", onClose }) {
|
||||||
useModalScrollLock();
|
useModalScrollLock();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState(initialName);
|
||||||
return (
|
return (
|
||||||
<div className="confirm-modal-root">
|
<div className="confirm-modal-root">
|
||||||
<div className="confirm-backdrop" onClick={() => onClose("")} />
|
<div className="confirm-backdrop" onClick={() => onClose("")} />
|
||||||
|
|
@ -1770,14 +1878,16 @@ function ScreenshotViewer({ shot, onClose }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StorageQuota() {
|
function StorageQuota({ usage }) {
|
||||||
const usage = getAppStorageUsage();
|
const safeUsage = usage || { used: 0, limit: 0, ratio: 0 };
|
||||||
const percent = Math.round(usage.ratio * 100);
|
const softRatio = Math.min(1, safeUsage.used / APP_STORAGE_SOFT_LIMIT_BYTES);
|
||||||
const state = usage.ratio >= 1 ? "danger" : usage.ratio >= APP_STORAGE_WARNING_RATIO ? "warning" : "ok";
|
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 (
|
return (
|
||||||
<section className={`storage-quota storage-quota-${state}`} aria-label="Quota de stockage local">
|
<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</span><strong>{formatBytes(usage.used)} / {formatBytes(usage.limit)}</strong></div>
|
<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="Stockage local utilisé"><span style={{ width: `${percent}%` }} /></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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -186,47 +186,28 @@ span {
|
||||||
filter: drop-shadow(0 22px 36px rgba(0, 0, 0, 0.34));
|
filter: drop-shadow(0 22px 36px rgba(0, 0, 0, 0.34));
|
||||||
}
|
}
|
||||||
|
|
||||||
.origin-section {
|
.nebula-panel {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: rgba(16, 20, 38, 0.42);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
|
||||||
|
@include mixins.gold-panel-frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nebula-panel > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.origin-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.65fr);
|
grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.65fr);
|
||||||
gap: var(--space-6);
|
gap: var(--space-6);
|
||||||
margin-top: var(--space-6);
|
margin-top: var(--space-6);
|
||||||
padding: var(--space-6);
|
padding: var(--space-6);
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
background: rgba(16, 20, 38, 0.42);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.origin-section::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: -1px;
|
|
||||||
padding: 1px;
|
|
||||||
border-radius: inherit;
|
|
||||||
background: rgba(165, 180, 252, 0.14);
|
|
||||||
@include mixins.border-mask;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.origin-section::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: -1px;
|
|
||||||
padding: 1px;
|
|
||||||
border-radius: inherit;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at calc(100% - var(--radius-lg)) calc(100% - var(--radius-lg)), rgba(246, 196, 83, 0.9) 0 18px, transparent 27px),
|
|
||||||
linear-gradient(90deg, transparent 0 30%, rgba(246, 196, 83, 0.12) 42%, rgba(246, 196, 83, 0.72) 72%, rgba(246, 196, 83, 0.9) 100%),
|
|
||||||
linear-gradient(180deg, transparent 0 30%, rgba(246, 196, 83, 0.12) 42%, rgba(246, 196, 83, 0.72) 72%, rgba(246, 196, 83, 0.9) 100%);
|
|
||||||
@include mixins.border-mask;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.origin-section > * {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.origin-copy {
|
.origin-copy {
|
||||||
|
|
@ -280,6 +261,181 @@ span {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about-page {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-story-section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(210px, 260px) minmax(0, 1fr);
|
||||||
|
gap: var(--space-6);
|
||||||
|
align-items: start;
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-section-heading {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-section-heading h2 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-story-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-story-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr);
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-4) 0;
|
||||||
|
border-bottom: 1px solid rgba(165, 180, 252, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-story-item:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-story-item:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-story-item > span {
|
||||||
|
color: var(--color-accent-gold);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-story-item h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-story-item > div > p {
|
||||||
|
max-width: 78ch;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-tools-section {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-tools-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0 var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-tool-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 38px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-bottom: 1px solid rgba(165, 180, 252, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-tool-item:nth-last-child(-n + 2) {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-tool-item .module-icon {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
flex: 0 0 34px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-tool-item .module-icon-svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-tool-item h3 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-tool-item p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-storage-reminders {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
padding-left: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-storage-reminders .eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-accent-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-reminders-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 2px 0 2px var(--space-5);
|
||||||
|
list-style: none;
|
||||||
|
border-left: 1px solid rgba(246, 196, 83, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-reminder-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 38px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid rgba(165, 180, 252, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-reminder-item:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-reminder-item:last-child {
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-reminder-item .module-icon {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
flex: 0 0 34px;
|
||||||
|
border: 1px solid rgba(246, 196, 83, 0.26);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(246, 196, 83, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-reminder-item h4 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-reminder-item p {
|
||||||
|
max-width: none;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.48;
|
||||||
|
}
|
||||||
|
|
||||||
.home-stats {
|
.home-stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|
|
||||||
|
|
@ -20,3 +20,32 @@
|
||||||
border-box;
|
border-box;
|
||||||
-webkit-mask-composite: xor;
|
-webkit-mask-composite: xor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@mixin gold-panel-frame($transition: false) {
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
padding: 1px;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
@include border-mask;
|
||||||
|
|
||||||
|
@if $transition {
|
||||||
|
transition: background var(--duration-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
background: rgba(165, 180, 252, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at calc(100% - var(--radius-lg)) calc(100% - var(--radius-lg)), rgba(246, 196, 83, 0.52) 0 18px, transparent 27px),
|
||||||
|
linear-gradient(90deg, transparent 0 30%, rgba(246, 196, 83, 0.08) 42%, rgba(246, 196, 83, 0.38) 72%, rgba(246, 196, 83, 0.52) 100%),
|
||||||
|
linear-gradient(180deg, transparent 0 30%, rgba(246, 196, 83, 0.08) 42%, rgba(246, 196, 83, 0.38) 72%, rgba(246, 196, 83, 0.52) 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,24 @@
|
||||||
|
|
||||||
.brand span:last-child,
|
.brand span:last-child,
|
||||||
.nav-item strong,
|
.nav-item strong,
|
||||||
|
.sidebar-about-link strong,
|
||||||
.sidebar-note {
|
.sidebar-note {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand,
|
.brand,
|
||||||
.nav-item {
|
.nav-item,
|
||||||
|
.sidebar-about-link {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about-story-section {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-section-heading {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
|
|
@ -63,11 +73,25 @@
|
||||||
.game-hero,
|
.game-hero,
|
||||||
.page-hero,
|
.page-hero,
|
||||||
.origin-section,
|
.origin-section,
|
||||||
|
.about-story-section,
|
||||||
|
.about-tools-list,
|
||||||
.section-grid,
|
.section-grid,
|
||||||
.info-grid {
|
.info-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about-tool-item:nth-last-child(-n + 2) {
|
||||||
|
border-bottom: 1px solid rgba(165, 180, 252, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-tool-item:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-storage-reminders {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.hero,
|
.hero,
|
||||||
.game-hero,
|
.game-hero,
|
||||||
.page-hero {
|
.page-hero {
|
||||||
|
|
|
||||||
|
|
@ -143,10 +143,60 @@
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-about-link {
|
||||||
|
display: flex;
|
||||||
|
min-height: 34px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: auto;
|
||||||
|
margin-bottom: calc(var(--space-3) * -1);
|
||||||
|
padding: 7px 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
transition: var(--duration-normal) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-about-link strong {
|
||||||
|
color: inherit;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-about-icon {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--color-accent-gold);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
transition:
|
||||||
|
color var(--duration-fast) var(--ease-standard),
|
||||||
|
filter var(--duration-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-about-link:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-about-link:hover .sidebar-about-icon {
|
||||||
|
color: #ffd66b;
|
||||||
|
filter: drop-shadow(0 0 6px rgba(246, 196, 83, 0.24));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-about-link.active {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-note {
|
.sidebar-note {
|
||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
margin-top: auto;
|
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
|
|
|
||||||
|
|
@ -320,33 +320,8 @@
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
background: rgba(16, 20, 38, 0.42);
|
background: rgba(16, 20, 38, 0.42);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
|
||||||
|
|
||||||
.module::before {
|
@include mixins.gold-panel-frame(true);
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
padding: 1px;
|
|
||||||
border-radius: inherit;
|
|
||||||
background: rgba(165, 180, 252, 0.14);
|
|
||||||
@include mixins.border-mask;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: background var(--duration-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.module::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
padding: 1px;
|
|
||||||
border-radius: inherit;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at calc(100% - var(--radius-lg)) calc(100% - var(--radius-lg)), rgba(246, 196, 83, 0.52) 0 18px, transparent 27px),
|
|
||||||
linear-gradient(90deg, transparent 0 30%, rgba(246, 196, 83, 0.08) 42%, rgba(246, 196, 83, 0.38) 72%, rgba(246, 196, 83, 0.52) 100%),
|
|
||||||
linear-gradient(180deg, transparent 0 30%, rgba(246, 196, 83, 0.08) 42%, rgba(246, 196, 83, 0.38) 72%, rgba(246, 196, 83, 0.52) 100%);
|
|
||||||
@include mixins.border-mask;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: background var(--duration-fast) var(--ease-standard);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.module > * {
|
.module > * {
|
||||||
|
|
@ -554,6 +529,16 @@
|
||||||
-webkit-mask-image: url("/static/icons/link.svg");
|
-webkit-mask-image: url("/static/icons/link.svg");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-icon-toolbox {
|
||||||
|
mask-image: url("/static/icons/toolbox.svg");
|
||||||
|
-webkit-mask-image: url("/static/icons/toolbox.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-icon-export {
|
||||||
|
mask-image: url("/static/icons/export.svg");
|
||||||
|
-webkit-mask-image: url("/static/icons/export.svg");
|
||||||
|
}
|
||||||
|
|
||||||
.module-icon-abacus {
|
.module-icon-abacus {
|
||||||
mask-image: url("/static/icons/abacus.svg");
|
mask-image: url("/static/icons/abacus.svg");
|
||||||
-webkit-mask-image: url("/static/icons/abacus.svg");
|
-webkit-mask-image: url("/static/icons/abacus.svg");
|
||||||
|
|
|
||||||
100
website/src/utils/indexedDbStorage.js
Normal file
100
website/src/utils/indexedDbStorage.js
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
const DB_NAME = "sokkog";
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
const KV_STORE = "kv";
|
||||||
|
const MODULE_STORE = "modules";
|
||||||
|
|
||||||
|
let dbPromise;
|
||||||
|
|
||||||
|
function openDatabase() {
|
||||||
|
if (dbPromise) return dbPromise;
|
||||||
|
dbPromise = new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const db = request.result;
|
||||||
|
if (!db.objectStoreNames.contains(KV_STORE)) db.createObjectStore(KV_STORE);
|
||||||
|
if (!db.objectStoreNames.contains(MODULE_STORE)) db.createObjectStore(MODULE_STORE, { keyPath: "key" });
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestToPromise(request) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeRequest(storeName, mode, callback) {
|
||||||
|
const db = await openDatabase();
|
||||||
|
const transaction = db.transaction(storeName, mode);
|
||||||
|
const store = transaction.objectStore(storeName);
|
||||||
|
return requestToPromise(callback(store));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getValue(key, fallback) {
|
||||||
|
const value = await storeRequest(KV_STORE, "readonly", (store) => store.get(key));
|
||||||
|
return value == null ? fallback : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setValue(key, value) {
|
||||||
|
await storeRequest(KV_STORE, "readwrite", (store) => store.put(value, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getToolboxes() {
|
||||||
|
return getValue("toolboxes", []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setToolboxes(toolboxes) {
|
||||||
|
await setValue("toolboxes", toolboxes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLinks() {
|
||||||
|
return getValue("links", {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLinks(links) {
|
||||||
|
await setValue("links", links);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSetting(key, fallback) {
|
||||||
|
return getValue(`setting:${key}`, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSetting(key, value) {
|
||||||
|
await setValue(`setting:${key}`, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllModuleData() {
|
||||||
|
return storeRequest(MODULE_STORE, "readonly", (store) => store.getAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setModuleData(key, data) {
|
||||||
|
await storeRequest(MODULE_STORE, "readwrite", (store) => store.put({ key, data }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeModuleData(key) {
|
||||||
|
await storeRequest(MODULE_STORE, "readwrite", (store) => store.delete(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeModuleDataKeys(keys) {
|
||||||
|
if (!keys.length) return;
|
||||||
|
const db = await openDatabase();
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(MODULE_STORE, "readwrite");
|
||||||
|
const store = transaction.objectStore(MODULE_STORE);
|
||||||
|
keys.forEach((key) => store.delete(key));
|
||||||
|
transaction.oncomplete = () => resolve();
|
||||||
|
transaction.onerror = () => reject(transaction.error);
|
||||||
|
transaction.onabort = () => reject(transaction.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStorageEstimate() {
|
||||||
|
if (!navigator.storage?.estimate) return { usage: 0, quota: 0 };
|
||||||
|
return navigator.storage.estimate();
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue