From 0a3bb4dffd8e5a04fc00363a58655ac6a7178160 Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Thu, 24 Sep 2026 09:39:33 +0200 Subject: [PATCH] Add local automatic backups and Aniimo regions lists --- docs/FEATURE_CHECKLIST.md | 1 + docs/STORAGE_SCHEMA.md | 16 +- e2e/aniimo.spec.js | 10 +- e2e/toolbox.spec.js | 18 ++ tests/aniimo.test.mjs | 9 +- tests/auto-backup.test.mjs | 128 +++++++++++ tests/helpers/data-validation.mjs | 18 ++ tests/static-games.test.mjs | 5 + website/public/data/aniimo/lists/index.json | 9 + website/public/data/aniimo/lists/regions.json | 61 +++++ website/public/data/site.json | 24 ++ website/src/components/AppOverlays.jsx | 9 +- website/src/components/ToolboxModals.jsx | 53 +++++ .../src/features/games/aniimo/AniimoLists.jsx | 18 ++ .../src/features/games/aniimo/AniimoPage.jsx | 13 +- website/src/features/games/loaders.js | 11 +- .../src/features/toolboxes/ToolboxPages.jsx | 3 +- .../features/toolboxes/storage/autoBackup.js | 140 ++++++++++++ .../toolboxes/storage/useAutoBackup.js | 148 ++++++++++++ .../toolboxes/storage/useIndexedToolboxes.js | 4 +- .../features/toolboxes/useToolboxActions.js | 3 +- website/src/main.jsx | 8 + website/src/router/hashRouter.js | 3 + website/src/styles/overlays/_modals.scss | 212 ++++++++++++++++++ 24 files changed, 908 insertions(+), 16 deletions(-) create mode 100644 tests/auto-backup.test.mjs create mode 100644 website/public/data/aniimo/lists/index.json create mode 100644 website/public/data/aniimo/lists/regions.json create mode 100644 website/src/features/games/aniimo/AniimoLists.jsx create mode 100644 website/src/features/toolboxes/storage/autoBackup.js create mode 100644 website/src/features/toolboxes/storage/useAutoBackup.js diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md index 29be803..8932dfc 100644 --- a/docs/FEATURE_CHECKLIST.md +++ b/docs/FEATURE_CHECKLIST.md @@ -40,6 +40,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page - Vérifier que l'ajout, la suppression et la réorganisation respectent `moduleOrder.one` et `moduleOrder.two`. - Pour les déplacements par pointeur, vérifier les dépôts avant/après, le rejet des cibles interdites et l'annulation navigateur (`pointercancel`) sans modification de l'ordre ; le démontage doit retirer les listeners actifs. - Vérifier le quota de stockage si l'outil manipule des données lourdes. +- Pour la sauvegarde locale, vérifier la feature detection, les permissions sans popup au démarrage, le nommage en date locale, la déduplication quotidienne, le remplacement manuel, la rétention de dix fichiers et la conservation des fichiers étrangers. ## Page Jeu diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index eb32165..e156cbb 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -20,10 +20,24 @@ Clés principales dans `kv` : "links": {}, "setting:toolboxOrder": [], "setting:storageBudgetBytes": 536870912, - "setting:toastPosition": "right" + "setting:toastPosition": "right", + "setting:autoBackup": { + "enabled": false, + "directoryHandle": null, + "lastBackup": "" + } } ``` +`setting:autoBackup` conserve le `FileSystemDirectoryHandle` choisi par l'utilisateur +et le dernier timestamp de réussite. Le handle n'est jamais inclus dans les exports. +Les sauvegardes utilisent le format de l'export global, sont nommées avec la date +locale du navigateur et conservent les dix fichiers `sokkog-global-YYYY-MM-DD.json` +les plus récents. La vérification au démarrage ne demande pas de permission et ne +crée pas les jours manquants lorsque l'application était fermée. Une nouvelle +sauvegarde automatique est ignorée si son contenu est identique à la version la +plus récente, en excluant uniquement l'horodatage d'export. + Entrée type dans `modules` : ```json diff --git a/e2e/aniimo.spec.js b/e2e/aniimo.spec.js index 9bc515f..46c3b26 100644 --- a/e2e/aniimo.spec.js +++ b/e2e/aniimo.spec.js @@ -105,7 +105,15 @@ test("catalogue, overview and deferred Aniimo loading preserve filters across na await page.locator('a[href="#/games/aniimo/aniimos"]').click(); await expect(cards(page)).toHaveCount(3); await expect(page.getByRole("searchbox")).toHaveValue("EMBERPUP"); - expect(requests).toHaveLength(3); + expect(requests).toHaveLength(5); +}); + +test("Aniimo exposes its regions list", async ({ page }) => { + await page.goto("/#/games/aniimo/lists"); + await expect(page.getByRole("heading", { name: "Listes", exact: true })).toBeVisible(); + await expect(page.locator("h2", { hasText: "Regions" })).toBeVisible(); + await expect(page.getByText("14 éléments", { exact: true })).toBeVisible(); + await expect(page.getByText("Champs de Cumulaine", { exact: true })).toBeVisible(); }); test("tabs, flip, matching variants and reset remain independent", async ({ page }) => { diff --git a/e2e/toolbox.spec.js b/e2e/toolbox.spec.js index 08d3f0b..6aa09ef 100644 --- a/e2e/toolbox.spec.js +++ b/e2e/toolbox.spec.js @@ -240,6 +240,24 @@ test("imports and exports a toolbox payload", async ({ page }) => { expect(download.suggestedFilename()).toBe("fixture-toolbox.sokkog.json"); }); +test("opens the local automatic backup settings modal", async ({ page }) => { + await page.goto("/#/toolboxes"); + await expect(page.getByRole("button", { name: "Sauvegarde automatique", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Sauvegarde automatique", exact: true }).click(); + + const dialog = page.getByRole("dialog", { name: "Sauvegarde automatique" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText("Cette fonctionnalité n’est pas disponible dans tous les navigateurs.")).toBeVisible(); + await expect(dialog.getByText("Opera GX", { exact: true })).toBeVisible(); + await expect(dialog.getByText("10 versions sont conservées.", { exact: true })).toBeVisible(); + await expect(dialog.getByText("Dossier configuré :", { exact: true })).toBeVisible(); + await expect(dialog.getByText("Aucun dossier", { exact: true })).toBeVisible(); + await expect(dialog.getByRole("checkbox")).toBeVisible(); + + await dialog.getByRole("button", { name: "Fermer", exact: true }).last().click(); + await expect(dialog).toHaveCount(0); +}); + test("edits a table with formulas and persists values", async ({ page }) => { await createToolbox(page, "Table E2E Toolbox"); await addTool(page, "Tableau"); diff --git a/tests/aniimo.test.mjs b/tests/aniimo.test.mjs index a90f4b9..d638bc2 100644 --- a/tests/aniimo.test.mjs +++ b/tests/aniimo.test.mjs @@ -112,7 +112,9 @@ test("grouping preserves order and contains only variants that pass all filters" test("loader passes cancellation to all requests and reports failed responses", async (context) => { const calls = []; const controller = new AbortController(); - const bodies = [aniimos, fr, en]; + const listIndex = await readJson("aniimo/lists/index.json"); + const regions = await readJson("aniimo/lists/regions.json"); + const bodies = [aniimos, fr, en, listIndex, regions]; context.mock.method(globalThis, "fetch", async (url, options) => { calls.push({ url, options }); const body = bodies[calls.length - 1]; @@ -120,7 +122,10 @@ test("loader passes cancellation to all requests and reports failed responses", }); const result = await loadAniimoData(controller.signal); assert.equal(result.variants.length, data.variants.length); - assert.equal(calls.length, 3); + assert.equal(result.lists.length, 1); + assert.equal(result.lists[0].id, "regions"); + assert.equal(result.lists[0].itemCount, 14); + assert.equal(calls.length, 5); assert.ok(calls.every((call) => call.options.signal === controller.signal)); globalThis.fetch.mock.mockImplementation(async () => ({ ok: false })); await assert.rejects(loadAniimoData(controller.signal), /indisponibles/); diff --git a/tests/auto-backup.test.mjs b/tests/auto-backup.test.mjs new file mode 100644 index 0000000..e48d5c8 --- /dev/null +++ b/tests/auto-backup.test.mjs @@ -0,0 +1,128 @@ +// Rôle : vérifie les règles de nommage, d’écriture et de rétention des sauvegardes locales. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { backupContentsEqual, getBackupFileName, getLocalDateKey, isFileSystemAccessSupported, saveBackup } from "../website/src/features/toolboxes/storage/autoBackup.js"; + +class FakeDirectory { + constructor(names = []) { + this.files = new Map(names.map((name) => [name, "old"])); + this.directories = new Set(); + this.permission = "granted"; + this.failCleanup = false; + this.failWrite = false; + } + + async queryPermission() { return this.permission; } + + async *values() { + for (const name of this.files.keys()) yield { kind: "file", name }; + for (const name of this.directories) yield { kind: "directory", name }; + } + + async getFileHandle(name, { create = false } = {}) { + if (create && !this.files.has(name)) this.files.set(name, ""); + const directory = this; + return { + async getFile() { + return { text: async () => directory.files.get(name) || "" }; + }, + async createWritable() { + if (directory.failWrite) throw new Error("write failed"); + return { + async write(contents) { directory.files.set(name, contents); }, + async close() {}, + async abort() {} + }; + } + }; + } + + async removeEntry(name) { + if (this.failCleanup) throw new Error("cleanup failed"); + this.files.delete(name); + } +} + +test("feature detection does not rely on the user agent", () => { + assert.equal(isFileSystemAccessSupported({ showDirectoryPicker() {} }), true); + assert.equal(isFileSystemAccessSupported({}), false); +}); + +test("backup names use the browser local date", () => { + const date = new Date(2026, 8, 24, 0, 5); + assert.equal(getLocalDateKey(date), "2026-09-24"); + assert.equal(getBackupFileName(date), "sokkog-global-2026-09-24.json"); +}); + +test("automatic backup skips an existing day and creates the next day", async () => { + const directory = new FakeDirectory(["sokkog-global-2026-09-23.json"]); + let generated = 0; + const generateJson = async () => { generated += 1; return "{}"; }; + const first = await saveBackup({ directoryHandle: directory, generateJson, date: new Date(2026, 8, 23) }); + const second = await saveBackup({ directoryHandle: directory, generateJson, date: new Date(2026, 8, 24) }); + assert.equal(first.created, false); + assert.equal(second.created, true); + assert.equal(generated, 1); + assert.ok(directory.files.has("sokkog-global-2026-09-24.json")); +}); + +test("automatic backup skips a new day when the export is unchanged", async () => { + const previous = JSON.stringify({ version: 1, exportedAt: "2026-09-23T08:00:00.000Z", toolboxes: [], modules: {}, links: {} }); + const directory = new FakeDirectory(["sokkog-global-2026-09-23.json"]); + directory.files.set("sokkog-global-2026-09-23.json", previous); + const current = JSON.stringify({ version: 1, exportedAt: "2026-09-24T08:00:00.000Z", toolboxes: [], modules: {}, links: {} }); + const result = await saveBackup({ directoryHandle: directory, generateJson: async () => current, date: new Date(2026, 8, 24) }); + assert.equal(backupContentsEqual(previous, current), true); + assert.equal(result.ok, true); + assert.equal(result.created, false); + assert.equal(result.unchanged, true); + assert.equal(directory.files.has("sokkog-global-2026-09-24.json"), false); +}); + +test("manual backup replaces the day and retains ten non-consecutive versions", async () => { + const names = [ + "sokkog-global-2024-01-01.json", "sokkog-global-2024-06-01.json", "sokkog-global-2025-01-01.json", + "sokkog-global-2025-03-01.json", "sokkog-global-2025-09-01.json", "sokkog-global-2026-01-01.json", + "sokkog-global-2026-03-01.json", "sokkog-global-2026-06-01.json", "sokkog-global-2026-07-01.json", + "sokkog-global-2026-08-01.json", "sokkog-global-2026-09-24.json", "notes.json" + ]; + const directory = new FakeDirectory(names); + const result = await saveBackup({ directoryHandle: directory, force: true, date: new Date(2026, 8, 24), generateJson: async () => "new" }); + assert.equal(result.ok, true); + assert.equal(directory.files.get("sokkog-global-2026-09-24.json"), "new"); + assert.equal([...directory.files.keys()].filter((name) => /^sokkog-global-\d{4}-\d{2}-\d{2}\.json$/.test(name)).length, 10); + assert.equal(directory.files.get("notes.json"), "old"); +}); + +test("permission failure prevents export and cleanup", async () => { + const directory = new FakeDirectory(); + directory.permission = "prompt"; + let generated = false; + const result = await saveBackup({ directoryHandle: directory, generateJson: async () => { generated = true; return "{}"; } }); + assert.equal(result.reason, "permission"); + assert.equal(generated, false); +}); + +test("cleanup failure does not invalidate a successfully written backup", async () => { + const directory = new FakeDirectory(Array.from({ length: 10 }, (_, index) => `sokkog-global-2020-01-${String(index + 1).padStart(2, "0")}.json`)); + directory.failCleanup = true; + const result = await saveBackup({ directoryHandle: directory, generateJson: async () => "{}", date: new Date(2026, 8, 24) }); + assert.equal(result.ok, true); + assert.match(result.cleanupError, /cleanup failed/); + assert.equal(directory.files.get("sokkog-global-2026-09-24.json"), "{}"); +}); + +test("failed write does not leave a newly created backup", async () => { + const directory = new FakeDirectory(); + directory.failWrite = true; + const result = await saveBackup({ directoryHandle: directory, generateJson: async () => "{}", date: new Date(2026, 8, 24) }); + assert.equal(result.ok, false); + assert.equal(directory.files.has("sokkog-global-2026-09-24.json"), false); +}); + +test("export failure does not create a partial backup", async () => { + const directory = new FakeDirectory(); + const result = await saveBackup({ directoryHandle: directory, generateJson: async () => { throw new Error("export failed"); }, date: new Date(2026, 8, 24) }); + assert.equal(result.ok, false); + assert.equal(directory.files.has("sokkog-global-2026-09-24.json"), false); +}); diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index 36e0e44..644edb4 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -220,6 +220,23 @@ export function validateSiteContent(site) { "toolboxes.toastPositionLabel", "toolboxes.toastPositionLeft", "toolboxes.toastPositionRight", + "toolboxes.autoBackup.eyebrow", + "toolboxes.autoBackup.title", + "toolboxes.autoBackup.description", + "toolboxes.autoBackup.compatibleLabel", + "toolboxes.autoBackup.folderLabel", + "toolboxes.autoBackup.enabled", + "toolboxes.autoBackup.toggleHelp", + "toolboxes.autoBackup.chooseFolder", + "toolboxes.autoBackup.changeFolder", + "toolboxes.autoBackup.reauthorize", + "toolboxes.autoBackup.saveNow", + "toolboxes.autoBackup.noFolder", + "toolboxes.autoBackup.never", + "toolboxes.autoBackup.lastBackup", + "toolboxes.autoBackup.retention", + "toolboxes.autoBackup.unsupported", + "toolboxes.autoBackup.close", "toolboxes.summary", "toolboxes.storageHelp.title", "toolboxes.storageHelp.text", @@ -883,6 +900,7 @@ export function validateSiteContent(site) { ].forEach((path) => assertNonEmptyString(site, path)); assertStringArray(site, "toolboxes.storageHelp.items", 3); + assertStringArray(site, "toolboxes.autoBackup.browsers", 2); const librarySummaryItems = valueAt(site, "library.summaryItems"); assert.ok(Array.isArray(librarySummaryItems), "library.summaryItems must be an array"); diff --git a/tests/static-games.test.mjs b/tests/static-games.test.mjs index 4617fae..e42c1c3 100644 --- a/tests/static-games.test.mjs +++ b/tests/static-games.test.mjs @@ -53,6 +53,8 @@ test("mhwilds and diablo pages expose expected route pieces", async () => { const mhwildsOverview = await readFile("website/src/features/games/mhwilds/MhwildsOverview.jsx", "utf8"); const mhwildsListing = await readFile("website/src/features/games/mhwilds/MhwildsListing.jsx", "utf8"); const mhwildsLists = await readFile("website/src/features/games/mhwilds/MhwildsLists.jsx", "utf8"); + const aniimoPage = await readFile("website/src/features/games/aniimo/AniimoPage.jsx", "utf8"); + const aniimoLists = await readFile("website/src/features/games/aniimo/AniimoLists.jsx", "utf8"); const mhwildsFilters = await readFile("website/src/features/games/mhwilds/MhwildsFilters.jsx", "utf8"); const monsterCard = await readFile("website/src/features/games/mhwilds/cards/MonsterCard.jsx", "utf8"); const endemicCard = await readFile("website/src/features/games/mhwilds/cards/EndemicCard.jsx", "utf8"); @@ -79,6 +81,9 @@ test("mhwilds and diablo pages expose expected route pieces", async () => { assert.match(mhwildsLists, /export function MhwildsLists/); assert.match(mhwildsLists, /GameListsPage/); assert.match(mhwildsLists, /selectedListId/); + assert.match(aniimoPage, /AniimoLists/); + assert.match(aniimoPage, /games\/aniimo\/lists/); + assert.match(aniimoLists, /GameListsPage/); assert.match(mhwildsFilters, /export function MhwildsFilters/); assert.match(mhwildsFilters, /GameFiltersPanel/); assert.match(monsterCard, /export function MonsterCard/); diff --git a/website/public/data/aniimo/lists/index.json b/website/public/data/aniimo/lists/index.json new file mode 100644 index 0000000..8d70eff --- /dev/null +++ b/website/public/data/aniimo/lists/index.json @@ -0,0 +1,9 @@ +{ + "lists": [ + { + "id": "regions", + "title": "Regions", + "file": "regions.json" + } + ] +} diff --git a/website/public/data/aniimo/lists/regions.json b/website/public/data/aniimo/lists/regions.json new file mode 100644 index 0000000..90d94a5 --- /dev/null +++ b/website/public/data/aniimo/lists/regions.json @@ -0,0 +1,61 @@ +{ + "titre": "Regions", + "regions": [ + { + "nom": "Champs de Cumulaine", + "quantite": 1 + }, + { + "nom": "Mer florale", + "quantite": 1 + }, + { + "nom": "Forêt des météores", + "quantite": 1 + }, + { + "nom": "Le détroit argenté", + "quantite": 1 + }, + { + "nom": "Crête des crocs", + "quantite": 1 + }, + { + "nom": "Grève d'echoflux", + "quantite": 1 + }, + { + "nom": "Bois Brumeux", + "quantite": 1 + }, + { + "nom": "Forêt des foudres", + "quantite": 1 + }, + { + "nom": "Vallée de béryl", + "quantite": 1 + }, + { + "nom": "Côte de maréeflore", + "quantite": 1 + }, + { + "nom": "Hautes-plaines ocres", + "quantite": 1 + }, + { + "nom": "Boix de la tour-aux-roses", + "quantite": 1 + }, + { + "nom": "Pré-aux-frimas", + "quantite": 1 + }, + { + "nom": "Isthme du zéphyr", + "quantite": 1 + } + ] +} \ No newline at end of file diff --git a/website/public/data/site.json b/website/public/data/site.json index bb2737c..c514975 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -1813,6 +1813,30 @@ "toastPositionLabel": "Positionnement des alertes", "toastPositionLeft": "Positionner les alertes en bas à gauche", "toastPositionRight": "Positionner les alertes en bas à droite", + "autoBackup": { + "eyebrow": "Sauvegarde locale", + "title": "Sauvegarde automatique", + "description": "Cette fonctionnalité n’est pas disponible dans tous les navigateurs. Les sauvegardes restent locales à ce navigateur et ne sont envoyées à aucun serveur.", + "compatibleLabel": "Navigateurs compatibles", + "browsers": [ + "Opera GX", + "Google Chrome", + "Microsoft Edge" + ], + "folderLabel": "Dossier configuré :", + "enabled": "Activer la sauvegarde automatique", + "toggleHelp": "Une sauvegarde automatique est créée au maximum une fois par jour lorsque l’application est ouverte.", + "chooseFolder": "Choisir un dossier", + "changeFolder": "Changer de dossier", + "reauthorize": "Réautoriser l’accès", + "saveNow": "Sauvegarder maintenant", + "noFolder": "Aucun dossier", + "never": "Jamais", + "lastBackup": "Dernière sauvegarde", + "retention": "10 versions sont conservées.", + "unsupported": "Cette sauvegarde locale n’est pas disponible dans ce navigateur.", + "close": "Fermer" + }, "summary": "Créez des espaces modulaires pour regrouper vos outils de session : notes, suivis, références, captures et futurs outils restent accessibles rapidement, en local, par jeu ou par usage.", "creationTemplate": { "memos": { diff --git a/website/src/components/AppOverlays.jsx b/website/src/components/AppOverlays.jsx index 963277c..bb56eac 100644 --- a/website/src/components/AppOverlays.jsx +++ b/website/src/components/AppOverlays.jsx @@ -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 && setAutoBackupModalOpen(false)} />} {storageError && setStorageError("")} />} {image && setImage(null)} createMarkerId={() => uid("marker")} />} {notifications.length > 0 && ( diff --git a/website/src/components/ToolboxModals.jsx b/website/src/components/ToolboxModals.jsx index 6598c19..64e0a21 100644 --- a/website/src/components/ToolboxModals.jsx +++ b/website/src/components/ToolboxModals.jsx @@ -79,3 +79,56 @@ export function LinkToolboxModal({ toolboxes, selectedId, onClose }) { ); } + +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 ( +
+
+
+
+
+

