mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 11:07:04 +00:00
changes
This commit is contained in:
1 parent
ecc94e6c31
commit
5cc083d670
16 files changed
+1634
-15
No files matched your search
+39
-3
@@ -3,7 +3,9 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { Library } from "./components/Library";
|
||||
import { EditorView } from "./components/EditorView";
|
||||
import { BackupSettings } from "./components/BackupSettings";
|
||||
import { useBook } from "./store/useBook";
|
||||
import { useBackup } from "./store/useBackup";
|
||||
import { isDesktop } from "./ipc";
|
||||
import { createAndOpenBook, saveBook } from "./library";
|
||||
import { runExport } from "./export/run";
|
||||
@@ -37,9 +39,16 @@ function App() {
|
||||
const win = getCurrentWindow();
|
||||
const unlisten = win.onCloseRequested(async (event) => {
|
||||
const { book, dirty } = useBook.getState();
|
||||
if (!book || !dirty) return;
|
||||
const { connected } = useBackup.getState();
|
||||
if ((!book || !dirty) && !connected) return;
|
||||
event.preventDefault();
|
||||
await saveBook(book).catch(() => {});
|
||||
if (book && dirty) await saveBook(book).catch(() => {});
|
||||
if (connected) {
|
||||
await Promise.race([
|
||||
useBackup.getState().backup(true),
|
||||
new Promise((resolve) => setTimeout(resolve, 8000)),
|
||||
]);
|
||||
}
|
||||
win.destroy();
|
||||
});
|
||||
return () => {
|
||||
@@ -47,7 +56,34 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return book ? <EditorView /> : <Library onOpen={openBook} />;
|
||||
useEffect(() => {
|
||||
if (!isDesktop) return;
|
||||
const unlisten = listen<{ ok: boolean; error: string | null }>("gdrive-auth", (event) => {
|
||||
useBackup.getState().handleAuthEvent(event.payload.ok, event.payload.error);
|
||||
});
|
||||
return () => {
|
||||
unlisten.then((stop) => stop());
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktop) return;
|
||||
const tick = async () => {
|
||||
await useBackup.getState().refresh();
|
||||
const state = useBackup.getState();
|
||||
if (state.connected && state.pending) state.backup(true);
|
||||
};
|
||||
tick();
|
||||
const id = setInterval(tick, 15 * 60 * 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{book ? <EditorView /> : <Library onOpen={openBook} />}
|
||||
{isDesktop && <BackupSettings />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { isDesktop } from "./ipc";
|
||||
|
||||
export interface BackupStatus {
|
||||
connected: boolean;
|
||||
email: string | null;
|
||||
lastBackup: number | null;
|
||||
pending: boolean;
|
||||
}
|
||||
|
||||
export interface RestoreResult {
|
||||
restored: number;
|
||||
}
|
||||
|
||||
export interface BackupOutcome extends BackupStatus {
|
||||
uploaded: number;
|
||||
}
|
||||
|
||||
export interface RemoteBackup {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const OFFLINE: BackupStatus = { connected: false, email: null, lastBackup: null, pending: false };
|
||||
|
||||
export async function gdriveStatus(): Promise<BackupStatus> {
|
||||
if (!isDesktop) return OFFLINE;
|
||||
return invoke<BackupStatus>("gdrive_status");
|
||||
}
|
||||
|
||||
export async function gdriveConnect(): Promise<string> {
|
||||
return invoke<string>("gdrive_connect");
|
||||
}
|
||||
|
||||
export async function gdriveDisconnect(): Promise<BackupStatus> {
|
||||
return invoke<BackupStatus>("gdrive_disconnect");
|
||||
}
|
||||
|
||||
export async function gdriveBackup(): Promise<BackupOutcome> {
|
||||
return invoke<BackupOutcome>("gdrive_backup");
|
||||
}
|
||||
|
||||
export async function gdriveRestore(): Promise<RestoreResult> {
|
||||
return invoke<RestoreResult>("gdrive_restore");
|
||||
}
|
||||
|
||||
export async function gdriveListBackups(): Promise<RemoteBackup[]> {
|
||||
if (!isDesktop) return [];
|
||||
return invoke<RemoteBackup[]>("gdrive_list_backups");
|
||||
}
|
||||
|
||||
export function formatBackupTime(ts: number | null): string {
|
||||
if (!ts) return "Not backed up yet";
|
||||
const diff = Date.now() - ts * 1000;
|
||||
const min = Math.floor(diff / 60000);
|
||||
if (min < 1) return "Backed up just now";
|
||||
if (min < 60) return `Backed up ${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `Backed up ${hr}h ago`;
|
||||
const day = Math.floor(hr / 24);
|
||||
if (day === 1) return "Backed up yesterday";
|
||||
if (day < 7) return `Backed up ${day}d ago`;
|
||||
return `Backed up ${new Date(ts * 1000).toLocaleDateString()}`;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Icon } from "./Icon";
|
||||
import { useBackup } from "../store/useBackup";
|
||||
import { formatBackupTime } from "../backup";
|
||||
|
||||
export function BackupButton() {
|
||||
const connected = useBackup((s) => s.connected);
|
||||
const pending = useBackup((s) => s.pending);
|
||||
const phase = useBackup((s) => s.phase);
|
||||
const lastBackup = useBackup((s) => s.lastBackup);
|
||||
const openSettings = useBackup((s) => s.openSettings);
|
||||
|
||||
const busy = phase === "working" || phase === "connecting";
|
||||
const error = phase === "error";
|
||||
|
||||
const state = busy ? "working" : error ? "error" : !connected ? "off" : pending ? "pending" : "synced";
|
||||
|
||||
const title =
|
||||
phase === "connecting"
|
||||
? "Waiting for Google authorization…"
|
||||
: state === "working"
|
||||
? "Backing up…"
|
||||
: state === "error"
|
||||
? "Backup needs attention"
|
||||
: state === "off"
|
||||
? "Connect Google Drive"
|
||||
: state === "pending"
|
||||
? "Changes not backed up"
|
||||
: formatBackupTime(lastBackup);
|
||||
|
||||
const onClick = () => {
|
||||
openSettings();
|
||||
};
|
||||
|
||||
return (
|
||||
<button className="icon-btn backup-btn" data-state={state} onClick={onClick} title={title}>
|
||||
{busy ? (
|
||||
<Icon size={18}>
|
||||
<path d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5" />
|
||||
</Icon>
|
||||
) : (
|
||||
<Icon size={18}>
|
||||
<path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.6-1.3A3.5 3.5 0 0 1 18 18H7z" />
|
||||
{connected ? <path d="M12 16.5v-5M9.7 12.8l2.3-2.3 2.3 2.3" /> : null}
|
||||
</Icon>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Icon } from "./Icon";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import { useBackup } from "../store/useBackup";
|
||||
import { formatBackupTime, gdriveListBackups } from "../backup";
|
||||
|
||||
export function BackupSettings() {
|
||||
const open = useBackup((s) => s.settingsOpen);
|
||||
const close = useBackup((s) => s.closeSettings);
|
||||
const connected = useBackup((s) => s.connected);
|
||||
const email = useBackup((s) => s.email);
|
||||
const lastBackup = useBackup((s) => s.lastBackup);
|
||||
const pending = useBackup((s) => s.pending);
|
||||
const phase = useBackup((s) => s.phase);
|
||||
const connect = useBackup((s) => s.connect);
|
||||
const disconnect = useBackup((s) => s.disconnect);
|
||||
const backup = useBackup((s) => s.backup);
|
||||
const restore = useBackup((s) => s.restore);
|
||||
const cancelConnect = useBackup((s) => s.cancelConnect);
|
||||
const openAuthUrl = useBackup((s) => s.openAuthUrl);
|
||||
const copyAuthUrl = useBackup((s) => s.copyAuthUrl);
|
||||
|
||||
const [remoteCount, setRemoteCount] = useState<number | null>(null);
|
||||
const [confirmRestore, setConfirmRestore] = useState(false);
|
||||
const working = phase === "working";
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !connected) {
|
||||
setRemoteCount(null);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
gdriveListBackups()
|
||||
.then((list) => active && setRemoteCount(list.length))
|
||||
.catch(() => active && setRemoteCount(null));
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [open, connected, lastBackup]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overlay" onClick={close}>
|
||||
<div className="panel" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="panel-head">
|
||||
<h2>Backup & Sync</h2>
|
||||
<button className="icon-btn" onClick={close} title="Close">
|
||||
<Icon d="M6 6l12 12M18 6L6 18" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
{phase === "connecting" ? (
|
||||
<div className="backup-connecting">
|
||||
<div className="backup-waiting">
|
||||
<Icon size={16}>
|
||||
<path d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5" />
|
||||
</Icon>
|
||||
<span>Waiting for Google authorization…</span>
|
||||
</div>
|
||||
<p>Finish in the browser tab that opened. If it didn't open, reopen the link or copy it into a browser.</p>
|
||||
<div className="backup-link-row">
|
||||
<button className="backup-text-btn" onClick={cancelConnect}>
|
||||
Stop linking
|
||||
</button>
|
||||
<div className="backup-links">
|
||||
<button className="backup-text-btn" onClick={openAuthUrl}>
|
||||
Open link again
|
||||
</button>
|
||||
<span className="backup-link-sep">·</span>
|
||||
<button className="backup-text-btn" onClick={copyAuthUrl}>
|
||||
Copy link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !connected ? (
|
||||
<div className="backup-intro">
|
||||
<p>
|
||||
Connect Google Drive to keep a private backup of every book. margin only ever sees the files it
|
||||
creates in a <strong>margin</strong> folder, never the rest of your Drive.
|
||||
</p>
|
||||
<button className="btn-primary" disabled={working} onClick={connect}>
|
||||
{working ? "Connecting…" : "Connect Google Drive"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="backup-account">
|
||||
<div className="backup-account-info">
|
||||
<span className="field-label">Connected account</span>
|
||||
<span className="backup-email">{email || "Google Drive"}</span>
|
||||
</div>
|
||||
<button className="btn-ghost" disabled={working} onClick={disconnect}>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="backup-status-row">
|
||||
<span className={`backup-dot ${pending ? "is-pending" : "is-synced"}`} />
|
||||
<span>{pending ? "Changes not backed up" : formatBackupTime(lastBackup)}</span>
|
||||
</div>
|
||||
|
||||
<div className="backup-actions">
|
||||
<button className="btn-primary" disabled={working} onClick={() => backup()}>
|
||||
{working ? "Working…" : "Back up now"}
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost"
|
||||
disabled={working || remoteCount === 0}
|
||||
onClick={() => setConfirmRestore(true)}
|
||||
>
|
||||
{remoteCount === null ? "Restore from Drive" : `Restore ${remoteCount} book${remoteCount === 1 ? "" : "s"}`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="backup-note">
|
||||
Backs up automatically when you close margin, and every 15 minutes while there are changes.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirmRestore && (
|
||||
<ConfirmDialog
|
||||
title="Restore from Google Drive"
|
||||
message={
|
||||
<>
|
||||
This replaces your local books with the copies in Google Drive. Any local changes that haven't been backed
|
||||
up will be lost.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Restore"
|
||||
onConfirm={() => {
|
||||
setConfirmRestore(false);
|
||||
restore();
|
||||
}}
|
||||
onClose={() => setConfirmRestore(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Dock } from "./Dock";
|
||||
import { ResizeHandle } from "./ResizeHandle";
|
||||
import { Icon } from "./Icon";
|
||||
import { Settings } from "./Settings";
|
||||
import { BackupButton } from "./BackupButton";
|
||||
import { CoverView } from "./CoverView";
|
||||
import { FindBar } from "./FindBar";
|
||||
import { ProofPopover } from "./ProofPopover";
|
||||
@@ -12,6 +13,7 @@ import { Editor } from "../editor/Editor";
|
||||
import { FloatingToolbar } from "../editor/FloatingToolbar";
|
||||
import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing";
|
||||
import { COVER_ID, useBook } from "../store/useBook";
|
||||
import { useBackup } from "../store/useBackup";
|
||||
import { useProofing } from "../store/useProofing";
|
||||
import { useTheme } from "../store/useTheme";
|
||||
import { useWidth } from "../store/useWidth";
|
||||
@@ -79,6 +81,7 @@ export function EditorView() {
|
||||
saveBook(current)
|
||||
.then(() => {
|
||||
if (useBook.getState().book === current) markSaved();
|
||||
useBackup.getState().refresh();
|
||||
})
|
||||
.catch((e) => setNotice(`Save failed: ${e}`));
|
||||
}, [markSaved, setNotice]);
|
||||
@@ -163,6 +166,7 @@ export function EditorView() {
|
||||
{dirty && <span className="dirty-dot" />}
|
||||
</button>
|
||||
<div className="actions">
|
||||
{isDesktop && <BackupButton />}
|
||||
<button className="icon-btn" data-on={findOpen} onClick={() => (findOpen ? setFindOpen(false) : openFind(false))} title="Find (⌘F)">
|
||||
<Icon d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" />
|
||||
</button>
|
||||
|
||||
@@ -7,13 +7,20 @@ import { isDesktop } from "../ipc";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import { Icon } from "./Icon";
|
||||
import { RowMenu } from "./RowMenu";
|
||||
import { BackupButton } from "./BackupButton";
|
||||
import { useBackup } from "../store/useBackup";
|
||||
import { useBook } from "../store/useBook";
|
||||
|
||||
export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||
const [books, setBooks] = useState<BookSummary[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(null);
|
||||
const notice = useBook((s) => s.notice);
|
||||
const setNotice = useBook((s) => s.setNotice);
|
||||
const restoreNonce = useBackup((s) => s.restoreNonce);
|
||||
const restoreWorking = useBackup((s) => s.phase === "working");
|
||||
const restoreFromDrive = useBackup((s) => s.restoreFromDrive);
|
||||
|
||||
const refresh = () =>
|
||||
listBooks()
|
||||
@@ -23,6 +30,14 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (restoreNonce) refresh();
|
||||
}, [restoreNonce]);
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
const timer = setTimeout(() => setNotice(null), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [notice]);
|
||||
|
||||
const handleExample = async () => {
|
||||
const copy = exampleBook();
|
||||
@@ -64,7 +79,9 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||
|
||||
return (
|
||||
<div className="library">
|
||||
<header className="library-head" data-tauri-drag-region />
|
||||
<header className="library-head" data-tauri-drag-region>
|
||||
{isDesktop && <BackupButton />}
|
||||
</header>
|
||||
<div className="shelf">
|
||||
<button className="card card-action" onClick={() => createAndOpenBook(onOpen, setNotice)}>
|
||||
<Icon d="M12 5v14M5 12h14" size={20} />
|
||||
@@ -100,6 +117,14 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{loaded && books.length === 0 && isDesktop && (
|
||||
<div className="library-restore">
|
||||
<button className="restore-link" onClick={restoreFromDrive} disabled={restoreWorking}>
|
||||
<Icon d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5" size={15} />
|
||||
{restoreWorking ? "Restoring…" : "Restore from Google Drive"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{notice && (
|
||||
<div className="toast" onClick={() => setNotice(null)}>
|
||||
{notice}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { create } from "zustand";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import {
|
||||
type BackupStatus,
|
||||
gdriveBackup,
|
||||
gdriveConnect,
|
||||
gdriveDisconnect,
|
||||
gdriveRestore,
|
||||
gdriveStatus,
|
||||
} from "../backup";
|
||||
import { isDesktop } from "../ipc";
|
||||
import { useBook } from "./useBook";
|
||||
|
||||
type Phase = "idle" | "connecting" | "working" | "error";
|
||||
|
||||
interface BackupState {
|
||||
connected: boolean;
|
||||
email: string | null;
|
||||
lastBackup: number | null;
|
||||
pending: boolean;
|
||||
phase: Phase;
|
||||
error: string | null;
|
||||
authUrl: string | null;
|
||||
settingsOpen: boolean;
|
||||
restoreNonce: number;
|
||||
resolveConnect: ((ok: boolean) => void) | null;
|
||||
apply: (status: BackupStatus) => void;
|
||||
refresh: () => Promise<void>;
|
||||
connect: () => Promise<boolean>;
|
||||
cancelConnect: () => void;
|
||||
handleAuthEvent: (ok: boolean, error: string | null) => Promise<void>;
|
||||
openAuthUrl: () => void;
|
||||
copyAuthUrl: () => Promise<void>;
|
||||
disconnect: () => Promise<void>;
|
||||
backup: (silent?: boolean) => Promise<void>;
|
||||
restore: () => Promise<void>;
|
||||
restoreFromDrive: () => Promise<void>;
|
||||
openSettings: () => void;
|
||||
closeSettings: () => void;
|
||||
}
|
||||
|
||||
function notify(message: string) {
|
||||
useBook.getState().setNotice(message);
|
||||
}
|
||||
|
||||
export const useBackup = create<BackupState>((set, get) => ({
|
||||
connected: false,
|
||||
email: null,
|
||||
lastBackup: null,
|
||||
pending: false,
|
||||
phase: "idle",
|
||||
error: null,
|
||||
authUrl: null,
|
||||
settingsOpen: false,
|
||||
restoreNonce: 0,
|
||||
resolveConnect: null,
|
||||
apply: (status) =>
|
||||
set({
|
||||
connected: status.connected,
|
||||
email: status.email,
|
||||
lastBackup: status.lastBackup,
|
||||
pending: status.pending,
|
||||
}),
|
||||
refresh: async () => {
|
||||
if (!isDesktop) return;
|
||||
try {
|
||||
get().apply(await gdriveStatus());
|
||||
} catch {}
|
||||
},
|
||||
connect: () =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
get().resolveConnect?.(false);
|
||||
set({ phase: "connecting", error: null, authUrl: null, resolveConnect: resolve });
|
||||
gdriveConnect()
|
||||
.then((url) => set({ authUrl: url }))
|
||||
.catch((e) => {
|
||||
set({ phase: "error", error: String(e), resolveConnect: null });
|
||||
notify(`Could not connect: ${e}`);
|
||||
resolve(false);
|
||||
});
|
||||
}),
|
||||
cancelConnect: () => {
|
||||
const resolve = get().resolveConnect;
|
||||
set({ phase: "idle", authUrl: null, resolveConnect: null });
|
||||
resolve?.(false);
|
||||
},
|
||||
handleAuthEvent: async (ok, error) => {
|
||||
if (get().phase !== "connecting") return;
|
||||
const resolve = get().resolveConnect;
|
||||
if (ok) {
|
||||
await get().refresh();
|
||||
set({ phase: "idle", authUrl: null, error: null, resolveConnect: null });
|
||||
notify("Connected to Google Drive");
|
||||
} else {
|
||||
set({ phase: "error", authUrl: null, error: error ?? "authorization failed", resolveConnect: null });
|
||||
notify(`Could not connect: ${error ?? "authorization failed"}`);
|
||||
}
|
||||
resolve?.(ok);
|
||||
},
|
||||
openAuthUrl: () => {
|
||||
const url = get().authUrl;
|
||||
if (url) openUrl(url).catch(() => {});
|
||||
},
|
||||
copyAuthUrl: async () => {
|
||||
const url = get().authUrl;
|
||||
if (!url) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
notify("Authorization link copied");
|
||||
} catch {
|
||||
notify("Could not copy the link");
|
||||
}
|
||||
},
|
||||
disconnect: async () => {
|
||||
set({ phase: "working", error: null });
|
||||
try {
|
||||
get().apply(await gdriveDisconnect());
|
||||
set({ phase: "idle" });
|
||||
notify("Disconnected from Google Drive");
|
||||
} catch (e) {
|
||||
set({ phase: "error", error: String(e) });
|
||||
notify(`Could not disconnect: ${e}`);
|
||||
}
|
||||
},
|
||||
backup: async (silent = false) => {
|
||||
if (!get().connected || get().phase === "working") return;
|
||||
set({ phase: "working", error: null });
|
||||
try {
|
||||
const outcome = await gdriveBackup();
|
||||
get().apply(outcome);
|
||||
set({ phase: "idle" });
|
||||
if (!silent) notify(outcome.uploaded > 0 ? "Backed up to Google Drive" : "Nothing new to back up");
|
||||
} catch (e) {
|
||||
set({ phase: "error", error: String(e) });
|
||||
if (!silent) notify(`Backup failed: ${e}`);
|
||||
}
|
||||
},
|
||||
restore: async () => {
|
||||
set({ phase: "working", error: null });
|
||||
try {
|
||||
const result = await gdriveRestore();
|
||||
await get().refresh();
|
||||
set((s) => ({ phase: "idle", restoreNonce: s.restoreNonce + 1 }));
|
||||
notify(result.restored > 0 ? `Restored ${result.restored} file${result.restored === 1 ? "" : "s"}` : "No backups found in Google Drive");
|
||||
} catch (e) {
|
||||
set({ phase: "error", error: String(e) });
|
||||
notify(`Restore failed: ${e}`);
|
||||
}
|
||||
},
|
||||
restoreFromDrive: async () => {
|
||||
const ok = get().connected || (await get().connect());
|
||||
if (ok) await get().restore();
|
||||
},
|
||||
openSettings: () => set({ settingsOpen: true }),
|
||||
closeSettings: () => set({ settingsOpen: false }),
|
||||
}));
|
||||
@@ -1257,6 +1257,10 @@ body.resizing {
|
||||
|
||||
.library-head {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.shelf {
|
||||
@@ -2283,3 +2287,170 @@ body.resizing {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Google Drive backup */
|
||||
.backup-btn[data-state="pending"] {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.backup-btn[data-state="error"] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.backup-btn[data-state="working"] svg {
|
||||
animation: backup-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes backup-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.library-restore {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0 44px 60px;
|
||||
}
|
||||
|
||||
.restore-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--ink-faint);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-2);
|
||||
transition: color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.restore-link:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.restore-link:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.backup-intro,
|
||||
.backup-connecting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.backup-intro p,
|
||||
.backup-connecting p {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-3);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.backup-text-btn {
|
||||
color: var(--ink-faint);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-2);
|
||||
transition: color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.backup-text-btn:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.backup-waiting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-3);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.backup-waiting svg {
|
||||
color: var(--ink-soft);
|
||||
animation: backup-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.backup-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.backup-link-sep {
|
||||
color: var(--line-strong);
|
||||
}
|
||||
|
||||
.backup-link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.backup-account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.backup-account-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.backup-email {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-3);
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.backup-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 18px 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-3);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.backup-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.backup-dot.is-synced {
|
||||
background: var(--ink-faint);
|
||||
}
|
||||
|
||||
.backup-dot.is-pending {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.backup-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.backup-note {
|
||||
margin: 18px 0 0;
|
||||
color: var(--ink-faint);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
Reference in new issue
Block a user