move to indexedb and add about page
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-07-24 12:09:45 +02:00
parent a8809afdfd
commit 772b9951de
12 changed files with 855 additions and 249 deletions

View file

@ -139,7 +139,19 @@ Style courant :
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
@ -189,7 +201,7 @@ Les icones utilisent `currentColor` via `.ui-icon`.
- hover danger : icone blanche + glow rouge discret ;
- utiliser les SVG de `website/public/static/icons`.
## 9. Formulaires Et Checkbox
## 10. Formulaires Et Checkbox
Inputs et textareas :
@ -207,7 +219,7 @@ Checkbox :
- etat coche avec gradient dore/violet et coche blanche ;
- spacing adapte dans les filtres.
## 10. Navigation
## 11. Navigation
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.
## 11. Filtres Et Tableaux
## 12. Filtres Et Tableaux
Filtres :
@ -234,7 +246,7 @@ Tableaux :
- bordures fines ;
- lisibilite prioritaire sur la decoration.
## 12. Toolboxes
## 13. Toolboxes
Les toolboxes sont locales et stockees dans le navigateur.
@ -244,12 +256,12 @@ Regles UI :
- image de jeu si associee a un jeu ;
- icone editable si toolbox libre ;
- 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.
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 :
@ -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.
## 14. Modales Et Drawer
## 15. Modales Et Drawer
Modales :
@ -274,7 +286,7 @@ Drawer toolbox :
- overlay floute leger ;
- les actions internes ne doivent pas fermer le drawer sauf action explicite.
## 15. Scrollbars
## 16. Scrollbars
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.
## 16. Images Et Assets
## 17. Images Et Assets
Regles :
@ -317,7 +329,7 @@ Regles :
- les categories de jeu utilisent des images representatives, entieres et bien cadrees ;
- eviter les images trop grandes dans les cards.
## 17. Accessibilite
## 18. Accessibilite
Minimum attendu :
@ -329,7 +341,7 @@ Minimum attendu :
- modales avec `role="dialog"` et `aria-modal="true"` ;
- ne pas transmettre une information uniquement par couleur.
## 18. A Eviter
## 19. A Eviter
- accents dores partout ;
- hover trop lumineux sur tous les elements ;

View file

@ -6,13 +6,13 @@ Sokko G propose des toolboxes modulaires stockées dans le navigateur, ainsi que
## Fonctionnalités
- Toolboxes locales stockées dans `localStorage`.
- Toolboxes locales stockées dans IndexedDB.
- Outils disponibles : bloc notes, checklist avec quantités, images, annotation dimages, liens, compteurs et calculateur.
- Import/export dune toolbox.
- Import/export global de toutes les toolboxes.
- Association locale entre une page jeu et une toolbox.
- Panneau latéral redimensionnable sur les pages jeux.
- Suivi et contrôle du quota localStorage.
- Suivi dun budget de stockage recommandé, avec quota navigateur en information.
- Pages de guides de jeu éditables via JSON.
## Démarrage
@ -64,7 +64,7 @@ Les contenus maintenus à la main sont regroupés dans `website/public/data`.
| 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/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. |
@ -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/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/CountersModule.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/` ;
- lajouter 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
@ -152,11 +152,11 @@ Pour ajouter ou maintenir un outil :
## Stockage local
Clés principales :
Les données utilisateur sont stockées dans IndexedDB, dans la base `sokkog`.
- `sokkog:toolboxes` : registre local des toolboxes.
- `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.
Espaces principaux :
Le quota applicatif est contrôlé côté navigateur avant écriture. Les imports globaux simulent lespace 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 lapp. Le quota navigateur réel vient de `navigator.storage.estimate()` et peut être beaucoup plus élevé selon lespace disque disponible. Les images et annotations restent locales et peuvent occuper beaucoup despace ; les exports globaux restent recommandés pour sauvegarder les données.

View file

