Protect toolbox data after IndexedDB startup failures
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-12 23:05:16 +02:00
parent b53c7c17c8
commit 09a2d4b71a
13 changed files with 242 additions and 5 deletions

View file

@ -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">

View file

@ -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,

View file

@ -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é.");
}

View file

@ -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}

View file

@ -19,6 +19,9 @@ function openDatabase() {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
}).catch((error) => {
dbPromise = undefined;
throw error;
});
return dbPromise;
}