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>

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { api, CardRef, Deck } from "./api";
import { api, CardRef, Deck, libraryKey } from "./api";
import { DialogFrame, useClickPreview } from "./components";
// Kept in step with App.tsx's Tab union by hand — it isn't exported.
@@ -111,11 +111,11 @@ export function CommandPalette({
hint: "Deck",
run: () => {
// DeckView reads its initial selection from this same
// localStorage key on mount (usePersistedState("deck-selected"),
// localStorage key on mount (usePersistedState(libraryKey("deck-selected")),
// src/views/DeckView.tsx). App.tsx unmounts/remounts DeckView on
// tab switch, so writing here before navigating is enough for it
// to pick up the selection on its next mount.
localStorage.setItem("deck-selected", JSON.stringify(d.id));
localStorage.setItem(libraryKey("deck-selected"), JSON.stringify(d.id));
onNavigate("decks");
},
})),

388
src/Libraries.tsx Normal file
View File

@@ -0,0 +1,388 @@
import { useEffect, useRef, useState } from "react";
import { api, LibraryInfo } from "./api";
import { ConfirmDialog, DialogFrame, IconButton, Icons, PromptDialog } from "./components";
/** Called with the new list after anything that changes it — including which
* library is open, so every view should refetch. */
type OnChanged = (libraries: LibraryInfo[]) => void;
/** Where a library stands with the sync server, in a few words. */
function syncState(lib: LibraryInfo, signedIn: boolean): string {
if (lib.server_deleted) return "Deleted from the server on another device";
if (!lib.sync) return "This device only";
if (!signedIn) return "Syncs once you sign in";
return lib.linked ? "Synced" : "Not on the server yet";
}
/** Start a library: a name, and whether it goes on the sync server. */
function NewLibraryDialog({
signedIn,
onChanged,
onCancel,
}: {
signedIn: boolean;
onChanged: OnChanged;
onCancel: () => void;
}) {
const [name, setName] = useState("");
const [sync, setSync] = useState(signedIn);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function create() {
if (!name.trim() || busy) return;
setBusy(true);
setError(null);
try {
onChanged(await api.createLibrary(name.trim(), sync));
} catch (err) {
setError(String(err));
setBusy(false);
}
}
return (
<DialogFrame title="New library" onCancel={onCancel}>
<label className="dialog-field">
<span className="muted">Name</span>
<input
autoFocus
placeholder="e.g. Scratch, Cube, Trades"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && create()}
/>
</label>
<label className="library-sync-check">
<input type="checkbox" checked={sync} onChange={(e) => setSync(e.target.checked)} />
<span>
Sync it to your other devices
<span className="hint">
{" "}
— leave this off for a library just for trying things out on this device.
</span>
</span>
</label>
{error && <p className="dialog-error">{error}</p>}
<div className="dialog-actions">
<button onClick={onCancel}>Cancel</button>
<button className="primary" onClick={create} disabled={!name.trim() || busy}>
Create and open
</button>
</div>
</DialogFrame>
);
}
/** Delete a library: from this device, or — when it's on the server — from
* everywhere. */
function DeleteLibraryDialog({
lib,
onChanged,
onCancel,
}: {
lib: LibraryInfo;
onChanged: OnChanged;
onCancel: () => void;
}) {
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function remove(everywhere: boolean) {
setBusy(true);
setError(null);
try {
onChanged(await api.deleteLibrary(lib.id, everywhere));
} catch (err) {
setError(String(err));
setBusy(false);
}
}
return (
<DialogFrame title={`Delete ${lib.name}?`} onCancel={onCancel}>
{lib.linked ? (
<p>
<strong>Remove from this device</strong> keeps it on the sync server for your other
devices; this one stops downloading it. <strong>Delete everywhere</strong> also removes
it from the server, and your other devices keep only the copy they already have.
</p>
) : (
<p>Its cards and decks will be deleted from this device. This can't be undone.</p>
)}
{error && <p className="dialog-error">{error}</p>}
<div className="dialog-actions">
<button onClick={onCancel} disabled={busy}>
Cancel
</button>
{lib.linked && (
<button onClick={() => remove(false)} disabled={busy}>
Remove from this device
</button>
)}
<button className="danger" onClick={() => remove(lib.linked)} disabled={busy}>
{lib.linked ? "Delete everywhere" : "Delete"}
</button>
</div>
</DialogFrame>
);
}
/**
* The top bar's library label, which opens a menu for switching libraries or
* starting a new one. Hidden on narrow screens with the rest of `.path`; the
* Info tab's {@link LibrariesPanel} does the same job there.
*/
export function LibrarySwitcher({
label,
libraries,
signedIn,
onChanged,
onManage,
}: {
label: string;
libraries: LibraryInfo[];
signedIn: boolean;
onChanged: OnChanged;
onManage: () => void;
}) {
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", onDown);
window.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
window.removeEventListener("keydown", onKey);
};
}, [open]);
async function choose(lib: LibraryInfo) {
if (lib.active) {
setOpen(false);
return;
}
setError(null);
try {
onChanged(await api.switchLibrary(lib.id));
setOpen(false);
} catch (err) {
setError(String(err));
}
}
return (
<div className="path library-switcher" ref={ref}>
<button
className="library-switcher-button"
title={`${label} — switch library`}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => {
setError(null);
setOpen(!open);
}}
>
<span className="library-switcher-label">{label}</span>
<span aria-hidden="true">▾</span>
</button>
{open && (
<div className="library-menu" role="menu">
{libraries.map((lib) => (
<button key={lib.id} role="menuitemradio" aria-checked={lib.active} onClick={() => choose(lib)}>
<span className="library-menu-check">{lib.active ? "✓" : ""}</span>
<span className="library-menu-name">{lib.name}</span>
<span className="hint">{syncState(lib, signedIn)}</span>
</button>
))}
{error && <div className="sync-bad library-menu-error">✕ {error}</div>}
<hr />
<button
role="menuitem"
onClick={() => {
setOpen(false);
setCreating(true);
}}
>
<span className="library-menu-check" />
New library…
</button>
<button
role="menuitem"
onClick={() => {
setOpen(false);
onManage();
}}
>
<span className="library-menu-check" />
Manage libraries…
</button>
</div>
)}
{creating && (
<NewLibraryDialog
signedIn={signedIn}
onCancel={() => setCreating(false)}
onChanged={(libs) => {
setCreating(false);
onChanged(libs);
}}
/>
)}
</div>
);
}
/**
* The Info tab's list of libraries: open, rename, sync or keep on this device,
* delete, and start new ones.
*/
export function LibrariesPanel({
libraries,
signedIn,
onChanged,
}: {
libraries: LibraryInfo[];
signedIn: boolean;
onChanged: OnChanged;
}) {
const [dialog, setDialog] = useState<
| { kind: "new" }
| { kind: "rename"; lib: LibraryInfo }
| { kind: "delete"; lib: LibraryInfo }
| { kind: "unsync"; lib: LibraryInfo }
| null
>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function act(work: () => Promise<LibraryInfo[]>) {
setBusy(true);
setError(null);
try {
onChanged(await work());
setDialog(null);
} catch (err) {
setError(String(err));
} finally {
setBusy(false);
}
}
const done: OnChanged = (libs) => {
setDialog(null);
onChanged(libs);
};
return (
<>
<h2 style={{ margin: 0 }}>Libraries</h2>
<div className="hint">
Each library has its own cards and decks. Libraries you sync appear on all your signed-in
devices; one kept on this device only is a safe place to try things out.
</div>
<ul className="library-list">
{libraries.map((lib) => (
<li key={lib.id} className={lib.active ? "active" : ""}>
<div className="library-list-main">
<span className="sync-device-name">
{lib.name}
{lib.active && <span className="badge">Open</span>}
</span>
<span className={lib.server_deleted ? "hint library-warn" : "hint"}>
{syncState(lib, signedIn)}
</span>
</div>
<div className="library-list-actions">
{!lib.active && (
<button onClick={() => act(() => api.switchLibrary(lib.id))} disabled={busy}>
Open
</button>
)}
{lib.server_deleted ? (
<button onClick={() => act(() => api.setLibrarySync(lib.id, true))} disabled={busy}>
Put back on the server
</button>
) : (
<label className="library-sync-check" title="Sync this library to your other devices">
<input
type="checkbox"
checked={lib.sync}
disabled={busy}
onChange={(e) =>
e.target.checked
? act(() => api.setLibrarySync(lib.id, true))
: lib.linked
? setDialog({ kind: "unsync", lib })
: act(() => api.setLibrarySync(lib.id, false))
}
/>
Sync
</label>
)}
<IconButton
icon={Icons.pencil}
label={`Rename ${lib.name}`}
onClick={() => {
setError(null);
setDialog({ kind: "rename", lib });
}}
disabled={busy}
/>
{!lib.is_default && (
<IconButton
icon={Icons.trash}
label={`Delete ${lib.name}`}
danger
onClick={() => setDialog({ kind: "delete", lib })}
disabled={busy}
/>
)}
</div>
</li>
))}
</ul>
{error && !dialog && <div className="sync-bad">✕ {error}</div>}
<div className="row">
<button onClick={() => setDialog({ kind: "new" })} disabled={busy}>
New library…
</button>
</div>
{dialog?.kind === "new" && (
<NewLibraryDialog signedIn={signedIn} onCancel={() => setDialog(null)} onChanged={done} />
)}
{dialog?.kind === "rename" && (
<PromptDialog
title={`Rename ${dialog.lib.name}`}
label="Name"
defaultValue={dialog.lib.name}
confirmLabel="Rename"
error={error}
onCancel={() => setDialog(null)}
onConfirm={(name) => act(() => api.renameLibrary(dialog.lib.id, name))}
/>
)}
{dialog?.kind === "delete" && (
<DeleteLibraryDialog lib={dialog.lib} onCancel={() => setDialog(null)} onChanged={done} />
)}
{dialog?.kind === "unsync" && (
<ConfirmDialog
title={`Stop syncing ${dialog.lib.name}?`}
message="It stays on the server for your other devices, and this device keeps its own copy, which no longer syncs. Turning sync back on combines them again."
confirmLabel="Stop syncing"
onCancel={() => setDialog(null)}
onConfirm={() => act(() => api.setLibrarySync(dialog.lib.id, false))}
/>
)}
</>
);
}

