Add local automatic backups and Aniimo regions lists
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
2255de94a7
commit
0a3bb4dffd
24 changed files with 908 additions and 16 deletions
|
|
@ -1,7 +1,7 @@
|
|||
// Rôle : regroupe les modales, notifications et viewers rendus au-dessus de l'application.
|
||||
import { Icon } from "./Icon.jsx";
|
||||
import { ImageViewer } from "./ImageViewer.jsx";
|
||||
import { ConfirmModal, CreateToolboxModal, LinkToolboxModal, NotificationToast } from "./ToolboxModals.jsx";
|
||||
import { AutoBackupModal, ConfirmModal, CreateToolboxModal, LinkToolboxModal, NotificationToast } from "./ToolboxModals.jsx";
|
||||
import { navigate } from "../router/hashRouter.js";
|
||||
import { uid } from "../features/toolboxes/storage/toolboxStorage.js";
|
||||
|
||||
|
|
@ -42,7 +42,11 @@ export function AppOverlays({
|
|||
getGame,
|
||||
toolboxes,
|
||||
links,
|
||||
setPendingTutorialToolboxId
|
||||
setPendingTutorialToolboxId,
|
||||
autoBackupModalOpen,
|
||||
setAutoBackupModalOpen,
|
||||
autoBackup,
|
||||
autoBackupContent
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
|
|
@ -70,6 +74,7 @@ export function AppOverlays({
|
|||
actions.linkToolboxToGame(gameId, toolboxId);
|
||||
setDrawerGameId(gameId);
|
||||
}} />}
|
||||
{autoBackupModalOpen && <AutoBackupModal backup={autoBackup} content={autoBackupContent} onClose={() => setAutoBackupModalOpen(false)} />}
|
||||
{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 && (
|
||||
|
|
|
|||
|
|
@ -79,3 +79,56 @@ export function LinkToolboxModal({ toolboxes, selectedId, onClose }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AutoBackupModal({ backup, content, onClose }) {
|
||||
useModalScrollLock();
|
||||
const folderName = backup.config.directoryHandle?.name || content.noFolder;
|
||||
const lastBackup = backup.config.lastBackup
|
||||
? new Date(backup.config.lastBackup).toLocaleString()
|
||||
: content.never;
|
||||
|
||||
return (
|
||||
<div className="confirm-modal-root auto-backup-modal-root">
|
||||
<div className="confirm-backdrop nds-modal-backdrop" onClick={onClose} />
|
||||
<section className="confirm-modal nds-modal auto-backup-modal" role="dialog" aria-modal="true" aria-labelledby="auto-backup-title">
|
||||
<header className="auto-backup-modal-header">
|
||||
<div>
|
||||
<p className="eyebrow">{content.eyebrow}</p>
|
||||
<h2 id="auto-backup-title">{content.title}</h2>
|
||||
</div>
|
||||
<button className="nds-icon-button" data-size="sm" type="button" onClick={onClose} aria-label={content.close} title={content.close}><Icon name="close" /></button>
|
||||
</header>
|
||||
<p className="auto-backup-description">{content.description}</p>
|
||||
<div className="auto-backup-compatibility">
|
||||
<strong>{content.compatibleLabel}</strong>
|
||||
<ul>{content.browsers.map((browser) => <li key={browser}>{browser}</li>)}</ul>
|
||||
</div>
|
||||
{!backup.supported ? <p className="nds-inline-notice" data-variant="warning" role="status">{content.unsupported}</p> : (
|
||||
<>
|
||||
<div className="auto-backup-folder">
|
||||
<div><span className="eyebrow">{content.folderLabel}</span><strong>{folderName}</strong></div>
|
||||
<button className="nds-button" data-size="sm" type="button" disabled={backup.busy} onClick={() => void backup.chooseDirectory()}>{backup.config.directoryHandle ? content.changeFolder : content.chooseFolder}</button>
|
||||
</div>
|
||||
<div className="auto-backup-last">
|
||||
<span className="eyebrow">{content.lastBackup}</span>
|
||||
<strong>{lastBackup}</strong>
|
||||
</div>
|
||||
<label className="auto-backup-toggle">
|
||||
<input className="native-switch-input" type="checkbox" checked={backup.config.enabled} disabled={backup.busy} onChange={(event) => void backup.setEnabled(event.target.checked)} />
|
||||
<span className="switch-track" aria-hidden="true"><span className="switch-thumb" /></span>
|
||||
<span className="auto-backup-toggle-copy"><strong>{content.enabled}</strong><small>{content.toggleHelp}</small><small>{content.retention}</small></span>
|
||||
</label>
|
||||
{backup.error && <p className="nds-inline-notice" data-variant="danger" role="alert">{backup.error}</p>}
|
||||
<footer className="nds-modal-footer auto-backup-modal-footer">
|
||||
{backup.config.directoryHandle && backup.permission !== "granted" && <button className="nds-button" data-size="sm" type="button" disabled={backup.busy} onClick={() => void backup.requestPermission()}>{content.reauthorize}</button>}
|
||||
<button className="nds-button" data-size="sm" data-variant="primary" type="button" disabled={backup.busy || !backup.config.directoryHandle} onClick={() => void backup.saveNow()}>{content.saveNow}</button>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
<footer className="nds-modal-footer auto-backup-close-footer">
|
||||
<button className="nds-button" data-size="sm" type="button" onClick={onClose}>{content.close}</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
18
website/src/features/games/aniimo/AniimoLists.jsx
Normal file
18
website/src/features/games/aniimo/AniimoLists.jsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Rôle : affiche les listes Aniimo exportables en checklist.
|
||||
import { useState } from "react";
|
||||
import { GameListsPage } from "../GameListsPage.jsx";
|
||||
|
||||
export function AniimoLists({ game, aniimo, linkedToolboxId, actions }) {
|
||||
const lists = aniimo.lists || [];
|
||||
const [selectedListId, setSelectedListId] = useState("");
|
||||
return (
|
||||
<GameListsPage
|
||||
game={game}
|
||||
lists={lists}
|
||||
selectedListId={selectedListId}
|
||||
onSelectList={setSelectedListId}
|
||||
linkedToolboxId={linkedToolboxId}
|
||||
onCreateChecklist={(list) => actions.createChecklistFromList(game.id, list)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { Icon } from "../../../components/Icon.jsx";
|
|||
import { GameBreadcrumb } from "../GameBreadcrumb.jsx";
|
||||
import { AniimoCard } from "./AniimoCard.jsx";
|
||||
import { AniimoFilters } from "./AniimoFilters.jsx";
|
||||
import { AniimoLists } from "./AniimoLists.jsx";
|
||||
import { getAniimoStatsMax, aniimoLabel, filterAniimoVariants, groupAniimoVariants } from "./utils.js";
|
||||
|
||||
function AniimoListing({ game, aniimo, filters, setFilters, content }) {
|
||||
|
|
@ -41,11 +42,19 @@ function AniimoListing({ game, aniimo, filters, setFilters, content }) {
|
|||
</>;
|
||||
}
|
||||
|
||||
export function AniimoPage({ category, game, aniimo, filters, setFilters, siteContent }) {
|
||||
export function AniimoPage({ category, game, aniimo, filters, setFilters, siteContent, links, actions }) {
|
||||
const content = siteContent.aniimo;
|
||||
if (category === "lists") {
|
||||
if (!aniimo.loaded) return <section className="empty nds-empty-state" aria-busy="true"><h1>{content.loading}</h1></section>;
|
||||
if (aniimo.error) return <section className="empty nds-empty-state" role="alert"><h1>{content.error}</h1><p>{aniimo.error}</p></section>;
|
||||
return <AniimoLists game={game} aniimo={aniimo} linkedToolboxId={links?.[game.id] || ""} actions={actions} />;
|
||||
}
|
||||
if (category !== "aniimos") return <>
|
||||
<section className="game-hero" style={{ "--game-hero-image": `url("${game.images.heroBg}")` }}><div><p className="eyebrow">{game.eyebrow}</p><h1>{game.title}</h1><p>{game.summary}</p></div></section>
|
||||
<section className="game-home-grid"><a className="feature game-home-card aniimo-home-card" href="#/games/aniimo/aniimos"><img src={game.images.aniimosCover} alt="" /><strong>{content.title}</strong><span>{content.homeDescription}</span></a></section>
|
||||
<section className="game-home-grid">
|
||||
<a className="feature game-home-card aniimo-home-card" href="#/games/aniimo/aniimos"><img src={game.images.aniimosCover} alt="" /><strong>{content.title}</strong><span>{content.homeDescription}</span></a>
|
||||
<a className="feature game-home-card" href="#/games/aniimo/lists"><span className="game-home-card-icon" aria-hidden="true"><span className="module-icon-svg module-icon-checklist" /></span><strong>Listes</strong><span>Données copiables au format checklist pour préparer vos suivis.</span></a>
|
||||
</section>
|
||||
</>;
|
||||
if (!aniimo.loaded) return <section className="empty nds-empty-state" aria-busy="true"><h1>{content.loading}</h1></section>;
|
||||
if (aniimo.error) return <section className="empty nds-empty-state" role="alert"><h1>{content.error}</h1><p>{aniimo.error}</p></section>;
|
||||
|
|
|
|||
|
|
@ -134,16 +134,19 @@ export const INITIAL_ANIIMO_STATE = {
|
|||
error: "",
|
||||
translations: {},
|
||||
variants: [],
|
||||
filterOptions: { elements: [], roles: [], abilities: [] }
|
||||
filterOptions: { elements: [], roles: [], abilities: [] },
|
||||
lists: []
|
||||
};
|
||||
|
||||
export async function loadAniimoData(signal) {
|
||||
const responses = await Promise.all([
|
||||
fetch("/data/aniimo/aniimos.json", { signal }),
|
||||
fetch("/data/aniimo/i18n/fr.json", { signal }),
|
||||
fetch("/data/aniimo/i18n/en.json", { signal })
|
||||
fetch("/data/aniimo/i18n/en.json", { signal }),
|
||||
fetch("/data/aniimo/lists/index.json", { signal })
|
||||
]);
|
||||
if (responses.some((response) => !response.ok)) throw new Error("Les données Aniimo sont indisponibles.");
|
||||
const [aniimos, translations, englishTranslations] = await Promise.all(responses.map((response) => response.json()));
|
||||
return { loaded: true, loading: false, error: "", translations, englishTranslations, ...prepareAniimoData(aniimos, translations, englishTranslations) };
|
||||
const [aniimos, translations, englishTranslations, listIndex] = await Promise.all(responses.map((response) => response.json()));
|
||||
const lists = await loadGameLists("aniimo", listIndex, signal);
|
||||
return { loaded: true, loading: false, error: "", translations, englishTranslations, lists, ...prepareAniimoData(aniimos, translations, englishTranslations) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ async function copyText(value) {
|
|||
}
|
||||
}
|
||||
|
||||
export function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions, storageUsage, toastPosition, setToastPosition }) {
|
||||
export function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions, storageUsage, toastPosition, setToastPosition, onOpenAutoBackup }) {
|
||||
const content = siteContent.toolboxes;
|
||||
const {
|
||||
itemReorder,
|
||||
|
|
@ -114,6 +114,7 @@ export function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions,
|
|||
<ImportButton className="nds-button" data-size="sm" label={content.importOne} onFile={actions.importToolbox} />
|
||||
</div>
|
||||
<div>
|
||||
<button className="nds-button" data-size="sm" type="button" onClick={onOpenAutoBackup}>{content.autoBackup.title}</button>
|
||||
<button className="nds-button" data-size="sm" data-variant="primary" onClick={actions.exportAllToolboxes}>{content.exportAll}</button>
|
||||
<ImportButton className="nds-button" data-size="sm" label={content.importAll} onFile={actions.importAllToolboxes} />
|
||||
</div>
|
||||
|
|
|
|||
140
website/src/features/toolboxes/storage/autoBackup.js
Normal file
140
website/src/features/toolboxes/storage/autoBackup.js
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// Rôle : gère les sauvegardes JSON locales via la File System Access API.
|
||||
|
||||
export const AUTO_BACKUP_SETTING = "autoBackup";
|
||||
export const AUTO_BACKUP_FILE_PATTERN = /^sokkog-global-\d{4}-\d{2}-\d{2}\.json$/;
|
||||
const AUTO_BACKUP_LOCK = "sokkog-auto-backup";
|
||||
|
||||
export function isFileSystemAccessSupported(scope = globalThis) {
|
||||
return Boolean(scope && "showDirectoryPicker" in scope);
|
||||
}
|
||||
|
||||
export function getLocalDateKey(date = new Date()) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function getBackupFileName(date = new Date()) {
|
||||
return `sokkog-global-${getLocalDateKey(date)}.json`;
|
||||
}
|
||||
|
||||
export function normalizeAutoBackupConfig(value) {
|
||||
return {
|
||||
enabled: value?.enabled === true,
|
||||
directoryHandle: value?.directoryHandle || null,
|
||||
lastBackup: typeof value?.lastBackup === "string" ? value.lastBackup : ""
|
||||
};
|
||||
}
|
||||
|
||||
async function withBackupLock(task) {
|
||||
if (globalThis.navigator?.locks?.request) {
|
||||
return globalThis.navigator.locks.request(AUTO_BACKUP_LOCK, { mode: "exclusive" }, task);
|
||||
}
|
||||
return task();
|
||||
}
|
||||
|
||||
async function queryReadWritePermission(directoryHandle) {
|
||||
if (!directoryHandle?.queryPermission) return "denied";
|
||||
return directoryHandle.queryPermission({ mode: "readwrite" });
|
||||
}
|
||||
|
||||
export async function getDirectoryPermission(directoryHandle) {
|
||||
try {
|
||||
return await queryReadWritePermission(directoryHandle);
|
||||
} catch {
|
||||
return "denied";
|
||||
}
|
||||
}
|
||||
|
||||
async function listBackupFiles(directoryHandle) {
|
||||
const files = [];
|
||||
for await (const entry of directoryHandle.values()) {
|
||||
if (entry.kind === "file" && AUTO_BACKUP_FILE_PATTERN.test(entry.name)) files.push(entry.name);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function sortBackupFiles(files) {
|
||||
return [...files].sort((left, right) => right.slice(14, 24).localeCompare(left.slice(14, 24)));
|
||||
}
|
||||
|
||||
async function cleanupBackups(directoryHandle) {
|
||||
const files = sortBackupFiles(await listBackupFiles(directoryHandle));
|
||||
for (const filename of files.slice(10)) await directoryHandle.removeEntry(filename);
|
||||
}
|
||||
|
||||
async function readBackupFile(directoryHandle, filename) {
|
||||
const fileHandle = await directoryHandle.getFileHandle(filename);
|
||||
const file = await fileHandle.getFile();
|
||||
return file.text();
|
||||
}
|
||||
|
||||
function comparableBackupContents(contents) {
|
||||
try {
|
||||
const payload = JSON.parse(contents);
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
||||
const withoutTimestamp = { ...payload };
|
||||
delete withoutTimestamp.exportedAt;
|
||||
return JSON.stringify(withoutTimestamp);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function backupContentsEqual(left, right) {
|
||||
const comparableLeft = comparableBackupContents(left);
|
||||
const comparableRight = comparableBackupContents(right);
|
||||
return comparableLeft !== null && comparableLeft === comparableRight;
|
||||
}
|
||||
|
||||
async function writeBackupFile(directoryHandle, filename, contents, preserveExisting) {
|
||||
const fileHandle = await directoryHandle.getFileHandle(filename, { create: true });
|
||||
let writable;
|
||||
try {
|
||||
writable = await fileHandle.createWritable();
|
||||
await writable.write(contents);
|
||||
await writable.close();
|
||||
} catch (error) {
|
||||
try { await writable?.abort(); } catch { /* Le fichier peut déjà être fermé. */ }
|
||||
if (!preserveExisting) {
|
||||
try { await directoryHandle.removeEntry(filename); } catch { /* Le fichier peut ne pas avoir été créé. */ }
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBackup({ directoryHandle, generateJson, date = new Date(), force = false }) {
|
||||
if (!directoryHandle) return { ok: false, reason: "missing-directory" };
|
||||
const permission = await getDirectoryPermission(directoryHandle);
|
||||
if (permission !== "granted") return { ok: false, reason: "permission", permission };
|
||||
const filename = getBackupFileName(date);
|
||||
|
||||
return withBackupLock(async () => {
|
||||
try {
|
||||
const existing = await listBackupFiles(directoryHandle);
|
||||
if (!force && existing.includes(filename)) return { ok: true, created: false, filename };
|
||||
const contents = await generateJson();
|
||||
if (!force && existing.length) {
|
||||
const latestFilename = sortBackupFiles(existing)[0];
|
||||
try {
|
||||
if (backupContentsEqual(contents, await readBackupFile(directoryHandle, latestFilename))) {
|
||||
return { ok: true, created: false, unchanged: true, filename, comparedTo: latestFilename };
|
||||
}
|
||||
} catch {
|
||||
// Une lecture impossible ne doit pas empêcher la création d'une nouvelle version.
|
||||
}
|
||||
}
|
||||
await writeBackupFile(directoryHandle, filename, contents, existing.includes(filename));
|
||||
let cleanupError = "";
|
||||
try {
|
||||
await cleanupBackups(directoryHandle);
|
||||
} catch (error) {
|
||||
cleanupError = error?.message || "Erreur de nettoyage des anciennes sauvegardes.";
|
||||
}
|
||||
return { ok: true, created: true, filename, cleanupError };
|
||||
} catch (error) {
|
||||
return { ok: false, reason: "backup", error };
|
||||
}
|
||||
});
|
||||
}
|
||||
148
website/src/features/toolboxes/storage/useAutoBackup.js
Normal file
148
website/src/features/toolboxes/storage/useAutoBackup.js
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// Rôle : expose la configuration et les actions de sauvegarde automatique à l'interface.
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createGlobalExportPayload } from "./toolboxStorage.js";
|
||||
import { getSetting, setSetting } from "../../../utils/indexedDbStorage.js";
|
||||
import {
|
||||
AUTO_BACKUP_SETTING,
|
||||
getBackupFileName,
|
||||
getDirectoryPermission,
|
||||
isFileSystemAccessSupported,
|
||||
normalizeAutoBackupConfig,
|
||||
saveBackup
|
||||
} from "./autoBackup.js";
|
||||
|
||||
function serializeGlobalExport(snapshot) {
|
||||
return JSON.stringify(createGlobalExportPayload(snapshot.toolboxes, snapshot.links, snapshot.moduleData), null, 2);
|
||||
}
|
||||
|
||||
export function useAutoBackup(store) {
|
||||
const supported = isFileSystemAccessSupported(typeof window === "undefined" ? undefined : window);
|
||||
const [config, setConfig] = useState(() => normalizeAutoBackupConfig());
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [permission, setPermission] = useState("unknown");
|
||||
const [error, setError] = useState("");
|
||||
const runRef = useRef(false);
|
||||
const startedRef = useRef(false);
|
||||
|
||||
const persist = useCallback(async (next) => {
|
||||
const normalized = normalizeAutoBackupConfig(next);
|
||||
setConfig(normalized);
|
||||
try {
|
||||
await setSetting(AUTO_BACKUP_SETTING, normalized);
|
||||
} catch (persistError) {
|
||||
setError(persistError?.message || "Impossible d'enregistrer les préférences de sauvegarde.");
|
||||
}
|
||||
return normalized;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getSetting(AUTO_BACKUP_SETTING, normalizeAutoBackupConfig()).then((value) => {
|
||||
if (!cancelled) {
|
||||
setConfig(normalizeAutoBackupConfig(value));
|
||||
setLoaded(true);
|
||||
}
|
||||
}).catch((loadError) => {
|
||||
if (!cancelled) {
|
||||
setLoaded(true);
|
||||
setError(loadError?.message || "Impossible de lire les préférences de sauvegarde.");
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const run = useCallback(async ({ force = false, directoryHandle = config.directoryHandle, configOverride = config } = {}) => {
|
||||
if (!supported || !directoryHandle || runRef.current) return { ok: false, reason: "unavailable" };
|
||||
runRef.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await saveBackup({
|
||||
directoryHandle,
|
||||
force,
|
||||
generateJson: async () => {
|
||||
const snapshot = await store.getExportSnapshot(undefined, { onError: () => {} });
|
||||
if (!snapshot) throw new Error("Impossible de préparer les données à sauvegarder.");
|
||||
return serializeGlobalExport(snapshot);
|
||||
}
|
||||
});
|
||||
setPermission(result.permission || await getDirectoryPermission(directoryHandle));
|
||||
if (result.ok && !result.cleanupError) setError("");
|
||||
if (result.ok && result.created) {
|
||||
const next = { ...configOverride, directoryHandle, lastBackup: new Date().toISOString() };
|
||||
await persist(next);
|
||||
}
|
||||
if (!result.ok) setError(result.error?.message || "La sauvegarde locale a échoué.");
|
||||
if (result.ok && result.cleanupError) setError(result.cleanupError);
|
||||
return result;
|
||||
} catch (runError) {
|
||||
setError(runError?.message || "La sauvegarde locale a échoué.");
|
||||
return { ok: false, reason: "backup", error: runError };
|
||||
} finally {
|
||||
runRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}, [config, persist, store, supported]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded || !supported || !store.ready || !config.enabled || !config.directoryHandle || startedRef.current) return;
|
||||
startedRef.current = true;
|
||||
void run();
|
||||
}, [config.directoryHandle, config.enabled, loaded, run, store.ready, supported]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded || !config.directoryHandle) return;
|
||||
void getDirectoryPermission(config.directoryHandle).then(setPermission);
|
||||
}, [config.directoryHandle, loaded]);
|
||||
|
||||
const chooseDirectory = useCallback(async () => {
|
||||
if (!supported) return false;
|
||||
try {
|
||||
const directoryHandle = await window.showDirectoryPicker({ mode: "readwrite" });
|
||||
const next = await persist({ ...config, directoryHandle });
|
||||
setPermission("granted");
|
||||
if (next.enabled) void run({ directoryHandle, configOverride: next });
|
||||
return true;
|
||||
} catch (chooseError) {
|
||||
if (chooseError?.name !== "AbortError") setError(chooseError?.message || "Impossible de choisir ce dossier.");
|
||||
return false;
|
||||
}
|
||||
}, [config, persist, run, supported]);
|
||||
|
||||
const requestPermission = useCallback(async () => {
|
||||
if (!config.directoryHandle?.requestPermission) return false;
|
||||
try {
|
||||
const result = await config.directoryHandle.requestPermission({ mode: "readwrite" });
|
||||
setPermission(result);
|
||||
if (result === "granted" && config.enabled) void run();
|
||||
return result === "granted";
|
||||
} catch (permissionError) {
|
||||
setError(permissionError?.message || "Impossible de réautoriser ce dossier.");
|
||||
return false;
|
||||
}
|
||||
}, [config.directoryHandle, config.enabled, run]);
|
||||
|
||||
const setEnabled = useCallback(async (enabled) => {
|
||||
const next = await persist({ ...config, enabled });
|
||||
if (enabled && next.directoryHandle) {
|
||||
const currentPermission = await getDirectoryPermission(next.directoryHandle);
|
||||
setPermission(currentPermission);
|
||||
if (currentPermission === "granted") void run({ configOverride: next });
|
||||
}
|
||||
}, [config, persist, run]);
|
||||
|
||||
return {
|
||||
supported,
|
||||
config,
|
||||
busy,
|
||||
permission,
|
||||
error,
|
||||
setError,
|
||||
setEnabled,
|
||||
chooseDirectory,
|
||||
requestPermission,
|
||||
saveNow: () => run({ force: true }),
|
||||
filename: getBackupFileName(),
|
||||
retention: 10
|
||||
};
|
||||
}
|
||||
|
|
@ -192,7 +192,7 @@ export function useIndexedToolboxes(onError) {
|
|||
return request;
|
||||
}, [queue]);
|
||||
|
||||
async function getExportSnapshot(toolboxId) {
|
||||
async function getExportSnapshot(toolboxId, options = {}) {
|
||||
if (!writableRef.current) return null;
|
||||
let snapshot;
|
||||
const success = await queue.enqueue((current) => current, async (current) => {
|
||||
|
|
@ -200,7 +200,7 @@ export function useIndexedToolboxes(onError) {
|
|||
const keys = deferred.filter((key) => !toolboxId || key.startsWith(`${toolboxId}:`));
|
||||
const entries = await dbGetModuleDataEntries(keys);
|
||||
snapshot = { ...current, moduleData: mergeLoadedModuleData(current.moduleData, keys, entries) };
|
||||
}, reportStorageError);
|
||||
}, options.onError || reportStorageError);
|
||||
return success ? snapshot : null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
uid
|
||||
} from "./storage/toolboxStorage.js";
|
||||
import { compressImage } from "../../utils/imageCompression.js";
|
||||
import { getBackupFileName } from "./storage/autoBackup.js";
|
||||
|
||||
function downloadJson(payload, filename) {
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
||||
|
|
@ -290,7 +291,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
|
|||
if (!store.ready) return false;
|
||||
const snapshot = await store.getExportSnapshot();
|
||||
if (!snapshot) return false;
|
||||
downloadJson(createGlobalExportPayload(snapshot.toolboxes, snapshot.links, snapshot.moduleData), `sokkog-global-${new Date().toISOString().slice(0, 10)}.json`);
|
||||
downloadJson(createGlobalExportPayload(snapshot.toolboxes, snapshot.links, snapshot.moduleData), getBackupFileName());
|
||||
notify("Export global lancé.");
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { useToolboxAlerts } from "./hooks/useToolboxAlerts.js";
|
|||
import { RouteContent } from "./router/RouteContent.jsx";
|
||||
import { useHashRoute } from "./router/hashRouter.js";
|
||||
import { normalizeCalendarData, normalizeTimerData } from "./features/toolboxes/storage/toolboxStorage.js";
|
||||
import { useAutoBackup } from "./features/toolboxes/storage/useAutoBackup.js";
|
||||
|
||||
const ToolboxDrawer = lazy(() => import("./features/toolboxes/ToolboxPages.jsx").then((module) => ({ default: module.ToolboxDrawer })));
|
||||
const DEFAULT_NOTIFICATION_DURATION_MS = 120000;
|
||||
|
|
@ -34,6 +35,7 @@ function App() {
|
|||
const [filters, setFilters] = useGameFilters();
|
||||
const [confirmModal, setConfirmModal] = useState(null);
|
||||
const [createModal, setCreateModal] = useState(null);
|
||||
const [autoBackupModalOpen, setAutoBackupModalOpen] = useState(false);
|
||||
const [linkModalGameId, setLinkModalGameId] = useState("");
|
||||
const [drawerGameId, setDrawerGameId] = useState("");
|
||||
const [image, setImage] = useState(null);
|
||||
|
|
@ -42,6 +44,7 @@ function App() {
|
|||
const [storageError, setStorageError] = useState("");
|
||||
const handleStorageError = useCallback((message) => setStorageError(message), []);
|
||||
const store = useIndexedToolboxes(handleStorageError);
|
||||
const autoBackup = useAutoBackup(store);
|
||||
const guideChecklistSession = useGuideChecklistSession(store);
|
||||
|
||||
const notify = useCallback((message, options = {}) => {
|
||||
|
|
@ -148,6 +151,7 @@ function App() {
|
|||
storageReady={store.ready}
|
||||
toastPosition={store.toastPosition}
|
||||
setToastPosition={store.setToastPosition}
|
||||
onOpenAutoBackup={() => setAutoBackupModalOpen(true)}
|
||||
getModuleData={store.getModuleData}
|
||||
updateToolbox={actions.updateToolbox}
|
||||
updateModuleData={store.updateModuleData}
|
||||
|
|
@ -195,6 +199,10 @@ function App() {
|
|||
toolboxes={store.toolboxes}
|
||||
links={store.links}
|
||||
setPendingTutorialToolboxId={setPendingTutorialToolboxId}
|
||||
autoBackupModalOpen={autoBackupModalOpen}
|
||||
setAutoBackupModalOpen={setAutoBackupModalOpen}
|
||||
autoBackup={autoBackup}
|
||||
autoBackupContent={siteContent.toolboxes.autoBackup}
|
||||
/>
|
||||
</Shell>
|
||||
</GuideChecklistContext.Provider>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ function currentRoute() {
|
|||
if (path === "/mhwilds/monsters") return "/games/mhwilds/monsters";
|
||||
if (path === "/mhwilds/endemic") return "/games/mhwilds/endemic";
|
||||
if (path === "/mhwilds/lists") return "/games/mhwilds/lists";
|
||||
if (path === "/aniimo") return "/games/aniimo";
|
||||
if (path === "/aniimo/aniimos") return "/games/aniimo/aniimos";
|
||||
if (path === "/aniimo/lists") return "/games/aniimo/lists";
|
||||
return path;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,218 @@
|
|||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.auto-backup-modal {
|
||||
width: min(620px, calc(100vw - 32px));
|
||||
max-height: min(760px, calc(100vh - 32px));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.auto-backup-modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.auto-backup-modal-header h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.auto-backup-modal-header .eyebrow,
|
||||
.auto-backup-folder .eyebrow,
|
||||
.auto-backup-last .eyebrow {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.auto-backup-description {
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.auto-backup-compatibility {
|
||||
margin-bottom: var(--space-5);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid rgba(165, 180, 252, 0.14);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(5, 7, 17, 0.32);
|
||||
}
|
||||
|
||||
.auto-backup-compatibility strong {
|
||||
display: block;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.auto-backup-compatibility ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: var(--space-3) 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.auto-backup-compatibility li {
|
||||
padding: 5px 9px;
|
||||
border: 1px solid rgba(246, 196, 83, 0.2);
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(246, 196, 83, 0.08);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.auto-backup-compatibility p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.auto-backup-folder {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
|
||||
.auto-backup-folder,
|
||||
.auto-backup-last {
|
||||
padding: var(--space-4);
|
||||
border: 1px solid rgba(165, 180, 252, 0.14);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(16, 20, 38, 0.42);
|
||||
}
|
||||
|
||||
.auto-backup-folder > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.auto-backup-folder strong {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.auto-backup-last {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.auto-backup-last strong {
|
||||
display: block;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.auto-backup-toggle {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-5);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid rgba(165, 180, 252, 0.16);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(16, 20, 38, 0.52);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auto-backup-toggle:has(input:disabled) {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.auto-backup-toggle .native-switch-input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auto-backup-toggle .switch-track {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 52px;
|
||||
height: 30px;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgba(165, 180, 252, 0.24);
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(7, 10, 24, 0.72);
|
||||
transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.auto-backup-toggle .switch-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-text-muted);
|
||||
transform: translateY(-50%);
|
||||
transition: left var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.auto-backup-toggle input:checked + .switch-track {
|
||||
border-color: rgba(246, 196, 83, 0.5);
|
||||
background: rgba(246, 196, 83, 0.16);
|
||||
}
|
||||
|
||||
.auto-backup-toggle input:checked + .switch-track .switch-thumb {
|
||||
left: 26px;
|
||||
background: var(--color-accent-gold);
|
||||
}
|
||||
|
||||
.auto-backup-toggle:focus-within {
|
||||
border-color: rgba(246, 196, 83, 0.45);
|
||||
}
|
||||
|
||||
.auto-backup-toggle-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.auto-backup-toggle-copy strong {
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.auto-backup-toggle-copy small {
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.auto-backup-modal-footer {
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
|
||||
.auto-backup-close-footer {
|
||||
margin-top: var(--space-3);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid rgba(165, 180, 252, 0.12);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.auto-backup-folder {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.auto-backup-modal-footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.auto-backup-modal-footer > button {
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
}
|
||||
|
||||
@property --notification-toast-angle {
|
||||
syntax: "<angle>";
|
||||
inherits: false;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue