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

@ -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 });
}
}
function persistToolboxes(nextToolboxes) {
try {
saveToolboxes(nextToolboxes);
setToolboxesState(loadToolboxes());
refreshQuota();
return true;
} catch (error) {
onError(error.message);
return false;
useEffect(() => {
let cancelled = false;
async function loadStore() {
try {
const [storedToolboxes, storedLinks, storedModules] = await Promise.all([
dbGetToolboxes(),
dbGetLinks(),
dbGetAllModuleData()
]);
if (cancelled) return;
setToolboxesState((Array.isArray(storedToolboxes) ? storedToolboxes : []).map(normalizeToolbox).filter(Boolean));
setLinksState(storedLinks && typeof storedLinks === "object" ? storedLinks : {});
setModuleDataState(Object.fromEntries((storedModules || []).map((entry) => [entry.key, entry.data])));
setReady(true);
refreshQuota();
} catch (error) {
if (!cancelled) {
setReady(true);
onError(error.message || "Stockage IndexedDB indisponible.");
}
}
}
loadStore();
return () => { cancelled = true; };
}, []);
function persistToolboxes(nextToolboxes) {
const normalized = compactToolboxesForStorage(nextToolboxes).map(normalizeToolbox).filter(Boolean);
setToolboxesState(normalized);
dbSetToolboxes(compactToolboxesForStorage(normalized))
.then(refreshQuota)
.catch((error) => onError(error.message));
return true;
}
function persistLinks(nextLinks) {
try {
saveLinks(nextLinks);
setLinksState(loadLinks());
refreshQuota();
return true;
} catch (error) {
onError(error.message);
return false;
}
setLinksState(nextLinks);
dbSetLinks(nextLinks)
.then(refreshQuota)
.catch((error) => onError(error.message));
return true;
}
function getModuleData(toolboxId, moduleId, fallback) {
const value = moduleData[moduleStorageKey(toolboxId, moduleId)];
return value == null ? fallback : value;
}
function updateModuleData(toolboxId, moduleId, value, moduleType = "") {
try {
setModuleData(toolboxes, toolboxId, moduleId, value, moduleType);
refreshQuota();
return true;
} catch (error) {
onError(error.message);
return false;
}
const compact = prepareModuleDataForStorage(toolboxes, toolboxId, moduleId, value, moduleType);
const key = moduleStorageKey(toolboxId, moduleId);
setModuleDataState((state) => {
const nextState = { ...state };
if (compact == null) delete nextState[key];
else nextState[key] = compact;
return nextState;
});
const operation = compact == null ? dbRemoveModuleData(key) : dbSetModuleData(key, compact);
operation.then(refreshQuota).catch((error) => onError(error.message));
return true;
}
function removeModuleData(toolboxId, moduleId) {
const key = moduleStorageKey(toolboxId, moduleId);
setModuleDataState((state) => {
const nextState = { ...state };
delete nextState[key];
return nextState;
});
dbRemoveModuleData(key).then(refreshQuota).catch((error) => onError(error.message));
}
function removeToolboxModuleData(toolbox) {
const keys = (toolbox?.modules || []).map((module) => moduleStorageKey(toolbox.id, module.id));
setModuleDataState((state) => {
const nextState = { ...state };
keys.forEach((key) => delete nextState[key]);
return nextState;
});
dbRemoveModuleDataKeys(keys).then(refreshQuota).catch((error) => onError(error.message));
}
return {
ready,
toolboxes,
links,
moduleData,
storageUsage,
setToolboxes: persistToolboxes,
setLinks: persistLinks,
getModuleData,
updateModuleData,
removeModuleData,
removeToolboxModuleData,
refreshQuota
};
}
@ -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>
);
}