@ -31,6 +31,7 @@ export function validateSiteContent(site) {
"navigation.home",
"navigation.toolboxes",
"navigation.games",
"navigation.about",
"navigation.mobileGames",
"sidebar.badge",
"sidebar.note",
@ -56,8 +57,14 @@ export function validateSiteContent(site) {
"home.origin.visualAlt",
"home.origin.dialogueAriaLabel",
"home.origin.caption",
"home.aboutLink",
"home.legal.copyright",
"home.legal.disclaimer",
"about.eyebrow",
"about.title",
"about.description",
"about.toolsTitle",
"about.limitsTitle",
"toolboxes.eyebrow",
"toolboxes.title",
"toolboxes.newButton",
@ -139,6 +146,37 @@ export function validateSiteContent(site) {
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");
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");

View file

@ -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 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");
@ -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 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, /sokkog:toolboxes/);
assert.match(source, /sokkog:game-toolbox-links/);
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"/);
@ -71,6 +76,13 @@ test("react application defines the expected local toolbox primitives", async ()
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/);
@ -183,7 +195,8 @@ test("react application defines the expected local toolbox primitives", async ()
assert.match(linksModule, /copyText/);
assert.match(source, /DEFAULT_SITE_CONTENT/);
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, /TOOLBOX_ICON_FILES/);
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, /toolbox-card-drag-handle/);
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, /function createGlobalExportPayload/);
assert.match(source, /async function importAllToolboxes/);

View file