View File

@@ -182,8 +182,11 @@ export interface LibraryFilter {
}
export interface Status {
/** Where the library syncs: "user @ host", or this device only. */
/** The open library's name and where it syncs: "Main · user @ host", or
* this device only. Changes whenever another library is opened. */
library_label: string;
/** The open library. */
library: { id: string; name: string };
reference_path: string;
reference_ready: boolean;
card_count: number;
@@ -266,6 +269,39 @@ export interface SyncReport {
/** The first sync is waiting on the user — see `SyncStatus.needs_choice`. */
needs_choice: boolean;
report: MergeReport;
/** Libraries were added, renamed, or deleted on the server: re-list them. */
libraries_changed: boolean;
}
/** One of the libraries on this device. */
export interface LibraryInfo {
/** Local only; never shown. */
id: string;
name: string;
/** The one that's open. */
active: boolean;
/** The library the device started with, which can't be deleted. */
is_default: boolean;
/** Whether it should be on the sync server. */
sync: boolean;
/** Whether it's linked to a library on the server yet. */
linked: boolean;
/** Another device deleted it from the server; this copy is all that's left. */
server_deleted: boolean;
}
/** The open library, for `libraryKey`. Set by `App` from each status. */
let openLibraryId = "default";
export function setOpenLibrary(id: string) {
openLibraryId = id;
}
/** A localStorage key for something that belongs to the open library — a
* selected deck, say — so each library remembers its own. The default
* library keeps the bare key it always had. */
export function libraryKey(key: string): string {
return openLibraryId === "default" ? key : `${key}:${openLibraryId}`;
}
/** A published or upcoming Magic set, from Scryfall. */
@@ -399,9 +435,22 @@ export const api = {
syncDevices: () => invoke<SyncDevice[]>("sync_devices"),
/** Sign another device out (a lost phone, say); returns the remaining list. */
syncRevokeDevice: (id: number) => invoke<SyncDevice[]>("sync_revoke_device", { id }),
/** Merge a library file into this one: a JSON backup, or an old library.db. */
listLibraries: () => invoke<LibraryInfo[]>("list_libraries"),
/** Start a new, empty library and open it. Resolves with the new list. */
createLibrary: (name: string, sync: boolean) =>
invoke<LibraryInfo[]>("create_library", { name, sync }),
/** Open another library. */
switchLibrary: (id: string) => invoke<LibraryInfo[]>("switch_library", { id }),
/** Rename a library, on the sync server too when it's there. */
renameLibrary: (id: string, name: string) => invoke<LibraryInfo[]>("rename_library", { id, name }),
/** Delete a library from this device; with `everywhere`, from the server too. */
deleteLibrary: (id: string, everywhere: boolean) =>
invoke<LibraryInfo[]>("delete_library", { id, everywhere }),
/** Put a library on the sync server, or keep it on this device only. */
setLibrarySync: (id: string, sync: boolean) => invoke<LibraryInfo[]>("set_library_sync", { id, sync }),
/** Merge a library file into the open one: a JSON backup, or an old library.db. */
importLibraryFile: (path: string) => invoke<ImportReport>("import_library_file", { path }),
/** Save the whole library as JSON, as a backup. */
/** Save the whole open library as JSON, as a backup. */
exportLibraryDocument: (path: string) => invoke<void>("export_library_document", { path }),
/** Set releases and article links. `refresh` forces a fetch past the cache. */

View File

@@ -3428,3 +3428,140 @@ tbody tr:hover {
font-size: 12px;
color: var(--muted);
}
/* Library switcher: the top bar's library label, as a menu button. Keeps
`.path`'s look (and its hiding on narrow screens). */
.library-switcher {
position: relative;
overflow: visible;
padding: 0;
display: flex;
}
@media (max-width: 720px) {
/* Like `.path`: the Info tab's library list stands in for it on phones. */
.library-switcher {
display: none;
}
}
.library-switcher-button {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-2);
background: none;
border: none;
color: inherit;
font: inherit;
padding: 4px 10px;
text-align: left;
cursor: pointer;
}
.library-switcher-button:hover {
color: var(--text);
}
.library-switcher-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.library-menu {
position: absolute;
top: calc(100% + var(--space-1));
left: 0;
z-index: 50;
min-width: 280px;
max-width: min(420px, 90vw);
display: flex;
flex-direction: column;
padding: var(--space-1);
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
font-family: inherit;
white-space: normal;
}
.library-menu > button {
display: flex;
align-items: baseline;
gap: var(--space-2);
background: none;
border: none;
border-radius: var(--radius-sm);
padding: var(--space-2);
color: var(--text);
text-align: left;
font-family: system-ui, sans-serif;
}
.library-menu > button:hover {
background: var(--bg-hover);
}
.library-menu hr {
border: none;
border-top: 1px solid var(--border);
margin: var(--space-1) 0;
width: 100%;
}
.library-menu-check {
width: 1em;
flex-shrink: 0;
color: var(--accent-quiet);
}
.library-menu-name {
font-weight: 600;
color: var(--text-strong);
}
.library-menu .hint {
margin-left: auto;
padding-left: var(--space-3);
}
.library-menu-error {
padding: var(--space-2);
font-family: system-ui, sans-serif;
}
/* The Info tab's list of libraries. */
.library-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.library-list li {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.library-list li.active {
border-color: var(--accent-quiet);
}
.library-list-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1 1 160px;
}
.library-list-actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.library-warn {
color: var(--warn);
}
.library-sync-check {
display: inline-flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { api, CardRef } from "../api";
import { api, CardRef, libraryKey } from "../api";
import { CardSearchBox, QtyStepper } from "../components";
/** Rapid card entry: fuzzy search-as-you-type, ↑/↓ + Enter (or click) adds one copy. */
@@ -10,11 +10,12 @@ export function CardEntry() {
{ oracleId: string; name: string; qty: number; location: string }[]
>([]);
// Persisted so a "box" location sticks while entering a stack of cards.
const [location, setLocation] = useState(() => localStorage.getItem("add-location") || "");
// Each library has its own boxes.
const [location, setLocation] = useState(() => localStorage.getItem(libraryKey("add-location")) || "");
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
localStorage.setItem("add-location", location);
localStorage.setItem(libraryKey("add-location"), location);
}, [location]);
// Typing anywhere on this tab — without having clicked into another field

View File

@@ -1,6 +1,6 @@
import { open, save } from "@tauri-apps/plugin-dialog";
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import { api, CardRef, COLORS, Deck, DeckEntry, DeckImportResult, legalStatusFor, PreviewContext } from "../api";
import { api, CardRef, COLORS, Deck, DeckEntry, DeckImportResult, legalStatusFor, libraryKey, PreviewContext } from "../api";
import {
CardSearchBox,
CardThumb,
@@ -361,7 +361,7 @@ function computeDeckLegality(deck: Deck | null | undefined, contents: DeckEntry[
*/
export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
const [decks, setDecks] = useState<Deck[]>([]);
const [selected, setSelected] = usePersistedState<number | null>("deck-selected", null);
const [selected, setSelected] = usePersistedState<number | null>(libraryKey("deck-selected"), null);
const [newName, setNewName] = useState("");
const [contents, setContents] = useState<DeckEntry[]>([]);
const [view, setView] = useViewMode("deck-view");