Protect toolbox data after IndexedDB startup failures
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s
This commit is contained in:
parent
b53c7c17c8
commit
09a2d4b71a
13 changed files with 242 additions and 5 deletions
|
|
@ -39,3 +39,4 @@
|
|||
{"id":"int-e79db6c1131fb3c5c57dde09917ef735","kind":"field_change","created_at":"2026-09-12T10:50:44.387707762Z","actor":"Shinuwa","issue_id":"sokko-g-8po","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Enabled app-wide StrictMode, restored recommended react-hooks/set-state-in-effect rule, documented development checks and added an effective ESLint configuration regression test. npm run check:full passed: lint, 101 Node tests, build and 36 E2E. All C10 subsets complete."}}
|
||||
{"id":"int-4d9a5305507377a0f0d42d8c0e073c0e","kind":"field_change","created_at":"2026-09-12T11:13:23.757763936Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
|
||||
{"id":"int-e86990d8d18afb8dec65036e0ab7712f","kind":"field_change","created_at":"2026-09-12T11:14:17.70653268Z","actor":"Shinuwa","issue_id":"sokko-g-usb","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Reproducible production profiling completed and documented: npm run profile:toolboxes, four disk-backed scenarios and five genuine reloads each, 117.9 MiB identical media dataset. Raw and analysis reports in ignored .local/performance. Closed media retains about118MiB extra JS heap. check:full passes lint,101 Node tests,build,40 E2E. Initial read failure handling tracked separately as P1 sokko-g-d5u; no storage/schema changes."}}
|
||||
{"id":"int-5a1d6dccbbcebf69807b6134785175ef","kind":"field_change","created_at":"2026-09-12T18:30:49.351851361Z","actor":"Shinuwa","issue_id":"sokko-g-d5u","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Protected IndexedDB bootstrap: readiness granted only after complete successful reads; all store writes rejected before initialization; create/import/export guards; startup error/loading gate with non-destructive retry; rejected connection promise evicted for reopening; generic accurate write-error title. Editorial strings validated and storage/workflow docs updated. Four new E2E cover repeated open/read failures, preserved data and exports, creation after retry, direct command guards and later write errors. Desktop/mobile screenshots reviewed. npm run check:full passed lint,101 Node tests,build,44 E2E. No schema or format changes."}}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
|
|||
- Mettre à jour `docs/STORAGE_SCHEMA.md`.
|
||||
- Vérifier l'import et l'export si l'outil stocke des données.
|
||||
- Pour une modification de persistance, vérifier les écritures successives en succès/échec, les suppressions, ainsi qu'un import concurrent avec une édition ; un échec ne doit pas restaurer une valeur jamais confirmée.
|
||||
- Vérifier qu'une erreur d'ouverture ou de lecture au démarrage bloque les écritures, créations, imports et exports sans afficher une fausse liste vide ; plusieurs tentatives puis une reprise réussie doivent retrouver les données initiales sans recharger la page ni les effacer.
|
||||
- Si l'outil a un contenu représentable en texte, ajouter ou vérifier l'import/export texte round-trip depuis le toggle import/export du header.
|
||||
- Si l'outil porte un état de progression, vérifier que l'import texte repart de l'état initial attendu.
|
||||
- Vérifier l'affichage dans la page toolbox complète.
|
||||
|
|
|
|||
|
|
@ -37,6 +37,15 @@ Entrée type dans `modules` :
|
|||
|
||||
### Ordre et confirmation des écritures
|
||||
|
||||
Le démarrage attend la lecture complète du registre, des liens, des modules et
|
||||
de la position des notifications avant d'ouvrir l'interface. Un échec laisse
|
||||
le stockage non prêt : les méthodes d'écriture refusent les opérations, et la
|
||||
création, l'import et l'export ne peuvent pas utiliser un état vide incomplet.
|
||||
Le bouton de reprise relance uniquement les lectures, sans supprimer de données.
|
||||
Une ouverture IndexedDB échouée n'est pas conservée dans le cache de connexion.
|
||||
Les erreurs de lecture sont distinctes des erreurs d'écriture et ne sont pas
|
||||
présentées systématiquement comme un dépassement de quota.
|
||||
|
||||
Dans une instance de l'application, le registre, les liens, les modules et la
|
||||
position des notifications partagent une file d'écritures. L'interface présente
|
||||
les modifications en attente ; en cas d'échec, elle est reconstruite depuis le
|
||||
|
|
|
|||
20
e2e/fixtures/storage-startup-probe.js
Normal file
20
e2e/fixtures/storage-startup-probe.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// Rôle : expose les commandes du vrai stockage et des actions sous StrictMode pour tester leur verrouillage initial.
|
||||
import { createElement, StrictMode, useLayoutEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useIndexedToolboxes } from "../../website/src/features/toolboxes/storage/useIndexedToolboxes.js";
|
||||
import { useToolboxActions } from "../../website/src/features/toolboxes/useToolboxActions.js";
|
||||
|
||||
export function mountStorageStartupProbe(element) {
|
||||
const root = createRoot(element);
|
||||
const api = { store: null, actions: null, notices: [] };
|
||||
const notify = (message) => api.notices.push(message);
|
||||
function Probe() {
|
||||
const store = useIndexedToolboxes(notify);
|
||||
const actions = useToolboxActions({ store, notify, setConfirmModal: notify });
|
||||
useLayoutEffect(() => { api.store = store; api.actions = actions; });
|
||||
return null;
|
||||
}
|
||||
flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe))));
|
||||
return { ...api, get store() { return api.store; }, get actions() { return api.actions; }, unmount: () => root.unmount() };
|
||||
}
|
||||
143
e2e/storage-startup.spec.js
Normal file
143
e2e/storage-startup.spec.js
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// Rôle : vérifie qu'une panne IndexedDB ne peut pas transformer les données existantes en un état vide modifiable.
|
||||
/* global window, document, IDBFactory, IDBObjectStore, DOMException, Buffer */
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import { resetSokkoStorage } from "./helpers/storage.js";
|
||||
|
||||
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/storage-startup-probe.js", import.meta.url))}`;
|
||||
const original = {
|
||||
toolboxes: [{ id: "saved", name: "Conservée", updatedAt: "2026-09-12T12:00:00.000Z", modules: [{ id: "note", type: "memos", title: "Mémos" }] }],
|
||||
links: { mhwilds: "saved" },
|
||||
modules: [{ key: "saved:note", data: { items: [{ id: "memo", text: "Ne pas perdre" }] } }]
|
||||
};
|
||||
|
||||
async function setup(page, fault) {
|
||||
await resetSokkoStorage(page);
|
||||
await page.evaluate(async (data) => {
|
||||
const db = await import("/src/utils/indexedDbStorage.js");
|
||||
await db.setToolboxSnapshot(data.toolboxes, data.links, Object.fromEntries(data.modules.map((entry) => [entry.key, entry.data])));
|
||||
}, original);
|
||||
await page.addInitScript((fault) => {
|
||||
window.storageFault = fault;
|
||||
const open = IDBFactory.prototype.open;
|
||||
IDBFactory.prototype.open = function (...args) {
|
||||
if (window.storageFault === "open") throw new DOMException("Ouverture indisponible", "UnknownError");
|
||||
return open.apply(this, args);
|
||||
};
|
||||
const getAll = IDBObjectStore.prototype.getAll;
|
||||
IDBObjectStore.prototype.getAll = function (...args) {
|
||||
if (window.storageFault === "read" && this.name === "modules") throw new DOMException("Lecture indisponible", "UnknownError");
|
||||
return getAll.apply(this, args);
|
||||
};
|
||||
const put = IDBObjectStore.prototype.put;
|
||||
IDBObjectStore.prototype.put = function (...args) {
|
||||
if (window.storageFault === "write") throw new DOMException("Écriture indisponible", "UnknownError");
|
||||
return put.apply(this, args);
|
||||
};
|
||||
}, fault);
|
||||
await page.goto("/#/toolboxes");
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
async function snapshot(page) {
|
||||
return page.evaluate(async () => {
|
||||
const fault = window.storageFault;
|
||||
window.storageFault = "";
|
||||
try {
|
||||
const db = await import("/src/utils/indexedDbStorage.js");
|
||||
return { toolboxes: await db.getToolboxes(), links: await db.getLinks(), modules: await db.getAllModuleData() };
|
||||
} finally {
|
||||
window.storageFault = fault;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const fault of ["open", "read"]) {
|
||||
test(`initial ${fault} failure blocks the interface and retries without changing persisted data`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize(fault === "read" ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
|
||||
await setup(page, fault);
|
||||
await expect(page.getByRole("heading", { name: "Données locales indisponibles" })).toBeVisible();
|
||||
await expect(page.getByRole("alert")).toHaveText(fault === "open" ? "Ouverture indisponible" : "Lecture indisponible");
|
||||
await expect(page.getByRole("button", { name: /Nouvelle toolbox|Exporter tout|Importer/ })).toHaveCount(0);
|
||||
await expect(page.getByText("Aucune toolbox", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("Quota local atteint")).toHaveCount(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
await page.screenshot({ path: testInfo.outputPath("storage-unavailable.png"), fullPage: true });
|
||||
await page.getByRole("button", { name: "Réessayer" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Données locales indisponibles" })).toBeVisible();
|
||||
await page.evaluate(() => { window.location.hash = "/toolbox/saved"; });
|
||||
await expect(page.getByRole("heading", { name: "Toolbox introuvable" })).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "Données locales indisponibles" })).toBeVisible();
|
||||
await page.evaluate(() => { window.storageFault = ""; });
|
||||
await page.getByRole("button", { name: "Réessayer" }).click();
|
||||
await expect(page.locator('[data-module-type="memos"]')).toBeVisible();
|
||||
await expect(page.getByText("Ne pas perdre", { exact: true }).first()).toBeVisible();
|
||||
expect(await snapshot(page)).toEqual(original);
|
||||
|
||||
await page.evaluate(() => { window.location.hash = "/toolboxes"; });
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Exporter tout", exact: true }).click();
|
||||
const download = await downloadPromise;
|
||||
const stream = await download.createReadStream();
|
||||
const chunks = [];
|
||||
for await (const chunk of stream) chunks.push(chunk);
|
||||
const exported = JSON.parse(Buffer.concat(chunks).toString());
|
||||
expect(exported.toolboxes[0].id).toBe("saved");
|
||||
expect(exported.modules["saved:note"].items[0].text).toBe("Ne pas perdre");
|
||||
expect(await snapshot(page)).toEqual(original);
|
||||
|
||||
await page.getByRole("button", { name: "Nouvelle toolbox", exact: true }).click();
|
||||
await page.getByRole("textbox", { name: "Nom", exact: true }).fill("Après reprise");
|
||||
await page.getByRole("button", { name: "Créer", exact: true }).click();
|
||||
await expect.poll(async () => (await snapshot(page)).toolboxes.length).toBe(2);
|
||||
const saved = await snapshot(page);
|
||||
expect(saved.toolboxes.find((toolbox) => toolbox.id === "saved").name).toBe("Conservée");
|
||||
expect(saved.modules.find((entry) => entry.key === "saved:note")).toEqual(original.modules[0]);
|
||||
expect(saved.links).toEqual(original.links);
|
||||
});
|
||||
}
|
||||
|
||||
test("commands reject writes, creation, imports and exports while the store is not initialized", async ({ page }) => {
|
||||
await setup(page, "read");
|
||||
await page.evaluate(async (url) => {
|
||||
const { mountStorageStartupProbe } = await import(url);
|
||||
const element = document.createElement("div");
|
||||
document.body.append(element);
|
||||
window.storageProbe = mountStorageStartupProbe(element);
|
||||
}, probeUrl);
|
||||
await expect.poll(() => page.evaluate(() => window.storageProbe.store.loadError)).toBe("Lecture indisponible");
|
||||
const results = await page.evaluate(async () => {
|
||||
const { store, actions } = window.storageProbe;
|
||||
const forbidden = () => { throw new Error("Incomplete state must not be consumed"); };
|
||||
return {
|
||||
ready: store.ready,
|
||||
writes: await Promise.all([
|
||||
store.setToolboxes(forbidden), store.setLinks(forbidden), store.setToastPosition("left"),
|
||||
store.updateModuleData("saved", "note", {}), store.removeModuleData("saved", "note"),
|
||||
store.removeToolboxModuleData({ id: "saved", modules: [{ id: "note" }] }),
|
||||
store.persistToolboxSnapshot(forbidden)
|
||||
]),
|
||||
created: actions.createToolbox("New"),
|
||||
imported: await actions.importToolbox({ text: forbidden }),
|
||||
importedAll: await actions.importAllToolboxes({ text: forbidden }),
|
||||
exported: actions.exportToolbox("saved"), exportedAll: actions.exportAllToolboxes(),
|
||||
notices: window.storageProbe.notices
|
||||
};
|
||||
});
|
||||
expect(results).toEqual({ ready: false, writes: Array(7).fill(false), created: null, imported: null, importedAll: false, exported: false, exportedAll: false, notices: [] });
|
||||
expect(await snapshot(page)).toEqual(original);
|
||||
await page.evaluate(() => window.storageProbe.unmount());
|
||||
});
|
||||
|
||||
test("a later write failure uses an accurate title and preserves the confirmed toolbox", async ({ page }) => {
|
||||
await setup(page, "");
|
||||
await expect(page.locator(".toolbox-card")).toHaveCount(1);
|
||||
await page.evaluate(() => { window.storageFault = "write"; });
|
||||
await page.getByRole("button", { name: "Nouvelle toolbox", exact: true }).click();
|
||||
await page.getByRole("textbox", { name: "Nom", exact: true }).fill("Not saved");
|
||||
await page.getByRole("button", { name: "Créer", exact: true }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Erreur de stockage local" })).toBeVisible();
|
||||
await expect(page.getByText("Quota local atteint")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Compris", exact: true }).click();
|
||||
expect(await snapshot(page)).toEqual(original);
|
||||
});
|
||||
|
|
@ -116,6 +116,12 @@ export function validateSiteContent(site) {
|
|||
"about.contribute.promptIntro",
|
||||
"about.contribute.promptText",
|
||||
"toolboxes.eyebrow",
|
||||
"toolboxes.storageStatus.loadingTitle",
|
||||
"toolboxes.storageStatus.loadingMessage",
|
||||
"toolboxes.storageStatus.errorTitle",
|
||||
"toolboxes.storageStatus.errorMessage",
|
||||
"toolboxes.storageStatus.retryLabel",
|
||||
"toolboxes.storageStatus.writeErrorTitle",
|
||||
"toolboxes.title",
|
||||
"toolboxes.newButton",
|
||||
"toolboxes.libraryLink",
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ test("imports merge after file reading and preserve edits queued during the tran
|
|||
const notices = [];
|
||||
const actions = useToolboxActions({
|
||||
store: {
|
||||
ready: true,
|
||||
...initial,
|
||||
persistToolboxSnapshot: (apply) => queue.enqueue(apply, async (next) => {
|
||||
await transaction.promise;
|
||||
|
|
@ -115,6 +116,7 @@ test("global imports started from the same render both survive", async () => {
|
|||
const queue = createPersistenceQueue(initial, () => {});
|
||||
const actions = useToolboxActions({
|
||||
store: {
|
||||
ready: true,
|
||||
...initial,
|
||||
persistToolboxSnapshot: (apply) => queue.enqueue(apply, async (next) => { disk = next; })
|
||||
},
|
||||
|
|
@ -144,6 +146,7 @@ test("failed toolbox deletion preserves registry links and modules together", as
|
|||
const queue = createPersistenceQueue(initial, () => {});
|
||||
const actions = useToolboxActions({
|
||||
store: {
|
||||
ready: true,
|
||||
...initial,
|
||||
persistToolboxSnapshot: (apply) => queue.enqueue(apply, async () => { throw new Error("abort"); })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1793,6 +1793,14 @@
|
|||
]
|
||||
},
|
||||
"toolboxes": {
|
||||
"storageStatus": {
|
||||
"loadingTitle": "Chargement des données locales",
|
||||
"loadingMessage": "Lecture des toolboxes en cours…",
|
||||
"errorTitle": "Données locales indisponibles",
|
||||
"errorMessage": "Le chargement a échoué. Les modifications et les exports sont bloqués pour protéger les données existantes.",
|
||||
"retryLabel": "Réessayer",
|
||||
"writeErrorTitle": "Erreur de stockage local"
|
||||
},
|
||||
"eyebrow": "Données locales",
|
||||
"title": "Toolboxes",
|
||||
"newButton": "Nouvelle toolbox",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export function AppOverlays({
|
|||
setLinkModalGameId,
|
||||
setDrawerGameId,
|
||||
storageError,
|
||||
storageErrorTitle,
|
||||
setStorageError,
|
||||
image,
|
||||
setImage,
|
||||
|
|
@ -69,7 +70,7 @@ export function AppOverlays({
|
|||
actions.linkToolboxToGame(gameId, toolboxId);
|
||||
setDrawerGameId(gameId);
|
||||
}} />}
|
||||
{storageError && <ConfirmModal title="Quota local atteint" message={storageError} confirmLabel="Compris" cancelLabel="Fermer" danger onClose={() => setStorageError("")} />}
|
||||
{storageError && <ConfirmModal title={storageErrorTitle} message={storageError} confirmLabel="Compris" cancelLabel="Fermer" danger onClose={() => setStorageError("")} />}
|
||||
{image && <ImageViewer image={image} onClose={() => setImage(null)} createMarkerId={() => uid("marker")} />}
|
||||
{notifications.length > 0 && (
|
||||
<div className={`notification-toast-stack nds-toast-stack is-${toastPosition === "left" ? "left" : "right"}`} data-position={toastPosition === "left" ? "left" : "right"} aria-live="polite" aria-relevant="additions">
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ function normalizeToolboxes(toolboxes) {
|
|||
|
||||
export function useIndexedToolboxes(onError) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||
const writableRef = useRef(false);
|
||||
const [state, setState] = useState({ toolboxes: [], links: {}, moduleData: {}, toastPosition: "right" });
|
||||
const [queue] = useState(() => createPersistenceQueue(state, setState));
|
||||
const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 });
|
||||
|
|
@ -71,26 +74,34 @@ export function useIndexedToolboxes(onError) {
|
|||
moduleData: Object.fromEntries((modules || []).map((entry) => [entry.key, entry.data])),
|
||||
toastPosition: toastPosition === "left" ? "left" : "right"
|
||||
});
|
||||
writableRef.current = true;
|
||||
setReady(true);
|
||||
refreshQuota();
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setReady(true);
|
||||
reportStorageError(error);
|
||||
setLoadError(error?.message || "Erreur de stockage IndexedDB.");
|
||||
}
|
||||
}
|
||||
}
|
||||
loadStore();
|
||||
return () => { cancelled = true; };
|
||||
}, [queue, refreshQuota, reportStorageError]);
|
||||
return () => { cancelled = true; writableRef.current = false; };
|
||||
}, [queue, refreshQuota, loadAttempt]);
|
||||
|
||||
function retryLoad() {
|
||||
if (ready || !loadError) return;
|
||||
setLoadError("");
|
||||
setLoadAttempt((attempt) => attempt + 1);
|
||||
}
|
||||
|
||||
function enqueue(apply, persist) {
|
||||
if (!writableRef.current) return Promise.resolve(false);
|
||||
const result = queue.enqueue(apply, persist, reportStorageError);
|
||||
void result.then((success) => { if (success) refreshQuota(); });
|
||||
return result;
|
||||
}
|
||||
|
||||
function persistToolboxes(value) {
|
||||
if (!writableRef.current) return false;
|
||||
const apply = typeof value === "function" ? value : () => value;
|
||||
enqueue(
|
||||
(current) => ({ ...current, toolboxes: normalizeToolboxes(apply(current.toolboxes)) }),
|
||||
|
|
@ -100,6 +111,7 @@ export function useIndexedToolboxes(onError) {
|
|||
}
|
||||
|
||||
function persistLinks(value) {
|
||||
if (!writableRef.current) return false;
|
||||
enqueue(
|
||||
(current) => ({ ...current, links: typeof value === "function" ? value(current.links) : value }),
|
||||
(next) => dbSetLinks(next.links)
|
||||
|
|
@ -112,6 +124,7 @@ export function useIndexedToolboxes(onError) {
|
|||
}
|
||||
|
||||
function updateModuleData(toolboxId, moduleId, value, moduleType = "") {
|
||||
if (!writableRef.current) return false;
|
||||
const compact = prepareModuleDataForStorage(queue.getCurrent().toolboxes, toolboxId, moduleId, value, moduleType);
|
||||
const key = moduleStorageKey(toolboxId, moduleId);
|
||||
const hasModule = (current) => current.toolboxes.some((toolbox) => toolbox.id === toolboxId && toolbox.modules.some((module) => module.id === moduleId));
|
||||
|
|
@ -134,6 +147,7 @@ export function useIndexedToolboxes(onError) {
|
|||
}
|
||||
|
||||
function removeToolboxModuleData(toolbox) {
|
||||
if (!writableRef.current) return Promise.resolve(false);
|
||||
const keys = (toolbox?.modules || []).map((module) => moduleStorageKey(toolbox.id, module.id));
|
||||
if (!keys.length) return Promise.resolve(true);
|
||||
return enqueue(
|
||||
|
|
@ -150,6 +164,7 @@ export function useIndexedToolboxes(onError) {
|
|||
}
|
||||
|
||||
function setToastPosition(position) {
|
||||
if (!writableRef.current) return false;
|
||||
const toastPosition = position === "left" ? "left" : "right";
|
||||
enqueue(
|
||||
(current) => ({ ...current, toastPosition }),
|
||||
|
|
@ -160,6 +175,8 @@ export function useIndexedToolboxes(onError) {
|
|||
return {
|
||||
...state,
|
||||
ready,
|
||||
loadError,
|
||||
retryLoad,
|
||||
storageUsage,
|
||||
setToolboxes: persistToolboxes,
|
||||
setLinks: persistLinks,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ function getLibraryUrl() {
|
|||
|
||||
export function useToolboxActions({ store, notify, creationTemplate, setConfirmModal, setCreateModal, setLinkModalGameId, setDrawerGameId, setImage }) {
|
||||
function createToolbox(name, gameId = "", options = {}) {
|
||||
if (!store.ready) return null;
|
||||
const now = new Date().toISOString();
|
||||
const memosModuleId = uid("mod");
|
||||
const linksModuleId = uid("mod");
|
||||
|
|
@ -241,6 +242,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
|
|||
store.setToolboxes((toolboxes) => [...toolboxes].sort((a, b) => (order.get(a.id) ?? 9999) - (order.get(b.id) ?? 9999)));
|
||||
},
|
||||
importToolbox: async (file, gameId = "") => {
|
||||
if (!store.ready) return null;
|
||||
try {
|
||||
const toolbox = await importToolboxPayload(file, gameId);
|
||||
if (toolbox) notify("Toolbox importée.");
|
||||
|
|
@ -257,6 +259,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
|
|||
}
|
||||
},
|
||||
importAllToolboxes: async (file) => {
|
||||
if (!store.ready) return false;
|
||||
try {
|
||||
await importAllToolboxesPayload(file);
|
||||
notify("Import global terminé.");
|
||||
|
|
@ -273,12 +276,14 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
|
|||
}
|
||||
},
|
||||
exportToolbox: (id) => {
|
||||
if (!store.ready) return false;
|
||||
const toolbox = normalizeToolbox(store.toolboxes.find((item) => item.id === id));
|
||||
if (!toolbox) return;
|
||||
downloadJson(createToolboxExportPayload(toolbox, store.moduleData), `${toolbox.name.toLowerCase().replace(/[^a-z0-9]+/gi, "-")}.sokkog.json`);
|
||||
notify("Export de la toolbox lancé.");
|
||||
},
|
||||
exportAllToolboxes: () => {
|
||||
if (!store.ready) return false;
|
||||
downloadJson(createGlobalExportPayload(store.toolboxes, store.links, store.moduleData), `sokkog-global-${new Date().toISOString().slice(0, 10)}.json`);
|
||||
notify("Export global lancé.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import "./styles/main.scss";
|
|||
import { AppOverlays } from "./components/AppOverlays.jsx";
|
||||
import { AppErrorBoundary } from "./components/AppErrorBoundary.jsx";
|
||||
import { Shell } from "./components/Shell.jsx";
|
||||
import { Icon } from "./components/Icon.jsx";
|
||||
import { useIndexedToolboxes } from "./features/toolboxes/storage/useIndexedToolboxes.js";
|
||||
import { useToolboxActions } from "./features/toolboxes/useToolboxActions.js";
|
||||
import { useAppData } from "./hooks/useAppData.js";
|
||||
|
|
@ -102,6 +103,24 @@ function App() {
|
|||
);
|
||||
}
|
||||
|
||||
if (!store.ready) {
|
||||
const content = siteContent.toolboxes.storageStatus;
|
||||
return (
|
||||
<main className="startup-error">
|
||||
<section className="empty nds-empty-state" aria-busy={!store.loadError}>
|
||||
<h1>{store.loadError ? content.errorTitle : content.loadingTitle}</h1>
|
||||
<p>{store.loadError ? content.errorMessage : content.loadingMessage}</p>
|
||||
{store.loadError && <>
|
||||
<p role="alert">{store.loadError}</p>
|
||||
<button className="nds-button" data-size="sm" data-variant="primary" type="button" onClick={store.retryLoad}>
|
||||
<Icon name="refresh" />{content.retryLabel}
|
||||
</button>
|
||||
</>}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell route={route} content={siteContent} games={games} toolboxes={store.toolboxes} links={store.links} drawerGameId={drawerGameId} actions={actions}>
|
||||
<AppErrorBoundary route={route}>
|
||||
|
|
@ -151,6 +170,7 @@ function App() {
|
|||
</Suspense>
|
||||
)}
|
||||
<AppOverlays
|
||||
storageErrorTitle={siteContent.toolboxes.storageStatus.writeErrorTitle}
|
||||
actions={actions}
|
||||
confirmModal={confirmModal}
|
||||
setConfirmModal={setConfirmModal}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ function openDatabase() {
|
|||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
}).catch((error) => {
|
||||
dbPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue