Named libraries: switch between several, each syncing on its own

The app can keep any number of named libraries and switch between them
from the top bar or Info → Libraries. Each can sync to the account's other
devices or stay on this one (a scratch library to try things in).

The sync server now keeps several libraries per user under
/api/libraries; a migration makes each existing library the user's default
"Main", which /api/library keeps serving for older app builds. Every sync
lines the device's libraries up with the account's: new ones elsewhere are
downloaded, new ones here created on the server, ones deleted elsewhere
flagged rather than thrown away. The existing library keeps its files and
merge base, so upgrading asks nothing.
This commit is contained in:
2026-10-16 21:44:55 -05:00
parent 6072d44ebe
commit 81b1df8c9f
21 changed files with 2451 additions and 202 deletions

View File

@@ -1,7 +1,16 @@
import { useEffect, useRef, useState, useCallback, ReactNode } from "react";
import { listen } from "@tauri-apps/api/event";
import { open } from "@tauri-apps/plugin-dialog";
import { api, IngestProgress, IngestStageInfo, LibraryStats, Status, SyncStatus } from "./api";
import {
api,
IngestProgress,
IngestStageInfo,
LibraryInfo,
LibraryStats,
setOpenLibrary,
Status,
SyncStatus,
} from "./api";
import {
ColorDistributionBar,
formatRelativeTime,
@@ -13,6 +22,7 @@ import {
usePersistedState,
} from "./components";
import { CommandPalette } from "./CommandPalette";
import { LibrariesPanel, LibrarySwitcher } from "./Libraries";
import { SyncIndicator, SyncPanel } from "./SyncPanel";
import { CardEntry } from "./views/CardEntry";
import { LibraryBrowser } from "./views/LibraryBrowser";
@@ -236,6 +246,7 @@ export default function App() {
// Library sync state: signed in or not, and how the last sync went. Fetched
// at startup, after every sync, and whenever the Info tab opens.
const [sync, setSync] = useState<SyncStatus | null>(null);
const [libraries, setLibraries] = useState<LibraryInfo[]>([]);
// A sync is in flight, for the top-bar indicator.
const [syncing, setSyncing] = useState(false);
const lastProgress = useRef<IngestProgress | null>(null);
@@ -462,16 +473,37 @@ export default function App() {
/**
* Bring every view into line after the library may have changed wholesale:
* signing in or out, adopting the server's copy, importing a file. The top
* bar's label comes from get_status, which nothing else re-fetches; the
* sidebar reads `libStats`; and each tab holds rows it fetched earlier.
* signing in or out, opening another library, adopting the server's copy,
* importing a file. The top bar's label comes from get_status, which nothing
* else re-fetches; the sidebar reads `libStats`; each tab holds rows it
* fetched earlier; and signing in can bring libraries from other devices.
*/
const refreshEverything = useCallback(() => {
api.getStatus().then(setStatus).catch(() => {});
api.listLibraries().then(setLibraries).catch(() => {});
if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {});
setDeckRefreshSignal((v) => v + 1);
}, []);
const refreshLibraries = useCallback(() => {
api.listLibraries().then(setLibraries).catch(() => {});
}, []);
useEffect(() => {
if (statusLoaded) refreshLibraries();
}, [statusLoaded, refreshLibraries]);
/** Another library was opened, or the list changed: the open library may
* be a different one now, so everything refetches. */
const librariesChanged = useCallback(
(libs: LibraryInfo[]) => {
setLibraries(libs);
refreshEverything();
api.syncStatus().then(setSync).catch(() => {});
},
[refreshEverything],
);
/** Merge with the server and publish local edits.
*
* Shared by the Info tab's button and the automatic triggers below. Resolves
@@ -490,12 +522,18 @@ export default function App() {
}
setDeckRefreshSignal((v) => v + 1);
}
if (report?.libraries_changed) {
// Downloaded, renamed or deleted elsewhere — the open one's name in
// the top bar included.
refreshLibraries();
api.getStatus().then(setStatus).catch(() => {});
}
return report;
} finally {
setSyncing(false);
api.syncStatus().then(setSync).catch(() => {});
}
}, []);
}, [refreshLibraries]);
// Automatic sync, whenever signed in (and not waiting on the first-sync
// choice): once now, on every trip to or from the background — leaving
@@ -555,6 +593,10 @@ export default function App() {
}
}
// Before any view renders: they read per-library settings (`libraryKey`)
// as they mount.
if (status) setOpenLibrary(status.library.id);
return (
<HoverPreviewProvider>
<div className="app">
@@ -589,9 +631,17 @@ export default function App() {
<div className="main">
<header className="topbar">
<span className="path" title={status?.library_label ?? ""}>
{status?.library_label ?? ""}
</span>
{status ? (
<LibrarySwitcher
label={status.library_label}
libraries={libraries}
signedIn={sync?.signed_in ?? false}
onChanged={librariesChanged}
onManage={() => setTab("info")}
/>
) : (
<span className="path" />
)}
<SyncIndicator sync={sync} syncing={syncing} onClick={() => setTab("info")} />
{/* On mobile this lives in the bottom nav's Info tab instead —
see below — rather than crowding the topbar. */}
@@ -701,12 +751,13 @@ export default function App() {
</button>
</div>
) : tab === "add" ? (
<CardEntry />
) : /* Keyed on the sync account: switching to a different one can
replace every card, deck and id these views are holding, so
they're remounted rather than left to reconcile state that
belongs to a library that's no longer open. Stable across
ordinary status refreshes, so it costs nothing otherwise. */
<CardEntry key={status?.library_label} />
) : /* Keyed on the open library and its sync account: switching
either can replace every card, deck and id these views are
holding, so they're remounted rather than left to reconcile
state that belongs to a library that's no longer open.
Stable across ordinary status refreshes, so it costs
nothing otherwise. */
tab === "library" ? (
<LibraryBrowser key={status?.library_label} onOfferUndo={offerUndo} />
) : tab === "search" ? (
@@ -741,6 +792,12 @@ export default function App() {
</button>
</div>
<hr className="deck-section-divider" style={{ margin: 0 }} />
<LibrariesPanel
libraries={libraries}
signedIn={sync?.signed_in ?? false}
onChanged={librariesChanged}
/>
<hr className="deck-section-divider" style={{ margin: 0 }} />
{sync === null ? (
<>
<h2 style={{ margin: 0 }}>Your library</h2>