{content.eyebrow}

+

{content.title}

+
+ +
+

{content.description}

+
+ {content.compatibleLabel} +
    {content.browsers.map((browser) =>
  • {browser}
  • )}
+
+ {!backup.supported ?

{content.unsupported}

: ( + <> +
+
{content.folderLabel}{folderName}
+ +
+
+ {content.lastBackup} + {lastBackup} +
+ + {backup.error &&

{backup.error}

} +
+ {backup.config.directoryHandle && backup.permission !== "granted" && } + +
+ + )} +
+ +
+
+
+ ); +} diff --git a/website/src/features/games/aniimo/AniimoLists.jsx b/website/src/features/games/aniimo/AniimoLists.jsx new file mode 100644 index 0000000..96e62df --- /dev/null +++ b/website/src/features/games/aniimo/AniimoLists.jsx @@ -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 ( + actions.createChecklistFromList(game.id, list)} + /> + ); +} diff --git a/website/src/features/games/aniimo/AniimoPage.jsx b/website/src/features/games/aniimo/AniimoPage.jsx index 3629cd3..56aa3e5 100644 --- a/website/src/features/games/aniimo/AniimoPage.jsx +++ b/website/src/features/games/aniimo/AniimoPage.jsx @@ -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

{content.loading}

; + if (aniimo.error) return

