Improve local backup permissions and change tracking
All checks were successful
Deploy Sokko G / deploy (push) Successful in 19s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 19s
This commit is contained in:
parent
34e542be28
commit
ee823d2c37
13 changed files with 372 additions and 37 deletions
|
|
@ -1823,13 +1823,22 @@
|
|||
"Google Chrome",
|
||||
"Microsoft Edge"
|
||||
],
|
||||
"permissionTitle": "Fonctionnement de l’autorisation",
|
||||
"permissionDescription": "Opera GX peut redemander l’accès après la fermeture du navigateur. Pour des raisons de sécurité, la popup système ne peut apparaître qu’après un clic de votre part.",
|
||||
"permissionSteps": [
|
||||
"Si l’accès est toujours accordé, la vérification et la sauvegarde éventuelle restent silencieuses.",
|
||||
"L’application mémorise localement les modifications confirmées. Si l’accès a expiré, une alerte vous propose alors de les sauvegarder.",
|
||||
"Cliquez sur « Sauvegarder » dans cette alerte : le navigateur redemande l’accès, puis l’application vérifie le dossier avant toute écriture."
|
||||
],
|
||||
"reminderMessage": "Des modifications ont été détectées dans vos toolboxes. Voulez-vous créer une sauvegarde locale ?",
|
||||
"reminderAction": "Sauvegarder",
|
||||
"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",
|
||||
"permissionRequired": "L’accès au dossier doit être réautorisé pour reprendre les sauvegardes. Utilisez « Réautoriser l’accès » ci-dessous.",
|
||||
"reauthorize": "Autoriser et vérifier",
|
||||
"permissionRequired": "L’accès au dossier doit être redonné pour effectuer les vérifications et reprendre les sauvegardes.",
|
||||
"saveNow": "Sauvegarder maintenant",
|
||||
"noFolder": "Aucun dossier",
|
||||
"never": "Jamais",
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@ export function AppOverlays({
|
|||
id={notification.id}
|
||||
message={notification.message}
|
||||
durationMs={notification.durationMs}
|
||||
actionLabel={notification.actionLabel}
|
||||
onAction={notification.onAction}
|
||||
onClose={dismissNotification}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -24,15 +24,20 @@ export function ConfirmModal({ title, message, confirmLabel = "Confirmer", cance
|
|||
);
|
||||
}
|
||||
|
||||
export function NotificationToast({ id, message, durationMs = 120000, onClose }) {
|
||||
export function NotificationToast({ id, message, durationMs = 120000, actionLabel = "", onAction, onClose }) {
|
||||
useEffect(() => {
|
||||
if (durationMs <= 0) return undefined;
|
||||
const timeoutId = window.setTimeout(() => onClose(id), durationMs);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [durationMs, id, onClose]);
|
||||
|
||||
return (
|
||||
<div className="notification-toast nds-toast" role="status">
|
||||
<div className={`notification-toast nds-toast${actionLabel && onAction ? " has-action" : ""}`} role="status">
|
||||
<span>{message}</span>
|
||||
{actionLabel && onAction && <button className="nds-button notification-toast-action" data-size="xs" data-variant="primary" type="button" onClick={() => {
|
||||
onAction();
|
||||
onClose(id);
|
||||
}}>{actionLabel}</button>}
|
||||
<button className="nds-icon-button" data-size="xs" type="button" onClick={() => onClose(id)} aria-label="Fermer l'alerte" title="Fermer">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
|
|
@ -103,6 +108,11 @@ export function AutoBackupModal({ backup, content, onClose }) {
|
|||
<strong>{content.compatibleLabel}</strong>
|
||||
<ul>{content.browsers.map((browser) => <li key={browser}>{browser}</li>)}</ul>
|
||||
</div>
|
||||
<div className="auto-backup-permission-guide">
|
||||
<strong>{content.permissionTitle}</strong>
|
||||
<p>{content.permissionDescription}</p>
|
||||
<ol>{content.permissionSteps.map((step) => <li key={step}>{step}</li>)}</ol>
|
||||
</div>
|
||||
{!backup.supported ? <p className="nds-inline-notice" data-variant="warning" role="status">{content.unsupported}</p> : (
|
||||
<>
|
||||
<div className="auto-backup-folder">
|
||||
|
|
@ -121,7 +131,7 @@ export function AutoBackupModal({ backup, content, onClose }) {
|
|||
{backup.config.directoryHandle && (backup.permission === "prompt" || backup.permission === "denied") && <p className="nds-inline-notice" data-variant="warning" role="status">{content.permissionRequired}</p>}
|
||||
{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 === "prompt" || backup.permission === "denied") && <button className="nds-button" data-size="sm" type="button" disabled={backup.busy} onClick={() => void backup.requestPermission()}>{content.reauthorize}</button>}
|
||||
{backup.config.directoryHandle && (backup.permission === "prompt" || backup.permission === "denied") && <button className="nds-button" data-size="sm" data-variant="primary" 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 || backup.permission !== "granted"} onClick={() => void backup.saveNow()}>{content.saveNow}</button>
|
||||
</footer>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Rôle : gère les sauvegardes JSON locales via la File System Access API.
|
||||
|
||||
export const AUTO_BACKUP_SETTING = "autoBackup";
|
||||
export const AUTO_BACKUP_DATA_CHANGED_EVENT = "sokkog:auto-backup-data-changed";
|
||||
export const AUTO_BACKUP_FILE_PATTERN = /^sokkog-global-\d{4}-\d{2}-\d{2}\.json$/;
|
||||
const AUTO_BACKUP_LOCK = "sokkog-auto-backup";
|
||||
|
||||
|
|
@ -23,7 +24,11 @@ export function normalizeAutoBackupConfig(value) {
|
|||
return {
|
||||
enabled: value?.enabled === true,
|
||||
directoryHandle: value?.directoryHandle || null,
|
||||
lastBackup: typeof value?.lastBackup === "string" ? value.lastBackup : ""
|
||||
lastBackup: typeof value?.lastBackup === "string" ? value.lastBackup : "",
|
||||
lastBackupSignature: typeof value?.lastBackupSignature === "string" ? value.lastBackupSignature : "",
|
||||
hasUnsavedChanges: typeof value?.hasUnsavedChanges === "boolean"
|
||||
? value.hasUnsavedChanges
|
||||
: Boolean(value?.enabled && value?.directoryHandle)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -82,6 +87,29 @@ function comparableBackupContents(contents) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function getBackupContentSignature(contents, cryptoScope = globalThis.crypto) {
|
||||
const comparable = comparableBackupContents(contents);
|
||||
if (comparable === null) return "";
|
||||
try {
|
||||
if (cryptoScope?.subtle) {
|
||||
const digest = await cryptoScope.subtle.digest("SHA-256", new TextEncoder().encode(comparable));
|
||||
return `sha256-${[...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
} catch {
|
||||
// Le hash de secours reste déterministe lorsque Web Crypto est indisponible.
|
||||
}
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < comparable.length; index += 1) {
|
||||
hash ^= comparable.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return `fnv1a-${(hash >>> 0).toString(16).padStart(8, "0")}-${comparable.length}`;
|
||||
}
|
||||
|
||||
export function hasBackupDataChanged({ lastBackupSignature = "", currentSignature = "" }) {
|
||||
return Boolean(currentSignature) && currentSignature !== lastBackupSignature;
|
||||
}
|
||||
|
||||
export function backupContentsEqual(left, right) {
|
||||
const comparableLeft = comparableBackupContents(left);
|
||||
const comparableRight = comparableBackupContents(right);
|
||||
|
|
@ -113,7 +141,15 @@ export async function saveBackup({ directoryHandle, generateJson, date = new Dat
|
|||
return withBackupLock(async () => {
|
||||
try {
|
||||
const existing = await listBackupFiles(directoryHandle);
|
||||
if (!force && existing.includes(filename)) return { ok: true, created: false, filename };
|
||||
if (!force && existing.includes(filename)) {
|
||||
let contentSignature = "";
|
||||
try {
|
||||
contentSignature = await getBackupContentSignature(await readBackupFile(directoryHandle, filename));
|
||||
} catch {
|
||||
// L'existence réelle du fichier suffit à respecter la limite quotidienne.
|
||||
}
|
||||
return { ok: true, created: false, filename, contentSignature };
|
||||
}
|
||||
const contents = await generateJson();
|
||||
if (!force && existing.length) {
|
||||
const latestFilename = sortBackupFiles(existing)[0];
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@ import { createGlobalExportPayload } from "./toolboxStorage.js";
|
|||
import { getSetting, setSetting } from "../../../utils/indexedDbStorage.js";
|
||||
import {
|
||||
AUTO_BACKUP_SETTING,
|
||||
AUTO_BACKUP_DATA_CHANGED_EVENT,
|
||||
getBackupContentSignature,
|
||||
getBackupFileName,
|
||||
getDirectoryPermission,
|
||||
getLocalDateKey,
|
||||
isFileSystemAccessSupported,
|
||||
normalizeAutoBackupConfig,
|
||||
saveBackup
|
||||
|
|
@ -22,25 +25,47 @@ export function useAutoBackup(store) {
|
|||
const [busy, setBusy] = useState(false);
|
||||
const [permission, setPermission] = useState("unknown");
|
||||
const [error, setError] = useState("");
|
||||
const [backupNeeded, setBackupNeeded] = useState(false);
|
||||
const configRef = useRef(config);
|
||||
const configWriteRef = useRef(Promise.resolve());
|
||||
const mutationVersionRef = useRef(0);
|
||||
const storeRef = useRef(store);
|
||||
const runRef = useRef(false);
|
||||
const startedRef = useRef(false);
|
||||
|
||||
const persist = useCallback(async (next) => {
|
||||
const normalized = normalizeAutoBackupConfig(next);
|
||||
useEffect(() => {
|
||||
storeRef.current = store;
|
||||
}, [store]);
|
||||
|
||||
const persist = useCallback(async (update) => {
|
||||
const current = configRef.current;
|
||||
const value = typeof update === "function" ? update(current) : { ...current, ...update };
|
||||
const normalized = normalizeAutoBackupConfig(value);
|
||||
configRef.current = normalized;
|
||||
setConfig(normalized);
|
||||
const write = configWriteRef.current.then(() => setSetting(AUTO_BACKUP_SETTING, normalized));
|
||||
configWriteRef.current = write.catch(() => {});
|
||||
try {
|
||||
await setSetting(AUTO_BACKUP_SETTING, normalized);
|
||||
await write;
|
||||
} catch (persistError) {
|
||||
setError(persistError?.message || "Impossible d'enregistrer les préférences de sauvegarde.");
|
||||
}
|
||||
return normalized;
|
||||
}, []);
|
||||
|
||||
const generateJson = useCallback(async () => {
|
||||
const snapshot = await storeRef.current.getExportSnapshot(undefined, { onError: () => {} });
|
||||
if (!snapshot) throw new Error("Impossible de préparer les données à sauvegarder.");
|
||||
return serializeGlobalExport(snapshot);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getSetting(AUTO_BACKUP_SETTING, normalizeAutoBackupConfig()).then((value) => {
|
||||
if (!cancelled) {
|
||||
setConfig(normalizeAutoBackupConfig(value));
|
||||
const normalized = normalizeAutoBackupConfig(value);
|
||||
configRef.current = normalized;
|
||||
setConfig(normalized);
|
||||
setLoaded(true);
|
||||
}
|
||||
}).catch((loadError) => {
|
||||
|
|
@ -52,25 +77,53 @@ export function useAutoBackup(store) {
|
|||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const run = useCallback(async ({ force = false, directoryHandle = config.directoryHandle, configOverride = config } = {}) => {
|
||||
useEffect(() => {
|
||||
if (!loaded) return undefined;
|
||||
function markBackupDirty() {
|
||||
mutationVersionRef.current += 1;
|
||||
if (configRef.current.hasUnsavedChanges) return;
|
||||
void persist((current) => ({ ...current, hasUnsavedChanges: true }));
|
||||
}
|
||||
window.addEventListener(AUTO_BACKUP_DATA_CHANGED_EVENT, markBackupDirty);
|
||||
return () => window.removeEventListener(AUTO_BACKUP_DATA_CHANGED_EVENT, markBackupDirty);
|
||||
}, [loaded, persist]);
|
||||
|
||||
const run = useCallback(async ({ force = false, directoryHandle = config.directoryHandle } = {}) => {
|
||||
if (!supported || !directoryHandle || runRef.current) return { ok: false, reason: "unavailable" };
|
||||
runRef.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
let generatedContents = "";
|
||||
let exportedMutationVersion = null;
|
||||
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);
|
||||
generatedContents = await generateJson();
|
||||
exportedMutationVersion = mutationVersionRef.current;
|
||||
return generatedContents;
|
||||
}
|
||||
});
|
||||
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) {
|
||||
setBackupNeeded(false);
|
||||
const contentSignature = result.contentSignature || (generatedContents
|
||||
? await getBackupContentSignature(generatedContents)
|
||||
: "");
|
||||
const shouldPersist = result.created || result.unchanged || Boolean(contentSignature);
|
||||
const backedUpCurrentData = (result.created || result.unchanged)
|
||||
&& exportedMutationVersion !== null
|
||||
&& exportedMutationVersion === mutationVersionRef.current;
|
||||
if (shouldPersist) {
|
||||
await persist((current) => ({
|
||||
...current,
|
||||
directoryHandle,
|
||||
lastBackup: result.created ? new Date().toISOString() : current.lastBackup,
|
||||
lastBackupSignature: contentSignature || current.lastBackupSignature,
|
||||
hasUnsavedChanges: backedUpCurrentData ? false : current.hasUnsavedChanges
|
||||
}));
|
||||
}
|
||||
}
|
||||
if (!result.ok) setError(result.reason === "permission" ? "" : result.error?.message || "La sauvegarde locale a échoué.");
|
||||
if (result.ok && result.cleanupError) setError(result.cleanupError);
|
||||
|
|
@ -82,7 +135,7 @@ export function useAutoBackup(store) {
|
|||
runRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}, [config, persist, store, supported]);
|
||||
}, [config, generateJson, persist, supported]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded || !supported || !store.ready || !config.enabled || !config.directoryHandle || startedRef.current) return;
|
||||
|
|
@ -95,25 +148,42 @@ export function useAutoBackup(store) {
|
|||
void getDirectoryPermission(config.directoryHandle).then(setPermission);
|
||||
}, [config.directoryHandle, loaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded || !store.ready || !supported || !config.enabled || !config.directoryHandle
|
||||
|| (permission !== "prompt" && permission !== "denied")) return undefined;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
await Promise.resolve();
|
||||
if (config.lastBackup && getLocalDateKey(new Date(config.lastBackup)) === getLocalDateKey()) {
|
||||
if (!cancelled) setBackupNeeded(false);
|
||||
return;
|
||||
}
|
||||
if (!cancelled) setBackupNeeded(config.hasUnsavedChanges);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [config.directoryHandle, config.enabled, config.hasUnsavedChanges, config.lastBackup, loaded, permission, store.ready, supported]);
|
||||
|
||||
const chooseDirectory = useCallback(async () => {
|
||||
if (!supported) return false;
|
||||
try {
|
||||
const directoryHandle = await window.showDirectoryPicker({ mode: "readwrite" });
|
||||
const next = await persist({ ...config, directoryHandle });
|
||||
const next = await persist({ directoryHandle });
|
||||
setPermission("granted");
|
||||
if (next.enabled) void run({ directoryHandle, configOverride: next });
|
||||
setBackupNeeded(false);
|
||||
if (next.enabled) void run({ directoryHandle });
|
||||
return true;
|
||||
} catch (chooseError) {
|
||||
if (chooseError?.name !== "AbortError") setError(chooseError?.message || "Impossible de choisir ce dossier.");
|
||||
return false;
|
||||
}
|
||||
}, [config, persist, run, supported]);
|
||||
}, [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") setBackupNeeded(false);
|
||||
if (result === "granted" && config.enabled) void run();
|
||||
return result === "granted";
|
||||
} catch (permissionError) {
|
||||
|
|
@ -123,16 +193,18 @@ export function useAutoBackup(store) {
|
|||
}, [config.directoryHandle, config.enabled, run]);
|
||||
|
||||
const setEnabled = useCallback(async (enabled) => {
|
||||
const next = await persist({ ...config, enabled });
|
||||
const next = await persist({ enabled });
|
||||
if (!enabled) setBackupNeeded(false);
|
||||
if (enabled && next.directoryHandle) {
|
||||
const currentPermission = await getDirectoryPermission(next.directoryHandle);
|
||||
setPermission(currentPermission);
|
||||
if (currentPermission === "granted") void run({ configOverride: next });
|
||||
if (currentPermission === "granted") void run({ directoryHandle: next.directoryHandle });
|
||||
}
|
||||
}, [config, persist, run]);
|
||||
}, [persist, run]);
|
||||
|
||||
return {
|
||||
supported,
|
||||
backupNeeded,
|
||||
config,
|
||||
busy,
|
||||
permission,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { applyModuleDataValue, removeModuleDataKeys } from "./optimisticModuleDa
|
|||
import { createPersistenceQueue } from "./persistenceQueue.js";
|
||||
import { createQuotaRefresh } from "./quotaRefresh.js";
|
||||
import { DEFERRED_MODULE_DATA, getDeferredModuleKeys, mergeLoadedModuleData, splitModuleData } from "./deferredModuleData.js";
|
||||
import { AUTO_BACKUP_DATA_CHANGED_EVENT } from "./autoBackup.js";
|
||||
|
||||
function normalizeToolboxes(toolboxes) {
|
||||
return compactToolboxesForStorage(toolboxes).map(normalizeToolbox).filter(Boolean);
|
||||
|
|
@ -102,10 +103,16 @@ export function useIndexedToolboxes(onError) {
|
|||
setLoadAttempt((attempt) => attempt + 1);
|
||||
}
|
||||
|
||||
function enqueue(apply, persist) {
|
||||
function enqueue(apply, persist, { marksBackupDirty = true } = {}) {
|
||||
if (!writableRef.current) return Promise.resolve(false);
|
||||
const result = queue.enqueue(apply, persist, reportStorageError);
|
||||
void result.then((success) => { if (success) refreshQuota(); });
|
||||
void result.then((success) => {
|
||||
if (!success) return;
|
||||
refreshQuota();
|
||||
if (marksBackupDirty && typeof window !== "undefined") {
|
||||
window.dispatchEvent(new window.Event(AUTO_BACKUP_DATA_CHANGED_EVENT));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -209,7 +216,8 @@ export function useIndexedToolboxes(onError) {
|
|||
const toastPosition = position === "left" ? "left" : "right";
|
||||
enqueue(
|
||||
(current) => ({ ...current, toastPosition }),
|
||||
() => dbSetSetting("toastPosition", toastPosition)
|
||||
() => dbSetSetting("toastPosition", toastPosition),
|
||||
{ marksBackupDirty: false }
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Rôle : point d'entrée React, assemble données, routes, shell et overlays.
|
||||
/* eslint-disable react-refresh/only-export-components -- point d'entrée Vite sans export de composant. */
|
||||
import { lazy, StrictMode, Suspense, useCallback, useEffect, useState } from "react";
|
||||
import { lazy, StrictMode, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./styles/main.scss";
|
||||
import { AppOverlays } from "./components/AppOverlays.jsx";
|
||||
|
|
@ -24,6 +24,7 @@ const DEFAULT_NOTIFICATION_DURATION_MS = 120000;
|
|||
const SHORT_NOTIFICATION_DURATION_MS = 5000;
|
||||
|
||||
function getNotificationDuration(message, options = {}) {
|
||||
if (options.persistent) return 0;
|
||||
if (Number.isFinite(options.durationMs)) return Math.max(1000, Number(options.durationMs));
|
||||
const normalized = String(message || "").toLowerCase();
|
||||
return /\bcopi[ée]|\bcoll[ée]|presse-papiers/.test(normalized) ? SHORT_NOTIFICATION_DURATION_MS : DEFAULT_NOTIFICATION_DURATION_MS;
|
||||
|
|
@ -42,16 +43,26 @@ function App() {
|
|||
const [pendingTutorialToolboxId, setPendingTutorialToolboxId] = useState("");
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [storageError, setStorageError] = useState("");
|
||||
const backupReminderIdRef = useRef("");
|
||||
const handleStorageError = useCallback((message) => setStorageError(message), []);
|
||||
const store = useIndexedToolboxes(handleStorageError);
|
||||
const autoBackup = useAutoBackup(store);
|
||||
const { backupNeeded, requestPermission: requestBackupPermission } = autoBackup;
|
||||
const guideChecklistSession = useGuideChecklistSession(store);
|
||||
|
||||
const notify = useCallback((message, options = {}) => {
|
||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
setNotifications((current) => [
|
||||
{ id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, message, durationMs: getNotificationDuration(message, options) },
|
||||
{
|
||||
id,
|
||||
message,
|
||||
durationMs: getNotificationDuration(message, options),
|
||||
actionLabel: options.actionLabel,
|
||||
onAction: options.onAction
|
||||
},
|
||||
...current
|
||||
].slice(0, 5));
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
const dismissNotification = useCallback((notificationId) => {
|
||||
|
|
@ -67,6 +78,21 @@ function App() {
|
|||
return () => window.removeEventListener("sokkog:notify", handleNotification);
|
||||
}, [notify]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!backupNeeded) {
|
||||
if (backupReminderIdRef.current) dismissNotification(backupReminderIdRef.current);
|
||||
backupReminderIdRef.current = "";
|
||||
return;
|
||||
}
|
||||
if (backupReminderIdRef.current || !siteContent?.toolboxes?.autoBackup) return;
|
||||
const content = siteContent.toolboxes.autoBackup;
|
||||
backupReminderIdRef.current = notify(content.reminderMessage, {
|
||||
persistent: true,
|
||||
actionLabel: content.reminderAction,
|
||||
onAction: () => void requestBackupPermission()
|
||||
});
|
||||
}, [backupNeeded, dismissNotification, notify, requestBackupPermission, siteContent]);
|
||||
|
||||
const t = (key, { capitalize = false } = {}) => {
|
||||
const value = mhwilds.translations[key] || String(key || "").replace(/_/g, " ");
|
||||
return capitalize ? value.charAt(0).toUpperCase() + value.slice(1) : value;
|
||||
|
|
|
|||
|
|
@ -98,6 +98,35 @@
|
|||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.auto-backup-permission-guide {
|
||||
margin-bottom: var(--space-5);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid rgba(34, 211, 238, 0.2);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(34, 211, 238, 0.06);
|
||||
}
|
||||
|
||||
.auto-backup-permission-guide strong {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.auto-backup-permission-guide p {
|
||||
margin: var(--space-2) 0 var(--space-3);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.auto-backup-permission-guide ol {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.auto-backup-folder {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,14 @@
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-toast.has-action {
|
||||
grid-template-columns: minmax(0, 1fr) auto 34px;
|
||||
}
|
||||
|
||||
.notification-toast .notification-toast-action {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notification-toast button .ui-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue