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";
|
Add a News tab: set releases and links to Wizards' articles
The app knew nothing about the game outside your own collection — no way to
see that a set drops next week, or that a Secret Lair was announced.
Two sources with very different reliability, kept apart so one failing can't
take the page down.
Releases come from Scryfall's documented /sets endpoint. Clicking a set fetches
its cards live rather than reading reference.db, so a set released or spoiled
since the last card-data download still works; ownership is joined locally by
oracle_id, and the same add-to-library stepper works there as anywhere else.
Articles have no supported source at all: Wizards publishes no RSS or Atom feed
and no public API, and their page is a Nuxt app whose CSS classes are per-build
hashes. So the app links out rather than reproducing anyone's writing, which
keeps the scraped surface to titles and hrefs. parse_articles keys on nothing
cosmetic — anchors pointing at /en/news/<category>/<slug>, taking the anchor's
own text as the title. A restyle can't break that; only a change to their URL
scheme could, and the fixture tests fail loudly when it does rather than the
page quietly going empty. Verified against a live capture: 15 articles, correct
titles and categories.
Two things the fixtures caught. Where an article is linked several times per
card, the first link is a "Learn More" button, so the longest text wins rather
than the first. And a *successful* fetch yielding zero articles is treated as a
failure, since that is the signature of their markup changing.
get_set_cards returns a full CardRef rather than the handful of fields the grid
needs, so the tab renders the same CardTile as the rest of the app and gets
click-to-preview instead of a lookalike that does nothing when clicked.
Both feeds cache to news-cache.json for six hours and fall back to whatever was
last seen, so opening the tab offline shows stale content with its age rather
than an error.
Also promotes the HTTP agent out of ingest.rs into a shared http module, so
timeouts and User-Agent can't drift between the ingester and anything else that
reaches the network. Adds tauri-plugin-opener for opening articles externally.
2026-08-07 23:35:50 -05:00
|
|
|
|
import { NewsView } from "./views/NewsView";
|
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
|
|
|
|
|
Add a News tab: set releases and links to Wizards' articles
The app knew nothing about the game outside your own collection — no way to
see that a set drops next week, or that a Secret Lair was announced.
Two sources with very different reliability, kept apart so one failing can't
take the page down.
Releases come from Scryfall's documented /sets endpoint. Clicking a set fetches
its cards live rather than reading reference.db, so a set released or spoiled
since the last card-data download still works; ownership is joined locally by
oracle_id, and the same add-to-library stepper works there as anywhere else.
Articles have no supported source at all: Wizards publishes no RSS or Atom feed
and no public API, and their page is a Nuxt app whose CSS classes are per-build
hashes. So the app links out rather than reproducing anyone's writing, which
keeps the scraped surface to titles and hrefs. parse_articles keys on nothing
cosmetic — anchors pointing at /en/news/<category>/<slug>, taking the anchor's
own text as the title. A restyle can't break that; only a change to their URL
scheme could, and the fixture tests fail loudly when it does rather than the
page quietly going empty. Verified against a live capture: 15 articles, correct
titles and categories.
Two things the fixtures caught. Where an article is linked several times per
card, the first link is a "Learn More" button, so the longest text wins rather
than the first. And a *successful* fetch yielding zero articles is treated as a
failure, since that is the signature of their markup changing.
get_set_cards returns a full CardRef rather than the handful of fields the grid
needs, so the tab renders the same CardTile as the rest of the app and gets
click-to-preview instead of a lookalike that does nothing when clicked.
Both feeds cache to news-cache.json for six hours and fall back to whatever was
last seen, so opening the tab offline shows stale content with its age rather
than an error.
Also promotes the HTTP agent out of ingest.rs into a shared http module, so
timeouts and User-Agent can't drift between the ingester and anything else that
reaches the network. Adds tauri-plugin-opener for opening articles externally.
2026-08-07 23:35:50 -05:00
|
|
|
|
type Tab = "add" | "library" | "search" | "decks" | "news" | "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" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
Add a News tab: set releases and links to Wizards' articles
The app knew nothing about the game outside your own collection — no way to
see that a set drops next week, or that a Secret Lair was announced.
Two sources with very different reliability, kept apart so one failing can't
take the page down.
Releases come from Scryfall's documented /sets endpoint. Clicking a set fetches
its cards live rather than reading reference.db, so a set released or spoiled
since the last card-data download still works; ownership is joined locally by
oracle_id, and the same add-to-library stepper works there as anywhere else.
Articles have no supported source at all: Wizards publishes no RSS or Atom feed
and no public API, and their page is a Nuxt app whose CSS classes are per-build
hashes. So the app links out rather than reproducing anyone's writing, which
keeps the scraped surface to titles and hrefs. parse_articles keys on nothing
cosmetic — anchors pointing at /en/news/<category>/<slug>, taking the anchor's
own text as the title. A restyle can't break that; only a change to their URL
scheme could, and the fixture tests fail loudly when it does rather than the
page quietly going empty. Verified against a live capture: 15 articles, correct
titles and categories.
Two things the fixtures caught. Where an article is linked several times per
card, the first link is a "Learn More" button, so the longest text wins rather
than the first. And a *successful* fetch yielding zero articles is treated as a
failure, since that is the signature of their markup changing.
get_set_cards returns a full CardRef rather than the handful of fields the grid
needs, so the tab renders the same CardTile as the rest of the app and gets
click-to-preview instead of a lookalike that does nothing when clicked.
Both feeds cache to news-cache.json for six hours and fall back to whatever was
last seen, so opening the tab offline shows stale content with its age rather
than an error.
Also promotes the HTTP agent out of ingest.rs into a shared http module, so
timeouts and User-Agent can't drift between the ingester and anything else that
reaches the network. Adds tauri-plugin-opener for opening articles externally.
2026-08-07 23:35:50 -05:00
|
|
|
|
{
|
|
|
|
|
|
id: "news",
|
|
|
|
|
|
label: "News",
|
|
|
|
|
|
icon: svg(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<path d="M4 5h11a1 1 0 0 1 1 1v13H5a1 1 0 0 1-1-1z" />
|
|
|
|
|
|
<path d="M16 9h3a1 1 0 0 1 1 1v8a1 1 0 0 1-2 0V9" />
|
|
|
|
|
|
<line x1="7" y1="9" x2="13" y2="9" />
|
|
|
|
|
|
<line x1="7" y1="13" x2="13" y2="13" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
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
|
|
|
|
];
|
|
|
|
|
|
|
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} />
|
Add a News tab: set releases and links to Wizards' articles
The app knew nothing about the game outside your own collection — no way to
see that a set drops next week, or that a Secret Lair was announced.
Two sources with very different reliability, kept apart so one failing can't
take the page down.
Releases come from Scryfall's documented /sets endpoint. Clicking a set fetches
its cards live rather than reading reference.db, so a set released or spoiled
since the last card-data download still works; ownership is joined locally by
oracle_id, and the same add-to-library stepper works there as anywhere else.
Articles have no supported source at all: Wizards publishes no RSS or Atom feed
and no public API, and their page is a Nuxt app whose CSS classes are per-build
hashes. So the app links out rather than reproducing anyone's writing, which
keeps the scraped surface to titles and hrefs. parse_articles keys on nothing
cosmetic — anchors pointing at /en/news/<category>/<slug>, taking the anchor's
own text as the title. A restyle can't break that; only a change to their URL
scheme could, and the fixture tests fail loudly when it does rather than the
page quietly going empty. Verified against a live capture: 15 articles, correct
titles and categories.
Two things the fixtures caught. Where an article is linked several times per
card, the first link is a "Learn More" button, so the longest text wins rather
than the first. And a *successful* fetch yielding zero articles is treated as a
failure, since that is the signature of their markup changing.
get_set_cards returns a full CardRef rather than the handful of fields the grid
needs, so the tab renders the same CardTile as the rest of the app and gets
click-to-preview instead of a lookalike that does nothing when clicked.
Both feeds cache to news-cache.json for six hours and fall back to whatever was
last seen, so opening the tab offline shows stale content with its age rather
than an error.
Also promotes the HTTP agent out of ingest.rs into a shared http module, so
timeouts and User-Agent can't drift between the ingester and anything else that
reaches the network. Adds tauri-plugin-opener for opening articles externally.
2026-08-07 23:35:50 -05:00
|
|
|
|
) : tab === "news" ? (
|
|
|
|
|
|
// Library-independent, so unlike the others it isn't keyed on the
|
|
|
|
|
|
// document — switching libraries doesn't change the news.
|
|
|
|
|
|
<NewsView />
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|