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