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:
85
src/App.tsx
85
src/App.tsx
@@ -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>
|
||||
|
||||
@@ -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
388
src/Libraries.tsx
Normal 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))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
55
src/api.ts
55
src/api.ts
@@ -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. */
|
||||
|
||||
137
src/styles.css
137
src/styles.css
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user