@ -7,6 +7,7 @@
"home": "Accueil",
"toolboxes": "Toolboxes",
"games": "Jeux",
"about": "C'est quoi Sokko G ?",
"mobileGames": "Infos"
},
"sidebar": {
@ -76,11 +77,96 @@
}
]
},
"aboutLink": "Comprendre Sokko G",
"legal": {
"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."
}
},
"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. Lobjectif est de garder les informations importantes accessibles rapidement sur un second écran."
},
{
"title": "Fonctionnement",
"text": "Chaque toolbox est composée doutils 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 dappareil 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 dobjectifs 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 lapp sert de repère de confort. Ce nest pas une limite serveur, mais un seuil au-delà duquel lexpé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": {
"eyebrow": "Données locales",
"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.",
"storageHelp": {
"title": "Sauvegardes locales",
"text": "Vos toolboxes restent sur cet appareil, dans ce navigateur. Rien nest envoyé sur un serveur.",
"text": "Vos toolboxes restent dans l'IndexedDB de ce navigateur. Rien nest envoyé sur un serveur.",
"items": [
"Un nettoyage du navigateur ou un changement dappareil 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 lapp peut devenir moins fluide.",
"Faites un export global régulier pour garder une sauvegarde."
]
},

View file

@ -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 { usePointerReorder } from "./hooks/usePointerReorder.js";
import { lockBodyScroll } from "./utils/bodyScrollLock.js";
import {
getAllModuleData as dbGetAllModuleData,
getLinks as dbGetLinks,
getSetting as dbGetSetting,
getStorageEstimate,
getToolboxes as dbGetToolboxes,
removeModuleData as dbRemoveModuleData,
removeModuleDataKeys as dbRemoveModuleDataKeys,
setLinks as dbSetLinks,
setModuleData as dbSetModuleData,
setSetting as dbSetSetting,
setToolboxes as dbSetToolboxes
} from "./utils/indexedDbStorage.js";
const 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_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_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
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 = {
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: "" },
topbar: { dashboard: "", toolbox: "", games: "" },
gamesPage: {
@ -78,11 +86,30 @@ const DEFAULT_SITE_CONTENT = {
caption: "",
lines: []
},
aboutLink: "Comprendre Sokko G",
legal: {
copyright: "",
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: {
eyebrow: "",
title: "Toolboxes",
@ -194,51 +221,13 @@ function uid(prefix) {
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) {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 o";
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1).replace(".", ",")} Mio`;
if (bytes >= 1024) return `${Math.ceil(bytes / 1024)} Kio`;
return `${bytes} o`;
}
function 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) {
return TOOLBOX_MODULES[type]?.label || "Outil";
}
@ -295,37 +284,14 @@ function compactToolboxesForStorage(toolboxes) {
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) {
return `sokkog:toolbox:${toolboxId}:module:${moduleId}`;
return `${toolboxId}:${moduleId}`;
}
function globalModuleKey(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) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
@ -517,15 +483,9 @@ function compactModuleDataForStorage(type, 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 compact = compactModuleDataForStorage(moduleType || module?.type, value);
const key = moduleStorageKey(toolboxId, moduleId);
if (compact == null) {
localStorage.removeItem(key);
return;
}
writeJson(key, compact);
return compactModuleDataForStorage(moduleType || module?.type, value);
}
function createExportIdFactory() {
@ -578,7 +538,7 @@ function remapModuleDataForExport(type, data, nextId) {
return compact;
}
function createToolboxExportPayload(toolbox) {
function createToolboxExportPayload(toolbox, moduleData) {
const source = normalizeToolbox(toolbox);
const nextId = createExportIdFactory();
const moduleIdMap = new Map();
@ -594,17 +554,17 @@ function createToolboxExportPayload(toolbox) {
const modules = Object.fromEntries(source.modules
.map((module) => [
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));
return { toolbox: exportedToolbox, modules };
}
function createGlobalExportPayload(toolboxes, links) {
function createGlobalExportPayload(toolboxes, links, moduleData) {
const modules = {};
toolboxes.forEach((toolbox) => {
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;
});
});
@ -707,56 +667,118 @@ function useModalScrollLock() {
useEffect(() => lockBodyScroll(), []);
}
function useLocalToolboxes(onError) {
const [toolboxes, setToolboxesState] = useState(loadToolboxes);
const [links, setLinksState] = useState(loadLinks);
const [, setQuotaTick] = useState(0);
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 });
function refreshQuota() {
setQuotaTick((value) => value + 1);
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) {
try {
saveToolboxes(nextToolboxes);
setToolboxesState(loadToolboxes());
refreshQuota();
const normalized = compactToolboxesForStorage(nextToolboxes).map(normalizeToolbox).filter(Boolean);
setToolboxesState(normalized);
dbSetToolboxes(compactToolboxesForStorage(normalized))
.then(refreshQuota)
.catch((error) => onError(error.message));
return true;
} catch (error) {
onError(error.message);
return false;
}
}
function persistLinks(nextLinks) {
try {
saveLinks(nextLinks);
setLinksState(loadLinks());
refreshQuota();
setLinksState(nextLinks);
dbSetLinks(nextLinks)
.then(refreshQuota)
.catch((error) => onError(error.message));
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 = "") {
try {
setModuleData(toolboxes, toolboxId, moduleId, value, moduleType);
refreshQuota();
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;
} 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 {
ready,
toolboxes,
links,
moduleData,
storageUsage,
setToolboxes: persistToolboxes,
setLinks: persistLinks,
getModuleData,
updateModuleData,
removeModuleData,
removeToolboxModuleData,
refreshQuota
};
}
@ -779,7 +801,7 @@ function App() {
const [drawerGameId, setDrawerGameId] = useState("");
const [screenshot, setScreenshot] = useState(null);
const [storageError, setStorageError] = useState("");
const store = useLocalToolboxes((message) => setStorageError(message));
const store = useIndexedToolboxes((message) => setStorageError(message));
useEffect(() => {
Promise.all([
@ -842,7 +864,7 @@ function App() {
function deleteToolbox(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));
const nextLinks = { ...store.links };
Object.entries(nextLinks).forEach(([gameId, toolboxId]) => {
@ -866,7 +888,7 @@ function App() {
async function addScreenshotFiles(toolboxId, moduleId, files) {
const imageFiles = [...files].filter((file) => file?.type?.startsWith("image/"));
if (!imageFiles.length) return false;
const data = getModuleData(toolboxId, moduleId, { shots: [] });
const data = store.getModuleData(toolboxId, moduleId, { shots: [] });
for (const file of imageFiles) {
data.shots.unshift({ id: uid("shot"), dataUrl: await compressImage(file) });
}
@ -886,7 +908,8 @@ function App() {
if (!store.setToolboxes([imported, ...store.toolboxes])) return null;
Object.entries(payload.modules || {}).forEach(([oldId, data]) => {
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);
return imported;
@ -914,10 +937,6 @@ function App() {
const nextToolboxId = toolboxIdMap.get(oldToolboxId);
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]) => {
const [oldToolboxId] = key.split(":");
const nextToolboxId = toolboxIdMap.get(oldToolboxId);
@ -925,13 +944,10 @@ function App() {
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) projected[moduleStorageKey(nextToolboxId, nextModuleId)] = JSON.stringify(compact);
if (nextToolboxId && nextModuleId && compact) store.updateModuleData(nextToolboxId, nextModuleId, compact, module?.type);
});
const projectedUsage = getAppStorageUsage(projected);
if (projectedUsage.used > APP_STORAGE_LIMIT_BYTES) throw new Error(`Quota local atteint (${formatBytes(projectedUsage.used)} / ${formatBytes(projectedUsage.limit)}).`);
Object.entries(projected).forEach(([key, value]) => writeStorageValue(key, value));
store.setToolboxes(loadToolboxes());
store.setLinks(loadLinks());
store.setToolboxes([...importedToolboxes, ...store.toolboxes]);
store.setLinks(nextLinks);
store.refreshQuota();
}
@ -946,6 +962,7 @@ function App() {
setDrawerGameId,
setScreenshot,
addScreenshotFiles,
removeModuleData: store.removeModuleData,
updateModuleData: store.updateModuleData,
updateToolboxOrder: (orderedIds) => {
const order = new Map(orderedIds.map((id, index) => [id, index]));
@ -983,9 +1000,9 @@ function App() {
exportToolbox: (id) => {
const toolbox = normalizeToolbox(store.toolboxes.find((item) => item.id === id));
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 (
@ -1005,6 +1022,8 @@ function App() {
getGame={getGame}
getToolboxGame={getToolboxGame}
actions={actions}
storageUsage={store.storageUsage}
getModuleData={store.getModuleData}
updateToolbox={updateToolbox}
updateModuleData={store.updateModuleData}
addScreenshotFiles={addScreenshotFiles}
@ -1017,6 +1036,8 @@ function App() {
toolboxes={store.toolboxes}
links={store.links}
actions={actions}
storageUsage={store.storageUsage}
getModuleData={store.getModuleData}
updateToolbox={updateToolbox}
updateModuleData={store.updateModuleData}
addScreenshotFiles={addScreenshotFiles}
@ -1027,7 +1048,7 @@ function App() {
setConfirmModal(null);
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 || "";
setCreateModal(null);
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("/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>
@ -1102,6 +1124,7 @@ function Shell({ route, content, games, toolboxes, links, actions, children }) {
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} />;
@ -1130,7 +1153,7 @@ function HomePage({ siteContent, toolboxes }) {
<article><strong>{toolCount}</strong><span>{toolCount > 1 ? content.stats.toolPlural : content.stats.toolSingular}</span></article>
</div>
</section>
<section className="origin-section" aria-labelledby="origin-title">
<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>
@ -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 }) {
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 updateToolboxOrder = actions.updateToolboxOrder;
const {
@ -1226,7 +1326,7 @@ function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions }) {
<div className="empty"><h2>{content.emptyTitle}</h2><p>{content.emptyText}</p></div>
)}
</section>
<StorageQuota />
<StorageQuota usage={storageUsage} />
</div>
);
}
@ -1237,7 +1337,7 @@ function StorageHelpCard({ help }) {
<div className="card-body">
<div className="storage-help-heading">
<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>
<p className="storage-help-intro">{help.text}</p>
<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 toolboxGameCover = getGameCardCover(toolboxGame);
const moduleText = siteContent?.toolboxes?.modules || DEFAULT_SITE_CONTENT.toolboxes.modules;
@ -1475,27 +1575,35 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, upd
danger: true,
onResolve: (confirmed) => {
if (!confirmed) return;
localStorage.removeItem(moduleStorageKey(toolbox.id, moduleId));
actions.removeModuleData(toolbox.id, moduleId);
updateToolbox({ ...toolbox, modules: toolbox.modules.filter((module) => module.id !== moduleId) });
}
})}
onMove={moveModule}
/>
{!embedded && <StorageQuota />}
{!embedded && <StorageQuota usage={storageUsage} />}
</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 toolbox = toolboxes.find((item) => item.id === selectedId);
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 [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;
@ -1508,7 +1616,7 @@ function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, u
const onMove = (moveEvent) => {
const nextWidth = Math.min(Math.max(startWidth + startX - moveEvent.clientX, minWidth), maxWidth);
setWidth(nextWidth);
writeStorageValue(STORAGE_KEYS.drawerWidth, String(Math.round(nextWidth)));
dbSetSetting(DRAWER_WIDTH_SETTING, Math.round(nextWidth)).catch(() => {});
};
const stop = () => {
document.body.classList.remove("is-resizing-drawer");
@ -1538,10 +1646,10 @@ function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, u
</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} 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>
)}
<StorageQuota />
<StorageQuota usage={storageUsage} />
</div>
</section>
</aside>
@ -1595,9 +1703,9 @@ function ConfirmModal({ title, message, confirmLabel = "Confirmer", cancelLabel
);
}
function CreateToolboxModal({ gameId, onClose }) {
function CreateToolboxModal({ gameId, initialName = "", onClose }) {
useModalScrollLock();
const [name, setName] = useState("");
const [name, setName] = useState(initialName);
return (
<div className="confirm-modal-root">
<div className="confirm-backdrop" onClick={() => onClose("")} />
@ -1770,14 +1878,16 @@ function ScreenshotViewer({ shot, onClose }) {
);
}
function StorageQuota() {
const usage = getAppStorageUsage();
const percent = Math.round(usage.ratio * 100);
const state = usage.ratio >= 1 ? "danger" : usage.ratio >= APP_STORAGE_WARNING_RATIO ? "warning" : "ok";
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="Quota de stockage local">
<div><span>Stockage local</span><strong>{formatBytes(usage.used)} / {formatBytes(usage.limit)}</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>
<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>
);
}

View file

@ -186,47 +186,28 @@ span {
filter: drop-shadow(0 22px 36px rgba(0, 0, 0, 0.34));
}
.origin-section {
.nebula-panel {
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;
grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.65fr);
gap: var(--space-6);
margin-top: 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 {
@ -280,6 +261,181 @@ span {
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 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));

View file

@ -20,3 +20,32 @@
border-box;
-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%);
}
}

View file

@ -19,14 +19,24 @@
.brand span:last-child,
.nav-item strong,
.sidebar-about-link strong,
.sidebar-note {
display: none;
}
.brand,
.nav-item {
.nav-item,
.sidebar-about-link {
justify-content: center;
}
.about-story-section {
grid-template-columns: 1fr;
}
.about-section-heading {
position: static;
}
}
@media (max-width: 760px) {
@ -63,11 +73,25 @@
.game-hero,
.page-hero,
.origin-section,
.about-story-section,
.about-tools-list,
.section-grid,
.info-grid {
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,
.game-hero,
.page-hero {

View file

@ -143,10 +143,60 @@
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 {
position: relative;
isolation: isolate;
margin-top: auto;
padding: var(--space-4);
overflow: hidden;
border: 1px solid transparent;

View file

@ -320,33 +320,8 @@
border-color: transparent;
background: rgba(16, 20, 38, 0.42);
box-shadow: var(--shadow-sm);
}
.module::before {
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);
@include mixins.gold-panel-frame(true);
}
.module > * {
@ -554,6 +529,16 @@
-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 {
mask-image: url("/static/icons/abacus.svg");
-webkit-mask-image: url("/static/icons/abacus.svg");

View 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();
}