{content.error}

{aniimo.error}

; + return ; + } if (category !== "aniimos") return <>

{game.eyebrow}

{game.title}

{game.summary}

-
{content.title}{content.homeDescription}
+
+ {content.title}{content.homeDescription} + +
; if (!aniimo.loaded) return

{content.loading}

; if (aniimo.error) return

{content.error}

{aniimo.error}

; diff --git a/website/src/features/games/loaders.js b/website/src/features/games/loaders.js index 96531fa..ae0d1a3 100644 --- a/website/src/features/games/loaders.js +++ b/website/src/features/games/loaders.js @@ -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) }; } diff --git a/website/src/features/toolboxes/ToolboxPages.jsx b/website/src/features/toolboxes/ToolboxPages.jsx index 97ad24a..26242ee 100644 --- a/website/src/features/toolboxes/ToolboxPages.jsx +++ b/website/src/features/toolboxes/ToolboxPages.jsx @@ -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,
+
diff --git a/website/src/features/toolboxes/storage/autoBackup.js b/website/src/features/toolboxes/storage/autoBackup.js new file mode 100644 index 0000000..d603e0c --- /dev/null +++ b/website/src/features/toolboxes/storage/autoBackup.js @@ -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 }; + } + }); +} diff --git a/website/src/features/toolboxes/storage/useAutoBackup.js b/website/src/features/toolboxes/storage/useAutoBackup.js new file mode 100644 index 0000000..3780aa6 --- /dev/null +++ b/website/src/features/toolboxes/storage/useAutoBackup.js @@ -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 + }; +} diff --git a/website/src/features/toolboxes/storage/useIndexedToolboxes.js b/website/src/features/toolboxes/storage/useIndexedToolboxes.js index dddecb8..6515207 100644 --- a/website/src/features/toolboxes/storage/useIndexedToolboxes.js +++ b/website/src/features/toolboxes/storage/useIndexedToolboxes.js @@ -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; } diff --git a/website/src/features/toolboxes/useToolboxActions.js b/website/src/features/toolboxes/useToolboxActions.js index 5a479eb..7d82f05 100644 --- a/website/src/features/toolboxes/useToolboxActions.js +++ b/website/src/features/toolboxes/useToolboxActions.js @@ -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é."); } }; diff --git a/website/src/main.jsx b/website/src/main.jsx index 03fa908..2e4d74e 100644 --- a/website/src/main.jsx +++ b/website/src/main.jsx @@ -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} /> diff --git a/website/src/router/hashRouter.js b/website/src/router/hashRouter.js index 4759bb8..25257ba 100644 --- a/website/src/router/hashRouter.js +++ b/website/src/router/hashRouter.js @@ -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; } diff --git a/website/src/styles/overlays/_modals.scss b/website/src/styles/overlays/_modals.scss index 87ca2b7..5e0d73d 100644 --- a/website/src/styles/overlays/_modals.scss +++ b/website/src/styles/overlays/_modals.scss @@ -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: ""; inherits: false;