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
|
|
@ -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`.
|
- 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.
|
- 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.
|
- 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
|
## Page Jeu
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,24 @@ Clés principales dans `kv` :
|
||||||
"links": {},
|
"links": {},
|
||||||
"setting:toolboxOrder": [],
|
"setting:toolboxOrder": [],
|
||||||
"setting:storageBudgetBytes": 536870912,
|
"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` :
|
Entrée type dans `modules` :
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
|
||||||
|
|
@ -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 page.locator('a[href="#/games/aniimo/aniimos"]').click();
|
||||||
await expect(cards(page)).toHaveCount(3);
|
await expect(cards(page)).toHaveCount(3);
|
||||||
await expect(page.getByRole("searchbox")).toHaveValue("EMBERPUP");
|
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 }) => {
|
test("tabs, flip, matching variants and reset remain independent", async ({ page }) => {
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,24 @@ test("imports and exports a toolbox payload", async ({ page }) => {
|
||||||
expect(download.suggestedFilename()).toBe("fixture-toolbox.sokkog.json");
|
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 }) => {
|
test("edits a table with formulas and persists values", async ({ page }) => {
|
||||||
await createToolbox(page, "Table E2E Toolbox");
|
await createToolbox(page, "Table E2E Toolbox");
|
||||||
await addTool(page, "Tableau");
|
await addTool(page, "Tableau");
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
test("loader passes cancellation to all requests and reports failed responses", async (context) => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const controller = new AbortController();
|
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) => {
|
context.mock.method(globalThis, "fetch", async (url, options) => {
|
||||||
calls.push({ url, options });
|
calls.push({ url, options });
|
||||||
const body = bodies[calls.length - 1];
|
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);
|
const result = await loadAniimoData(controller.signal);
|
||||||
assert.equal(result.variants.length, data.variants.length);
|
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));
|
assert.ok(calls.every((call) => call.options.signal === controller.signal));
|
||||||
globalThis.fetch.mock.mockImplementation(async () => ({ ok: false }));
|
globalThis.fetch.mock.mockImplementation(async () => ({ ok: false }));
|
||||||
await assert.rejects(loadAniimoData(controller.signal), /indisponibles/);
|
await assert.rejects(loadAniimoData(controller.signal), /indisponibles/);
|
||||||
|
|
|
||||||
128
tests/auto-backup.test.mjs
Normal file
128
tests/auto-backup.test.mjs
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -220,6 +220,23 @@ export function validateSiteContent(site) {
|
||||||
"toolboxes.toastPositionLabel",
|
"toolboxes.toastPositionLabel",
|
||||||
"toolboxes.toastPositionLeft",
|
"toolboxes.toastPositionLeft",
|
||||||
"toolboxes.toastPositionRight",
|
"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.summary",
|
||||||
"toolboxes.storageHelp.title",
|
"toolboxes.storageHelp.title",
|
||||||
"toolboxes.storageHelp.text",
|
"toolboxes.storageHelp.text",
|
||||||
|
|
@ -883,6 +900,7 @@ export function validateSiteContent(site) {
|
||||||
].forEach((path) => assertNonEmptyString(site, path));
|
].forEach((path) => assertNonEmptyString(site, path));
|
||||||
|
|
||||||
assertStringArray(site, "toolboxes.storageHelp.items", 3);
|
assertStringArray(site, "toolboxes.storageHelp.items", 3);
|
||||||
|
assertStringArray(site, "toolboxes.autoBackup.browsers", 2);
|
||||||
|
|
||||||
const librarySummaryItems = valueAt(site, "library.summaryItems");
|
const librarySummaryItems = valueAt(site, "library.summaryItems");
|
||||||
assert.ok(Array.isArray(librarySummaryItems), "library.summaryItems must be an array");
|
assert.ok(Array.isArray(librarySummaryItems), "library.summaryItems must be an array");
|
||||||
|
|
|
||||||
|
|
@ -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 mhwildsOverview = await readFile("website/src/features/games/mhwilds/MhwildsOverview.jsx", "utf8");
|
||||||
const mhwildsListing = await readFile("website/src/features/games/mhwilds/MhwildsListing.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 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 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 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");
|
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, /export function MhwildsLists/);
|
||||||
assert.match(mhwildsLists, /GameListsPage/);
|
assert.match(mhwildsLists, /GameListsPage/);
|
||||||
assert.match(mhwildsLists, /selectedListId/);
|
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, /export function MhwildsFilters/);
|
||||||
assert.match(mhwildsFilters, /GameFiltersPanel/);
|
assert.match(mhwildsFilters, /GameFiltersPanel/);
|
||||||
assert.match(monsterCard, /export function MonsterCard/);
|
assert.match(monsterCard, /export function MonsterCard/);
|
||||||
|
|
|
||||||
9
website/public/data/aniimo/lists/index.json
Normal file
9
website/public/data/aniimo/lists/index.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"lists": [
|
||||||
|
{
|
||||||
|
"id": "regions",
|
||||||
|
"title": "Regions",
|
||||||
|
"file": "regions.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
61
website/public/data/aniimo/lists/regions.json
Normal file
61
website/public/data/aniimo/lists/regions.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1813,6 +1813,30 @@
|
||||||
"toastPositionLabel": "Positionnement des alertes",
|
"toastPositionLabel": "Positionnement des alertes",
|
||||||
"toastPositionLeft": "Positionner les alertes en bas à gauche",
|
"toastPositionLeft": "Positionner les alertes en bas à gauche",
|
||||||
"toastPositionRight": "Positionner les alertes en bas à droite",
|
"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.",
|
"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": {
|
"creationTemplate": {
|
||||||
"memos": {
|
"memos": {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Rôle : regroupe les modales, notifications et viewers rendus au-dessus de l'application.
|
// Rôle : regroupe les modales, notifications et viewers rendus au-dessus de l'application.
|
||||||
import { Icon } from "./Icon.jsx";
|
import { Icon } from "./Icon.jsx";
|
||||||
import { ImageViewer } from "./ImageViewer.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 { navigate } from "../router/hashRouter.js";
|
||||||
import { uid } from "../features/toolboxes/storage/toolboxStorage.js";
|
import { uid } from "../features/toolboxes/storage/toolboxStorage.js";
|
||||||
|
|
||||||
|
|
@ -42,7 +42,11 @@ export function AppOverlays({
|
||||||
getGame,
|
getGame,
|
||||||
toolboxes,
|
toolboxes,
|
||||||
links,
|
links,
|
||||||
setPendingTutorialToolboxId
|
setPendingTutorialToolboxId,
|
||||||
|
autoBackupModalOpen,
|
||||||
|
setAutoBackupModalOpen,
|
||||||
|
autoBackup,
|
||||||
|
autoBackupContent
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -70,6 +74,7 @@ export function AppOverlays({
|
||||||
actions.linkToolboxToGame(gameId, toolboxId);
|
actions.linkToolboxToGame(gameId, toolboxId);
|
||||||
setDrawerGameId(gameId);
|
setDrawerGameId(gameId);
|
||||||
}} />}
|
}} />}
|
||||||
|
{autoBackupModalOpen && <AutoBackupModal backup={autoBackup} content={autoBackupContent} onClose={() => setAutoBackupModalOpen(false)} />}
|
||||||
{storageError && <ConfirmModal title={storageErrorTitle} 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")} />}
|
{image && <ImageViewer image={image} onClose={() => setImage(null)} createMarkerId={() => uid("marker")} />}
|
||||||
{notifications.length > 0 && (
|
{notifications.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -79,3 +79,56 @@ export function LinkToolboxModal({ toolboxes, selectedId, onClose }) {
|
||||||
</div>
|
</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 { GameBreadcrumb } from "../GameBreadcrumb.jsx";
|
||||||
import { AniimoCard } from "./AniimoCard.jsx";
|
import { AniimoCard } from "./AniimoCard.jsx";
|
||||||
import { AniimoFilters } from "./AniimoFilters.jsx";
|
import { AniimoFilters } from "./AniimoFilters.jsx";
|
||||||
|
import { AniimoLists } from "./AniimoLists.jsx";
|
||||||
import { getAniimoStatsMax, aniimoLabel, filterAniimoVariants, groupAniimoVariants } from "./utils.js";
|
import { getAniimoStatsMax, aniimoLabel, filterAniimoVariants, groupAniimoVariants } from "./utils.js";
|
||||||
|
|
||||||
function AniimoListing({ game, aniimo, filters, setFilters, content }) {
|
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;
|
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 <>
|
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-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.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>;
|
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: "",
|
error: "",
|
||||||
translations: {},
|
translations: {},
|
||||||
variants: [],
|
variants: [],
|
||||||
filterOptions: { elements: [], roles: [], abilities: [] }
|
filterOptions: { elements: [], roles: [], abilities: [] },
|
||||||
|
lists: []
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loadAniimoData(signal) {
|
export async function loadAniimoData(signal) {
|
||||||
const responses = await Promise.all([
|
const responses = await Promise.all([
|
||||||
fetch("/data/aniimo/aniimos.json", { signal }),
|
fetch("/data/aniimo/aniimos.json", { signal }),
|
||||||
fetch("/data/aniimo/i18n/fr.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.");
|
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()));
|
const [aniimos, translations, englishTranslations, listIndex] = await Promise.all(responses.map((response) => response.json()));
|
||||||
return { loaded: true, loading: false, error: "", translations, englishTranslations, ...prepareAniimoData(aniimos, translations, englishTranslations) };
|
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 content = siteContent.toolboxes;
|
||||||
const {
|
const {
|
||||||
itemReorder,
|
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} />
|
<ImportButton className="nds-button" data-size="sm" label={content.importOne} onFile={actions.importToolbox} />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<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} />
|
<ImportButton className="nds-button" data-size="sm" label={content.importAll} onFile={actions.importAllToolboxes} />
|
||||||
</div>
|
</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;
|
return request;
|
||||||
}, [queue]);
|
}, [queue]);
|
||||||
|
|
||||||
async function getExportSnapshot(toolboxId) {
|
async function getExportSnapshot(toolboxId, options = {}) {
|
||||||
if (!writableRef.current) return null;
|
if (!writableRef.current) return null;
|
||||||
let snapshot;
|
let snapshot;
|
||||||
const success = await queue.enqueue((current) => current, async (current) => {
|
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 keys = deferred.filter((key) => !toolboxId || key.startsWith(`${toolboxId}:`));
|
||||||
const entries = await dbGetModuleDataEntries(keys);
|
const entries = await dbGetModuleDataEntries(keys);
|
||||||
snapshot = { ...current, moduleData: mergeLoadedModuleData(current.moduleData, keys, entries) };
|
snapshot = { ...current, moduleData: mergeLoadedModuleData(current.moduleData, keys, entries) };
|
||||||
}, reportStorageError);
|
}, options.onError || reportStorageError);
|
||||||
return success ? snapshot : null;
|
return success ? snapshot : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
uid
|
uid
|
||||||
} from "./storage/toolboxStorage.js";
|
} from "./storage/toolboxStorage.js";
|
||||||
import { compressImage } from "../../utils/imageCompression.js";
|
import { compressImage } from "../../utils/imageCompression.js";
|
||||||
|
import { getBackupFileName } from "./storage/autoBackup.js";
|
||||||
|
|
||||||
function downloadJson(payload, filename) {
|
function downloadJson(payload, filename) {
|
||||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
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;
|
if (!store.ready) return false;
|
||||||
const snapshot = await store.getExportSnapshot();
|
const snapshot = await store.getExportSnapshot();
|
||||||
if (!snapshot) return false;
|
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é.");
|
notify("Export global lancé.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { useToolboxAlerts } from "./hooks/useToolboxAlerts.js";
|
||||||
import { RouteContent } from "./router/RouteContent.jsx";
|
import { RouteContent } from "./router/RouteContent.jsx";
|
||||||
import { useHashRoute } from "./router/hashRouter.js";
|
import { useHashRoute } from "./router/hashRouter.js";
|
||||||
import { normalizeCalendarData, normalizeTimerData } from "./features/toolboxes/storage/toolboxStorage.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 ToolboxDrawer = lazy(() => import("./features/toolboxes/ToolboxPages.jsx").then((module) => ({ default: module.ToolboxDrawer })));
|
||||||
const DEFAULT_NOTIFICATION_DURATION_MS = 120000;
|
const DEFAULT_NOTIFICATION_DURATION_MS = 120000;
|
||||||
|
|
@ -34,6 +35,7 @@ function App() {
|
||||||
const [filters, setFilters] = useGameFilters();
|
const [filters, setFilters] = useGameFilters();
|
||||||
const [confirmModal, setConfirmModal] = useState(null);
|
const [confirmModal, setConfirmModal] = useState(null);
|
||||||
const [createModal, setCreateModal] = useState(null);
|
const [createModal, setCreateModal] = useState(null);
|
||||||
|
const [autoBackupModalOpen, setAutoBackupModalOpen] = useState(false);
|
||||||
const [linkModalGameId, setLinkModalGameId] = useState("");
|
const [linkModalGameId, setLinkModalGameId] = useState("");
|
||||||
const [drawerGameId, setDrawerGameId] = useState("");
|
const [drawerGameId, setDrawerGameId] = useState("");
|
||||||
const [image, setImage] = useState(null);
|
const [image, setImage] = useState(null);
|
||||||
|
|
@ -42,6 +44,7 @@ function App() {
|
||||||
const [storageError, setStorageError] = useState("");
|
const [storageError, setStorageError] = useState("");
|
||||||
const handleStorageError = useCallback((message) => setStorageError(message), []);
|
const handleStorageError = useCallback((message) => setStorageError(message), []);
|
||||||
const store = useIndexedToolboxes(handleStorageError);
|
const store = useIndexedToolboxes(handleStorageError);
|
||||||
|
const autoBackup = useAutoBackup(store);
|
||||||
const guideChecklistSession = useGuideChecklistSession(store);
|
const guideChecklistSession = useGuideChecklistSession(store);
|
||||||
|
|
||||||
const notify = useCallback((message, options = {}) => {
|
const notify = useCallback((message, options = {}) => {
|
||||||
|
|
@ -148,6 +151,7 @@ function App() {
|
||||||
storageReady={store.ready}
|
storageReady={store.ready}
|
||||||
toastPosition={store.toastPosition}
|
toastPosition={store.toastPosition}
|
||||||
setToastPosition={store.setToastPosition}
|
setToastPosition={store.setToastPosition}
|
||||||
|
onOpenAutoBackup={() => setAutoBackupModalOpen(true)}
|
||||||
getModuleData={store.getModuleData}
|
getModuleData={store.getModuleData}
|
||||||
updateToolbox={actions.updateToolbox}
|
updateToolbox={actions.updateToolbox}
|
||||||
updateModuleData={store.updateModuleData}
|
updateModuleData={store.updateModuleData}
|
||||||
|
|
@ -195,6 +199,10 @@ function App() {
|
||||||
toolboxes={store.toolboxes}
|
toolboxes={store.toolboxes}
|
||||||
links={store.links}
|
links={store.links}
|
||||||
setPendingTutorialToolboxId={setPendingTutorialToolboxId}
|
setPendingTutorialToolboxId={setPendingTutorialToolboxId}
|
||||||
|
autoBackupModalOpen={autoBackupModalOpen}
|
||||||
|
setAutoBackupModalOpen={setAutoBackupModalOpen}
|
||||||
|
autoBackup={autoBackup}
|
||||||
|
autoBackupContent={siteContent.toolboxes.autoBackup}
|
||||||
/>
|
/>
|
||||||
</Shell>
|
</Shell>
|
||||||
</GuideChecklistContext.Provider>
|
</GuideChecklistContext.Provider>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ function currentRoute() {
|
||||||
if (path === "/mhwilds/monsters") return "/games/mhwilds/monsters";
|
if (path === "/mhwilds/monsters") return "/games/mhwilds/monsters";
|
||||||
if (path === "/mhwilds/endemic") return "/games/mhwilds/endemic";
|
if (path === "/mhwilds/endemic") return "/games/mhwilds/endemic";
|
||||||
if (path === "/mhwilds/lists") return "/games/mhwilds/lists";
|
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;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,218 @@
|
||||||
margin-bottom: var(--space-6);
|
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 {
|
@property --notification-toast-angle {
|
||||||
syntax: "<angle>";
|
syntax: "<angle>";
|
||||||
inherits: false;
|
inherits: false;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue