2026-07-07 20:22:31 -05:00
|
|
|
|
import { useEffect, useRef, useState, useCallback, ReactNode } from "react";
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
import { listen } from "@tauri-apps/api/event";
|
2026-07-07 20:22:31 -05:00
|
|
|
|
import { open, save } from "@tauri-apps/plugin-dialog";
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
|
import { api, IngestProgress, IngestStageInfo, LibraryStats, Status, SyncStatus } from "./api";
|
2026-07-08 23:54:47 -05:00
|
|
|
|
import {
|
|
|
|
|
|
ColorDistributionBar,
|
|
|
|
|
|
HoverPreviewProvider,
|
|
|
|
|
|
IconButton,
|
|
|
|
|
|
Icons,
|
|
|
|
|
|
StatTile,
|
2026-07-21 21:04:36 -05:00
|
|
|
|
UndoBar,
|
2026-07-08 23:54:47 -05:00
|
|
|
|
usePersistedState,
|
|
|
|
|
|
} from "./components";
|
2026-07-20 00:21:22 -05:00
|
|
|
|
import { CommandPalette } from "./CommandPalette";
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
import { CardEntry } from "./views/CardEntry";
|
|
|
|
|
|
import { LibraryBrowser } from "./views/LibraryBrowser";
|
2026-07-22 20:55:17 -05:00
|
|
|
|
import { CardSearch } from "./views/CardSearch";
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
import { DeckView } from "./views/DeckView";
|
|
|
|
|
|
|
2026-08-02 16:10:25 -05:00
|
|
|
|
type Tab = "add" | "library" | "search" | "decks" | "info";
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
|
Card preview modal, library quantity steppers, and UI polish
- Fix keyboard-nav selection highlight (search results, deck list): the
Discord-styling rewrite dropped the --panel-2 CSS variable it relied
on, so ↑/↓ moved selection but rendered no visible highlight.
- Add click-to-preview: clicking a card thumbnail/tile now opens a
larger centered modal (list and tile views), with a flip button for
double-faced cards' back face. Hover preview is unchanged and clears
when a click-preview opens.
- Add a QtyStepper component (rounded [− N +] pill, with an `overlay`
variant for tile corners) and use it consistently for owned-copy
counts on Add Cards, and the Library list and tile views.
- Add matching +/- steppers to the Library views; decrementing to 0
dims the row for a 5s grace period (still fully interactive) before
it drops out, so an accidental removal can be undone with +.
- Topbar: fill remaining width with the library path (no more premature
truncation), add icons to Open/New Library and Download/Update card
data, and drop the trailing "…" from the library buttons.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 20:55:18 -05:00
|
|
|
|
const svg = (children: ReactNode, size = 20) => (
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
<svg
|
|
|
|
|
|
viewBox="0 0 24 24"
|
Card preview modal, library quantity steppers, and UI polish
- Fix keyboard-nav selection highlight (search results, deck list): the
Discord-styling rewrite dropped the --panel-2 CSS variable it relied
on, so ↑/↓ moved selection but rendered no visible highlight.
- Add click-to-preview: clicking a card thumbnail/tile now opens a
larger centered modal (list and tile views), with a flip button for
double-faced cards' back face. Hover preview is unchanged and clears
when a click-preview opens.
- Add a QtyStepper component (rounded [− N +] pill, with an `overlay`
variant for tile corners) and use it consistently for owned-copy
counts on Add Cards, and the Library list and tile views.
- Add matching +/- steppers to the Library views; decrementing to 0
dims the row for a 5s grace period (still fully interactive) before
it drops out, so an accidental removal can be undone with +.
- Topbar: fill remaining width with the library path (no more premature
truncation), add icons to Open/New Library and Download/Update card
data, and drop the trailing "…" from the library buttons.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 20:55:18 -05:00
|
|
|
|
width={size}
|
|
|
|
|
|
height={size}
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
|
strokeWidth="2"
|
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
|
>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
Card preview modal, library quantity steppers, and UI polish
- Fix keyboard-nav selection highlight (search results, deck list): the
Discord-styling rewrite dropped the --panel-2 CSS variable it relied
on, so ↑/↓ moved selection but rendered no visible highlight.
- Add click-to-preview: clicking a card thumbnail/tile now opens a
larger centered modal (list and tile views), with a flip button for
double-faced cards' back face. Hover preview is unchanged and clears
when a click-preview opens.
- Add a QtyStepper component (rounded [− N +] pill, with an `overlay`
variant for tile corners) and use it consistently for owned-copy
counts on Add Cards, and the Library list and tile views.
- Add matching +/- steppers to the Library views; decrementing to 0
dims the row for a 5s grace period (still fully interactive) before
it drops out, so an accidental removal can be undone with +.
- Topbar: fill remaining width with the library path (no more premature
truncation), add icons to Open/New Library and Download/Update card
data, and drop the trailing "…" from the library buttons.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 20:55:18 -05:00
|
|
|
|
const ICON_OPEN_LIBRARY = svg(
|
|
|
|
|
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />,
|
|
|
|
|
|
16,
|
|
|
|
|
|
);
|
|
|
|
|
|
const ICON_NEW_LIBRARY = svg(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
|
|
|
|
|
<line x1="12" y1="11" x2="12" y2="17" />
|
|
|
|
|
|
<line x1="9" y1="14" x2="15" y2="14" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
16,
|
|
|
|
|
|
);
|
|
|
|
|
|
const ICON_DOWNLOAD = svg(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
|
|
|
|
<polyline points="7 10 12 15 17 10" />
|
|
|
|
|
|
<line x1="12" y1="15" x2="12" y2="3" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
16,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
Rebrand to mtgc: pirate-ship logo, lowercase app name, mtgc binary
- New logomark: an original pirate-ship silhouette (hull, twin billowing
sails, swallow-tail pennant) in the terracotta brand gradient on a dark
square — a nod to the MTG card "Pirate Ship", not a reproduction of its
art. Source at src-tauri/icons/logo-source.svg; regenerate the full
platform icon set (desktop/Windows Store/iOS/Android) with
`cargo tauri icon src-tauri/icons/logo-source.svg`.
- Apply it in-app via a new inline ShipLogo component (App.tsx), replacing
the old "M" sidebar mark and "✦" empty-state mark with dark rounded
squares containing the ship glyph.
- Rename the app to lowercase "mtgc" everywhere: window title and
productName (tauri.conf.json), <title> (index.html), sidebar brand text,
empty-state heading.
- Rename the Tauri package/binary mtgc-app -> mtgc (and its lib crate
mtgc_app_lib -> mtgc_lib), so `cargo tauri build` now produces
target/release/mtgc; updated main.rs and every README reference.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 21:53:36 -05:00
|
|
|
|
/**
|
|
|
|
|
|
* mtgc's logomark: an original pirate-ship silhouette in the terracotta brand
|
|
|
|
|
|
* color (a nod to the MTG card "Pirate Ship", not a reproduction of its art).
|
|
|
|
|
|
* `gradientId` must be unique per instance rendered on the page at once.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function ShipLogo({ size = 24, gradientId }: { size?: number; gradientId: string }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<svg viewBox="0 0 200 200" width={size} height={size}>
|
|
|
|
|
|
<defs>
|
|
|
|
|
|
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
|
|
|
|
|
<stop offset="0" stopColor="var(--accent-hover)" />
|
|
|
|
|
|
<stop offset="1" stopColor="var(--accent-active)" />
|
|
|
|
|
|
</linearGradient>
|
|
|
|
|
|
</defs>
|
|
|
|
|
|
<g fill={`url(#${gradientId})`}>
|
|
|
|
|
|
<path d="M21,137 L179,137 L159,176 Q100,193 41,176 Z" />
|
|
|
|
|
|
<rect x="21" y="133" width="158" height="6" rx="3" />
|
|
|
|
|
|
<rect x="95" y="7" width="10" height="127" />
|
|
|
|
|
|
<path d="M100,32 C73,40 56,65 59,94 C60,103 71,110 87,113 L100,113 Z" />
|
|
|
|
|
|
<path d="M100,14 C147,26 168,73 160,114 C158,127 140,137 119,140 L100,140 Z" />
|
|
|
|
|
|
<path d="M100,7 L126,13 L109,18 L126,23 L100,30 Z" />
|
|
|
|
|
|
</g>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
const NAV: { id: Tab; label: string; icon: ReactNode }[] = [
|
|
|
|
|
|
{ id: "add", label: "Add Cards", icon: svg(<path d="M12 5v14M5 12h14" />) },
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "library",
|
|
|
|
|
|
label: "Library",
|
|
|
|
|
|
icon: svg(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
|
|
|
|
|
<rect x="14" y="3" width="7" height="7" rx="1.5" />
|
|
|
|
|
|
<rect x="3" y="14" width="7" height="7" rx="1.5" />
|
|
|
|
|
|
<rect x="14" y="14" width="7" height="7" rx="1.5" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
2026-07-22 20:55:17 -05:00
|
|
|
|
{
|
|
|
|
|
|
id: "search",
|
|
|
|
|
|
label: "Card Search",
|
|
|
|
|
|
icon: svg(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<circle cx="11" cy="11" r="7" />
|
|
|
|
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
{
|
|
|
|
|
|
id: "decks",
|
|
|
|
|
|
label: "Decks",
|
|
|
|
|
|
icon: svg(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<path d="M12 2.5l8.5 4.5-8.5 4.5L3.5 7z" />
|
|
|
|
|
|
<path d="M3.5 12l8.5 4.5 8.5-4.5" />
|
|
|
|
|
|
<path d="M3.5 16.5l8.5 4.5 8.5-4.5" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-08-02 16:10:25 -05:00
|
|
|
|
/** Mobile-only tab (see `.bottom-nav`) holding card-database management —
|
|
|
|
|
|
* on desktop that lives in the topbar instead, so this isn't in `NAV`. */
|
|
|
|
|
|
const INFO_TAB: { id: Tab; label: string; icon: ReactNode } = {
|
|
|
|
|
|
id: "info",
|
|
|
|
|
|
label: "Info",
|
|
|
|
|
|
icon: svg(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<circle cx="12" cy="12" r="10" />
|
|
|
|
|
|
<line x1="12" y1="16" x2="12" y2="12" />
|
|
|
|
|
|
<line x1="12" y1="8" x2="12.01" y2="8" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-07 21:35:09 -05:00
|
|
|
|
/** Compact relative time, e.g. "2h ago" — for the "database updated" stamp. */
|
|
|
|
|
|
function formatRelativeTime(epochSeconds: number): string {
|
|
|
|
|
|
const diffSec = Math.round((Date.now() - epochSeconds * 1000) / 1000);
|
|
|
|
|
|
if (diffSec < 30) return "just now";
|
|
|
|
|
|
const mins = Math.round(diffSec / 60);
|
|
|
|
|
|
if (mins < 60) return `${mins}m ago`;
|
|
|
|
|
|
const hours = Math.round(mins / 60);
|
|
|
|
|
|
if (hours < 24) return `${hours}h ago`;
|
|
|
|
|
|
const days = Math.round(hours / 24);
|
|
|
|
|
|
if (days < 30) return `${days}d ago`;
|
|
|
|
|
|
const months = Math.round(days / 30);
|
|
|
|
|
|
if (months < 12) return `${months}mo ago`;
|
|
|
|
|
|
return `${Math.round(months / 12)}y ago`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-02 16:10:25 -05:00
|
|
|
|
/** "Your library" summary: unique/copy/deck counts, color distribution, and
|
|
|
|
|
|
* the reference-db freshness stamp. Shared by the desktop nav rail's footer
|
|
|
|
|
|
* and the mobile Info tab, which otherwise have no equivalent place for it. */
|
|
|
|
|
|
function LibrarySummary({
|
|
|
|
|
|
ready,
|
|
|
|
|
|
libStats,
|
|
|
|
|
|
status,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
ready: boolean;
|
|
|
|
|
|
libStats: LibraryStats | null;
|
|
|
|
|
|
status: Status | null;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
if (!ready) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="nav-foot-simple">
|
|
|
|
|
|
<span className="status-dot off" />
|
|
|
|
|
|
No card data
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="lib-stats">
|
|
|
|
|
|
<div className="lib-stats-head">
|
|
|
|
|
|
<span className="status-dot on" />
|
|
|
|
|
|
<span className="lib-stats-title">Your library</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{!libStats ? (
|
|
|
|
|
|
<div className="lib-stats-loading">Loading…</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="stat-tiles-row">
|
|
|
|
|
|
<StatTile value={libStats.unique_cards.toLocaleString()} label="unique" />
|
|
|
|
|
|
<StatTile value={libStats.total_copies.toLocaleString()} label="copies" />
|
|
|
|
|
|
<StatTile value={libStats.deck_count.toLocaleString()} label="decks" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ColorDistributionBar
|
|
|
|
|
|
colorCounts={libStats.color_counts}
|
|
|
|
|
|
colorlessCount={libStats.colorless_count}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="lib-stats-sub">
|
|
|
|
|
|
<div>{status?.card_count.toLocaleString()} cards in database</div>
|
|
|
|
|
|
{status?.reference_updated_at != null && (
|
|
|
|
|
|
<div title={new Date(status.reference_updated_at * 1000).toLocaleString()}>
|
|
|
|
|
|
Updated {formatRelativeTime(status.reference_updated_at)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 20:22:31 -05:00
|
|
|
|
/** Percent-fill (0..100) of one segment, given overall cumulative percent. */
|
|
|
|
|
|
function segmentFill(stages: IngestStageInfo[], index: number, overallPercent: number): number {
|
|
|
|
|
|
let start = 0;
|
|
|
|
|
|
for (let i = 0; i < index; i++) start += stages[i].weight;
|
|
|
|
|
|
const end = start + stages[index].weight;
|
|
|
|
|
|
const [startPct, endPct] = [start * 100, end * 100];
|
|
|
|
|
|
if (overallPercent <= startPct) return 0;
|
|
|
|
|
|
if (overallPercent >= endPct) return 100;
|
|
|
|
|
|
if (endPct === startPct) return 100;
|
|
|
|
|
|
return ((overallPercent - startPct) / (endPct - startPct)) * 100;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 21:04:36 -05:00
|
|
|
|
// How long the floating "Undo" snackbar stays up before auto-hiding itself.
|
|
|
|
|
|
// This only controls the snackbar's visibility — the underlying undo action
|
|
|
|
|
|
// stays available in the command palette well past this (see `undo` below),
|
|
|
|
|
|
// so missing the snackbar isn't the last chance to undo.
|
|
|
|
|
|
const UNDO_SNACKBAR_MS = 8000;
|
|
|
|
|
|
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
|
/** How often an open, visible window re-syncs. Long enough not to churn the
|
|
|
|
|
|
* synced folder, short enough that a second device sees edits without being
|
|
|
|
|
|
* restarted. Launch and foreground/background transitions sync too. */
|
|
|
|
|
|
const AUTO_SYNC_MS = 3 * 60 * 1000;
|
|
|
|
|
|
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
export default function App() {
|
|
|
|
|
|
const [status, setStatus] = useState<Status | null>(null);
|
Deck actions UI, consistent icon buttons, native select fix, sidebar stats
- DeckView: rename/duplicate/delete deck via row-hover icon actions, with
a ConfirmDialog for delete and PromptDialog (with friendly name-
collision errors) for rename/duplicate.
- Add a shared IconButton (plus overlay/danger variants) and replace
every ad hoc close/remove button in the app with it: deck actions,
remove-card-from-deck (list and tile), clear-commander, the card-
preview close, and the ingest-progress dismiss.
- Fix native <select> (Group by, Format, In deck): only color/border were
overridden, leaving native OS chrome (height, arrow, popup) in place.
Drop appearance and draw a custom chevron so they match text inputs.
- Click-to-preview: show both faces of a double-faced card side by side
on wide viewports instead of requiring Flip, and size the preview
images larger overall.
- Sidebar footer: replace the plain "N cards" line with a "Your library"
panel (unique/copies/decks tiles + a WUBRG color-distribution bar),
polling get_library_stats while card data is loaded.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 21:21:47 -05:00
|
|
|
|
const [libStats, setLibStats] = useState<LibraryStats | null>(null);
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
const [tab, setTab] = usePersistedState<Tab>("active-tab", "add");
|
2026-07-07 20:22:31 -05:00
|
|
|
|
const [progress, setProgress] = useState<IngestProgress | null>(null);
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
const [busy, setBusy] = useState(false);
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
|
// Synced-folder grant for library sync. Only fetched when the Info tab is
|
|
|
|
|
|
// open: the grant can be revoked from system settings while the app is
|
|
|
|
|
|
// backgrounded, so a value cached at startup would be stale exactly when the
|
|
|
|
|
|
// user comes back to check it.
|
|
|
|
|
|
const [sync, setSync] = useState<SyncStatus | null>(null);
|
|
|
|
|
|
const [syncBusy, setSyncBusy] = useState(false);
|
|
|
|
|
|
// Result of the last round-trip self-test, shown until the next action.
|
|
|
|
|
|
const [syncTest, setSyncTest] = useState<{ ok: boolean; message: string } | null>(null);
|
2026-07-07 20:22:31 -05:00
|
|
|
|
const lastProgress = useRef<IngestProgress | null>(null);
|
2026-08-02 16:10:25 -05:00
|
|
|
|
// Set by the ingest-progress listener below; reset before each
|
|
|
|
|
|
// updateCardData() call so it can detect a stalled invoke — see there.
|
|
|
|
|
|
const gotProgress = useRef(false);
|
2026-07-21 21:04:36 -05:00
|
|
|
|
// Lives here (not in LibraryBrowser) so it's reachable from the command
|
|
|
|
|
|
// palette, which is mounted at the app level and outlives any one tab's
|
|
|
|
|
|
// view across tab switches. Unlike the snackbar, this has no timeout —
|
|
|
|
|
|
// it's only replaced by a newer undo offer, run, or explicitly dismissed —
|
|
|
|
|
|
// so an undo that's missed in the snackbar is still reachable via Alt+/.
|
|
|
|
|
|
const [undo, setUndo] = useState<{ message: string; run: () => void | Promise<void> } | null>(
|
|
|
|
|
|
null,
|
|
|
|
|
|
);
|
|
|
|
|
|
const [snackbarVisible, setSnackbarVisible] = useState(false);
|
|
|
|
|
|
const snackbarTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
|
// Bumped once an undo actually runs, so views mounted elsewhere than where
|
|
|
|
|
|
// the undo was offered (e.g. the Decks tab, when the undo came from a
|
|
|
|
|
|
// Library bulk add-to-deck) know to refetch instead of showing stale data
|
|
|
|
|
|
// until the user leaves and comes back.
|
|
|
|
|
|
const [deckRefreshSignal, setDeckRefreshSignal] = useState(0);
|
|
|
|
|
|
|
|
|
|
|
|
function offerUndo(message: string, run: () => void | Promise<void>) {
|
|
|
|
|
|
if (snackbarTimer.current) clearTimeout(snackbarTimer.current);
|
|
|
|
|
|
setUndo({ message, run });
|
|
|
|
|
|
setSnackbarVisible(true);
|
|
|
|
|
|
snackbarTimer.current = setTimeout(() => setSnackbarVisible(false), UNDO_SNACKBAR_MS);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Explicit "×" on the snackbar: reject the whole offer, not just hide it.
|
|
|
|
|
|
function dismissUndo() {
|
|
|
|
|
|
if (snackbarTimer.current) clearTimeout(snackbarTimer.current);
|
|
|
|
|
|
setSnackbarVisible(false);
|
|
|
|
|
|
setUndo(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function runUndo() {
|
|
|
|
|
|
if (!undo) return;
|
|
|
|
|
|
const { run } = undo;
|
|
|
|
|
|
if (snackbarTimer.current) clearTimeout(snackbarTimer.current);
|
|
|
|
|
|
setSnackbarVisible(false);
|
|
|
|
|
|
setUndo(null);
|
|
|
|
|
|
Promise.resolve(run()).then(() => setDeckRefreshSignal((v) => v + 1));
|
|
|
|
|
|
}
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
|
|
|
|
|
|
const refreshStatus = useCallback(async () => {
|
2026-08-02 16:10:25 -05:00
|
|
|
|
// This is the *only* place status gets fetched — nothing else re-polls it.
|
|
|
|
|
|
// The Android IPC bridge has been observed to silently drop an invoke()
|
|
|
|
|
|
// outright: the promise never resolves *or* rejects (confirmed
|
|
|
|
|
|
// separately for update_card_data — same symptom, no backend log line at
|
|
|
|
|
|
// all, so the call never even reached Rust). A try/catch alone doesn't
|
|
|
|
|
|
// help against that, since nothing ever throws; the race against a
|
|
|
|
|
|
// per-attempt timeout below is what actually turns a hang into a
|
|
|
|
|
|
// retryable failure. Without it, this ran once, hung forever, and the
|
|
|
|
|
|
// app was permanently stuck on "no card data yet" every launch — even
|
|
|
|
|
|
// with a fully loaded reference.db on the backend, since nothing else
|
|
|
|
|
|
// ever asks it again.
|
|
|
|
|
|
for (let attempt = 0; ; attempt++) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const s = await Promise.race([
|
|
|
|
|
|
api.getStatus(),
|
|
|
|
|
|
new Promise<never>((_, reject) =>
|
|
|
|
|
|
setTimeout(() => reject(new Error("get_status timed out")), 5000),
|
|
|
|
|
|
),
|
|
|
|
|
|
]);
|
|
|
|
|
|
setStatus(s);
|
|
|
|
|
|
return;
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (attempt >= 4) {
|
|
|
|
|
|
console.error("getStatus failed after retries:", err);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, 300 * (attempt + 1)));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
refreshStatus();
|
2026-07-07 20:22:31 -05:00
|
|
|
|
const un = listen<IngestProgress>("ingest-progress", (e) => {
|
2026-08-02 16:10:25 -05:00
|
|
|
|
gotProgress.current = true;
|
2026-07-07 20:22:31 -05:00
|
|
|
|
lastProgress.current = e.payload;
|
|
|
|
|
|
setProgress(e.payload);
|
|
|
|
|
|
});
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
return () => {
|
|
|
|
|
|
un.then((f) => f());
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [refreshStatus]);
|
|
|
|
|
|
|
2026-07-07 21:35:09 -05:00
|
|
|
|
// Force a re-render every minute so the "Updated Xh ago" stamp stays fresh
|
|
|
|
|
|
// over a long session without needing an actual status refetch.
|
|
|
|
|
|
const [, tick] = useState(0);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const interval = setInterval(() => tick((n) => n + 1), 60000);
|
|
|
|
|
|
return () => clearInterval(interval);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
Deck actions UI, consistent icon buttons, native select fix, sidebar stats
- DeckView: rename/duplicate/delete deck via row-hover icon actions, with
a ConfirmDialog for delete and PromptDialog (with friendly name-
collision errors) for rename/duplicate.
- Add a shared IconButton (plus overlay/danger variants) and replace
every ad hoc close/remove button in the app with it: deck actions,
remove-card-from-deck (list and tile), clear-commander, the card-
preview close, and the ingest-progress dismiss.
- Fix native <select> (Group by, Format, In deck): only color/border were
overridden, leaving native OS chrome (height, arrow, popup) in place.
Drop appearance and draw a custom chevron so they match text inputs.
- Click-to-preview: show both faces of a double-faced card side by side
on wide viewports instead of requiring Flip, and size the preview
images larger overall.
- Sidebar footer: replace the plain "N cards" line with a "Your library"
panel (unique/copies/decks tiles + a WUBRG color-distribution bar),
polling get_library_stats while card data is loaded.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 21:21:47 -05:00
|
|
|
|
const ready = status?.reference_ready ?? false;
|
2026-08-02 16:10:25 -05:00
|
|
|
|
// On Android the very first IPC call after launch has been observed taking
|
|
|
|
|
|
// several seconds (likely one-time JIT/class-verification warm-up on the
|
|
|
|
|
|
// native bridge, not a hang — it does complete). `!ready` was previously
|
|
|
|
|
|
// used both for "we don't know yet" and "confirmed no card data", so during
|
|
|
|
|
|
// that warm-up the app showed the full "Download card data" prompt — which
|
|
|
|
|
|
// invited exactly what happened: a redundant download click before the
|
|
|
|
|
|
// real status had even loaded. Distinguish the two explicitly.
|
|
|
|
|
|
const statusLoaded = status !== null;
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
|
// Mirrors `ready` for the automatic-sync callbacks, which must stay stable
|
|
|
|
|
|
// across renders (re-creating them would re-arm the listeners below and fire
|
|
|
|
|
|
// a fresh sync every time card data finished loading).
|
|
|
|
|
|
const readyRef = useRef(ready);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
readyRef.current = ready;
|
|
|
|
|
|
}, [ready]);
|
Deck actions UI, consistent icon buttons, native select fix, sidebar stats
- DeckView: rename/duplicate/delete deck via row-hover icon actions, with
a ConfirmDialog for delete and PromptDialog (with friendly name-
collision errors) for rename/duplicate.
- Add a shared IconButton (plus overlay/danger variants) and replace
every ad hoc close/remove button in the app with it: deck actions,
remove-card-from-deck (list and tile), clear-commander, the card-
preview close, and the ingest-progress dismiss.
- Fix native <select> (Group by, Format, In deck): only color/border were
overridden, leaving native OS chrome (height, arrow, popup) in place.
Drop appearance and draw a custom chevron so they match text inputs.
- Click-to-preview: show both faces of a double-faced card side by side
on wide viewports instead of requiring Flip, and size the preview
images larger overall.
- Sidebar footer: replace the plain "N cards" line with a "Your library"
panel (unique/copies/decks tiles + a WUBRG color-distribution bar),
polling get_library_stats while card data is loaded.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 21:21:47 -05:00
|
|
|
|
|
|
|
|
|
|
// Keep the sidebar's library summary fresh: fetch once card data is ready,
|
|
|
|
|
|
// then poll (cards/decks can change from any tab without a shared event bus).
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!ready) {
|
|
|
|
|
|
setLibStats(null);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
const refresh = () => api.getLibraryStats().then((s) => !cancelled && setLibStats(s));
|
|
|
|
|
|
refresh();
|
|
|
|
|
|
const interval = setInterval(refresh, 5000);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true;
|
|
|
|
|
|
clearInterval(interval);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [ready]);
|
|
|
|
|
|
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
async function updateCardData() {
|
|
|
|
|
|
setBusy(true);
|
2026-08-02 16:10:25 -05:00
|
|
|
|
gotProgress.current = false;
|
|
|
|
|
|
let stallTimer: ReturnType<typeof setTimeout> | undefined;
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
try {
|
2026-08-02 16:10:25 -05:00
|
|
|
|
// Android's Tauri IPC bridge has been observed to silently drop an
|
|
|
|
|
|
// invoke() during a dev-mode webview reload: the promise never settles,
|
|
|
|
|
|
// no ingest-progress event ever arrives, and the button was stuck
|
|
|
|
|
|
// "Downloading…" forever with no way to retry (the backend never even
|
|
|
|
|
|
// logged that it started). A real ingest always emits its first
|
|
|
|
|
|
// progress event within milliseconds — it happens before any network
|
|
|
|
|
|
// I/O — so treat "no progress after a generous window" as a dropped
|
|
|
|
|
|
// call rather than hang indefinitely.
|
|
|
|
|
|
const s = await Promise.race([
|
|
|
|
|
|
api.updateCardData(),
|
|
|
|
|
|
new Promise<never>((_, reject) => {
|
|
|
|
|
|
stallTimer = setTimeout(() => {
|
|
|
|
|
|
if (!gotProgress.current) {
|
|
|
|
|
|
reject(new Error("Couldn't reach the app. Try again — if this keeps happening, restart the app."));
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 8000);
|
|
|
|
|
|
}),
|
|
|
|
|
|
]);
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
setStatus(s);
|
2026-07-07 20:22:31 -05:00
|
|
|
|
setProgress({
|
|
|
|
|
|
stage: "done",
|
|
|
|
|
|
message: `Loaded ${s.card_count.toLocaleString()} cards.`,
|
|
|
|
|
|
overall_percent: 100,
|
|
|
|
|
|
stages: lastProgress.current?.stages ?? [],
|
|
|
|
|
|
});
|
|
|
|
|
|
setTimeout(() => setProgress((p) => (p?.stage === "done" ? null : p)), 4000);
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
} catch (err) {
|
2026-07-07 20:22:31 -05:00
|
|
|
|
const msg = String(err);
|
|
|
|
|
|
if (msg === "cancelled") {
|
|
|
|
|
|
setProgress({
|
|
|
|
|
|
stage: "cancelled",
|
|
|
|
|
|
message: "Cancelled.",
|
|
|
|
|
|
overall_percent: lastProgress.current?.overall_percent ?? 0,
|
|
|
|
|
|
stages: lastProgress.current?.stages ?? [],
|
|
|
|
|
|
});
|
|
|
|
|
|
setTimeout(() => setProgress((p) => (p?.stage === "cancelled" ? null : p)), 3000);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setProgress({
|
|
|
|
|
|
stage: "error",
|
|
|
|
|
|
message: `Failed: ${msg}`,
|
|
|
|
|
|
overall_percent: lastProgress.current?.overall_percent ?? 0,
|
|
|
|
|
|
stages: lastProgress.current?.stages ?? [],
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
} finally {
|
2026-08-02 16:10:25 -05:00
|
|
|
|
clearTimeout(stallTimer);
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
setBusy(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 20:22:31 -05:00
|
|
|
|
async function cancelUpdate() {
|
|
|
|
|
|
await api.cancelUpdateCardData();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
|
|
|
|
|
|
|
/** Refresh the synced-folder grant state. Never throws: "no folder chosen" is
|
|
|
|
|
|
* a normal state, reported in the panel rather than as an error. */
|
|
|
|
|
|
const refreshSync = useCallback(async () => {
|
|
|
|
|
|
setSyncBusy(true);
|
Card preview modal, library quantity steppers, and UI polish
- Fix keyboard-nav selection highlight (search results, deck list): the
Discord-styling rewrite dropped the --panel-2 CSS variable it relied
on, so ↑/↓ moved selection but rendered no visible highlight.
- Add click-to-preview: clicking a card thumbnail/tile now opens a
larger centered modal (list and tile views), with a flip button for
double-faced cards' back face. Hover preview is unchanged and clears
when a click-preview opens.
- Add a QtyStepper component (rounded [− N +] pill, with an `overlay`
variant for tile corners) and use it consistently for owned-copy
counts on Add Cards, and the Library list and tile views.
- Add matching +/- steppers to the Library views; decrementing to 0
dims the row for a 5s grace period (still fully interactive) before
it drops out, so an accidental removal can be undone with +.
- Topbar: fill remaining width with the library path (no more premature
truncation), add icons to Open/New Library and Download/Update card
data, and drop the trailing "…" from the library buttons.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 20:55:18 -05:00
|
|
|
|
try {
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
|
setSync(await api.syncStatus());
|
Card preview modal, library quantity steppers, and UI polish
- Fix keyboard-nav selection highlight (search results, deck list): the
Discord-styling rewrite dropped the --panel-2 CSS variable it relied
on, so ↑/↓ moved selection but rendered no visible highlight.
- Add click-to-preview: clicking a card thumbnail/tile now opens a
larger centered modal (list and tile views), with a flip button for
double-faced cards' back face. Hover preview is unchanged and clears
when a click-preview opens.
- Add a QtyStepper component (rounded [− N +] pill, with an `overlay`
variant for tile corners) and use it consistently for owned-copy
counts on Add Cards, and the Library list and tile views.
- Add matching +/- steppers to the Library views; decrementing to 0
dims the row for a 5s grace period (still fully interactive) before
it drops out, so an accidental removal can be undone with +.
- Topbar: fill remaining width with the library path (no more premature
truncation), add icons to Open/New Library and Download/Update card
data, and drop the trailing "…" from the library buttons.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 20:55:18 -05:00
|
|
|
|
} catch (err) {
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
|
setSyncTest({ ok: false, message: String(err) });
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSyncBusy(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// Once at startup: the automatic sync below needs to know whether a folder is
|
|
|
|
|
|
// configured at all.
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (statusLoaded) refreshSync();
|
|
|
|
|
|
}, [statusLoaded, refreshSync]);
|
|
|
|
|
|
|
|
|
|
|
|
// And whenever the Info tab is opened, which is also when the user gets back
|
|
|
|
|
|
// from the folder picker, and when a revoked grant would first matter.
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (tab === "info") refreshSync();
|
|
|
|
|
|
}, [tab, refreshSync]);
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-07 22:28:45 -05:00
|
|
|
|
/**
|
|
|
|
|
|
* Point the app at `path` and bring the whole UI into line with it.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Switching documents changes the entire library underneath every view, so
|
|
|
|
|
|
* refreshing the sync panel alone isn't enough: the path bar reads
|
|
|
|
|
|
* `status.library_path`, the sidebar reads `libStats`, and each tab is
|
|
|
|
|
|
* holding rows fetched from the *previous* library. Returns a short " — N
|
|
|
|
|
|
* cards, M decks" suffix so the caller can confirm what actually opened.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function adoptDocument(path: string): Promise<string> {
|
|
|
|
|
|
setSync(await api.syncSetDocument(path));
|
|
|
|
|
|
// The path bar comes from get_status, which nothing else re-fetches.
|
|
|
|
|
|
setStatus(await api.getStatus());
|
|
|
|
|
|
setDeckRefreshSignal((v) => v + 1);
|
|
|
|
|
|
if (!readyRef.current) return "";
|
|
|
|
|
|
try {
|
|
|
|
|
|
const stats = await api.getLibraryStats();
|
|
|
|
|
|
setLibStats(stats);
|
|
|
|
|
|
return ` — ${stats.unique_cards} card${stats.unique_cards === 1 ? "" : "s"}, ${
|
|
|
|
|
|
stats.deck_count
|
|
|
|
|
|
} deck${stats.deck_count === 1 ? "" : "s"}`;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
|
/** Choose where the library lives. Android grants a folder through the system
|
|
|
|
|
|
* picker (SAF has no path to hand back); desktop picks the JSON file itself. */
|
|
|
|
|
|
async function pickLibrary() {
|
|
|
|
|
|
setSyncTest(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (sync?.picker_based) {
|
|
|
|
|
|
setSyncBusy(true);
|
|
|
|
|
|
setSync(await api.syncPickFolder());
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const path = await open({
|
|
|
|
|
|
title: "Choose your library file",
|
|
|
|
|
|
// Start where the library currently lives, so the dialog opens
|
|
|
|
|
|
// somewhere useful rather than at an arbitrary default.
|
|
|
|
|
|
defaultPath: sync?.label ?? undefined,
|
|
|
|
|
|
filters: [
|
|
|
|
|
|
{ name: "mtgc library", extensions: ["json"] },
|
|
|
|
|
|
{ name: "All files", extensions: ["*"] },
|
|
|
|
|
|
],
|
Card preview modal, library quantity steppers, and UI polish
- Fix keyboard-nav selection highlight (search results, deck list): the
Discord-styling rewrite dropped the --panel-2 CSS variable it relied
on, so ↑/↓ moved selection but rendered no visible highlight.
- Add click-to-preview: clicking a card thumbnail/tile now opens a
larger centered modal (list and tile views), with a flip button for
double-faced cards' back face. Hover preview is unchanged and clears
when a click-preview opens.
- Add a QtyStepper component (rounded [− N +] pill, with an `overlay`
variant for tile corners) and use it consistently for owned-copy
counts on Add Cards, and the Library list and tile views.
- Add matching +/- steppers to the Library views; decrementing to 0
dims the row for a 5s grace period (still fully interactive) before
it drops out, so an accidental removal can be undone with +.
- Topbar: fill remaining width with the library path (no more premature
truncation), add icons to Open/New Library and Download/Update card
data, and drop the trailing "…" from the library buttons.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 20:55:18 -05:00
|
|
|
|
});
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
|
if (typeof path !== "string") return;
|
|
|
|
|
|
setSyncBusy(true);
|
2026-08-07 22:28:45 -05:00
|
|
|
|
const opened = await adoptDocument(path);
|
|
|
|
|
|
setSyncTest({ ok: true, message: `Opened ${path}${opened}.` });
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setSyncTest({ ok: false, message: String(err) });
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSyncBusy(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Desktop: start a new library file, e.g. inside a synced folder. */
|
|
|
|
|
|
async function newLibrary() {
|
|
|
|
|
|
setSyncTest(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const path = await save({
|
|
|
|
|
|
title: "Save your library here (put it in a synced folder)",
|
|
|
|
|
|
defaultPath: "mtgc-library.json",
|
|
|
|
|
|
filters: [{ name: "mtgc library", extensions: ["json"] }],
|
|
|
|
|
|
});
|
|
|
|
|
|
if (typeof path !== "string") return;
|
|
|
|
|
|
setSyncBusy(true);
|
|
|
|
|
|
// Pointing at a file that doesn't exist yet moves the current library
|
|
|
|
|
|
// there rather than starting empty — see sync_set_document.
|
2026-08-07 22:28:45 -05:00
|
|
|
|
const moved = await adoptDocument(path);
|
|
|
|
|
|
setSyncTest({ ok: true, message: `Your library is now kept at ${path}${moved}.` });
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setSyncTest({ ok: false, message: String(err) });
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSyncBusy(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function forgetLibrary() {
|
|
|
|
|
|
setSyncBusy(true);
|
|
|
|
|
|
setSyncTest(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
setSync(await api.syncForgetDocument());
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setSyncTest({ ok: false, message: String(err) });
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSyncBusy(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Convert a library.db from an older build into the current format. */
|
|
|
|
|
|
async function convertLegacy(path: string) {
|
|
|
|
|
|
setSyncBusy(true);
|
|
|
|
|
|
setSyncTest(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await api.convertLegacyLibrary(path);
|
|
|
|
|
|
const n = r.report.cards_changed + r.report.decks_changed;
|
|
|
|
|
|
const status = await api.syncStatus();
|
|
|
|
|
|
setSync(status);
|
|
|
|
|
|
// Say where it landed — otherwise a successful conversion looks like
|
|
|
|
|
|
// nothing happened.
|
|
|
|
|
|
setSyncTest({
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
message: `Converted your old library (${n} entries). It's now kept at ${status.label ?? "your library file"}.`,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {});
|
|
|
|
|
|
setDeckRefreshSignal((v) => v + 1);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setSyncTest({ ok: false, message: String(err) });
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSyncBusy(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Merge the library document with local edits and publish the result.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Shared by the Info tab's button and the automatic triggers below. Returns
|
|
|
|
|
|
* the report so the caller can decide whether to say anything; throws only
|
|
|
|
|
|
* what the backend threw. */
|
|
|
|
|
|
const runSync = useCallback(async () => {
|
|
|
|
|
|
const report = await api.syncNow();
|
|
|
|
|
|
if (!report) return null; // Another sync had it in hand.
|
|
|
|
|
|
const changed = report.changed;
|
|
|
|
|
|
if (changed) {
|
|
|
|
|
|
// Library and deck views hold fetched copies; a merge changes rows
|
|
|
|
|
|
// underneath them, so pull them back into line rather than waiting for
|
|
|
|
|
|
// the summary's 5s poll. Needs card data loaded, hence the guard.
|
|
|
|
|
|
if (readyRef.current) {
|
|
|
|
|
|
api.getLibraryStats().then(setLibStats).catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
setDeckRefreshSignal((v) => v + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
return { report, changed };
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// Automatic sync. Runs once the folder is usable, then on every trip to or
|
|
|
|
|
|
// from the background: leaving publishes what changed here, returning picks
|
|
|
|
|
|
// up what changed elsewhere. Keyed on `granted` alone so merely re-checking
|
|
|
|
|
|
// the folder's status doesn't re-arm the listeners and resync.
|
|
|
|
|
|
const syncGranted = sync?.granted ?? false;
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!syncGranted) return;
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
|
|
|
|
|
|
// Failures here are deliberately silent: the folder can be temporarily
|
|
|
|
|
|
// unavailable (drive unmounted, grant revoked), and nagging on every app
|
|
|
|
|
|
// switch would be worse than the Info tab reporting it on demand. Nothing
|
|
|
|
|
|
// is lost by a skipped run — the merge base only advances to a document we
|
|
|
|
|
|
// actually read, so a lost publish is simply re-detected next time.
|
|
|
|
|
|
const run = () => {
|
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
|
runSync().catch(() => {});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
run();
|
|
|
|
|
|
document.addEventListener("visibilitychange", run);
|
|
|
|
|
|
// Visibility changes alone would strand a desktop session that simply stays
|
|
|
|
|
|
// open: edits made after launch would never reach the other devices. Poll
|
|
|
|
|
|
// while visible so an always-open window still publishes. Skipped while
|
|
|
|
|
|
// hidden — the transition back handles that case.
|
|
|
|
|
|
const timer = setInterval(() => {
|
|
|
|
|
|
if (!document.hidden) run();
|
|
|
|
|
|
}, AUTO_SYNC_MS);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true;
|
|
|
|
|
|
clearInterval(timer);
|
|
|
|
|
|
document.removeEventListener("visibilitychange", run);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [syncGranted, runSync]);
|
|
|
|
|
|
|
|
|
|
|
|
/** The Info tab's explicit "Sync now" — same work, but reports the outcome. */
|
|
|
|
|
|
async function syncNow() {
|
|
|
|
|
|
setSyncBusy(true);
|
|
|
|
|
|
setSyncTest(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await runSync();
|
|
|
|
|
|
if (!result) {
|
|
|
|
|
|
setSyncTest({ ok: true, message: "A sync was already running." });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const { report, changed } = result;
|
|
|
|
|
|
const parts: string[] = [];
|
|
|
|
|
|
if (changed) {
|
|
|
|
|
|
const r = report.report;
|
|
|
|
|
|
const bits = [
|
|
|
|
|
|
r.cards_changed && `${r.cards_changed} card${r.cards_changed === 1 ? "" : "s"}`,
|
|
|
|
|
|
r.decks_changed && `${r.decks_changed} deck${r.decks_changed === 1 ? "" : "s"}`,
|
|
|
|
|
|
r.decks_removed && `${r.decks_removed} removed`,
|
|
|
|
|
|
].filter(Boolean);
|
|
|
|
|
|
parts.push(`Merged ${bits.join(", ")}.`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
parts.push(report.published ? "Published your changes." : "Already up to date.");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (report.absorbed > 0) {
|
|
|
|
|
|
parts.push(
|
|
|
|
|
|
`Absorbed ${report.absorbed} conflict cop${report.absorbed === 1 ? "y" : "ies"}.`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Conflicts are the one thing worth spelling out — the user may want to
|
|
|
|
|
|
// correct a value that lost.
|
|
|
|
|
|
if (report.report.conflicts.length > 0) {
|
|
|
|
|
|
parts.push(`Kept the other copy's version of: ${report.report.conflicts.join("; ")}.`);
|
|
|
|
|
|
}
|
|
|
|
|
|
setSyncTest({ ok: true, message: parts.join(" ") });
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setSyncTest({ ok: false, message: String(err) });
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSyncBusy(false);
|
Card preview modal, library quantity steppers, and UI polish
- Fix keyboard-nav selection highlight (search results, deck list): the
Discord-styling rewrite dropped the --panel-2 CSS variable it relied
on, so ↑/↓ moved selection but rendered no visible highlight.
- Add click-to-preview: clicking a card thumbnail/tile now opens a
larger centered modal (list and tile views), with a flip button for
double-faced cards' back face. Hover preview is unchanged and clears
when a click-preview opens.
- Add a QtyStepper component (rounded [− N +] pill, with an `overlay`
variant for tile corners) and use it consistently for owned-copy
counts on Add Cards, and the Library list and tile views.
- Add matching +/- steppers to the Library views; decrementing to 0
dims the row for a 5s grace period (still fully interactive) before
it drops out, so an accidental removal can be undone with +.
- Topbar: fill remaining width with the library path (no more premature
truncation), add icons to Open/New Library and Download/Update card
data, and drop the trailing "…" from the library buttons.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 20:55:18 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add Android support via Tauri
The Rust core was already portable (caller-provided db paths, rustls rather
than openssl) and the shell already had the mobile entry point, so this is
mostly toolchain, provisioning, and platform-gating.
Toolchain:
- scripts/android-setup.sh installs what Android Studio doesn't: SDK
cmdline-tools, a pinned stable NDK, and the Rust Android targets. The NDK is
pinned rather than "latest" because sdkmanager's newest is often a beta
(30.0.15729638 is r30-beta2) -- not something to compile SQLite with. Prefers
Android Studio's bundled JDK 21, since Gradle rejects newer JDKs.
- Track src-tauri/gen/android (excluding build output): AndroidManifest.xml is
hand-edited and Android permissions have no tauri.conf.json equivalent.
- vite: serve on TAURI_DEV_HOST so a device/emulator can reach the dev server.
Card data: downloading streams ~740MB to build a ~73MB db, which is rough on a
phone. Add import_reference_db to adopt a desktop-built file instead, validated
by db::inspect_reference_db first so picking the wrong file is a clean error
rather than a broken install. Installs via copy-then-rename so an interrupted
copy can't leave a partial db.
Ingest hardening (helps desktop too):
- Give ureq connect/read timeouts. Cancellation is only polled per parsed
record, so a stalled socket previously hung forever with no way to cancel.
- Drop the remove_file before rename; POSIX rename already replaces atomically,
and the delete-first window could leave no reference.db at all if the process
was killed -- much likelier on Android.
UI: hide Open/New Library on mobile (scoped storage has no arbitrary-path
equivalent, and SQLite can't open a content:// URI), reported via a cfg!(mobile)
flag on Status rather than user-agent sniffing. Use 100dvh so the soft keyboard
doesn't break the shell.
Verified: 70/70 core tests, desktop build clean, and the aarch64 APK builds
(bundled SQLite C and ring both cross-compile under NDK 28). Not yet run on a
device.
2026-08-02 13:16:34 -05:00
|
|
|
|
/** Adopt a reference.db built elsewhere (e.g. copied from a desktop install)
|
|
|
|
|
|
* instead of streaming ~740MB to rebuild it on this device. */
|
|
|
|
|
|
async function importReferenceDb() {
|
|
|
|
|
|
const path = await open({
|
|
|
|
|
|
title: "Import a card database (reference.db)",
|
|
|
|
|
|
filters: [{ name: "Card database", extensions: ["db", "sqlite"] }],
|
|
|
|
|
|
});
|
|
|
|
|
|
if (typeof path !== "string") return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
setStatus(await api.importReferenceDb(path));
|
|
|
|
|
|
setProgress(null);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setProgress({
|
|
|
|
|
|
stage: "error",
|
|
|
|
|
|
message: `Couldn't import that card database: ${String(err)}`,
|
|
|
|
|
|
overall_percent: 0,
|
|
|
|
|
|
stages: lastProgress.current?.stages ?? [],
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
return (
|
|
|
|
|
|
<HoverPreviewProvider>
|
|
|
|
|
|
<div className="app">
|
|
|
|
|
|
<nav className="nav-rail">
|
|
|
|
|
|
<div className="brand">
|
Rebrand to mtgc: pirate-ship logo, lowercase app name, mtgc binary
- New logomark: an original pirate-ship silhouette (hull, twin billowing
sails, swallow-tail pennant) in the terracotta brand gradient on a dark
square — a nod to the MTG card "Pirate Ship", not a reproduction of its
art. Source at src-tauri/icons/logo-source.svg; regenerate the full
platform icon set (desktop/Windows Store/iOS/Android) with
`cargo tauri icon src-tauri/icons/logo-source.svg`.
- Apply it in-app via a new inline ShipLogo component (App.tsx), replacing
the old "M" sidebar mark and "✦" empty-state mark with dark rounded
squares containing the ship glyph.
- Rename the app to lowercase "mtgc" everywhere: window title and
productName (tauri.conf.json), <title> (index.html), sidebar brand text,
empty-state heading.
- Rename the Tauri package/binary mtgc-app -> mtgc (and its lib crate
mtgc_app_lib -> mtgc_lib), so `cargo tauri build` now produces
target/release/mtgc; updated main.rs and every README reference.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 21:53:36 -05:00
|
|
|
|
<span className="brand-mark">
|
|
|
|
|
|
<ShipLogo size={20} gradientId="brand-ship-grad" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="brand-text">mtgc</span>
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="nav-items">
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
|
{/* Info carries library sync, which desktop needs too — it's no
|
|
|
|
|
|
longer the mobile-only card-data page it started as. */}
|
|
|
|
|
|
{[...NAV, INFO_TAB].map((n) => (
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
<button
|
|
|
|
|
|
key={n.id}
|
|
|
|
|
|
className={`nav-item ${tab === n.id ? "active" : ""}`}
|
|
|
|
|
|
onClick={() => setTab(n.id)}
|
Cohesive styling pass: design tokens, shared primitives, responsive groundwork
The UI had a good terracotta-on-dark system that wasn't applied
consistently — magic-number spacing, ad-hoc page headers, ~6 copied
panel surfaces, ~8 drifting label variants, and scattered accent use.
- Tokens: warm the neutral ramp, add spacing/radius/type/motion scales,
--accent-quiet + panel highlight. Terracotta hue unchanged.
- Primitives: .panel, .section-label, .page-header/.toolbar — collapse
the copied surfaces and label variants onto shared classes.
- Unify each filter-heavy page's display controls (group/sort/view,
select-all, count) into a workspace toolbar inside the filter panel,
so they read as one surface instead of floating above it.
- Labels: sentence-case, higher-contrast form labels; uppercase section
markers bumped faint -> muted.
- Accent hierarchy: reserve full terracotta for primary actions/active
state; demote decorative labels and the group-header underline.
- Responsive groundwork: reflow classes for the deck two-pane and nav
rail (stacks / icon-collapse at breakpoints), touch-target sizing.
Desktop unchanged in kind.
2026-07-22 23:44:21 -05:00
|
|
|
|
title={n.label}
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
>
|
|
|
|
|
|
<span className="nav-icon">{n.icon}</span>
|
Cohesive styling pass: design tokens, shared primitives, responsive groundwork
The UI had a good terracotta-on-dark system that wasn't applied
consistently — magic-number spacing, ad-hoc page headers, ~6 copied
panel surfaces, ~8 drifting label variants, and scattered accent use.
- Tokens: warm the neutral ramp, add spacing/radius/type/motion scales,
--accent-quiet + panel highlight. Terracotta hue unchanged.
- Primitives: .panel, .section-label, .page-header/.toolbar — collapse
the copied surfaces and label variants onto shared classes.
- Unify each filter-heavy page's display controls (group/sort/view,
select-all, count) into a workspace toolbar inside the filter panel,
so they read as one surface instead of floating above it.
- Labels: sentence-case, higher-contrast form labels; uppercase section
markers bumped faint -> muted.
- Accent hierarchy: reserve full terracotta for primary actions/active
state; demote decorative labels and the group-header underline.
- Responsive groundwork: reflow classes for the deck two-pane and nav
rail (stacks / icon-collapse at breakpoints), touch-target sizing.
Desktop unchanged in kind.
2026-07-22 23:44:21 -05:00
|
|
|
|
<span className="nav-label">{n.label}</span>
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="nav-foot">
|
2026-08-02 16:10:25 -05:00
|
|
|
|
<LibrarySummary ready={ready} libStats={libStats} status={status} />
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="main">
|
|
|
|
|
|
<header className="topbar">
|
Card preview modal, library quantity steppers, and UI polish
- Fix keyboard-nav selection highlight (search results, deck list): the
Discord-styling rewrite dropped the --panel-2 CSS variable it relied
on, so ↑/↓ moved selection but rendered no visible highlight.
- Add click-to-preview: clicking a card thumbnail/tile now opens a
larger centered modal (list and tile views), with a flip button for
double-faced cards' back face. Hover preview is unchanged and clears
when a click-preview opens.
- Add a QtyStepper component (rounded [− N +] pill, with an `overlay`
variant for tile corners) and use it consistently for owned-copy
counts on Add Cards, and the Library list and tile views.
- Add matching +/- steppers to the Library views; decrementing to 0
dims the row for a 5s grace period (still fully interactive) before
it drops out, so an accidental removal can be undone with +.
- Topbar: fill remaining width with the library path (no more premature
truncation), add icons to Open/New Library and Download/Update card
data, and drop the trailing "…" from the library buttons.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 20:55:18 -05:00
|
|
|
|
<span className="path" title={status?.library_path ?? ""}>
|
|
|
|
|
|
{status?.library_path ?? ""}
|
|
|
|
|
|
</span>
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
|
{/* Desktop-only: Android grants a folder through the system picker
|
|
|
|
|
|
instead of naming a path, so these live in the Info tab there. */}
|
Add Android support via Tauri
The Rust core was already portable (caller-provided db paths, rustls rather
than openssl) and the shell already had the mobile entry point, so this is
mostly toolchain, provisioning, and platform-gating.
Toolchain:
- scripts/android-setup.sh installs what Android Studio doesn't: SDK
cmdline-tools, a pinned stable NDK, and the Rust Android targets. The NDK is
pinned rather than "latest" because sdkmanager's newest is often a beta
(30.0.15729638 is r30-beta2) -- not something to compile SQLite with. Prefers
Android Studio's bundled JDK 21, since Gradle rejects newer JDKs.
- Track src-tauri/gen/android (excluding build output): AndroidManifest.xml is
hand-edited and Android permissions have no tauri.conf.json equivalent.
- vite: serve on TAURI_DEV_HOST so a device/emulator can reach the dev server.
Card data: downloading streams ~740MB to build a ~73MB db, which is rough on a
phone. Add import_reference_db to adopt a desktop-built file instead, validated
by db::inspect_reference_db first so picking the wrong file is a clean error
rather than a broken install. Installs via copy-then-rename so an interrupted
copy can't leave a partial db.
Ingest hardening (helps desktop too):
- Give ureq connect/read timeouts. Cancellation is only polled per parsed
record, so a stalled socket previously hung forever with no way to cancel.
- Drop the remove_file before rename; POSIX rename already replaces atomically,
and the delete-first window could leave no reference.db at all if the process
was killed -- much likelier on Android.
UI: hide Open/New Library on mobile (scoped storage has no arbitrary-path
equivalent, and SQLite can't open a content:// URI), reported via a cfg!(mobile)
flag on Status rather than user-agent sniffing. Use 100dvh so the soft keyboard
doesn't break the shell.
Verified: 70/70 core tests, desktop build clean, and the aarch64 APK builds
(bundled SQLite C and ring both cross-compile under NDK 28). Not yet run on a
device.
2026-08-02 13:16:34 -05:00
|
|
|
|
{status && !status.is_mobile && (
|
|
|
|
|
|
<>
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
|
<button onClick={pickLibrary} disabled={busy || syncBusy}>
|
Add Android support via Tauri
The Rust core was already portable (caller-provided db paths, rustls rather
than openssl) and the shell already had the mobile entry point, so this is
mostly toolchain, provisioning, and platform-gating.
Toolchain:
- scripts/android-setup.sh installs what Android Studio doesn't: SDK
cmdline-tools, a pinned stable NDK, and the Rust Android targets. The NDK is
pinned rather than "latest" because sdkmanager's newest is often a beta
(30.0.15729638 is r30-beta2) -- not something to compile SQLite with. Prefers
Android Studio's bundled JDK 21, since Gradle rejects newer JDKs.
- Track src-tauri/gen/android (excluding build output): AndroidManifest.xml is
hand-edited and Android permissions have no tauri.conf.json equivalent.
- vite: serve on TAURI_DEV_HOST so a device/emulator can reach the dev server.
Card data: downloading streams ~740MB to build a ~73MB db, which is rough on a
phone. Add import_reference_db to adopt a desktop-built file instead, validated
by db::inspect_reference_db first so picking the wrong file is a clean error
rather than a broken install. Installs via copy-then-rename so an interrupted
copy can't leave a partial db.
Ingest hardening (helps desktop too):
- Give ureq connect/read timeouts. Cancellation is only polled per parsed
record, so a stalled socket previously hung forever with no way to cancel.
- Drop the remove_file before rename; POSIX rename already replaces atomically,
and the delete-first window could leave no reference.db at all if the process
was killed -- much likelier on Android.
UI: hide Open/New Library on mobile (scoped storage has no arbitrary-path
equivalent, and SQLite can't open a content:// URI), reported via a cfg!(mobile)
flag on Status rather than user-agent sniffing. Use 100dvh so the soft keyboard
doesn't break the shell.
Verified: 70/70 core tests, desktop build clean, and the aarch64 APK builds
(bundled SQLite C and ring both cross-compile under NDK 28). Not yet run on a
device.
2026-08-02 13:16:34 -05:00
|
|
|
|
{ICON_OPEN_LIBRARY}
|
|
|
|
|
|
Open Library
|
|
|
|
|
|
</button>
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
|
<button onClick={newLibrary} disabled={busy || syncBusy}>
|
Add Android support via Tauri
The Rust core was already portable (caller-provided db paths, rustls rather
than openssl) and the shell already had the mobile entry point, so this is
mostly toolchain, provisioning, and platform-gating.
Toolchain:
- scripts/android-setup.sh installs what Android Studio doesn't: SDK
cmdline-tools, a pinned stable NDK, and the Rust Android targets. The NDK is
pinned rather than "latest" because sdkmanager's newest is often a beta
(30.0.15729638 is r30-beta2) -- not something to compile SQLite with. Prefers
Android Studio's bundled JDK 21, since Gradle rejects newer JDKs.
- Track src-tauri/gen/android (excluding build output): AndroidManifest.xml is
hand-edited and Android permissions have no tauri.conf.json equivalent.
- vite: serve on TAURI_DEV_HOST so a device/emulator can reach the dev server.
Card data: downloading streams ~740MB to build a ~73MB db, which is rough on a
phone. Add import_reference_db to adopt a desktop-built file instead, validated
by db::inspect_reference_db first so picking the wrong file is a clean error
rather than a broken install. Installs via copy-then-rename so an interrupted
copy can't leave a partial db.
Ingest hardening (helps desktop too):
- Give ureq connect/read timeouts. Cancellation is only polled per parsed
record, so a stalled socket previously hung forever with no way to cancel.
- Drop the remove_file before rename; POSIX rename already replaces atomically,
and the delete-first window could leave no reference.db at all if the process
was killed -- much likelier on Android.
UI: hide Open/New Library on mobile (scoped storage has no arbitrary-path
equivalent, and SQLite can't open a content:// URI), reported via a cfg!(mobile)
flag on Status rather than user-agent sniffing. Use 100dvh so the soft keyboard
doesn't break the shell.
Verified: 70/70 core tests, desktop build clean, and the aarch64 APK builds
(bundled SQLite C and ring both cross-compile under NDK 28). Not yet run on a
device.
2026-08-02 13:16:34 -05:00
|
|
|
|
{ICON_NEW_LIBRARY}
|
|
|
|
|
|
New Library
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-08-02 16:10:25 -05:00
|
|
|
|
{/* On mobile this lives in the bottom nav's Info tab instead —
|
|
|
|
|
|
see below — rather than crowding the topbar. */}
|
|
|
|
|
|
{status && !status.is_mobile && (
|
|
|
|
|
|
<button onClick={updateCardData} disabled={busy} className="primary">
|
|
|
|
|
|
{ICON_DOWNLOAD}
|
|
|
|
|
|
{ready ? "Update card data" : "Download card data"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
</header>
|
|
|
|
|
|
|
2026-07-07 20:22:31 -05:00
|
|
|
|
{progress && (
|
|
|
|
|
|
<div className={`ingest-bar ingest-bar-${progress.stage}`}>
|
|
|
|
|
|
<div className="ingest-bar-head">
|
|
|
|
|
|
<span className="ingest-bar-message">{progress.message}</span>
|
|
|
|
|
|
<span className="spacer" />
|
|
|
|
|
|
{busy && (
|
|
|
|
|
|
<button className="ingest-bar-cancel" onClick={cancelUpdate}>
|
|
|
|
|
|
Cancel
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{!busy &&
|
|
|
|
|
|
(progress.stage === "done" ||
|
|
|
|
|
|
progress.stage === "error" ||
|
|
|
|
|
|
progress.stage === "cancelled") && (
|
Deck actions UI, consistent icon buttons, native select fix, sidebar stats
- DeckView: rename/duplicate/delete deck via row-hover icon actions, with
a ConfirmDialog for delete and PromptDialog (with friendly name-
collision errors) for rename/duplicate.
- Add a shared IconButton (plus overlay/danger variants) and replace
every ad hoc close/remove button in the app with it: deck actions,
remove-card-from-deck (list and tile), clear-commander, the card-
preview close, and the ingest-progress dismiss.
- Fix native <select> (Group by, Format, In deck): only color/border were
overridden, leaving native OS chrome (height, arrow, popup) in place.
Drop appearance and draw a custom chevron so they match text inputs.
- Click-to-preview: show both faces of a double-faced card side by side
on wide viewports instead of requiring Flip, and size the preview
images larger overall.
- Sidebar footer: replace the plain "N cards" line with a "Your library"
panel (unique/copies/decks tiles + a WUBRG color-distribution bar),
polling get_library_stats while card data is loaded.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 21:21:47 -05:00
|
|
|
|
<IconButton icon={Icons.close} label="Dismiss" onClick={() => setProgress(null)} />
|
2026-07-07 20:22:31 -05:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="ingest-segments">
|
|
|
|
|
|
{progress.stages.map((s, i) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={s.id}
|
|
|
|
|
|
className={`ingest-segment ${s.id === progress.stage ? "active" : ""}`}
|
|
|
|
|
|
style={{ flexGrow: s.weight, flexBasis: 0 }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="ingest-segment-track">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="ingest-segment-fill"
|
|
|
|
|
|
style={{ width: `${segmentFill(progress.stages, i, progress.overall_percent)}%` }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span className="ingest-segment-label">{s.label}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
<div className="content">
|
2026-08-02 16:10:25 -05:00
|
|
|
|
{!statusLoaded ? (
|
|
|
|
|
|
<div className="empty-state">
|
|
|
|
|
|
<div className="empty-mark">
|
|
|
|
|
|
<ShipLogo size={40} gradientId="empty-ship-grad" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="hint">Loading…</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : !ready ? (
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
<div className="empty-state">
|
Rebrand to mtgc: pirate-ship logo, lowercase app name, mtgc binary
- New logomark: an original pirate-ship silhouette (hull, twin billowing
sails, swallow-tail pennant) in the terracotta brand gradient on a dark
square — a nod to the MTG card "Pirate Ship", not a reproduction of its
art. Source at src-tauri/icons/logo-source.svg; regenerate the full
platform icon set (desktop/Windows Store/iOS/Android) with
`cargo tauri icon src-tauri/icons/logo-source.svg`.
- Apply it in-app via a new inline ShipLogo component (App.tsx), replacing
the old "M" sidebar mark and "✦" empty-state mark with dark rounded
squares containing the ship glyph.
- Rename the app to lowercase "mtgc" everywhere: window title and
productName (tauri.conf.json), <title> (index.html), sidebar brand text,
empty-state heading.
- Rename the Tauri package/binary mtgc-app -> mtgc (and its lib crate
mtgc_app_lib -> mtgc_lib), so `cargo tauri build` now produces
target/release/mtgc; updated main.rs and every README reference.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 21:53:36 -05:00
|
|
|
|
<div className="empty-mark">
|
|
|
|
|
|
<ShipLogo size={40} gradientId="empty-ship-grad" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<h2>Welcome to mtgc</h2>
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
<p className="hint">
|
Add Android support via Tauri
The Rust core was already portable (caller-provided db paths, rustls rather
than openssl) and the shell already had the mobile entry point, so this is
mostly toolchain, provisioning, and platform-gating.
Toolchain:
- scripts/android-setup.sh installs what Android Studio doesn't: SDK
cmdline-tools, a pinned stable NDK, and the Rust Android targets. The NDK is
pinned rather than "latest" because sdkmanager's newest is often a beta
(30.0.15729638 is r30-beta2) -- not something to compile SQLite with. Prefers
Android Studio's bundled JDK 21, since Gradle rejects newer JDKs.
- Track src-tauri/gen/android (excluding build output): AndroidManifest.xml is
hand-edited and Android permissions have no tauri.conf.json equivalent.
- vite: serve on TAURI_DEV_HOST so a device/emulator can reach the dev server.
Card data: downloading streams ~740MB to build a ~73MB db, which is rough on a
phone. Add import_reference_db to adopt a desktop-built file instead, validated
by db::inspect_reference_db first so picking the wrong file is a clean error
rather than a broken install. Installs via copy-then-rename so an interrupted
copy can't leave a partial db.
Ingest hardening (helps desktop too):
- Give ureq connect/read timeouts. Cancellation is only polled per parsed
record, so a stalled socket previously hung forever with no way to cancel.
- Drop the remove_file before rename; POSIX rename already replaces atomically,
and the delete-first window could leave no reference.db at all if the process
was killed -- much likelier on Android.
UI: hide Open/New Library on mobile (scoped storage has no arbitrary-path
equivalent, and SQLite can't open a content:// URI), reported via a cfg!(mobile)
flag on Status rather than user-agent sniffing. Use 100dvh so the soft keyboard
doesn't break the shell.
Verified: 70/70 core tests, desktop build clean, and the aarch64 APK builds
(bundled SQLite C and ring both cross-compile under NDK 28). Not yet run on a
device.
2026-08-02 13:16:34 -05:00
|
|
|
|
No card database yet. <strong>Download card data</strong> fetches the
|
|
|
|
|
|
latest cards from Scryfall (a few hundred MB
|
|
|
|
|
|
{status?.is_mobile ? " — slow over mobile data" : ", about a minute"}).
|
|
|
|
|
|
The reference data stays on this device — only your library file is meant
|
|
|
|
|
|
to be synced.
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
</p>
|
Add Android support via Tauri
The Rust core was already portable (caller-provided db paths, rustls rather
than openssl) and the shell already had the mobile entry point, so this is
mostly toolchain, provisioning, and platform-gating.
Toolchain:
- scripts/android-setup.sh installs what Android Studio doesn't: SDK
cmdline-tools, a pinned stable NDK, and the Rust Android targets. The NDK is
pinned rather than "latest" because sdkmanager's newest is often a beta
(30.0.15729638 is r30-beta2) -- not something to compile SQLite with. Prefers
Android Studio's bundled JDK 21, since Gradle rejects newer JDKs.
- Track src-tauri/gen/android (excluding build output): AndroidManifest.xml is
hand-edited and Android permissions have no tauri.conf.json equivalent.
- vite: serve on TAURI_DEV_HOST so a device/emulator can reach the dev server.
Card data: downloading streams ~740MB to build a ~73MB db, which is rough on a
phone. Add import_reference_db to adopt a desktop-built file instead, validated
by db::inspect_reference_db first so picking the wrong file is a clean error
rather than a broken install. Installs via copy-then-rename so an interrupted
copy can't leave a partial db.
Ingest hardening (helps desktop too):
- Give ureq connect/read timeouts. Cancellation is only polled per parsed
record, so a stalled socket previously hung forever with no way to cancel.
- Drop the remove_file before rename; POSIX rename already replaces atomically,
and the delete-first window could leave no reference.db at all if the process
was killed -- much likelier on Android.
UI: hide Open/New Library on mobile (scoped storage has no arbitrary-path
equivalent, and SQLite can't open a content:// URI), reported via a cfg!(mobile)
flag on Status rather than user-agent sniffing. Use 100dvh so the soft keyboard
doesn't break the shell.
Verified: 70/70 core tests, desktop build clean, and the aarch64 APK builds
(bundled SQLite C and ring both cross-compile under NDK 28). Not yet run on a
device.
2026-08-02 13:16:34 -05:00
|
|
|
|
{status?.is_mobile && (
|
|
|
|
|
|
<p className="hint">
|
|
|
|
|
|
Already have it on your computer? Copying that{" "}
|
|
|
|
|
|
<strong>reference.db</strong> over is much faster — it's about 73MB
|
|
|
|
|
|
versus the ~740MB the download has to stream.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
<button className="primary big" onClick={updateCardData} disabled={busy}>
|
Card preview modal, library quantity steppers, and UI polish
- Fix keyboard-nav selection highlight (search results, deck list): the
Discord-styling rewrite dropped the --panel-2 CSS variable it relied
on, so ↑/↓ moved selection but rendered no visible highlight.
- Add click-to-preview: clicking a card thumbnail/tile now opens a
larger centered modal (list and tile views), with a flip button for
double-faced cards' back face. Hover preview is unchanged and clears
when a click-preview opens.
- Add a QtyStepper component (rounded [− N +] pill, with an `overlay`
variant for tile corners) and use it consistently for owned-copy
counts on Add Cards, and the Library list and tile views.
- Add matching +/- steppers to the Library views; decrementing to 0
dims the row for a 5s grace period (still fully interactive) before
it drops out, so an accidental removal can be undone with +.
- Topbar: fill remaining width with the library path (no more premature
truncation), add icons to Open/New Library and Download/Update card
data, and drop the trailing "…" from the library buttons.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 20:55:18 -05:00
|
|
|
|
{ICON_DOWNLOAD}
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
{busy ? "Downloading…" : "Download card data"}
|
|
|
|
|
|
</button>
|
Add Android support via Tauri
The Rust core was already portable (caller-provided db paths, rustls rather
than openssl) and the shell already had the mobile entry point, so this is
mostly toolchain, provisioning, and platform-gating.
Toolchain:
- scripts/android-setup.sh installs what Android Studio doesn't: SDK
cmdline-tools, a pinned stable NDK, and the Rust Android targets. The NDK is
pinned rather than "latest" because sdkmanager's newest is often a beta
(30.0.15729638 is r30-beta2) -- not something to compile SQLite with. Prefers
Android Studio's bundled JDK 21, since Gradle rejects newer JDKs.
- Track src-tauri/gen/android (excluding build output): AndroidManifest.xml is
hand-edited and Android permissions have no tauri.conf.json equivalent.
- vite: serve on TAURI_DEV_HOST so a device/emulator can reach the dev server.
Card data: downloading streams ~740MB to build a ~73MB db, which is rough on a
phone. Add import_reference_db to adopt a desktop-built file instead, validated
by db::inspect_reference_db first so picking the wrong file is a clean error
rather than a broken install. Installs via copy-then-rename so an interrupted
copy can't leave a partial db.
Ingest hardening (helps desktop too):
- Give ureq connect/read timeouts. Cancellation is only polled per parsed
record, so a stalled socket previously hung forever with no way to cancel.
- Drop the remove_file before rename; POSIX rename already replaces atomically,
and the delete-first window could leave no reference.db at all if the process
was killed -- much likelier on Android.
UI: hide Open/New Library on mobile (scoped storage has no arbitrary-path
equivalent, and SQLite can't open a content:// URI), reported via a cfg!(mobile)
flag on Status rather than user-agent sniffing. Use 100dvh so the soft keyboard
doesn't break the shell.
Verified: 70/70 core tests, desktop build clean, and the aarch64 APK builds
(bundled SQLite C and ring both cross-compile under NDK 28). Not yet run on a
device.
2026-08-02 13:16:34 -05:00
|
|
|
|
<button onClick={importReferenceDb} disabled={busy}>
|
|
|
|
|
|
{ICON_OPEN_LIBRARY}
|
|
|
|
|
|
Import card data…
|
|
|
|
|
|
</button>
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
) : tab === "add" ? (
|
|
|
|
|
|
<CardEntry />
|
2026-08-07 22:28:45 -05:00
|
|
|
|
) : /* Keyed on the library document: switching to a different one
|
|
|
|
|
|
replaces 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_path} onOfferUndo={offerUndo} />
|
2026-07-22 20:55:17 -05:00
|
|
|
|
) : tab === "search" ? (
|
2026-08-07 22:28:45 -05:00
|
|
|
|
<CardSearch key={status?.library_path} onOfferUndo={offerUndo} />
|
2026-08-02 16:10:25 -05:00
|
|
|
|
) : tab === "decks" ? (
|
2026-08-07 22:28:45 -05:00
|
|
|
|
<DeckView key={status?.library_path} refreshSignal={deckRefreshSignal} />
|
2026-08-02 16:10:25 -05:00
|
|
|
|
) : (
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
|
// The per-install status page: card data, library sync, and the
|
|
|
|
|
|
// collection summary. On mobile it's the only home for the
|
|
|
|
|
|
// card-data buttons (no topbar room) and the summary (the nav
|
|
|
|
|
|
// rail it normally sits in is replaced by the bottom nav);
|
|
|
|
|
|
// on desktop those also appear in the topbar and rail, but sync
|
|
|
|
|
|
// lives only here.
|
2026-08-02 16:10:25 -05:00
|
|
|
|
<div className="panel info-panel">
|
|
|
|
|
|
<h2 style={{ margin: 0 }}>Card database</h2>
|
|
|
|
|
|
<div className="hint" style={{ wordBreak: "break-all" }}>
|
|
|
|
|
|
{status?.library_path}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="row" style={{ gap: "var(--space-3)", flexWrap: "wrap" }}>
|
|
|
|
|
|
<button className="primary" onClick={updateCardData} disabled={busy}>
|
|
|
|
|
|
{ICON_DOWNLOAD}
|
|
|
|
|
|
{busy ? "Downloading…" : "Update card data"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button onClick={importReferenceDb} disabled={busy}>
|
|
|
|
|
|
{ICON_OPEN_LIBRARY}
|
|
|
|
|
|
Import card data…
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<hr className="deck-section-divider" style={{ margin: 0 }} />
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
|
<h2 style={{ margin: 0 }}>Your library</h2>
|
|
|
|
|
|
{sync === null ? (
|
|
|
|
|
|
<div className="hint">Checking…</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="hint">
|
|
|
|
|
|
Your collection is kept in a single file. Put it in a folder you sync and
|
|
|
|
|
|
every device stays in step — edits made in two places are combined, not
|
|
|
|
|
|
overwritten.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{sync.label && (
|
|
|
|
|
|
<div className="sync-folder" style={{ wordBreak: "break-word" }}>
|
|
|
|
|
|
{sync.label}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{sync.chosen && !sync.granted && (
|
|
|
|
|
|
<div className="sync-bad">
|
|
|
|
|
|
✕{" "}
|
|
|
|
|
|
{sync.picker_based
|
|
|
|
|
|
? "Access to that folder was lost — choose it again."
|
|
|
|
|
|
: "That folder isn't there right now — is the drive mounted?"}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{syncTest && (
|
|
|
|
|
|
<div className={syncTest.ok ? "sync-ok" : "sync-bad"}>
|
|
|
|
|
|
{syncTest.ok ? "✓ " : "✕ "}
|
|
|
|
|
|
{syncTest.message}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="row" style={{ gap: "var(--space-3)", flexWrap: "wrap" }}>
|
|
|
|
|
|
{sync.granted && (
|
|
|
|
|
|
<button className="primary" onClick={syncNow} disabled={syncBusy}>
|
|
|
|
|
|
{syncBusy ? "Syncing…" : "Sync now"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={sync.granted ? "" : "primary"}
|
|
|
|
|
|
onClick={pickLibrary}
|
|
|
|
|
|
disabled={syncBusy}
|
|
|
|
|
|
>
|
|
|
|
|
|
{sync.chosen
|
|
|
|
|
|
? sync.picker_based
|
|
|
|
|
|
? "Change folder…"
|
|
|
|
|
|
: "Change file…"
|
|
|
|
|
|
: sync.picker_based
|
|
|
|
|
|
? "Choose folder…"
|
|
|
|
|
|
: "Choose file…"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{sync.chosen && (
|
|
|
|
|
|
<button onClick={forgetLibrary} disabled={syncBusy}>
|
|
|
|
|
|
Forget
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/* Only shown while an old library.db is still waiting; the
|
|
|
|
|
|
offer disappears for good once it's been converted. */}
|
|
|
|
|
|
{sync.legacy_library && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="hint">
|
|
|
|
|
|
An older library from a previous version is still on this device. Convert
|
|
|
|
|
|
it to bring your collection across.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="sync-folder" style={{ wordBreak: "break-word" }}>
|
|
|
|
|
|
{sync.legacy_library}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="row">
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="primary"
|
|
|
|
|
|
onClick={() => convertLegacy(sync.legacy_library!)}
|
|
|
|
|
|
disabled={syncBusy}
|
|
|
|
|
|
>
|
|
|
|
|
|
Convert old library
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<hr className="deck-section-divider" style={{ margin: 0 }} />
|
2026-08-02 16:10:25 -05:00
|
|
|
|
<LibrarySummary ready={ready} libStats={libStats} status={status} />
|
|
|
|
|
|
</div>
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-02 16:10:25 -05:00
|
|
|
|
|
|
|
|
|
|
{/* Android-customary bottom tab bar — replaces the rail above on
|
|
|
|
|
|
narrow viewports (see the ≤720px CSS breakpoint), rather than the
|
|
|
|
|
|
rail collapsing to an icon strip in place. Hidden while the
|
|
|
|
|
|
initial status fetch is still pending: there's nothing to
|
|
|
|
|
|
navigate to yet, and showing it makes the loading screen look
|
|
|
|
|
|
further along than it is. Carries an extra "Info" tab beyond
|
|
|
|
|
|
`NAV` — card-database management, which lives in the topbar on
|
|
|
|
|
|
desktop instead (see the button below). */}
|
|
|
|
|
|
{statusLoaded && (
|
|
|
|
|
|
<nav className="bottom-nav">
|
|
|
|
|
|
{[...NAV, INFO_TAB].map((n) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={n.id}
|
|
|
|
|
|
className={`bottom-nav-item ${tab === n.id ? "active" : ""}`}
|
|
|
|
|
|
onClick={() => setTab(n.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="nav-icon">{n.icon}</span>
|
|
|
|
|
|
<span>{n.label}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
)}
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
</div>
|
2026-07-21 21:04:36 -05:00
|
|
|
|
{undo && snackbarVisible && (
|
|
|
|
|
|
<UndoBar message={undo.message} onUndo={runUndo} onDismiss={dismissUndo} />
|
|
|
|
|
|
)}
|
2026-07-20 00:21:22 -05:00
|
|
|
|
<CommandPalette
|
|
|
|
|
|
onNavigate={setTab}
|
|
|
|
|
|
onUpdateCardData={updateCardData}
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
|
onOpenLibrary={pickLibrary}
|
2026-07-20 00:21:22 -05:00
|
|
|
|
onNewLibrary={newLibrary}
|
2026-07-21 21:04:36 -05:00
|
|
|
|
undo={undo}
|
|
|
|
|
|
onRunUndo={runUndo}
|
2026-07-20 00:21:22 -05:00
|
|
|
|
/>
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
</HoverPreviewProvider>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|