mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 19:17:03 +00:00
149 lines
5.1 KiB
TypeScript
149 lines
5.1 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { type BookSummary, createAndOpenBook, deleteBook, exampleBook, listBooks, loadBook, saveBook } from "../library";
|
|
import type { Book } from "../model/book";
|
|
import { importEpub } from "../import/epub";
|
|
import { clearPositions } from "../editor/positions";
|
|
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 [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()
|
|
.then(setBooks)
|
|
.catch(() => setBooks([]))
|
|
.finally(() => setLoaded(true));
|
|
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();
|
|
if (isDesktop) {
|
|
try {
|
|
await saveBook(copy);
|
|
} catch (e) {
|
|
setNotice(`Could not save copy: ${e}`);
|
|
}
|
|
}
|
|
onOpen(copy);
|
|
};
|
|
|
|
const removeBook = async () => {
|
|
if (!pendingDelete) return;
|
|
await deleteBook(pendingDelete.id);
|
|
clearPositions(pendingDelete.id);
|
|
setPendingDelete(null);
|
|
refresh();
|
|
};
|
|
|
|
const handleImport = async () => {
|
|
if (!isDesktop) {
|
|
setNotice('Import runs in the desktop app only — open the window from "pnpm tauri dev".');
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
try {
|
|
const book = await importEpub();
|
|
if (!book) return;
|
|
await saveBook(book);
|
|
onOpen(book);
|
|
} catch (e) {
|
|
setNotice(`Import failed: ${e}`);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="library">
|
|
<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} />
|
|
<span>New book</span>
|
|
</button>
|
|
<button className="card card-action" onClick={handleImport} disabled={busy}>
|
|
<Icon d="M12 3v10m0 0l-4-4m4 4l4-4M5 19h14" size={20} />
|
|
<span>{busy ? "Importing…" : "Import EPUB"}</span>
|
|
</button>
|
|
{loaded && books.length === 0 && (
|
|
<button className="card card-example" onClick={handleExample}>
|
|
<span className="card-title">The Lighthouse</span>
|
|
<span className="card-badge">Example</span>
|
|
</button>
|
|
)}
|
|
{books.map((b) =>
|
|
b.corrupt ? (
|
|
<div key={b.id} className="card card-book card-corrupt">
|
|
<span className="card-title">{b.title}</span>
|
|
<span className="card-author">Couldn't be read; a .bak backup may sit beside it.</span>
|
|
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
|
|
</div>
|
|
) : (
|
|
<div
|
|
key={b.id}
|
|
className="card card-book"
|
|
onClick={() => loadBook(b.id).then(onOpen).catch((e) => setNotice(`Could not open book: ${e}`))}
|
|
>
|
|
<span className="card-title">{b.title || "Untitled"}</span>
|
|
{b.author && <span className="card-author">{b.author}</span>}
|
|
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
|
|
</div>
|
|
),
|
|
)}
|
|
</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}
|
|
</div>
|
|
)}
|
|
{pendingDelete && (
|
|
<ConfirmDialog
|
|
title="Delete book"
|
|
message={
|
|
<>
|
|
Delete <strong>{pendingDelete.title || "Untitled"}</strong>? This permanently removes the book and all its
|
|
chapters.
|
|
</>
|
|
}
|
|
onConfirm={removeBook}
|
|
onClose={() => setPendingDelete(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|