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 {
|
|
|
|
|
|
createContext,
|
2026-07-08 23:54:47 -05:00
|
|
|
|
forwardRef,
|
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
|
|
|
|
ReactNode,
|
|
|
|
|
|
useCallback,
|
|
|
|
|
|
useContext,
|
|
|
|
|
|
useEffect,
|
2026-10-14 20:29:32 -05:00
|
|
|
|
useLayoutEffect,
|
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
|
|
|
|
useRef,
|
|
|
|
|
|
useState,
|
|
|
|
|
|
} from "react";
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
import { createPortal } from "react-dom";
|
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,
|
|
|
|
|
|
CardRef,
|
|
|
|
|
|
COLORS,
|
|
|
|
|
|
LEGALITY_FORMATS,
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
LibraryCopy,
|
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
|
|
|
|
LibraryFilter,
|
|
|
|
|
|
legalStatusFor,
|
|
|
|
|
|
maskToLetters,
|
|
|
|
|
|
PreviewCard,
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
PreviewContext,
|
|
|
|
|
|
PrintingRef,
|
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
|
|
|
|
} from "./api";
|
2026-10-14 21:12:30 -05:00
|
|
|
|
import { useBackButton } from "./backButton";
|
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
|
|
|
|
|
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
|
|
|
|
// --- Icons ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
const icon = (children: ReactNode) => (
|
|
|
|
|
|
<svg
|
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
|
width="15"
|
|
|
|
|
|
height="15"
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
|
strokeWidth="2"
|
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
|
>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const ICON_TRASH = icon(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<polyline points="3 6 5 6 21 6" />
|
|
|
|
|
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
|
|
|
|
<path d="M10 11v6" />
|
|
|
|
|
|
<path d="M14 11v6" />
|
|
|
|
|
|
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
);
|
|
|
|
|
|
const ICON_COPY = icon(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
|
|
|
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
);
|
|
|
|
|
|
const ICON_PENCIL = icon(<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />);
|
|
|
|
|
|
const ICON_CLOSE = icon(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
|
|
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
);
|
2026-07-14 21:32:25 -05:00
|
|
|
|
const ICON_HELP_CIRCLE = icon(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<circle cx="12" cy="12" r="10" />
|
|
|
|
|
|
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
|
|
|
|
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
);
|
|
|
|
|
|
const ICON_ARROW_LEFT_CIRCLE = icon(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<circle cx="12" cy="12" r="10" />
|
|
|
|
|
|
<polyline points="12 8 8 12 12 16" />
|
|
|
|
|
|
<line x1="16" y1="12" x2="8" y2="12" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
);
|
2026-07-20 00:21:22 -05:00
|
|
|
|
const ICON_CARDS = icon(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<rect x="4" y="2" width="12" height="17" rx="2" />
|
|
|
|
|
|
<rect x="8" y="5" width="12" height="17" rx="2" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
);
|
|
|
|
|
|
const ICON_DOWNLOAD = icon(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<path d="M12 3v12" />
|
|
|
|
|
|
<polyline points="7 11 12 16 17 11" />
|
|
|
|
|
|
<path d="M4 18v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-1" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
);
|
|
|
|
|
|
const ICON_UPLOAD = icon(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<path d="M12 21V9" />
|
|
|
|
|
|
<polyline points="7 13 12 8 17 13" />
|
|
|
|
|
|
<path d="M4 18v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-1" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
);
|
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
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* A small round icon-only button, used consistently for lightweight actions
|
|
|
|
|
|
* (rename, duplicate, delete, dismiss, close) throughout the app.
|
|
|
|
|
|
* - `overlay`: dark translucent variant for sitting on top of card art.
|
|
|
|
|
|
* - `danger`: tints red on hover, for destructive actions.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function IconButton({
|
|
|
|
|
|
icon,
|
|
|
|
|
|
label,
|
|
|
|
|
|
onClick,
|
|
|
|
|
|
overlay,
|
|
|
|
|
|
danger,
|
|
|
|
|
|
disabled,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
icon: ReactNode;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
onClick: (e: React.MouseEvent) => void;
|
|
|
|
|
|
overlay?: boolean;
|
|
|
|
|
|
danger?: boolean;
|
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={["icon-btn", overlay ? "icon-btn-overlay" : "", danger ? "icon-btn-danger" : ""]
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join(" ")}
|
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
|
title={label}
|
|
|
|
|
|
aria-label={label}
|
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
|
>
|
|
|
|
|
|
{icon}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const Icons = {
|
|
|
|
|
|
trash: ICON_TRASH,
|
|
|
|
|
|
copy: ICON_COPY,
|
|
|
|
|
|
pencil: ICON_PENCIL,
|
|
|
|
|
|
close: ICON_CLOSE,
|
2026-07-14 21:32:25 -05:00
|
|
|
|
helpCircle: ICON_HELP_CIRCLE,
|
|
|
|
|
|
arrowLeftCircle: ICON_ARROW_LEFT_CIRCLE,
|
2026-07-20 00:21:22 -05:00
|
|
|
|
cards: ICON_CARDS,
|
|
|
|
|
|
download: ICON_DOWNLOAD,
|
|
|
|
|
|
upload: ICON_UPLOAD,
|
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
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// --- Dialogs ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-20 00:21:22 -05:00
|
|
|
|
* A centered modal dialog (backdrop + card) shared by [`ConfirmDialog`],
|
|
|
|
|
|
* [`PromptDialog`], and other dialogs built outside this module (e.g. the
|
2026-10-14 21:12:30 -05:00
|
|
|
|
* command palette). Dismisses on Escape, Android back, backdrop click, or the
|
|
|
|
|
|
* title-bar close button — the explicit button matters most on mobile, where
|
|
|
|
|
|
* there's no Escape key and a `wide` dialog can leave little to no backdrop
|
|
|
|
|
|
* to tap.
|
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
|
|
|
|
*/
|
2026-07-20 00:21:22 -05:00
|
|
|
|
export function DialogFrame({
|
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
|
|
|
|
title,
|
|
|
|
|
|
children,
|
|
|
|
|
|
onCancel,
|
2026-07-20 00:21:22 -05:00
|
|
|
|
wide,
|
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
|
|
|
|
}: {
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
|
onCancel: () => void;
|
2026-07-20 00:21:22 -05:00
|
|
|
|
/** Wider variant for content that needs more room than a typical confirm/prompt box. */
|
|
|
|
|
|
wide?: boolean;
|
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
|
|
|
|
}) {
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onCancel();
|
|
|
|
|
|
window.addEventListener("keydown", onKey);
|
|
|
|
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
|
|
|
|
}, [onCancel]);
|
2026-10-14 21:12:30 -05:00
|
|
|
|
useBackButton(onCancel);
|
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
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="dialog-backdrop" onClick={onCancel}>
|
2026-07-20 00:21:22 -05:00
|
|
|
|
<div className={`dialog ${wide ? "dialog-wide" : ""}`} onClick={(e) => e.stopPropagation()}>
|
2026-08-02 16:10:25 -05:00
|
|
|
|
<div className="dialog-title-row">
|
|
|
|
|
|
<h3 className="dialog-title">{title}</h3>
|
|
|
|
|
|
<IconButton icon={Icons.close} label="Close" onClick={onCancel} />
|
|
|
|
|
|
</div>
|
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
|
|
|
|
{children}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** A confirm/cancel dialog for destructive or otherwise consequential actions. */
|
|
|
|
|
|
export function ConfirmDialog({
|
|
|
|
|
|
title,
|
|
|
|
|
|
message,
|
|
|
|
|
|
confirmLabel = "Confirm",
|
|
|
|
|
|
danger,
|
|
|
|
|
|
onConfirm,
|
|
|
|
|
|
onCancel,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
message: ReactNode;
|
|
|
|
|
|
confirmLabel?: string;
|
|
|
|
|
|
danger?: boolean;
|
|
|
|
|
|
onConfirm: () => void;
|
|
|
|
|
|
onCancel: () => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<DialogFrame title={title} onCancel={onCancel}>
|
|
|
|
|
|
<p className="dialog-message">{message}</p>
|
|
|
|
|
|
<div className="dialog-actions">
|
|
|
|
|
|
<button onClick={onCancel}>Cancel</button>
|
|
|
|
|
|
<button className={danger ? "danger" : "primary"} onClick={onConfirm} autoFocus>
|
|
|
|
|
|
{confirmLabel}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DialogFrame>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** A single-text-field dialog (rename, duplicate-as, etc.), Enter to confirm. */
|
|
|
|
|
|
export function PromptDialog({
|
|
|
|
|
|
title,
|
|
|
|
|
|
label,
|
|
|
|
|
|
defaultValue,
|
|
|
|
|
|
confirmLabel = "Save",
|
|
|
|
|
|
error,
|
|
|
|
|
|
onConfirm,
|
|
|
|
|
|
onCancel,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
defaultValue: string;
|
|
|
|
|
|
confirmLabel?: string;
|
|
|
|
|
|
error?: string | null;
|
|
|
|
|
|
onConfirm: (value: string) => void;
|
|
|
|
|
|
onCancel: () => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [value, setValue] = useState(defaultValue);
|
|
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
useEffect(() => inputRef.current?.select(), []);
|
|
|
|
|
|
|
|
|
|
|
|
const confirm = () => {
|
|
|
|
|
|
const v = value.trim();
|
|
|
|
|
|
if (v) onConfirm(v);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<DialogFrame title={title} onCancel={onCancel}>
|
|
|
|
|
|
<label className="dialog-field">
|
|
|
|
|
|
<span className="muted">{label}</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
ref={inputRef}
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
value={value}
|
|
|
|
|
|
onChange={(e) => setValue(e.target.value)}
|
|
|
|
|
|
onKeyDown={(e) => e.key === "Enter" && confirm()}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
{error && <p className="dialog-error">{error}</p>}
|
|
|
|
|
|
<div className="dialog-actions">
|
|
|
|
|
|
<button onClick={onCancel}>Cancel</button>
|
|
|
|
|
|
<button className="primary" onClick={confirm} disabled={!value.trim()}>
|
|
|
|
|
|
{confirmLabel}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DialogFrame>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 00:21:22 -05:00
|
|
|
|
/** Read-only informational dialog: a message plus an optional bulleted list
|
|
|
|
|
|
* (e.g. unresolved card names after an import). Single "OK" dismiss button. */
|
|
|
|
|
|
export function InfoDialog({
|
|
|
|
|
|
title,
|
|
|
|
|
|
message,
|
|
|
|
|
|
items,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
message?: ReactNode;
|
|
|
|
|
|
items?: string[];
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<DialogFrame title={title} onCancel={onClose} wide={!!items?.length}>
|
|
|
|
|
|
{message && <p className="dialog-message">{message}</p>}
|
|
|
|
|
|
{items && items.length > 0 && (
|
|
|
|
|
|
<ul className="dialog-list">
|
|
|
|
|
|
{items.map((it, i) => (
|
|
|
|
|
|
<li key={i}>{it}</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="dialog-actions">
|
|
|
|
|
|
<button className="primary" onClick={onClose} autoFocus>
|
|
|
|
|
|
OK
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DialogFrame>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
ManaBox import, with progress and a format explainer
Library → Import cards… now reads a ManaBox collection export, either the
.csv or the .txt. The CSV is matched by Scryfall ID, falling back to name,
set and collector number, and its Foil column picks the copy group's finish.
Condition, language and prices are ignored; proxies count as owned.
A decklist line that names a printing now resolves to that printing's card
when several cards share the name (a card and its token, or an art-series
entry), and a bare name prefers the real card.
Imports run in one transaction (nestable via SAVEPOINT), which took the
942-row sample from 22s to 0.2s, and off the main thread, with a progress
dialog for slow ones. An explainer of the supported formats now comes
before the file picker.
2026-10-16 21:57:52 -05:00
|
|
|
|
/** Modal progress for work that can't be cancelled or dismissed (e.g. a
|
|
|
|
|
|
* library import): a title, a bar, and "N of M <unit>". `total` 0 shows an
|
|
|
|
|
|
* empty bar while the work is still starting. */
|
|
|
|
|
|
export function ProgressDialog({
|
|
|
|
|
|
title,
|
|
|
|
|
|
done,
|
|
|
|
|
|
total,
|
|
|
|
|
|
unit,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
done: number;
|
|
|
|
|
|
total: number;
|
|
|
|
|
|
unit: string;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const percent = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="dialog-backdrop">
|
|
|
|
|
|
<div className="dialog" role="progressbar" aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100}>
|
|
|
|
|
|
<h3 className="dialog-title">{title}</h3>
|
|
|
|
|
|
<div className="ingest-segment active progress-dialog-bar">
|
|
|
|
|
|
<div className="ingest-segment-track">
|
|
|
|
|
|
<div className="ingest-segment-fill" style={{ width: `${percent}%` }} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="dialog-message muted">
|
|
|
|
|
|
{total > 0 ? `${done.toLocaleString()} of ${total.toLocaleString()} ${unit}` : "Starting…"}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 21:04:36 -05:00
|
|
|
|
/** Floating snackbar offering to reverse the caller's last action. Auto-
|
|
|
|
|
|
* dismisses on a timer managed by the caller (this component is purely
|
|
|
|
|
|
* presentational) — pass `null`/omit rendering it once the window lapses. */
|
|
|
|
|
|
export function UndoBar({
|
|
|
|
|
|
message,
|
|
|
|
|
|
onUndo,
|
|
|
|
|
|
onDismiss,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
message: string;
|
|
|
|
|
|
onUndo: () => void;
|
|
|
|
|
|
onDismiss: () => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="undo-bar">
|
|
|
|
|
|
<span>{message}</span>
|
|
|
|
|
|
<button className="primary" onClick={onUndo}>
|
|
|
|
|
|
Undo
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button className="undo-bar-close" onClick={onDismiss} title="Dismiss">
|
|
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 00:21:22 -05:00
|
|
|
|
/** Modal shuffle-and-draw sample hand: shows the drawn cards and lets the
|
|
|
|
|
|
* caller draw more or reshuffle, on top of a click/Escape-to-close backdrop. */
|
|
|
|
|
|
export function SampleHandModal({
|
|
|
|
|
|
hand,
|
|
|
|
|
|
libraryCount,
|
|
|
|
|
|
onDrawOne,
|
|
|
|
|
|
onNewHand,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
hand: CardRef[];
|
|
|
|
|
|
libraryCount: number;
|
|
|
|
|
|
onDrawOne: () => void;
|
|
|
|
|
|
onNewHand: () => void;
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<DialogFrame title="Sample hand" onCancel={onClose} wide>
|
|
|
|
|
|
<div className="row" style={{ gap: 10, marginBottom: 12 }}>
|
|
|
|
|
|
<span className="muted" style={{ fontSize: 12 }}>
|
|
|
|
|
|
{libraryCount} card{libraryCount === 1 ? "" : "s"} left in library
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="spacer" />
|
|
|
|
|
|
<button onClick={onDrawOne} disabled={libraryCount === 0}>
|
|
|
|
|
|
Draw a card
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button onClick={onNewHand}>New hand</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{hand.length > 0 ? (
|
|
|
|
|
|
<div className="tile-grid">
|
|
|
|
|
|
{hand.map((c, i) => (
|
|
|
|
|
|
<CardTile key={`${c.oracle_id}-${i}`} card={c} />
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p className="hint">Your deck has no cards to draw yet.</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="dialog-actions">
|
|
|
|
|
|
<button className="primary" onClick={onClose} autoFocus>
|
|
|
|
|
|
Close
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DialogFrame>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/**
|
|
|
|
|
|
* Inline editor for a card's free-text storage location (e.g. "Box 1").
|
|
|
|
|
|
* Saves on blur; used for owned cards that aren't in any deck.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function LocationField({
|
|
|
|
|
|
oracleId,
|
|
|
|
|
|
location,
|
|
|
|
|
|
onSaved,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
oracleId: string;
|
|
|
|
|
|
location: string;
|
|
|
|
|
|
onSaved?: () => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [val, setVal] = useState(location);
|
|
|
|
|
|
useEffect(() => setVal(location), [location]);
|
|
|
|
|
|
|
|
|
|
|
|
const save = () => {
|
|
|
|
|
|
if (val !== location) api.setLocation(oracleId, val).then(() => onSaved?.());
|
|
|
|
|
|
};
|
|
|
|
|
|
return (
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="location-input"
|
|
|
|
|
|
placeholder="Location…"
|
|
|
|
|
|
value={val}
|
|
|
|
|
|
onChange={(e) => setVal(e.target.value)}
|
|
|
|
|
|
onBlur={save}
|
|
|
|
|
|
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/**
|
|
|
|
|
|
* A small pill-shaped [− N +] quantity stepper, used consistently for owned-copy
|
|
|
|
|
|
* counts on the Add Cards page and both Library views.
|
|
|
|
|
|
*
|
|
|
|
|
|
* - `overlay`: dark translucent variant for use atop card art (tile corners),
|
|
|
|
|
|
* instead of the default panel-toned variant used in rows/lists.
|
|
|
|
|
|
* - `warn`: tints the pill amber (e.g. "assigned to more decks than owned").
|
|
|
|
|
|
* - `pending`: dims the pill (e.g. mid grace-period before a 0-qty row is removed).
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function QtyStepper({
|
|
|
|
|
|
value,
|
|
|
|
|
|
onDecrement,
|
|
|
|
|
|
onIncrement,
|
|
|
|
|
|
overlay,
|
|
|
|
|
|
warn,
|
|
|
|
|
|
pending,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
value: number;
|
|
|
|
|
|
onDecrement: () => void;
|
|
|
|
|
|
onIncrement: () => void;
|
|
|
|
|
|
overlay?: boolean;
|
|
|
|
|
|
warn?: boolean;
|
|
|
|
|
|
pending?: boolean;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={[
|
|
|
|
|
|
"qty-stepper",
|
|
|
|
|
|
overlay ? "qty-stepper-overlay" : "",
|
|
|
|
|
|
warn ? "warn" : "",
|
|
|
|
|
|
pending ? "pending" : "",
|
|
|
|
|
|
]
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join(" ")}
|
|
|
|
|
|
>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="qty-stepper-btn"
|
|
|
|
|
|
onClick={onDecrement}
|
|
|
|
|
|
disabled={value <= 0}
|
|
|
|
|
|
title="Remove a copy"
|
|
|
|
|
|
>
|
|
|
|
|
|
−
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span className="qty-stepper-value">{value}</span>
|
|
|
|
|
|
<button type="button" className="qty-stepper-btn" onClick={onIncrement} title="Add a copy">
|
|
|
|
|
|
+
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</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
|
|
|
|
/** Render a color-identity/colors bitmask as WUBRG pips (colorless = a "C" pip). */
|
|
|
|
|
|
export function ColorPips({ mask }: { mask: number }) {
|
|
|
|
|
|
const letters = maskToLetters(mask);
|
|
|
|
|
|
if (letters.length === 0) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span className="pips">
|
|
|
|
|
|
<span className="pip C">C</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span className="pips">
|
|
|
|
|
|
{letters.split("").map((c) => (
|
|
|
|
|
|
<span key={c} className={`pip ${c}`}>
|
|
|
|
|
|
{c}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/**
|
|
|
|
|
|
* Small card-image thumbnail. Hovering it shows a cursor-tracking preview;
|
|
|
|
|
|
* clicking it opens a larger, centered preview (with a flip button for
|
|
|
|
|
|
* double-faced cards).
|
|
|
|
|
|
*/
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
export function CardThumb({ card, preview }: { card: PreviewCard; preview?: PreviewContext }) {
|
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 { handlers } = useHoverPreview();
|
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 { open } = useClickPreview();
|
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
|
|
|
|
if (!card.image_uri) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span className="thumb-empty" title="No image">
|
|
|
|
|
|
🂠
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<img
|
|
|
|
|
|
className="card-thumb"
|
|
|
|
|
|
src={card.image_uri}
|
|
|
|
|
|
alt={card.name}
|
|
|
|
|
|
loading="lazy"
|
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
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
open(card, preview);
|
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
|
|
|
|
}}
|
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
|
|
|
|
{...handlers(card)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* A search input plus a results dropdown with keyboard navigation:
|
|
|
|
|
|
* ↑/↓ move the highlight, Enter picks, mouse hover/click also work.
|
|
|
|
|
|
* `value`/`onChange` are controlled by the caller (which runs the search).
|
2026-07-08 23:54:47 -05:00
|
|
|
|
* Forwards a ref to the underlying `<input>` so callers can focus it
|
|
|
|
|
|
* imperatively (e.g. redirecting a stray keystroke into the search box).
|
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-07-08 23:54:47 -05:00
|
|
|
|
export const CardSearchBox = forwardRef<
|
|
|
|
|
|
HTMLInputElement,
|
|
|
|
|
|
{
|
|
|
|
|
|
placeholder: string;
|
|
|
|
|
|
value: string;
|
|
|
|
|
|
onChange: (v: string) => void;
|
|
|
|
|
|
results: CardRef[];
|
|
|
|
|
|
onPick: (card: CardRef) => void;
|
|
|
|
|
|
autoFocus?: boolean;
|
|
|
|
|
|
/** Larger, more prominent input (used for the primary Add Cards field). */
|
|
|
|
|
|
big?: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
>(function CardSearchBox(
|
|
|
|
|
|
{ placeholder, value, onChange, results, onPick, autoFocus, big },
|
|
|
|
|
|
forwardedRef,
|
|
|
|
|
|
) {
|
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 [sel, setSel] = useState(0);
|
2026-07-08 23:54:47 -05:00
|
|
|
|
const inputRef = useRef<HTMLInputElement | 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
|
|
|
|
useEffect(() => setSel(0), [results]);
|
|
|
|
|
|
|
|
|
|
|
|
// Keep focus in the box after a pick so cards can be added in a rapid streak.
|
|
|
|
|
|
function pick(card: CardRef) {
|
|
|
|
|
|
onPick(card);
|
|
|
|
|
|
inputRef.current?.focus();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onKeyDown(e: React.KeyboardEvent) {
|
|
|
|
|
|
if (e.key === "ArrowDown") {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
setSel((s) => Math.min(s + 1, results.length - 1));
|
|
|
|
|
|
} else if (e.key === "ArrowUp") {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
setSel((s) => Math.max(s - 1, 0));
|
|
|
|
|
|
} else if (e.key === "Enter" && results[sel]) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
pick(results[sel]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<input
|
2026-07-08 23:54:47 -05:00
|
|
|
|
ref={(el) => {
|
|
|
|
|
|
inputRef.current = el;
|
|
|
|
|
|
if (typeof forwardedRef === "function") forwardedRef(el);
|
|
|
|
|
|
else if (forwardedRef) (forwardedRef as React.MutableRefObject<HTMLInputElement | null>).current = el;
|
|
|
|
|
|
}}
|
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
|
|
|
|
autoFocus={autoFocus}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
width: "100%",
|
|
|
|
|
|
marginBottom: 8,
|
|
|
|
|
|
...(big ? { fontSize: 16, padding: "10px 12px" } : {}),
|
|
|
|
|
|
}}
|
|
|
|
|
|
placeholder={placeholder}
|
|
|
|
|
|
value={value}
|
|
|
|
|
|
onChange={(e) => onChange(e.target.value)}
|
|
|
|
|
|
onKeyDown={onKeyDown}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{results.length > 0 && (
|
|
|
|
|
|
<ul className="result-list" style={{ marginBottom: 14 }}>
|
|
|
|
|
|
{results.map((c, i) => (
|
|
|
|
|
|
<li
|
|
|
|
|
|
key={c.oracle_id}
|
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
|
|
|
|
style={i === sel ? { background: "var(--accent-subtle)" } : 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
|
|
|
|
onMouseEnter={() => setSel(i)}
|
|
|
|
|
|
onClick={() => pick(c)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<ColorPips mask={c.color_identity} />
|
|
|
|
|
|
<span className="card-name">{c.name}</span>
|
|
|
|
|
|
<span className="card-type">{c.type_line}</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
2026-07-08 23:54:47 -05:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* A chip-based multi-value text filter with autocomplete: selected values
|
|
|
|
|
|
* render as removable chips, and typing filters `suggestions` into a
|
|
|
|
|
|
* dropdown (↑/↓ to move, Enter to pick — or Enter with no match adds the
|
|
|
|
|
|
* typed text verbatim, so it's not limited to only-suggested values).
|
|
|
|
|
|
* Backspace on an empty input quick-removes the last chip.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function TagInput({
|
|
|
|
|
|
values,
|
|
|
|
|
|
onChange,
|
|
|
|
|
|
suggestions,
|
|
|
|
|
|
placeholder,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
values: string[];
|
|
|
|
|
|
onChange: (values: string[]) => void;
|
|
|
|
|
|
suggestions: string[];
|
|
|
|
|
|
placeholder?: string;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [text, setText] = useState("");
|
|
|
|
|
|
const [sel, setSel] = useState(0);
|
|
|
|
|
|
|
|
|
|
|
|
const query = text.trim().toLowerCase();
|
|
|
|
|
|
const filtered = query
|
|
|
|
|
|
? suggestions
|
|
|
|
|
|
.filter((s) => !values.some((v) => v.toLowerCase() === s.toLowerCase()))
|
|
|
|
|
|
.filter((s) => s.toLowerCase().includes(query))
|
|
|
|
|
|
.slice(0, 8)
|
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => setSel(0), [text]);
|
|
|
|
|
|
|
|
|
|
|
|
function addValue(v: string) {
|
|
|
|
|
|
const trimmed = v.trim();
|
|
|
|
|
|
if (!trimmed) return;
|
|
|
|
|
|
if (!values.some((x) => x.toLowerCase() === trimmed.toLowerCase())) {
|
|
|
|
|
|
onChange([...values, trimmed]);
|
|
|
|
|
|
}
|
|
|
|
|
|
setText("");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function removeValue(v: string) {
|
|
|
|
|
|
onChange(values.filter((x) => x !== v));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onKeyDown(e: React.KeyboardEvent) {
|
|
|
|
|
|
if (e.key === "ArrowDown" && filtered.length > 0) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
setSel((s) => Math.min(s + 1, filtered.length - 1));
|
|
|
|
|
|
} else if (e.key === "ArrowUp" && filtered.length > 0) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
setSel((s) => Math.max(s - 1, 0));
|
|
|
|
|
|
} else if (e.key === "Enter") {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
if (filtered[sel]) addValue(filtered[sel]);
|
|
|
|
|
|
else if (text.trim()) addValue(text);
|
|
|
|
|
|
} else if (e.key === "Backspace" && text === "" && values.length > 0) {
|
|
|
|
|
|
onChange(values.slice(0, -1));
|
|
|
|
|
|
} else if (e.key === "Escape") {
|
|
|
|
|
|
setText("");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="tag-input">
|
|
|
|
|
|
<div className="tag-chips">
|
|
|
|
|
|
{values.map((v) => (
|
|
|
|
|
|
<span key={v} className="tag-chip">
|
|
|
|
|
|
{v}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="tag-chip-remove"
|
|
|
|
|
|
onClick={() => removeValue(v)}
|
|
|
|
|
|
title={`Remove ${v}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="tag-chip-input"
|
|
|
|
|
|
value={text}
|
|
|
|
|
|
onChange={(e) => setText(e.target.value)}
|
|
|
|
|
|
onKeyDown={onKeyDown}
|
|
|
|
|
|
placeholder={values.length === 0 ? placeholder : ""}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{filtered.length > 0 && (
|
|
|
|
|
|
<ul className="result-list tag-suggestions">
|
|
|
|
|
|
{filtered.map((s, i) => (
|
|
|
|
|
|
<li
|
|
|
|
|
|
key={s}
|
|
|
|
|
|
style={i === sel ? { background: "var(--accent-subtle)" } : undefined}
|
|
|
|
|
|
onMouseEnter={() => setSel(i)}
|
|
|
|
|
|
onClick={() => addValue(s)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{s}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</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
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
// --- Hover + click preview -------------------------------------------------
|
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
|
|
|
|
|
|
|
|
|
|
interface HoverApi {
|
|
|
|
|
|
enter: (card: CardRef, e: React.MouseEvent) => void;
|
|
|
|
|
|
move: (e: React.MouseEvent) => void;
|
|
|
|
|
|
leave: () => void;
|
|
|
|
|
|
}
|
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
|
|
|
|
interface ClickApi {
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
open: (card: PreviewCard, ctx?: PreviewContext) => void;
|
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
|
|
|
|
}
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
type ClickedState = { card: PreviewCard; ctx?: PreviewContext; showBack: boolean };
|
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 HoverCtx = createContext<HoverApi | null>(null);
|
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 ClickCtx = createContext<ClickApi | 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
|
|
|
|
|
|
|
|
|
|
/**
|
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
|
|
|
|
* Provides card-image previews app-wide: a small floating preview that
|
|
|
|
|
|
* tracks the cursor while hovering (see [`useHoverPreview`]), and a larger
|
|
|
|
|
|
* centered modal on click (see [`useClickPreview`]) — with a flip button for
|
|
|
|
|
|
* double-faced cards. Wrap the app once.
|
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 function HoverPreviewProvider({ children }: { children: ReactNode }) {
|
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 [hover, setHover] = useState<{ card: CardRef; x: number; y: number } | null>(null);
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
const [clicked, setClicked] = useState<ClickedState | 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 enter = useCallback((card: CardRef, e: React.MouseEvent) => {
|
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
|
|
|
|
if (card.image_uri) setHover({ card, x: e.clientX, y: e.clientY });
|
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 move = useCallback((e: React.MouseEvent) => {
|
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
|
|
|
|
setHover((s) => (s ? { ...s, x: e.clientX, y: e.clientY } : s));
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
const leave = useCallback(() => setHover(null), []);
|
|
|
|
|
|
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
const open = useCallback((card: PreviewCard, ctx?: PreviewContext) => {
|
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
|
|
|
|
if (!card.image_uri) return;
|
|
|
|
|
|
setHover(null);
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
setClicked({ card, ctx, showBack: false });
|
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 close = useCallback(() => setClicked(null), []);
|
|
|
|
|
|
const flip = useCallback(() => setClicked((c) => (c ? { ...c, showBack: !c.showBack } : c)), []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!clicked) return;
|
|
|
|
|
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
|
|
|
|
|
|
window.addEventListener("keydown", onKey);
|
|
|
|
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
|
|
|
|
}, [clicked, close]);
|
2026-10-14 21:12:30 -05:00
|
|
|
|
useBackButton(close, !!clicked);
|
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 (
|
|
|
|
|
|
<HoverCtx.Provider value={{ enter, move, leave }}>
|
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
|
|
|
|
<ClickCtx.Provider value={{ open }}>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
{!clicked && hover && <FloatingCard card={hover.card} x={hover.x} y={hover.y} />}
|
|
|
|
|
|
{clicked && <ClickPreviewModal state={clicked} onClose={close} onFlip={flip} />}
|
|
|
|
|
|
</ClickCtx.Provider>
|
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
|
|
|
|
</HoverCtx.Provider>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Returns a `handlers(card)` factory to spread onto a row/cell for hover preview. */
|
|
|
|
|
|
export function useHoverPreview() {
|
|
|
|
|
|
const api = useContext(HoverCtx);
|
|
|
|
|
|
return {
|
|
|
|
|
|
handlers: (card: CardRef) =>
|
|
|
|
|
|
api
|
|
|
|
|
|
? {
|
|
|
|
|
|
onMouseEnter: (e: React.MouseEvent) => api.enter(card, e),
|
|
|
|
|
|
onMouseMove: (e: React.MouseEvent) => api.move(e),
|
|
|
|
|
|
onMouseLeave: () => api.leave(),
|
|
|
|
|
|
}
|
|
|
|
|
|
: {},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
/** Returns `open(card, ctx?)` to trigger the larger click-to-preview modal.
|
|
|
|
|
|
* Passing a `ctx` adds the Printings section to its info tile. */
|
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
|
|
|
|
export function useClickPreview() {
|
|
|
|
|
|
const api = useContext(ClickCtx);
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
return { open: (card: PreviewCard, ctx?: PreviewContext) => api?.open(card, ctx) };
|
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
|
|
|
|
}
|
|
|
|
|
|
|
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 PREVIEW_W = 320;
|
|
|
|
|
|
const PREVIEW_H = 446; // ~ card aspect for the normal image
|
|
|
|
|
|
const GAP = 24;
|
|
|
|
|
|
const PAD = 12;
|
|
|
|
|
|
|
|
|
|
|
|
function FloatingCard({ card, x, y }: { card: CardRef; x: number; y: number }) {
|
2026-07-22 20:55:17 -05:00
|
|
|
|
// Battles/Planes/Phenomena store their image portrait-shaped with the
|
|
|
|
|
|
// content itself rotated, so the box footprint swaps (landscape) and the
|
|
|
|
|
|
// image inside gets rotated 90° back into place — see `CardRef.horizontal`.
|
|
|
|
|
|
const boxW = card.horizontal ? PREVIEW_H : PREVIEW_W;
|
|
|
|
|
|
const boxH = card.horizontal ? PREVIEW_W : PREVIEW_H;
|
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
|
|
|
|
// Prefer the right of the cursor; flip left if it would overflow.
|
|
|
|
|
|
let left = x + GAP;
|
2026-07-22 20:55:17 -05:00
|
|
|
|
if (left + boxW + PAD > window.innerWidth) left = x - boxW - GAP;
|
|
|
|
|
|
const top = Math.max(PAD, Math.min(y - boxH / 2, window.innerHeight - boxH - PAD));
|
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 (
|
2026-07-22 20:55:17 -05:00
|
|
|
|
<div className="hover-card" style={{ left, top, width: boxW, height: boxH }}>
|
|
|
|
|
|
<img
|
|
|
|
|
|
className="hover-card-img"
|
|
|
|
|
|
src={card.image_uri}
|
|
|
|
|
|
alt={card.name}
|
|
|
|
|
|
style={
|
|
|
|
|
|
card.horizontal
|
|
|
|
|
|
? {
|
|
|
|
|
|
position: "absolute",
|
|
|
|
|
|
top: "50%",
|
|
|
|
|
|
left: "50%",
|
|
|
|
|
|
width: PREVIEW_W,
|
|
|
|
|
|
height: PREVIEW_H,
|
|
|
|
|
|
transform: "translate(-50%, -50%) rotate(90deg)",
|
|
|
|
|
|
}
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</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
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 20:55:17 -05:00
|
|
|
|
// Landscape (post-rotation) aspect ratio of a Scryfall "normal" card image —
|
|
|
|
|
|
// the raw file is portrait (488x680); rotated 90° it reads as 680x488.
|
|
|
|
|
|
const ROTATED_ASPECT = 680 / 488;
|
|
|
|
|
|
|
|
|
|
|
|
/** The largest landscape box (matching `.preview-modal-img`'s own
|
|
|
|
|
|
* `max-width: min(720px, 90vw)` / `max-height: 90vh` bounds) for a rotated
|
|
|
|
|
|
* horizontal-card preview. Recomputed on resize so it stays live like the
|
2026-07-22 23:11:26 -05:00
|
|
|
|
* CSS-only sizing used for normal (portrait) cards.
|
|
|
|
|
|
*
|
2026-10-14 20:35:50 -05:00
|
|
|
|
* `dfc` (when wide enough for side-by-side faces) shrinks the budget to
|
|
|
|
|
|
* roughly match a DFC back face's own `min(430px, 43vw)` box (see the `.preview-modal-dfc .preview-modal-img`
|
2026-07-22 23:11:26 -05:00
|
|
|
|
* rule) and leaves headroom below for the info tile — pass it whenever the
|
|
|
|
|
|
* horizontal front face has a sibling back face, so a transformed Battle
|
|
|
|
|
|
* doesn't render at full single-card size next to a much smaller back face
|
2026-10-14 20:35:50 -05:00
|
|
|
|
* and push the info tile off-screen.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `fitH` (compact mode, see `ClickPreviewModal`) overrides both with the
|
|
|
|
|
|
* exact art height budget left over after the pane toolbar. */
|
|
|
|
|
|
function useRotatedPreviewBox(dfc: boolean, fitH: number | null) {
|
2026-07-22 20:55:17 -05:00
|
|
|
|
const compute = () => {
|
2026-10-14 20:35:50 -05:00
|
|
|
|
// Faces only sit side by side at the `.preview-modal-dfc` ≥860px
|
|
|
|
|
|
// breakpoint; below it they flip one at a time, so use the full budget.
|
|
|
|
|
|
const sideBySide = dfc && fitH === null && window.innerWidth >= 860;
|
|
|
|
|
|
const boundW = sideBySide ? Math.min(430, window.innerWidth * 0.43) : Math.min(720, window.innerWidth * 0.9);
|
|
|
|
|
|
const boundH = fitH ?? (sideBySide ? window.innerHeight * 0.55 : window.innerHeight * 0.9);
|
|
|
|
|
|
const w = Math.min(boundW, boundH * ROTATED_ASPECT);
|
2026-07-22 20:55:17 -05:00
|
|
|
|
return { w, h: w / ROTATED_ASPECT };
|
|
|
|
|
|
};
|
|
|
|
|
|
const [box, setBox] = useState(compute);
|
|
|
|
|
|
useEffect(() => {
|
2026-07-22 23:11:26 -05:00
|
|
|
|
setBox(compute());
|
2026-07-22 20:55:17 -05:00
|
|
|
|
const onResize = () => setBox(compute());
|
|
|
|
|
|
window.addEventListener("resize", onResize);
|
|
|
|
|
|
return () => window.removeEventListener("resize", onResize);
|
2026-10-14 20:35:50 -05:00
|
|
|
|
}, [dfc, fitH]);
|
2026-07-22 20:55:17 -05:00
|
|
|
|
return box;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 23:11:26 -05:00
|
|
|
|
/** Legality status pills for every tracked format, e.g. inside the
|
|
|
|
|
|
* click-preview modal's info tile. Not-legal formats are shown too (dimmed)
|
|
|
|
|
|
* so the list reads as a complete checklist rather than a partial one. */
|
|
|
|
|
|
function LegalityPills({ legalities }: { legalities: number }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="preview-info-legal-grid">
|
|
|
|
|
|
{LEGALITY_FORMATS.map((f) => {
|
|
|
|
|
|
const status = legalStatusFor(legalities, f.label);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span key={f.key} className={`legal-pill legal-${status}`}>
|
|
|
|
|
|
{f.label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Details panel shown alongside the card art in the click-to-preview modal:
|
|
|
|
|
|
* type/mana/color, price, ownership context (when the caller had a
|
|
|
|
|
|
* `LibraryCard`/`DeckEntry` to hand — see `PreviewCard`), and per-format
|
|
|
|
|
|
* legality. */
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
const CardInfoTile = forwardRef<
|
|
|
|
|
|
HTMLDivElement,
|
|
|
|
|
|
{ card: PreviewCard; ctx?: PreviewContext; onArtChange: (art: CardArt) => void }
|
|
|
|
|
|
>(function CardInfoTile({ card, ctx, onArtChange }, ref) {
|
|
|
|
|
|
const printings = !!ctx && card.quantity_owned !== undefined;
|
2026-07-22 23:11:26 -05:00
|
|
|
|
return (
|
2026-10-14 20:35:50 -05:00
|
|
|
|
<div className="preview-info" ref={ref}>
|
2026-07-22 23:11:26 -05:00
|
|
|
|
<div className="preview-info-name">{card.name}</div>
|
|
|
|
|
|
<div className="preview-info-type">{card.type_line}</div>
|
|
|
|
|
|
<div className="preview-info-row">
|
|
|
|
|
|
<span className="preview-info-mv">Mana value {card.mana_value}</span>
|
|
|
|
|
|
<ColorPips mask={card.colors} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{card.price_usd > 0 && (
|
|
|
|
|
|
<div className="preview-info-price">${card.price_usd.toFixed(2)}</div>
|
|
|
|
|
|
)}
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
{printings ? (
|
|
|
|
|
|
<PrintingsSection card={card} ctx={ctx} onArtChange={onArtChange} />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
card.quantity_owned !== undefined && (
|
|
|
|
|
|
<div className="preview-info-section">
|
|
|
|
|
|
<span className="preview-info-label">Owned</span>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{card.quantity_owned} cop{card.quantity_owned === 1 ? "y" : "ies"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
2026-07-22 23:11:26 -05:00
|
|
|
|
)}
|
|
|
|
|
|
{card.decks && card.decks.length > 0 && (
|
|
|
|
|
|
<div className="preview-info-section">
|
|
|
|
|
|
<span className="preview-info-label">In decks</span>
|
|
|
|
|
|
<div className="preview-info-deck-chips">
|
|
|
|
|
|
{card.decks.map((d) => (
|
|
|
|
|
|
<span key={d} className="badge">
|
|
|
|
|
|
{d}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="preview-info-section">
|
|
|
|
|
|
<span className="preview-info-label">Legalities</span>
|
|
|
|
|
|
<LegalityPills legalities={card.legalities} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2026-10-14 20:35:50 -05:00
|
|
|
|
});
|
2026-07-22 23:11:26 -05:00
|
|
|
|
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
type CardArt = Pick<CardRef, "image_uri" | "back_image_uri">;
|
|
|
|
|
|
|
|
|
|
|
|
const FINISH_LABELS: Record<string, string> = { nonfoil: "Nonfoil", foil: "Foil", etched: "Etched" };
|
|
|
|
|
|
|
|
|
|
|
|
/** "2X2 #315", the way decklists and binders name a printing. */
|
|
|
|
|
|
function printingLabel(p: PrintingRef): string {
|
|
|
|
|
|
return `${p.set_code.toUpperCase()} #${p.collector_number}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The click preview's printing controls (shown when it was opened with a
|
|
|
|
|
|
* `PreviewContext`): owned copies broken down by printing + finish, and — for
|
|
|
|
|
|
* a deck slot — which printing's art the slot shows. Takes the place of the
|
|
|
|
|
|
* plain "Owned" row, since growing a copy group can change that total.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function PrintingsSection({
|
|
|
|
|
|
card,
|
|
|
|
|
|
ctx,
|
|
|
|
|
|
onArtChange,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
card: PreviewCard;
|
|
|
|
|
|
ctx: PreviewContext;
|
|
|
|
|
|
onArtChange: (art: CardArt) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [owned, setOwned] = useState(card.quantity_owned ?? 0);
|
|
|
|
|
|
const [copies, setCopies] = useState<LibraryCopy[]>([]);
|
|
|
|
|
|
const [printings, setPrintings] = useState<PrintingRef[] | null>(null);
|
|
|
|
|
|
const [preferred, setPreferred] = useState(card.preferred_scryfall_id ?? null);
|
|
|
|
|
|
const [picking, setPicking] = useState<"deck" | "copy" | null>(null);
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let live = true;
|
|
|
|
|
|
api
|
|
|
|
|
|
.listLibraryCopies(card.oracle_id)
|
|
|
|
|
|
.then((c) => live && setCopies(c))
|
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
|
api
|
|
|
|
|
|
.listPrintings(card.oracle_id)
|
|
|
|
|
|
.then((p) => live && setPrintings(p))
|
|
|
|
|
|
.catch(() => live && setPrintings([]));
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
live = false;
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [card.oracle_id]);
|
|
|
|
|
|
|
|
|
|
|
|
const tracked = copies.reduce((n, c) => n + c.quantity, 0);
|
|
|
|
|
|
const shown = preferred ? printings?.find((p) => p.scryfall_id === preferred) : undefined;
|
|
|
|
|
|
|
|
|
|
|
|
async function run(action: () => Promise<void>) {
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await action();
|
|
|
|
|
|
ctx.onChanged();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError(String(e));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const setCopy = (scryfallId: string, finish: string, quantity: number) =>
|
|
|
|
|
|
run(async () => {
|
|
|
|
|
|
setOwned(await api.setLibraryCopy(card.oracle_id, scryfallId, finish, quantity));
|
|
|
|
|
|
setCopies(await api.listLibraryCopies(card.oracle_id));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const showPrinting = (scryfallId: string | null) =>
|
|
|
|
|
|
run(async () => {
|
|
|
|
|
|
const entry = await api.setDeckCardPrinting(ctx.deckId!, card.oracle_id, scryfallId);
|
|
|
|
|
|
setPreferred(scryfallId);
|
|
|
|
|
|
if (entry) onArtChange({ image_uri: entry.image_uri, back_image_uri: entry.back_image_uri });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function onPick(p: PrintingRef, finish: string) {
|
|
|
|
|
|
const mode = picking;
|
|
|
|
|
|
setPicking(null);
|
|
|
|
|
|
if (mode === "deck") {
|
|
|
|
|
|
showPrinting(p.scryfall_id);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const existing = copies.find((c) => c.scryfall_id === p.scryfall_id && c.finish === finish);
|
|
|
|
|
|
setCopy(p.scryfall_id, finish, (existing?.quantity ?? 0) + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{ctx.deckId !== undefined && (
|
|
|
|
|
|
<div className="preview-info-section">
|
|
|
|
|
|
<span className="preview-info-label">Shown in this deck</span>
|
|
|
|
|
|
<div className="printing-row">
|
|
|
|
|
|
<span className="printing-row-label">
|
|
|
|
|
|
{!preferred ? "Default printing" : shown ? printingLabel(shown) : "Unavailable printing"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="spacer" />
|
|
|
|
|
|
<span className="printing-actions">
|
|
|
|
|
|
<button onClick={() => setPicking("deck")} disabled={!printings?.length}>
|
|
|
|
|
|
Change…
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{preferred && <button onClick={() => showPrinting(null)}>Default</button>}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="preview-info-section">
|
|
|
|
|
|
<span className="preview-info-label">Owned</span>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{owned} cop{owned === 1 ? "y" : "ies"}
|
|
|
|
|
|
{tracked > 0 && (
|
|
|
|
|
|
<span className="muted">
|
|
|
|
|
|
{" "}
|
|
|
|
|
|
· {tracked} by printing · {Math.max(0, owned - tracked)} unspecified
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{copies.map((c) => (
|
|
|
|
|
|
<div key={`${c.scryfall_id}/${c.finish}`} className="printing-row">
|
|
|
|
|
|
{c.printing?.image_uri ? (
|
|
|
|
|
|
<img className="printing-row-thumb" src={c.printing.image_uri} alt="" loading="lazy" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="printing-row-thumb" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span className="printing-row-label">
|
|
|
|
|
|
{c.printing ? printingLabel(c.printing) : "Unavailable printing"}
|
|
|
|
|
|
<span className="muted"> · {FINISH_LABELS[c.finish] ?? c.finish}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="spacer" />
|
|
|
|
|
|
<QtyStepper
|
|
|
|
|
|
value={c.quantity}
|
|
|
|
|
|
onDecrement={() => setCopy(c.scryfall_id, c.finish, c.quantity - 1)}
|
|
|
|
|
|
onIncrement={() => setCopy(c.scryfall_id, c.finish, c.quantity + 1)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<span className="printing-actions">
|
|
|
|
|
|
<button onClick={() => setPicking("copy")} disabled={!printings?.length}>
|
|
|
|
|
|
Add a printing…
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{error && <div className="dialog-error">{error}</div>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{picking && printings && (
|
|
|
|
|
|
<PrintingPicker
|
|
|
|
|
|
name={card.name}
|
|
|
|
|
|
printings={printings}
|
|
|
|
|
|
chooseFinish={picking === "copy"}
|
|
|
|
|
|
current={picking === "deck" ? preferred : null}
|
|
|
|
|
|
onPick={onPick}
|
|
|
|
|
|
onCancel={() => setPicking(null)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* A grid of a card's printings to pick from. With `chooseFinish`, each
|
|
|
|
|
|
* printing also offers its finishes (clicking the art takes the first).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Opens above the click preview, which closes on any Escape it hears on
|
|
|
|
|
|
* `window` — so this listens in the capture phase and stops the event there,
|
|
|
|
|
|
* and only the picker closes.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function PrintingPicker({
|
|
|
|
|
|
name,
|
|
|
|
|
|
printings,
|
|
|
|
|
|
chooseFinish,
|
|
|
|
|
|
current,
|
|
|
|
|
|
onPick,
|
|
|
|
|
|
onCancel,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
printings: PrintingRef[];
|
|
|
|
|
|
chooseFinish: boolean;
|
|
|
|
|
|
current: string | null;
|
|
|
|
|
|
onPick: (p: PrintingRef, finish: string) => void;
|
|
|
|
|
|
onCancel: () => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
|
|
|
|
if (e.key !== "Escape") return;
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
onCancel();
|
|
|
|
|
|
};
|
|
|
|
|
|
window.addEventListener("keydown", onKey, true);
|
|
|
|
|
|
return () => window.removeEventListener("keydown", onKey, true);
|
|
|
|
|
|
}, [onCancel]);
|
|
|
|
|
|
useBackButton(onCancel);
|
|
|
|
|
|
|
|
|
|
|
|
return createPortal(
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="dialog-backdrop printing-picker-backdrop"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
onCancel();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="dialog dialog-wide" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
|
<div className="dialog-title-row">
|
|
|
|
|
|
<h3 className="dialog-title">{chooseFinish ? `Add a printing of ${name}` : `Show ${name} as`}</h3>
|
|
|
|
|
|
<IconButton icon={Icons.close} label="Close" onClick={onCancel} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="printing-grid">
|
|
|
|
|
|
{printings.map((p) => {
|
|
|
|
|
|
const finishes = p.finishes.length > 0 ? p.finishes : ["nonfoil"];
|
|
|
|
|
|
const meta = [
|
|
|
|
|
|
p.released_at.slice(0, 4),
|
|
|
|
|
|
p.rarity && p.rarity[0].toUpperCase() + p.rarity.slice(1),
|
|
|
|
|
|
p.lang && p.lang !== "en" ? p.lang.toUpperCase() : "",
|
|
|
|
|
|
p.price_usd > 0 ? `$${p.price_usd.toFixed(2)}` : "",
|
|
|
|
|
|
]
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join(" · ");
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={p.scryfall_id}
|
|
|
|
|
|
className={`printing-option ${p.scryfall_id === current ? "selected" : ""}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="printing-option-art"
|
|
|
|
|
|
onClick={() => onPick(p, finishes[0])}
|
|
|
|
|
|
title={chooseFinish ? `Add a ${FINISH_LABELS[finishes[0]] ?? finishes[0]} copy` : "Show this printing"}
|
|
|
|
|
|
>
|
|
|
|
|
|
{p.image_uri ? (
|
|
|
|
|
|
<img src={p.image_uri} alt={printingLabel(p)} loading="lazy" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="tile-noimg">{printingLabel(p)}</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div className="printing-option-label">{printingLabel(p)}</div>
|
|
|
|
|
|
{meta && <div className="printing-option-meta">{meta}</div>}
|
|
|
|
|
|
{chooseFinish && finishes.length > 1 && (
|
|
|
|
|
|
<div className="printing-actions">
|
|
|
|
|
|
{finishes.map((f) => (
|
|
|
|
|
|
<button key={f} onClick={() => onPick(p, f)}>
|
|
|
|
|
|
{FINISH_LABELS[f] ?? f}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>,
|
|
|
|
|
|
document.body,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-14 20:29:32 -05:00
|
|
|
|
type PreviewPane = "card" | "info";
|
|
|
|
|
|
|
2026-10-14 20:35:50 -05:00
|
|
|
|
// Compact-mode toolbar height and the modal's flex gap — keep in sync with
|
|
|
|
|
|
// `.preview-modal-bar` / `.preview-modal` in styles.css.
|
|
|
|
|
|
const PREVIEW_BAR_H = 36;
|
|
|
|
|
|
const PREVIEW_GAP = 16;
|
|
|
|
|
|
|
2026-10-14 20:29:32 -05:00
|
|
|
|
/** Segmented control for the compact (one-pane-at-a-time) click preview:
|
|
|
|
|
|
* front face / back face (double-faced cards only) / info tile. */
|
|
|
|
|
|
function PreviewPaneToggle({
|
|
|
|
|
|
hasBack,
|
|
|
|
|
|
showBack,
|
|
|
|
|
|
pane,
|
|
|
|
|
|
onSelect,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
hasBack: boolean;
|
|
|
|
|
|
showBack: boolean;
|
|
|
|
|
|
pane: PreviewPane;
|
|
|
|
|
|
onSelect: (pane: PreviewPane, back: boolean) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const btn = (label: string, active: boolean, onClick: () => void) => (
|
|
|
|
|
|
<button className={active ? "active" : ""} onClick={onClick}>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span className="view-toggle">
|
|
|
|
|
|
{btn(hasBack ? "Front" : "Card", pane === "card" && !showBack, () => onSelect("card", false))}
|
|
|
|
|
|
{hasBack && btn("Back", pane === "card" && showBack, () => onSelect("card", true))}
|
|
|
|
|
|
{btn("Info", pane === "info", () => onSelect("info", showBack))}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
function ClickPreviewModal({
|
|
|
|
|
|
state,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
onFlip,
|
|
|
|
|
|
}: {
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
state: ClickedState;
|
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
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
onFlip: () => void;
|
|
|
|
|
|
}) {
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
const { ctx, showBack } = state;
|
|
|
|
|
|
// A printing just chosen for this deck slot: show its art right away
|
|
|
|
|
|
// rather than the art the preview was opened with.
|
|
|
|
|
|
const [art, setArt] = useState<CardArt | null>(null);
|
|
|
|
|
|
const card: PreviewCard = art ? { ...state.card, ...art } : state.card;
|
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 hasBack = !!card.back_image_uri;
|
2026-10-14 20:29:32 -05:00
|
|
|
|
const backdropRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
const modalRef = useRef<HTMLDivElement>(null);
|
2026-10-14 20:35:50 -05:00
|
|
|
|
const infoRef = useRef<HTMLDivElement>(null);
|
2026-10-14 20:29:32 -05:00
|
|
|
|
// Compact mode: when the full layout (art + info tile) would be taller than
|
|
|
|
|
|
// the viewport — i.e. on phones, where the tile wraps below the art — show
|
2026-10-14 20:35:50 -05:00
|
|
|
|
// one pane at a time above a Front/Back/Info toggle instead of making the
|
2026-10-14 20:29:32 -05:00
|
|
|
|
// user scroll. Decided by measuring the full layout itself: every resize
|
|
|
|
|
|
// drops back to it, and the layout effect below re-measures and switches
|
|
|
|
|
|
// before paint, so there's no flash and no feedback loop (compact mode is
|
|
|
|
|
|
// never what gets measured). The art's size doesn't depend on the image
|
|
|
|
|
|
// having loaded (see `.preview-modal-img`), so measuring on mount is exact.
|
2026-10-14 20:35:50 -05:00
|
|
|
|
// `fit` holds the measurements compact mode sizes itself from; null = full.
|
|
|
|
|
|
const [fit, setFit] = useState<{ avail: number; infoH: number } | null>(null);
|
2026-10-14 20:29:32 -05:00
|
|
|
|
const [pane, setPane] = useState<PreviewPane>("card");
|
|
|
|
|
|
const [measureKey, setMeasureKey] = useState(0);
|
2026-10-14 20:35:50 -05:00
|
|
|
|
const artBudget = fit && fit.avail - PREVIEW_BAR_H - PREVIEW_GAP;
|
|
|
|
|
|
const rotatedBox = useRotatedPreviewBox(hasBack, artBudget);
|
2026-10-14 20:29:32 -05:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const onResize = () => {
|
2026-10-14 20:35:50 -05:00
|
|
|
|
setFit(null);
|
2026-10-14 20:29:32 -05:00
|
|
|
|
setMeasureKey((k) => k + 1);
|
|
|
|
|
|
};
|
|
|
|
|
|
window.addEventListener("resize", onResize);
|
|
|
|
|
|
return () => window.removeEventListener("resize", onResize);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
useLayoutEffect(() => {
|
|
|
|
|
|
const backdrop = backdropRef.current;
|
|
|
|
|
|
const modal = modalRef.current;
|
2026-10-14 20:35:50 -05:00
|
|
|
|
if (fit || !backdrop || !modal) return;
|
2026-10-14 20:29:32 -05:00
|
|
|
|
const cs = getComputedStyle(backdrop);
|
|
|
|
|
|
const avail = backdrop.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
|
2026-10-14 20:35:50 -05:00
|
|
|
|
if (modal.getBoundingClientRect().height > avail) {
|
|
|
|
|
|
setFit({ avail, infoH: infoRef.current?.getBoundingClientRect().height ?? 0 });
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [fit, measureKey, card, showBack]);
|
|
|
|
|
|
|
|
|
|
|
|
// In compact mode the modal gets a fixed box — as wide and tall as the
|
|
|
|
|
|
// largest pane (capped to the screen), toolbar pinned to its bottom — so
|
|
|
|
|
|
// switching panes never moves the toolbar. Mirrors the CSS sizing of the
|
|
|
|
|
|
// art (`.preview-modal-img`) and info tile (`.preview-info`).
|
|
|
|
|
|
let compactStyle: React.CSSProperties | undefined;
|
|
|
|
|
|
if (fit && artBudget !== null) {
|
|
|
|
|
|
const vw = window.innerWidth;
|
|
|
|
|
|
const portraitW = Math.min(488, vw * 0.9, (artBudget * 488) / 680);
|
|
|
|
|
|
const faces = [
|
|
|
|
|
|
card.horizontal ? rotatedBox : { w: portraitW, h: (portraitW * 680) / 488 },
|
|
|
|
|
|
...(hasBack ? [{ w: portraitW, h: (portraitW * 680) / 488 }] : []),
|
|
|
|
|
|
];
|
|
|
|
|
|
const paneW = Math.max(Math.min(320, vw * 0.9), ...faces.map((f) => f.w));
|
|
|
|
|
|
const paneH = Math.min(artBudget, Math.max(fit.infoH, ...faces.map((f) => f.h)));
|
|
|
|
|
|
compactStyle = {
|
|
|
|
|
|
width: paneW,
|
|
|
|
|
|
height: paneH + PREVIEW_GAP + PREVIEW_BAR_H,
|
|
|
|
|
|
["--preview-img-h" as string]: `${artBudget}px`,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
const compact = !!fit;
|
2026-10-14 20:29:32 -05:00
|
|
|
|
|
|
|
|
|
|
const selectPane = (next: PreviewPane, back: boolean) => {
|
|
|
|
|
|
setPane(next);
|
|
|
|
|
|
if (back !== showBack) onFlip();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
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
|
|
|
|
// Both faces are always in the DOM when the card has one; a wide-viewport
|
|
|
|
|
|
// media query shows them side by side (no flip needed), while a narrow one
|
|
|
|
|
|
// shows only the "active" face and the flip button toggles between them.
|
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
|
|
|
|
return (
|
2026-10-14 20:29:32 -05:00
|
|
|
|
<div className="preview-modal-backdrop" ref={backdropRef} onClick={onClose}>
|
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
|
|
|
|
<div
|
2026-10-14 20:29:32 -05:00
|
|
|
|
ref={modalRef}
|
2026-10-14 20:35:50 -05:00
|
|
|
|
style={compactStyle}
|
2026-10-14 20:29:32 -05:00
|
|
|
|
className={[
|
|
|
|
|
|
"preview-modal",
|
|
|
|
|
|
hasBack ? "preview-modal-dfc" : "",
|
|
|
|
|
|
compact ? "preview-modal-compact" : "",
|
|
|
|
|
|
compact && pane === "info" ? "preview-modal-show-info" : "",
|
|
|
|
|
|
]
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join(" ")}
|
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
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="preview-modal-faces">
|
2026-07-22 20:55:17 -05:00
|
|
|
|
{/* Only the front face can be horizontal (Battles) — a transformed
|
|
|
|
|
|
Battle's back face is always a normal, correctly-oriented
|
|
|
|
|
|
creature card, per `CardRef.horizontal`'s doc comment. */}
|
|
|
|
|
|
{card.horizontal ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`preview-modal-img preview-modal-img-rotated ${showBack ? "" : "active"}`}
|
|
|
|
|
|
style={{ width: rotatedBox.w, height: rotatedBox.h }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<img
|
|
|
|
|
|
className="preview-modal-rotate-img"
|
|
|
|
|
|
src={card.image_uri}
|
|
|
|
|
|
alt={card.name}
|
|
|
|
|
|
style={{ width: rotatedBox.h, height: rotatedBox.w }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<img
|
|
|
|
|
|
className={`preview-modal-img ${showBack ? "" : "active"}`}
|
|
|
|
|
|
src={card.image_uri}
|
|
|
|
|
|
alt={card.name}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
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
|
|
|
|
{hasBack && (
|
|
|
|
|
|
<img
|
|
|
|
|
|
className={`preview-modal-img ${showBack ? "active" : ""}`}
|
|
|
|
|
|
src={card.back_image_uri}
|
|
|
|
|
|
alt={`${card.name} (back)`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-07-22 23:11:26 -05:00
|
|
|
|
<span className="preview-modal-close">
|
|
|
|
|
|
<IconButton icon={Icons.close} label="Close (Esc)" onClick={onClose} overlay />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{hasBack && (
|
|
|
|
|
|
<button className="preview-modal-flip" onClick={onFlip} title="Show the other side">
|
|
|
|
|
|
⟲ Flip
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
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
|
|
|
|
</div>
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
<CardInfoTile ref={infoRef} card={card} ctx={ctx} onArtChange={setArt} />
|
2026-10-14 20:35:50 -05:00
|
|
|
|
{compact && (
|
|
|
|
|
|
<div className="preview-modal-bar">
|
|
|
|
|
|
<PreviewPaneToggle hasBack={hasBack} showBack={showBack} pane={pane} onSelect={selectPane} />
|
|
|
|
|
|
<IconButton icon={Icons.close} label="Close (Esc)" onClick={onClose} overlay />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
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
|
|
|
|
</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
|
|
|
|
// --- Grouping -------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
export type GroupMode = "none" | "type" | "subtype" | "cmc" | "colors";
|
|
|
|
|
|
|
|
|
|
|
|
export const GROUP_OPTIONS: { value: GroupMode; label: string }[] = [
|
|
|
|
|
|
{ value: "none", label: "No grouping" },
|
|
|
|
|
|
{ value: "type", label: "Card type" },
|
|
|
|
|
|
{ value: "subtype", label: "Subtype" },
|
|
|
|
|
|
{ value: "cmc", label: "Mana value" },
|
|
|
|
|
|
{ value: "colors", label: "Color identity" },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// Priority for choosing a card's primary type from its (multi-type) type line.
|
|
|
|
|
|
const TYPE_PRIORITY = [
|
|
|
|
|
|
"Creature",
|
|
|
|
|
|
"Planeswalker",
|
|
|
|
|
|
"Battle",
|
|
|
|
|
|
"Land",
|
|
|
|
|
|
"Artifact",
|
|
|
|
|
|
"Enchantment",
|
|
|
|
|
|
"Instant",
|
|
|
|
|
|
"Sorcery",
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
function primaryType(typeLine: string): string {
|
|
|
|
|
|
const left = typeLine.split("—")[0];
|
|
|
|
|
|
for (const t of TYPE_PRIORITY) if (left.includes(t)) return t;
|
|
|
|
|
|
const words = left.trim().split(/\s+/);
|
|
|
|
|
|
return words[words.length - 1] || "Other";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 20:55:17 -05:00
|
|
|
|
/** Where a primary type falls in [`TYPE_PRIORITY`] — used both to order
|
|
|
|
|
|
* type groups and to sort cards by type. */
|
|
|
|
|
|
function typeRank(primary: string): number {
|
|
|
|
|
|
const i = TYPE_PRIORITY.indexOf(primary);
|
|
|
|
|
|
return i === -1 ? TYPE_PRIORITY.length : i;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 00:21:22 -05:00
|
|
|
|
/** e.g. "Creature — Chimera" — prefixed with the primary type so cards group
|
|
|
|
|
|
* by (type, subtype) rather than colliding on a subtype shared across types. */
|
|
|
|
|
|
function subtypeLabel(typeLine: string): string {
|
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 parts = typeLine.split("—");
|
2026-07-20 00:21:22 -05:00
|
|
|
|
const subtype = parts.length < 2 ? "No subtype" : parts[1].trim().split(/\s+/)[0] || "No subtype";
|
|
|
|
|
|
return `${primaryType(typeLine)} — ${subtype}`;
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function colorLabel(mask: number): string {
|
|
|
|
|
|
const letters = maskToLetters(mask);
|
|
|
|
|
|
return letters === "" ? "Colorless" : letters;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Group cards by the chosen attribute, returning ordered groups. */
|
|
|
|
|
|
export function groupCards<T extends CardRef>(
|
|
|
|
|
|
cards: T[],
|
|
|
|
|
|
mode: GroupMode,
|
|
|
|
|
|
): { label: string; cards: T[] }[] {
|
|
|
|
|
|
if (mode === "none") return [{ label: "", cards }];
|
|
|
|
|
|
|
|
|
|
|
|
const keyOf = (c: T) =>
|
|
|
|
|
|
mode === "type"
|
|
|
|
|
|
? primaryType(c.type_line)
|
|
|
|
|
|
: mode === "subtype"
|
2026-07-20 00:21:22 -05:00
|
|
|
|
? subtypeLabel(c.type_line)
|
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
|
|
|
|
: mode === "cmc"
|
|
|
|
|
|
? `Mana value ${c.mana_value}`
|
|
|
|
|
|
: colorLabel(c.color_identity);
|
|
|
|
|
|
|
|
|
|
|
|
const map = new Map<string, T[]>();
|
|
|
|
|
|
for (const c of cards) {
|
|
|
|
|
|
const k = keyOf(c);
|
|
|
|
|
|
const arr = map.get(k);
|
|
|
|
|
|
if (arr) arr.push(c);
|
|
|
|
|
|
else map.set(k, [c]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const groups = [...map.entries()].map(([label, cs]) => ({ label, cards: cs }));
|
|
|
|
|
|
if (mode === "type") {
|
2026-07-22 20:55:17 -05:00
|
|
|
|
groups.sort((a, b) => typeRank(a.label) - typeRank(b.label) || a.label.localeCompare(b.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
|
|
|
|
} else if (mode === "cmc") {
|
|
|
|
|
|
groups.sort((a, b) => a.cards[0].mana_value - b.cards[0].mana_value);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
groups.sort((a, b) => a.label.localeCompare(b.label));
|
|
|
|
|
|
}
|
|
|
|
|
|
return groups;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Dropdown for choosing a [`GroupMode`]. */
|
|
|
|
|
|
export function GroupBySelect({
|
|
|
|
|
|
value,
|
|
|
|
|
|
onChange,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
value: GroupMode;
|
|
|
|
|
|
onChange: (m: GroupMode) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<label className="group-by">
|
|
|
|
|
|
<span className="muted">Group by</span>
|
|
|
|
|
|
<select value={value} onChange={(e) => onChange(e.target.value as GroupMode)}>
|
|
|
|
|
|
{GROUP_OPTIONS.map((o) => (
|
|
|
|
|
|
<option key={o.value} value={o.value}>
|
|
|
|
|
|
{o.label}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 20:55:17 -05:00
|
|
|
|
// --- Sorting ----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
export type SortMode = "name" | "mv" | "color" | "type" | "qty" | "price";
|
|
|
|
|
|
|
|
|
|
|
|
export const SORT_OPTIONS: { value: SortMode; label: string }[] = [
|
|
|
|
|
|
{ value: "name", label: "Name" },
|
|
|
|
|
|
{ value: "mv", label: "Mana value" },
|
|
|
|
|
|
{ value: "color", label: "Color identity" },
|
|
|
|
|
|
{ value: "type", label: "Card type" },
|
|
|
|
|
|
{ value: "qty", label: "Quantity owned" },
|
|
|
|
|
|
{ value: "price", label: "Price" },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
export type SortDir = "asc" | "desc";
|
|
|
|
|
|
|
|
|
|
|
|
/** Sort cards by the chosen attribute; ties always break by name so the
|
|
|
|
|
|
* order stays stable and predictable regardless of `mode`. Grouping (see
|
|
|
|
|
|
* [`groupCards`]) determines section order independently — sort this array
|
|
|
|
|
|
* first, then group it, and each group keeps this order internally. */
|
|
|
|
|
|
export function sortCards<T extends CardRef & { quantity_owned: number }>(
|
|
|
|
|
|
cards: T[],
|
|
|
|
|
|
mode: SortMode,
|
|
|
|
|
|
dir: SortDir,
|
|
|
|
|
|
): T[] {
|
|
|
|
|
|
const sorted = [...cards].sort((a, b) => {
|
|
|
|
|
|
const cmp =
|
|
|
|
|
|
mode === "name"
|
|
|
|
|
|
? a.name.localeCompare(b.name)
|
|
|
|
|
|
: mode === "mv"
|
|
|
|
|
|
? a.mana_value - b.mana_value || a.name.localeCompare(b.name)
|
|
|
|
|
|
: mode === "color"
|
|
|
|
|
|
? colorLabel(a.color_identity).localeCompare(colorLabel(b.color_identity)) ||
|
|
|
|
|
|
a.name.localeCompare(b.name)
|
|
|
|
|
|
: mode === "type"
|
|
|
|
|
|
? typeRank(primaryType(a.type_line)) - typeRank(primaryType(b.type_line)) ||
|
|
|
|
|
|
a.name.localeCompare(b.name)
|
|
|
|
|
|
: mode === "qty"
|
|
|
|
|
|
? a.quantity_owned - b.quantity_owned || a.name.localeCompare(b.name)
|
|
|
|
|
|
: a.price_usd - b.price_usd || a.name.localeCompare(b.name);
|
|
|
|
|
|
return dir === "asc" ? cmp : -cmp;
|
|
|
|
|
|
});
|
|
|
|
|
|
return sorted;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Dropdown + ascending/descending toggle for choosing a [`SortMode`]. */
|
|
|
|
|
|
export function SortBySelect({
|
|
|
|
|
|
value,
|
|
|
|
|
|
dir,
|
|
|
|
|
|
onChangeValue,
|
|
|
|
|
|
onToggleDir,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
value: SortMode;
|
|
|
|
|
|
dir: SortDir;
|
|
|
|
|
|
onChangeValue: (m: SortMode) => void;
|
|
|
|
|
|
onToggleDir: () => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span className="row" style={{ gap: 4 }}>
|
|
|
|
|
|
<label className="group-by">
|
|
|
|
|
|
<span className="muted">Sort by</span>
|
|
|
|
|
|
<select value={value} onChange={(e) => onChangeValue(e.target.value as SortMode)}>
|
|
|
|
|
|
{SORT_OPTIONS.map((o) => (
|
|
|
|
|
|
<option key={o.value} value={o.value}>
|
|
|
|
|
|
{o.label}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="sort-dir-btn"
|
|
|
|
|
|
onClick={onToggleDir}
|
|
|
|
|
|
title={dir === "asc" ? "Ascending — click for descending" : "Descending — click for ascending"}
|
|
|
|
|
|
>
|
|
|
|
|
|
{dir === "asc" ? "↑" : "↓"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 23:54:47 -05:00
|
|
|
|
// --- Stats widgets (sidebar "Your library" panel, per-deck analysis) ------
|
|
|
|
|
|
|
|
|
|
|
|
/** A single numeric stat in a small panel tile, e.g. "42 / unique". */
|
|
|
|
|
|
export function StatTile({ value, label }: { value: string | number; label: string }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="stat-tile">
|
|
|
|
|
|
<span className="stat-tile-value">{value}</span>
|
|
|
|
|
|
<span className="stat-tile-label">{label}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** A segmented WUBRG(+colorless) bar sized proportionally to `colorCounts`. */
|
|
|
|
|
|
export function ColorDistributionBar({
|
|
|
|
|
|
colorCounts,
|
|
|
|
|
|
colorlessCount,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
colorCounts: [number, number, number, number, number]; // W, U, B, R, G
|
|
|
|
|
|
colorlessCount: number;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const segs: [string, number][] = [
|
|
|
|
|
|
["W", colorCounts[0]],
|
|
|
|
|
|
["U", colorCounts[1]],
|
|
|
|
|
|
["B", colorCounts[2]],
|
|
|
|
|
|
["R", colorCounts[3]],
|
|
|
|
|
|
["G", colorCounts[4]],
|
|
|
|
|
|
["C", colorlessCount],
|
|
|
|
|
|
];
|
|
|
|
|
|
if (segs.every(([, count]) => count === 0)) return null;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="color-bar">
|
|
|
|
|
|
{segs.map(([code, count]) =>
|
|
|
|
|
|
count > 0 ? (
|
|
|
|
|
|
<span
|
|
|
|
|
|
key={code}
|
|
|
|
|
|
className={`color-bar-seg ${code}`}
|
|
|
|
|
|
style={{ flexGrow: count }}
|
|
|
|
|
|
title={`${code === "C" ? "Colorless" : code}: ${count.toLocaleString()}`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : null,
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** A mana-value histogram (bar height proportional to the largest bucket),
|
|
|
|
|
|
* each bar segmented by color like {@link ColorDistributionBar}. */
|
|
|
|
|
|
export function ManaCurve({
|
|
|
|
|
|
buckets,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
buckets: {
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
count: number;
|
|
|
|
|
|
colorCounts: [number, number, number, number, number]; // W, U, B, R, G
|
|
|
|
|
|
colorlessCount: number;
|
|
|
|
|
|
}[];
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const max = Math.max(1, ...buckets.map((b) => b.count));
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="mana-curve">
|
|
|
|
|
|
{buckets.map((b) => {
|
|
|
|
|
|
const segs: [string, number][] = [
|
|
|
|
|
|
["W", b.colorCounts[0]],
|
|
|
|
|
|
["U", b.colorCounts[1]],
|
|
|
|
|
|
["B", b.colorCounts[2]],
|
|
|
|
|
|
["R", b.colorCounts[3]],
|
|
|
|
|
|
["G", b.colorCounts[4]],
|
|
|
|
|
|
["C", b.colorlessCount],
|
|
|
|
|
|
];
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={b.label} className="mana-curve-col">
|
|
|
|
|
|
<span className="mana-curve-count">{b.count || ""}</span>
|
|
|
|
|
|
<div className="mana-curve-bar" style={{ height: `${(b.count / max) * 100}%` }}>
|
|
|
|
|
|
{segs.map(([code, count]) =>
|
|
|
|
|
|
count > 0 ? (
|
|
|
|
|
|
<span
|
|
|
|
|
|
key={code}
|
|
|
|
|
|
className={`mana-curve-seg ${code}`}
|
|
|
|
|
|
style={{ flexGrow: count }}
|
|
|
|
|
|
title={`${code === "C" ? "Colorless" : code}: ${count.toLocaleString()}`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : null,
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span className="mana-curve-label">{b.label}</span>
|
|
|
|
|
|
</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
|
|
|
|
// --- Persistence ----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/** useState that mirrors its value to localStorage (JSON), so it survives
|
|
|
|
|
|
* remounts and app restarts. */
|
|
|
|
|
|
export function usePersistedState<T>(key: string, initial: T): [T, (v: T) => void] {
|
|
|
|
|
|
const [val, setVal] = useState<T>(() => {
|
|
|
|
|
|
const raw = localStorage.getItem(key);
|
|
|
|
|
|
if (raw === null) return initial;
|
|
|
|
|
|
try {
|
|
|
|
|
|
return JSON.parse(raw) as T;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return initial;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
const set = useCallback(
|
|
|
|
|
|
(v: T) => {
|
|
|
|
|
|
localStorage.setItem(key, JSON.stringify(v));
|
|
|
|
|
|
setVal(v);
|
|
|
|
|
|
},
|
|
|
|
|
|
[key],
|
|
|
|
|
|
);
|
|
|
|
|
|
return [val, set];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- View mode (list vs. image tiles) ------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
export type ViewMode = "list" | "tiles";
|
|
|
|
|
|
|
2026-08-02 16:10:25 -05:00
|
|
|
|
/** View-mode state persisted to localStorage so it survives tab switches.
|
|
|
|
|
|
*
|
|
|
|
|
|
* With nothing persisted yet, the default itself is width-aware: List's wide
|
|
|
|
|
|
* multi-column table forces horizontal scrolling on a phone, so narrow
|
|
|
|
|
|
* screens start on Tiles (a reflowing grid, no horizontal scroll) instead.
|
|
|
|
|
|
* This only picks the *default* — once a mode is explicitly chosen it's
|
|
|
|
|
|
* remembered and used everywhere, same as before. */
|
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 function useViewMode(key: string): [ViewMode, (m: ViewMode) => void] {
|
2026-08-02 16:10:25 -05:00
|
|
|
|
const [mode, setMode] = useState<ViewMode>(() => {
|
|
|
|
|
|
const saved = localStorage.getItem(key) as ViewMode | null;
|
|
|
|
|
|
if (saved) return saved;
|
|
|
|
|
|
return typeof window !== "undefined" && window.innerWidth <= 720 ? "tiles" : "list";
|
|
|
|
|
|
});
|
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 set = useCallback(
|
|
|
|
|
|
(m: ViewMode) => {
|
|
|
|
|
|
localStorage.setItem(key, m);
|
|
|
|
|
|
setMode(m);
|
|
|
|
|
|
},
|
|
|
|
|
|
[key],
|
|
|
|
|
|
);
|
|
|
|
|
|
return [mode, set];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Segmented control switching between the list and image-tile layouts. */
|
|
|
|
|
|
export function ViewToggle({
|
|
|
|
|
|
mode,
|
|
|
|
|
|
onChange,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
mode: ViewMode;
|
|
|
|
|
|
onChange: (m: ViewMode) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span className="view-toggle">
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={mode === "list" ? "active" : ""}
|
|
|
|
|
|
onClick={() => onChange("list")}
|
|
|
|
|
|
title="List view"
|
|
|
|
|
|
>
|
|
|
|
|
|
☰ List
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={mode === "tiles" ? "active" : ""}
|
|
|
|
|
|
onClick={() => onChange("tiles")}
|
|
|
|
|
|
title="Image tiles"
|
|
|
|
|
|
>
|
|
|
|
|
|
▦ Tiles
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-16 21:14:43 -05:00
|
|
|
|
// --- Tiles per row --------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
// Mirror `.tile-grid`'s `repeat(auto-fill, minmax(190px, 1fr))` and `gap` in
|
|
|
|
|
|
// styles.css — used to work out how many columns that default fills.
|
|
|
|
|
|
const TILE_MIN_WIDTH = 190;
|
|
|
|
|
|
const TILE_GAP = 16;
|
|
|
|
|
|
const TILE_MAX_COLUMNS = 12;
|
|
|
|
|
|
|
|
|
|
|
|
export interface TileColumns {
|
|
|
|
|
|
/** Columns currently shown (the width-based default plus the adjustment). */
|
|
|
|
|
|
columns: number;
|
|
|
|
|
|
/** True while the user has moved away from the width-based default. */
|
|
|
|
|
|
adjusted: boolean;
|
|
|
|
|
|
adjust: (delta: number) => void;
|
|
|
|
|
|
reset: () => void;
|
|
|
|
|
|
/** Spread onto each `.tile-grid` (`<div className="tile-grid" {...gridProps}>`). */
|
|
|
|
|
|
gridProps: { ref: (el: HTMLDivElement | null) => void; className: string; style?: React.CSSProperties };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Per-row tile count for the Tiles view, adjustable with `TileColumnsControl`.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The default stays the CSS auto-fill (as many ~190px tiles as fit), so the
|
|
|
|
|
|
* persisted setting is an *offset* from that rather than a fixed count: +1
|
|
|
|
|
|
* means "one more than would fit by default" on a phone and a desktop alike,
|
|
|
|
|
|
* and it keeps tracking the width across resizes and rotation. One offset is
|
|
|
|
|
|
* shared by every tiles view. */
|
|
|
|
|
|
export function useTileColumns(): TileColumns {
|
|
|
|
|
|
const [offset, setOffset] = usePersistedState<number>("tile-columns-offset", 0);
|
|
|
|
|
|
const [auto, setAuto] = useState(0);
|
|
|
|
|
|
const observer = useRef<ResizeObserver | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
// Every grid in a view (one per group) shares the same width, so whichever
|
|
|
|
|
|
// mounted last is the one measured.
|
|
|
|
|
|
const ref = useCallback((el: HTMLDivElement | null) => {
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
observer.current?.disconnect();
|
|
|
|
|
|
// A grid that's been unmounted (e.g. switching to List) reports width 0;
|
|
|
|
|
|
// keep the last real measurement instead.
|
|
|
|
|
|
const measure = (w: number) => {
|
|
|
|
|
|
if (w > 0) setAuto(Math.max(1, Math.floor((w + TILE_GAP) / (TILE_MIN_WIDTH + TILE_GAP))));
|
|
|
|
|
|
};
|
|
|
|
|
|
measure(el.clientWidth);
|
|
|
|
|
|
observer.current = new ResizeObserver((entries) => measure(entries[0].contentRect.width));
|
|
|
|
|
|
observer.current.observe(el);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
useEffect(() => () => observer.current?.disconnect(), []);
|
|
|
|
|
|
|
|
|
|
|
|
const base = auto || 1;
|
|
|
|
|
|
const columns = Math.min(TILE_MAX_COLUMNS, Math.max(1, base + offset));
|
|
|
|
|
|
const adjusted = offset !== 0 && auto > 0;
|
|
|
|
|
|
return {
|
|
|
|
|
|
columns,
|
|
|
|
|
|
adjusted,
|
|
|
|
|
|
// Clamp against the visible count, so pressing − at one column doesn't
|
|
|
|
|
|
// bank an offset that has to be undone before + does anything.
|
|
|
|
|
|
adjust: (delta) => {
|
|
|
|
|
|
const next = Math.min(TILE_MAX_COLUMNS, Math.max(1, columns + delta));
|
|
|
|
|
|
setOffset(next - base);
|
|
|
|
|
|
},
|
|
|
|
|
|
reset: () => setOffset(0),
|
|
|
|
|
|
gridProps: adjusted
|
|
|
|
|
|
? {
|
|
|
|
|
|
ref,
|
|
|
|
|
|
className: columns === 1 ? "tile-grid tile-grid-single" : "tile-grid",
|
|
|
|
|
|
style: { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` },
|
|
|
|
|
|
}
|
|
|
|
|
|
: { ref, className: "tile-grid" },
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** − N + stepper for the tiles-per-row count; clicking N resets to the default. */
|
|
|
|
|
|
export function TileColumnsControl({ tiles, disabled }: { tiles: TileColumns; disabled?: boolean }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span className="view-toggle tile-columns" style={disabled ? { opacity: 0.5 } : undefined}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => tiles.adjust(-1)}
|
|
|
|
|
|
disabled={disabled || tiles.columns <= 1}
|
|
|
|
|
|
title="Fewer cards per row (bigger tiles)"
|
|
|
|
|
|
>
|
|
|
|
|
|
−
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="tile-columns-count"
|
|
|
|
|
|
onClick={tiles.reset}
|
|
|
|
|
|
disabled={disabled || !tiles.adjusted}
|
|
|
|
|
|
title={tiles.adjusted ? "Cards per row — click to reset to the default" : "Cards per row"}
|
|
|
|
|
|
>
|
|
|
|
|
|
{tiles.columns}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => tiles.adjust(1)}
|
|
|
|
|
|
disabled={disabled || tiles.columns >= TILE_MAX_COLUMNS}
|
|
|
|
|
|
title="More cards per row (smaller tiles)"
|
|
|
|
|
|
>
|
|
|
|
|
|
+
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/**
|
|
|
|
|
|
* A single card rendered as a full image tile, with optional corner + footer.
|
|
|
|
|
|
* Clicking the image opens a larger click-to-preview modal.
|
|
|
|
|
|
*/
|
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 function CardTile({
|
|
|
|
|
|
card,
|
|
|
|
|
|
corner,
|
2026-07-08 08:20:09 -05:00
|
|
|
|
cornerLeft,
|
2026-07-20 00:21:22 -05:00
|
|
|
|
cornerBottomLeft,
|
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
|
|
|
|
footer,
|
2026-07-08 08:20:09 -05:00
|
|
|
|
selected,
|
2026-08-22 19:14:27 -05:00
|
|
|
|
rotated,
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
preview,
|
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
|
|
|
|
}: {
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
card: PreviewCard;
|
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
|
|
|
|
corner?: ReactNode;
|
2026-07-08 08:20:09 -05:00
|
|
|
|
/** Rendered top-left, e.g. a selection checkbox (see `selected`). */
|
|
|
|
|
|
cornerLeft?: ReactNode;
|
2026-07-20 00:21:22 -05:00
|
|
|
|
/** Rendered bottom-left, e.g. a secondary per-card action button. */
|
|
|
|
|
|
cornerBottomLeft?: ReactNode;
|
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
|
|
|
|
footer?: ReactNode;
|
2026-07-08 08:20:09 -05:00
|
|
|
|
/** Highlights the tile and keeps `cornerLeft` visible without hovering. */
|
|
|
|
|
|
selected?: boolean;
|
2026-08-22 19:14:27 -05:00
|
|
|
|
/** Caller wants Horizontal view — only actually rotates when `card.horizontal`
|
|
|
|
|
|
* is also true, so mixed results (e.g. a set with both Sieges and normal
|
|
|
|
|
|
* cards) don't spin the ones that are already right-side up. */
|
|
|
|
|
|
rotated?: boolean;
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
/** Lets the click preview edit this card's printings. */
|
|
|
|
|
|
preview?: PreviewContext;
|
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 { open } = useClickPreview();
|
2026-08-22 19:14:27 -05:00
|
|
|
|
const showRotated = !!rotated && card.horizontal;
|
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 (
|
2026-08-22 19:14:27 -05:00
|
|
|
|
<div className={`tile ${selected ? "tile-selected" : ""} ${showRotated ? "tile-rotated" : ""}`}>
|
|
|
|
|
|
<div className={showRotated ? "tile-img-wrap-rotated" : "tile-img-wrap"}>
|
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.image_uri ? (
|
2026-08-22 19:14:27 -05:00
|
|
|
|
showRotated ? (
|
|
|
|
|
|
<img
|
|
|
|
|
|
className="tile-img-rotated"
|
|
|
|
|
|
src={card.image_uri}
|
|
|
|
|
|
alt={card.name}
|
|
|
|
|
|
loading="lazy"
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
onClick={() => open(card, preview)}
|
2026-08-22 19:14:27 -05:00
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<img
|
|
|
|
|
|
className="tile-img"
|
|
|
|
|
|
src={card.image_uri}
|
|
|
|
|
|
alt={card.name}
|
|
|
|
|
|
loading="lazy"
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
onClick={() => open(card, preview)}
|
2026-08-22 19:14:27 -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
|
|
|
|
) : (
|
|
|
|
|
|
<div className="tile-noimg">{card.name}</div>
|
|
|
|
|
|
)}
|
2026-07-08 08:20:09 -05:00
|
|
|
|
{cornerLeft && <div className="tile-corner-left">{cornerLeft}</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
|
|
|
|
{corner && <div className="tile-corner">{corner}</div>}
|
2026-07-20 00:21:22 -05:00
|
|
|
|
{cornerBottomLeft && <div className="tile-corner-bottom-left">{cornerBottomLeft}</div>}
|
2026-07-14 21:32:25 -05:00
|
|
|
|
{card.price_usd > 0 && (
|
|
|
|
|
|
<div className="tile-corner-bottom-right">
|
|
|
|
|
|
<span className="tile-badge">${card.price_usd.toFixed(2)}</span>
|
|
|
|
|
|
</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>
|
|
|
|
|
|
{footer && <div className="tile-footer">{footer}</div>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Clickable WUBRG toggles that edit a bitmask value. */
|
|
|
|
|
|
export function ColorToggles({
|
|
|
|
|
|
value,
|
|
|
|
|
|
onChange,
|
Add colorless filters, and make set listings filterable
Colorless
There was no way to ask for colorless cards — the colour toggles are a 5-bit
WUBRG mask where 0 already means "don't filter by colour", leaving nothing to
express "no colours at all". That's exactly what you need to find a colorless
commander's card pool.
Adds a "C" pip to both colour rows, matching how ColorPips already renders
colorless cards, backed by two explicit booleans rather than a sixth bit — a
filter mask and a card's real colour mask staying the same shape is worth
keeping. The two rows mean different things and both are useful: on the
commander row C filters colour *identity*, which is the Commander rule; on the
includes row it filters the card's own colours. A card printed colorless can
still carry a coloured identity through mana symbols in its text, and it's the
identity that bars it from a colorless commander's deck.
Set listings
News > Releases had its own card path and so couldn't be filtered. A new
set_code filter (matched against printings.set_code, already ingested) lets the
set browser run through search_reference instead, so every criterion works
there identically rather than being reimplemented over a live Scryfall
response.
That alone would have cost the ability to browse an upcoming set, since
search_reference only knows downloaded card data — and upcoming sets are half
the point of the tab. So the browser picks a source per set: downloaded data
where it exists, with the full filter panel; otherwise Scryfall directly, with
the panel hidden and a line explaining why. A panel that silently does nothing
would be worse than no panel.
The source is decided by an *unfiltered* probe. Keying off whether the current
criteria match anything would flip a properly indexed set to "live" the moment
a filter excluded everything, dropping the panel exactly when it was in use.
2026-08-10 22:34:46 -05:00
|
|
|
|
colorless,
|
|
|
|
|
|
onColorlessChange,
|
|
|
|
|
|
colorlessTitle,
|
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
|
|
|
|
}: {
|
|
|
|
|
|
value: number;
|
|
|
|
|
|
onChange: (mask: number) => void;
|
Add colorless filters, and make set listings filterable
Colorless
There was no way to ask for colorless cards — the colour toggles are a 5-bit
WUBRG mask where 0 already means "don't filter by colour", leaving nothing to
express "no colours at all". That's exactly what you need to find a colorless
commander's card pool.
Adds a "C" pip to both colour rows, matching how ColorPips already renders
colorless cards, backed by two explicit booleans rather than a sixth bit — a
filter mask and a card's real colour mask staying the same shape is worth
keeping. The two rows mean different things and both are useful: on the
commander row C filters colour *identity*, which is the Commander rule; on the
includes row it filters the card's own colours. A card printed colorless can
still carry a coloured identity through mana symbols in its text, and it's the
identity that bars it from a colorless commander's deck.
Set listings
News > Releases had its own card path and so couldn't be filtered. A new
set_code filter (matched against printings.set_code, already ingested) lets the
set browser run through search_reference instead, so every criterion works
there identically rather than being reimplemented over a live Scryfall
response.
That alone would have cost the ability to browse an upcoming set, since
search_reference only knows downloaded card data — and upcoming sets are half
the point of the tab. So the browser picks a source per set: downloaded data
where it exists, with the full filter panel; otherwise Scryfall directly, with
the panel hidden and a line explaining why. A panel that silently does nothing
would be worse than no panel.
The source is decided by an *unfiltered* probe. Keying off whether the current
criteria match anything would flip a properly indexed set to "live" the moment
a filter excluded everything, dropping the panel exactly when it was in use.
2026-08-10 22:34:46 -05:00
|
|
|
|
/** Renders a "C" pip alongside the colours when provided.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Colourlessness can't live in the bitmask: 0 there already means "don't
|
|
|
|
|
|
* filter by colour", so there'd be no way to say "no colours at all" — which
|
|
|
|
|
|
* is exactly what you need to find a colourless commander's card pool. */
|
|
|
|
|
|
colorless?: boolean;
|
|
|
|
|
|
onColorlessChange?: (v: boolean) => void;
|
|
|
|
|
|
colorlessTitle?: string;
|
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 (
|
|
|
|
|
|
<span className="color-toggles">
|
|
|
|
|
|
{COLORS.map((c) => (
|
|
|
|
|
|
<span
|
|
|
|
|
|
key={c.code}
|
|
|
|
|
|
title={c.label}
|
|
|
|
|
|
className={`pip ${c.code} ${value & c.bit ? "on" : ""}`}
|
|
|
|
|
|
onClick={() => onChange(value ^ c.bit)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{c.code}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
))}
|
Add colorless filters, and make set listings filterable
Colorless
There was no way to ask for colorless cards — the colour toggles are a 5-bit
WUBRG mask where 0 already means "don't filter by colour", leaving nothing to
express "no colours at all". That's exactly what you need to find a colorless
commander's card pool.
Adds a "C" pip to both colour rows, matching how ColorPips already renders
colorless cards, backed by two explicit booleans rather than a sixth bit — a
filter mask and a card's real colour mask staying the same shape is worth
keeping. The two rows mean different things and both are useful: on the
commander row C filters colour *identity*, which is the Commander rule; on the
includes row it filters the card's own colours. A card printed colorless can
still carry a coloured identity through mana symbols in its text, and it's the
identity that bars it from a colorless commander's deck.
Set listings
News > Releases had its own card path and so couldn't be filtered. A new
set_code filter (matched against printings.set_code, already ingested) lets the
set browser run through search_reference instead, so every criterion works
there identically rather than being reimplemented over a live Scryfall
response.
That alone would have cost the ability to browse an upcoming set, since
search_reference only knows downloaded card data — and upcoming sets are half
the point of the tab. So the browser picks a source per set: downloaded data
where it exists, with the full filter panel; otherwise Scryfall directly, with
the panel hidden and a line explaining why. A panel that silently does nothing
would be worse than no panel.
The source is decided by an *unfiltered* probe. Keying off whether the current
criteria match anything would flip a properly indexed set to "live" the moment
a filter excluded everything, dropping the panel exactly when it was in use.
2026-08-10 22:34:46 -05:00
|
|
|
|
{onColorlessChange && (
|
|
|
|
|
|
<span
|
|
|
|
|
|
title={colorlessTitle ?? "Colorless"}
|
|
|
|
|
|
className={`pip C ${colorless ? "on" : ""}`}
|
|
|
|
|
|
onClick={() => onColorlessChange(!colorless)}
|
|
|
|
|
|
>
|
|
|
|
|
|
C
|
|
|
|
|
|
</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
|
|
|
|
</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
|
|
|
|
|
|
|
|
|
|
// --- Shared card criteria -------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/** Ringed planet — a card from another world/property (Universes Beyond). */
|
|
|
|
|
|
const ICON_CROSSOVER = icon(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<circle cx="12" cy="11" r="6" />
|
|
|
|
|
|
<ellipse cx="12" cy="11" rx="11" ry="4" transform="rotate(-20 12 11)" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
);
|
|
|
|
|
|
/** Monitor — exists only on Arena/MTGO. */
|
|
|
|
|
|
const ICON_SCREEN = icon(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<rect x="3" y="4" width="18" height="12" rx="2" />
|
|
|
|
|
|
<path d="M9 20h6" />
|
|
|
|
|
|
<path d="M12 16v4" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
);
|
|
|
|
|
|
/** Smiley — the joke sets. */
|
|
|
|
|
|
const ICON_SMILEY = icon(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<circle cx="12" cy="12" r="9" />
|
|
|
|
|
|
<path d="M8.5 14.5a4.5 4.5 0 0 0 7 0" />
|
|
|
|
|
|
<line x1="9" y1="9.5" x2="9.01" y2="9.5" />
|
|
|
|
|
|
<line x1="15" y1="9.5" x2="15.01" y2="9.5" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
);
|
|
|
|
|
|
/** Crown — can head a Commander deck. */
|
|
|
|
|
|
const ICON_CROWN = icon(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<path d="M3 8l3.5 3L12 5l5.5 6L21 8l-1.5 10h-15z" />
|
|
|
|
|
|
<line x1="5" y1="21" x2="19" y2="21" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
);
|
2026-08-14 01:02:29 -05:00
|
|
|
|
/** An empty rules box — a vanilla creature, all body and no text. */
|
|
|
|
|
|
const ICON_BLANK_BOX = icon(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<rect x="3" y="4" width="18" height="16" rx="2" />
|
|
|
|
|
|
<line x1="3" y1="9" x2="21" y2="9" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
);
|
Spare/rarity filters, owned-only suggestions, and a Game night tab
Library filters:
- "Only spare copies" quick filter (Library): cards with more copies
owned than the decks' main boards use, the opposite of the ⚠
over-assignment warning. Rows show "N spare" while it's on.
- Rarity toggles (C/U/R/M) in the shared criteria panel, so Library,
Card Search and the set browser all get them. Any printing counts,
since the library tracks cards rather than printings; within a set
it's that set's printing. Arena-only printings don't count for a card
that also exists on paper, so a digital re-rarity can't make a paper
common read as rare.
Suggestions:
- "From my collection" toggle (remembered) narrows candidates to owned
cards before scoring and paging, so "Suggest more" keeps finding them.
- Each suggestion reports copies other decks already use, shown as
"N spare" / "all in other decks". suggest_cards now takes a
SuggestOptions struct instead of a bare `unusual` flag.
Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a
running change bubble, poison and per-opponent commander damage (which
also comes off life), out-of-the-game markers, dice and a coin, and a
random first player. A seat's deck puts its commander's art behind the
life total. On phones the board fills the screen with the far row
turned to face across the table, and asks to keep the screen awake.
The game is per-device scratch state in localStorage, not synced.
Plain filter chips now show an accent background when active; before,
"Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
|
|
|
|
/** A card with another peeking out behind it — a copy no deck is using. */
|
|
|
|
|
|
const ICON_SPARE = icon(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<rect x="8" y="3" width="12" height="16" rx="2" />
|
|
|
|
|
|
<path d="M4 7v12a2 2 0 0 0 2 2h9" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
);
|
2026-08-22 19:14:27 -05:00
|
|
|
|
/** An open package — cards you don't have a copy of yet. */
|
|
|
|
|
|
const ICON_PACKAGE = icon(
|
|
|
|
|
|
<>
|
|
|
|
|
|
<path d="M21 8l-9-5-9 5 9 5 9-5z" />
|
|
|
|
|
|
<path d="M3 8v8l9 5 9-5V8" />
|
|
|
|
|
|
<path d="M12 13v8" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
);
|
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
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Card-origin and numeric criteria shared by the Library and Card Search
|
|
|
|
|
|
* filter panels. Kept here rather than duplicated in both views, since they
|
|
|
|
|
|
* must stay in step: a filter that behaves differently between browsing what
|
|
|
|
|
|
* you own and searching everything would be a bug, not a feature.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Numeric fields are strings so the inputs can be empty (meaning "no bound")
|
|
|
|
|
|
* rather than coercing a blank to 0.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export type CardCriteria = {
|
|
|
|
|
|
excludeUb: boolean;
|
|
|
|
|
|
excludeDigital: boolean;
|
|
|
|
|
|
excludeFunny: boolean;
|
|
|
|
|
|
priceMin: string;
|
|
|
|
|
|
priceMax: string;
|
|
|
|
|
|
legalIn: string;
|
|
|
|
|
|
canBeCommander: boolean;
|
2026-08-14 01:02:29 -05:00
|
|
|
|
vanillaOnly: boolean;
|
2026-08-22 19:14:27 -05:00
|
|
|
|
notOwned: boolean;
|
Spare/rarity filters, owned-only suggestions, and a Game night tab
Library filters:
- "Only spare copies" quick filter (Library): cards with more copies
owned than the decks' main boards use, the opposite of the ⚠
over-assignment warning. Rows show "N spare" while it's on.
- Rarity toggles (C/U/R/M) in the shared criteria panel, so Library,
Card Search and the set browser all get them. Any printing counts,
since the library tracks cards rather than printings; within a set
it's that set's printing. Arena-only printings don't count for a card
that also exists on paper, so a digital re-rarity can't make a paper
common read as rare.
Suggestions:
- "From my collection" toggle (remembered) narrows candidates to owned
cards before scoring and paging, so "Suggest more" keeps finding them.
- Each suggestion reports copies other decks already use, shown as
"N spare" / "all in other decks". suggest_cards now takes a
SuggestOptions struct instead of a bare `unusual` flag.
Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a
running change bubble, poison and per-opponent commander damage (which
also comes off life), out-of-the-game markers, dice and a coin, and a
random first player. A seat's deck puts its commander's art behind the
life total. On phones the board fills the screen with the far row
turned to face across the table, and asks to keep the screen awake.
The game is per-device scratch state in localStorage, not synced.
Plain filter chips now show an accent background when active; before,
"Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
|
|
|
|
sparesOnly: boolean;
|
|
|
|
|
|
/** Scryfall rarity names; empty means any rarity. */
|
|
|
|
|
|
rarities: string[];
|
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
|
|
|
|
powerMin: string;
|
|
|
|
|
|
powerMax: string;
|
|
|
|
|
|
toughMin: string;
|
|
|
|
|
|
toughMax: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export const EMPTY_CRITERIA: CardCriteria = {
|
|
|
|
|
|
excludeUb: false,
|
|
|
|
|
|
excludeDigital: false,
|
|
|
|
|
|
excludeFunny: false,
|
|
|
|
|
|
priceMin: "",
|
|
|
|
|
|
priceMax: "",
|
|
|
|
|
|
legalIn: "",
|
|
|
|
|
|
canBeCommander: false,
|
2026-08-14 01:02:29 -05:00
|
|
|
|
vanillaOnly: false,
|
2026-08-22 19:14:27 -05:00
|
|
|
|
notOwned: false,
|
Spare/rarity filters, owned-only suggestions, and a Game night tab
Library filters:
- "Only spare copies" quick filter (Library): cards with more copies
owned than the decks' main boards use, the opposite of the ⚠
over-assignment warning. Rows show "N spare" while it's on.
- Rarity toggles (C/U/R/M) in the shared criteria panel, so Library,
Card Search and the set browser all get them. Any printing counts,
since the library tracks cards rather than printings; within a set
it's that set's printing. Arena-only printings don't count for a card
that also exists on paper, so a digital re-rarity can't make a paper
common read as rare.
Suggestions:
- "From my collection" toggle (remembered) narrows candidates to owned
cards before scoring and paging, so "Suggest more" keeps finding them.
- Each suggestion reports copies other decks already use, shown as
"N spare" / "all in other decks". suggest_cards now takes a
SuggestOptions struct instead of a bare `unusual` flag.
Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a
running change bubble, poison and per-opponent commander damage (which
also comes off life), out-of-the-game markers, dice and a coin, and a
random first player. A seat's deck puts its commander's art behind the
life total. On phones the board fills the screen with the far row
turned to face across the table, and asks to keep the screen awake.
The game is per-device scratch state in localStorage, not synced.
Plain filter chips now show an accent background when active; before,
"Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
|
|
|
|
sparesOnly: false,
|
|
|
|
|
|
rarities: [],
|
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
|
|
|
|
powerMin: "",
|
|
|
|
|
|
powerMax: "",
|
|
|
|
|
|
toughMin: "",
|
|
|
|
|
|
toughMax: "",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
Spare/rarity filters, owned-only suggestions, and a Game night tab
Library filters:
- "Only spare copies" quick filter (Library): cards with more copies
owned than the decks' main boards use, the opposite of the ⚠
over-assignment warning. Rows show "N spare" while it's on.
- Rarity toggles (C/U/R/M) in the shared criteria panel, so Library,
Card Search and the set browser all get them. Any printing counts,
since the library tracks cards rather than printings; within a set
it's that set's printing. Arena-only printings don't count for a card
that also exists on paper, so a digital re-rarity can't make a paper
common read as rare.
Suggestions:
- "From my collection" toggle (remembered) narrows candidates to owned
cards before scoring and paging, so "Suggest more" keeps finding them.
- Each suggestion reports copies other decks already use, shown as
"N spare" / "all in other decks". suggest_cards now takes a
SuggestOptions struct instead of a bare `unusual` flag.
Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a
running change bubble, poison and per-opponent commander damage (which
also comes off life), out-of-the-game markers, dice and a coin, and a
random first player. A seat's deck puts its commander's art behind the
life total. On phones the board fills the screen with the far row
turned to face across the table, and asks to keep the screen awake.
The game is per-device scratch state in localStorage, not synced.
Plain filter chips now show an accent background when active; before,
"Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
|
|
|
|
const RARITIES = [
|
|
|
|
|
|
{ key: "common", code: "C", label: "Common" },
|
|
|
|
|
|
{ key: "uncommon", code: "U", label: "Uncommon" },
|
|
|
|
|
|
{ key: "rare", code: "R", label: "Rare" },
|
|
|
|
|
|
{ key: "mythic", code: "M", label: "Mythic rare" },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
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
|
|
|
|
export function useCardCriteria(key: string): [CardCriteria, (patch: Partial<CardCriteria>) => void] {
|
|
|
|
|
|
const [value, setValue] = usePersistedState<CardCriteria>(key, EMPTY_CRITERIA);
|
|
|
|
|
|
// Merged over the defaults so a stored object written before a field existed
|
|
|
|
|
|
// doesn't leave it undefined.
|
|
|
|
|
|
const merged = { ...EMPTY_CRITERIA, ...value };
|
|
|
|
|
|
const patch = useCallback(
|
|
|
|
|
|
(p: Partial<CardCriteria>) => setValue({ ...EMPTY_CRITERIA, ...value, ...p }),
|
|
|
|
|
|
[setValue, value],
|
|
|
|
|
|
);
|
|
|
|
|
|
return [merged, patch];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const num = (s: string): number | null => (s.trim() === "" ? null : Number(s));
|
|
|
|
|
|
|
|
|
|
|
|
/** Fold criteria into the filter sent to the backend. */
|
|
|
|
|
|
export function applyCardCriteria(c: CardCriteria): Partial<LibraryFilter> {
|
|
|
|
|
|
return {
|
|
|
|
|
|
exclude_universes_beyond: c.excludeUb,
|
|
|
|
|
|
exclude_digital_only: c.excludeDigital,
|
|
|
|
|
|
exclude_funny: c.excludeFunny,
|
|
|
|
|
|
price_min: num(c.priceMin),
|
|
|
|
|
|
price_max: num(c.priceMax),
|
|
|
|
|
|
legal_in: c.legalIn || null,
|
|
|
|
|
|
can_be_commander: c.canBeCommander,
|
2026-08-14 01:02:29 -05:00
|
|
|
|
vanilla_only: c.vanillaOnly,
|
2026-08-22 19:14:27 -05:00
|
|
|
|
not_owned: c.notOwned,
|
Spare/rarity filters, owned-only suggestions, and a Game night tab
Library filters:
- "Only spare copies" quick filter (Library): cards with more copies
owned than the decks' main boards use, the opposite of the ⚠
over-assignment warning. Rows show "N spare" while it's on.
- Rarity toggles (C/U/R/M) in the shared criteria panel, so Library,
Card Search and the set browser all get them. Any printing counts,
since the library tracks cards rather than printings; within a set
it's that set's printing. Arena-only printings don't count for a card
that also exists on paper, so a digital re-rarity can't make a paper
common read as rare.
Suggestions:
- "From my collection" toggle (remembered) narrows candidates to owned
cards before scoring and paging, so "Suggest more" keeps finding them.
- Each suggestion reports copies other decks already use, shown as
"N spare" / "all in other decks". suggest_cards now takes a
SuggestOptions struct instead of a bare `unusual` flag.
Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a
running change bubble, poison and per-opponent commander damage (which
also comes off life), out-of-the-game markers, dice and a coin, and a
random first player. A seat's deck puts its commander's art behind the
life total. On phones the board fills the screen with the far row
turned to face across the table, and asks to keep the screen awake.
The game is per-device scratch state in localStorage, not synced.
Plain filter chips now show an accent background when active; before,
"Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
|
|
|
|
spares_only: c.sparesOnly,
|
|
|
|
|
|
rarities: c.rarities,
|
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
|
|
|
|
power_min: num(c.powerMin),
|
|
|
|
|
|
power_max: num(c.powerMax),
|
|
|
|
|
|
toughness_min: num(c.toughMin),
|
|
|
|
|
|
toughness_max: num(c.toughMax),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Whether these criteria amount to a search in their own right.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The three "exclude" toggles deliberately don't count: they refine a result
|
|
|
|
|
|
* set rather than select one, so ticking "hide Un-sets" on its own shouldn't
|
2026-08-22 19:14:27 -05:00
|
|
|
|
* make Card Search pull every card in the database. Choosing a format, a
|
|
|
|
|
|
* power range, or "not owned" is a real query, and does.
|
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
|
|
|
|
*/
|
|
|
|
|
|
export function criteriaActive(c: CardCriteria): boolean {
|
|
|
|
|
|
return (
|
|
|
|
|
|
c.canBeCommander ||
|
2026-08-14 01:02:29 -05:00
|
|
|
|
c.vanillaOnly ||
|
2026-08-22 19:14:27 -05:00
|
|
|
|
c.notOwned ||
|
Spare/rarity filters, owned-only suggestions, and a Game night tab
Library filters:
- "Only spare copies" quick filter (Library): cards with more copies
owned than the decks' main boards use, the opposite of the ⚠
over-assignment warning. Rows show "N spare" while it's on.
- Rarity toggles (C/U/R/M) in the shared criteria panel, so Library,
Card Search and the set browser all get them. Any printing counts,
since the library tracks cards rather than printings; within a set
it's that set's printing. Arena-only printings don't count for a card
that also exists on paper, so a digital re-rarity can't make a paper
common read as rare.
Suggestions:
- "From my collection" toggle (remembered) narrows candidates to owned
cards before scoring and paging, so "Suggest more" keeps finding them.
- Each suggestion reports copies other decks already use, shown as
"N spare" / "all in other decks". suggest_cards now takes a
SuggestOptions struct instead of a bare `unusual` flag.
Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a
running change bubble, poison and per-opponent commander damage (which
also comes off life), out-of-the-game markers, dice and a coin, and a
random first player. A seat's deck puts its commander's art behind the
life total. On phones the board fills the screen with the far row
turned to face across the table, and asks to keep the screen awake.
The game is per-device scratch state in localStorage, not synced.
Plain filter chips now show an accent background when active; before,
"Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
|
|
|
|
c.sparesOnly ||
|
|
|
|
|
|
c.rarities.length > 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
|
|
|
|
c.legalIn !== "" ||
|
|
|
|
|
|
[c.priceMin, c.priceMax, c.powerMin, c.powerMax, c.toughMin, c.toughMax].some(
|
|
|
|
|
|
(v) => v.trim() !== "",
|
|
|
|
|
|
)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function RangePair({
|
|
|
|
|
|
label,
|
|
|
|
|
|
min,
|
|
|
|
|
|
max,
|
|
|
|
|
|
onMin,
|
|
|
|
|
|
onMax,
|
|
|
|
|
|
step,
|
|
|
|
|
|
prefix,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
min: string;
|
|
|
|
|
|
max: string;
|
|
|
|
|
|
onMin: (v: string) => void;
|
|
|
|
|
|
onMax: (v: string) => void;
|
|
|
|
|
|
step?: string;
|
|
|
|
|
|
prefix?: string;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="field">
|
|
|
|
|
|
<label>{label}</label>
|
|
|
|
|
|
<div className="row">
|
|
|
|
|
|
{prefix && <span className="muted">{prefix}</span>}
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
step={step}
|
|
|
|
|
|
style={{ width: 70 }}
|
|
|
|
|
|
placeholder="min"
|
|
|
|
|
|
value={min}
|
|
|
|
|
|
onChange={(e) => onMin(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="muted">–</span>
|
|
|
|
|
|
{prefix && <span className="muted">{prefix}</span>}
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
step={step}
|
|
|
|
|
|
style={{ width: 70 }}
|
|
|
|
|
|
placeholder="max"
|
|
|
|
|
|
value={max}
|
|
|
|
|
|
onChange={(e) => onMax(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function CardCriteriaFields({
|
|
|
|
|
|
value: c,
|
|
|
|
|
|
onChange,
|
2026-08-22 19:14:27 -05:00
|
|
|
|
hideNotOwned,
|
Spare/rarity filters, owned-only suggestions, and a Game night tab
Library filters:
- "Only spare copies" quick filter (Library): cards with more copies
owned than the decks' main boards use, the opposite of the ⚠
over-assignment warning. Rows show "N spare" while it's on.
- Rarity toggles (C/U/R/M) in the shared criteria panel, so Library,
Card Search and the set browser all get them. Any printing counts,
since the library tracks cards rather than printings; within a set
it's that set's printing. Arena-only printings don't count for a card
that also exists on paper, so a digital re-rarity can't make a paper
common read as rare.
Suggestions:
- "From my collection" toggle (remembered) narrows candidates to owned
cards before scoring and paging, so "Suggest more" keeps finding them.
- Each suggestion reports copies other decks already use, shown as
"N spare" / "all in other decks". suggest_cards now takes a
SuggestOptions struct instead of a bare `unusual` flag.
Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a
running change bubble, poison and per-opponent commander damage (which
also comes off life), out-of-the-game markers, dice and a coin, and a
random first player. A seat's deck puts its commander's art behind the
life total. On phones the board fills the screen with the far row
turned to face across the table, and asks to keep the screen awake.
The game is per-device scratch state in localStorage, not synced.
Plain filter chips now show an accent background when active; before,
"Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
|
|
|
|
showSpares,
|
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
|
|
|
|
}: {
|
|
|
|
|
|
value: CardCriteria;
|
|
|
|
|
|
onChange: (patch: Partial<CardCriteria>) => void;
|
2026-08-22 19:14:27 -05:00
|
|
|
|
/** Omit the "Not owned" toggle — for the Library tab, which already shows
|
|
|
|
|
|
* nothing but owned cards, so the filter would just be a no-op there. */
|
|
|
|
|
|
hideNotOwned?: boolean;
|
Spare/rarity filters, owned-only suggestions, and a Game night tab
Library filters:
- "Only spare copies" quick filter (Library): cards with more copies
owned than the decks' main boards use, the opposite of the ⚠
over-assignment warning. Rows show "N spare" while it's on.
- Rarity toggles (C/U/R/M) in the shared criteria panel, so Library,
Card Search and the set browser all get them. Any printing counts,
since the library tracks cards rather than printings; within a set
it's that set's printing. Arena-only printings don't count for a card
that also exists on paper, so a digital re-rarity can't make a paper
common read as rare.
Suggestions:
- "From my collection" toggle (remembered) narrows candidates to owned
cards before scoring and paging, so "Suggest more" keeps finding them.
- Each suggestion reports copies other decks already use, shown as
"N spare" / "all in other decks". suggest_cards now takes a
SuggestOptions struct instead of a bare `unusual` flag.
Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a
running change bubble, poison and per-opponent commander damage (which
also comes off life), out-of-the-game markers, dice and a coin, and a
random first player. A seat's deck puts its commander's art behind the
life total. On phones the board fills the screen with the far row
turned to face across the table, and asks to keep the screen awake.
The game is per-device scratch state in localStorage, not synced.
Plain filter chips now show an accent background when active; before,
"Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
|
|
|
|
/** Offer the "spare copies" toggle — a question about your collection, so
|
|
|
|
|
|
* it belongs in the Library rather than in a search of every card. */
|
|
|
|
|
|
showSpares?: boolean;
|
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
|
|
|
|
}) {
|
Spare/rarity filters, owned-only suggestions, and a Game night tab
Library filters:
- "Only spare copies" quick filter (Library): cards with more copies
owned than the decks' main boards use, the opposite of the ⚠
over-assignment warning. Rows show "N spare" while it's on.
- Rarity toggles (C/U/R/M) in the shared criteria panel, so Library,
Card Search and the set browser all get them. Any printing counts,
since the library tracks cards rather than printings; within a set
it's that set's printing. Arena-only printings don't count for a card
that also exists on paper, so a digital re-rarity can't make a paper
common read as rare.
Suggestions:
- "From my collection" toggle (remembered) narrows candidates to owned
cards before scoring and paging, so "Suggest more" keeps finding them.
- Each suggestion reports copies other decks already use, shown as
"N spare" / "all in other decks". suggest_cards now takes a
SuggestOptions struct instead of a bare `unusual` flag.
Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a
running change bubble, poison and per-opponent commander damage (which
also comes off life), out-of-the-game markers, dice and a coin, and a
random first player. A seat's deck puts its commander's art behind the
life total. On phones the board fills the screen with the far row
turned to face across the table, and asks to keep the screen awake.
The game is per-device scratch state in localStorage, not synced.
Plain filter chips now show an accent background when active; before,
"Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
|
|
|
|
const toggleRarity = (key: string) =>
|
|
|
|
|
|
onChange({
|
|
|
|
|
|
rarities: c.rarities.includes(key)
|
|
|
|
|
|
? c.rarities.filter((r) => r !== key)
|
|
|
|
|
|
: [...c.rarities, key],
|
|
|
|
|
|
});
|
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
|
|
|
|
return (
|
|
|
|
|
|
<>
|
Spare/rarity filters, owned-only suggestions, and a Game night tab
Library filters:
- "Only spare copies" quick filter (Library): cards with more copies
owned than the decks' main boards use, the opposite of the ⚠
over-assignment warning. Rows show "N spare" while it's on.
- Rarity toggles (C/U/R/M) in the shared criteria panel, so Library,
Card Search and the set browser all get them. Any printing counts,
since the library tracks cards rather than printings; within a set
it's that set's printing. Arena-only printings don't count for a card
that also exists on paper, so a digital re-rarity can't make a paper
common read as rare.
Suggestions:
- "From my collection" toggle (remembered) narrows candidates to owned
cards before scoring and paging, so "Suggest more" keeps finding them.
- Each suggestion reports copies other decks already use, shown as
"N spare" / "all in other decks". suggest_cards now takes a
SuggestOptions struct instead of a bare `unusual` flag.
Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a
running change bubble, poison and per-opponent commander damage (which
also comes off life), out-of-the-game markers, dice and a coin, and a
random first player. A seat's deck puts its commander's art behind the
life total. On phones the board fills the screen with the far row
turned to face across the table, and asks to keep the screen awake.
The game is per-device scratch state in localStorage, not synced.
Plain filter chips now show an accent background when active; before,
"Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
|
|
|
|
<div className="field">
|
|
|
|
|
|
<label>Rarity</label>
|
|
|
|
|
|
<span className="color-toggles">
|
|
|
|
|
|
{RARITIES.map((r) => (
|
|
|
|
|
|
<span
|
|
|
|
|
|
key={r.key}
|
|
|
|
|
|
title={`${r.label} — printed at this rarity in any set (or in the set being browsed)`}
|
|
|
|
|
|
className={`pip rarity-pip ${r.key} ${c.rarities.includes(r.key) ? "on" : ""}`}
|
|
|
|
|
|
onClick={() => toggleRarity(r.key)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{r.code}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
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
|
|
|
|
<div className="field">
|
|
|
|
|
|
<label>Legal in</label>
|
|
|
|
|
|
<select value={c.legalIn} onChange={(e) => onChange({ legalIn: e.target.value })}>
|
|
|
|
|
|
<option value="">Any format</option>
|
|
|
|
|
|
{LEGALITY_FORMATS.map((f) => (
|
|
|
|
|
|
<option key={f.key} value={f.key}>
|
|
|
|
|
|
{f.label}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<RangePair
|
|
|
|
|
|
label="Price (USD)"
|
|
|
|
|
|
prefix="$"
|
|
|
|
|
|
step="0.01"
|
|
|
|
|
|
min={c.priceMin}
|
|
|
|
|
|
max={c.priceMax}
|
|
|
|
|
|
onMin={(v) => onChange({ priceMin: v })}
|
|
|
|
|
|
onMax={(v) => onChange({ priceMax: v })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<RangePair
|
|
|
|
|
|
label="Power"
|
|
|
|
|
|
min={c.powerMin}
|
|
|
|
|
|
max={c.powerMax}
|
|
|
|
|
|
onMin={(v) => onChange({ powerMin: v })}
|
|
|
|
|
|
onMax={(v) => onChange({ powerMax: v })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<RangePair
|
|
|
|
|
|
label="Toughness"
|
|
|
|
|
|
min={c.toughMin}
|
|
|
|
|
|
max={c.toughMax}
|
|
|
|
|
|
onMin={(v) => onChange({ toughMin: v })}
|
|
|
|
|
|
onMax={(v) => onChange({ toughMax: v })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="field">
|
|
|
|
|
|
<label>Quick filters</label>
|
|
|
|
|
|
<div className="icon-toggles">
|
|
|
|
|
|
{/* Exclusions read as "no X" — tinted with the danger colour when
|
|
|
|
|
|
active, so removing things looks different from selecting them. */}
|
|
|
|
|
|
<IconToggle
|
|
|
|
|
|
icon={ICON_CROSSOVER}
|
|
|
|
|
|
label="Hide Universes Beyond"
|
|
|
|
|
|
hint="Cards that only exist as another property — Lord of the Rings, Warhammer 40,000, Marvel. Normal cards reprinted in those sets stay."
|
|
|
|
|
|
exclude
|
|
|
|
|
|
on={c.excludeUb}
|
|
|
|
|
|
onToggle={() => onChange({ excludeUb: !c.excludeUb })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<IconToggle
|
|
|
|
|
|
icon={ICON_SCREEN}
|
|
|
|
|
|
label="Hide digital-only"
|
|
|
|
|
|
hint="Arena and MTGO cards such as Alchemy rebalances, which don't exist on paper."
|
|
|
|
|
|
exclude
|
|
|
|
|
|
on={c.excludeDigital}
|
|
|
|
|
|
onToggle={() => onChange({ excludeDigital: !c.excludeDigital })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<IconToggle
|
|
|
|
|
|
icon={ICON_SMILEY}
|
|
|
|
|
|
label="Hide Un-set cards"
|
|
|
|
|
|
hint="Unglued, Unhinged, Unstable and Unfinity joke cards."
|
|
|
|
|
|
exclude
|
|
|
|
|
|
on={c.excludeFunny}
|
|
|
|
|
|
onToggle={() => onChange({ excludeFunny: !c.excludeFunny })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="icon-toggles-sep" />
|
|
|
|
|
|
<IconToggle
|
|
|
|
|
|
icon={ICON_CROWN}
|
|
|
|
|
|
label="Only cards that can be a commander"
|
|
|
|
|
|
hint="Legendary creatures, plus cards whose rules text says they can be your commander."
|
|
|
|
|
|
on={c.canBeCommander}
|
|
|
|
|
|
onToggle={() => onChange({ canBeCommander: !c.canBeCommander })}
|
|
|
|
|
|
/>
|
2026-08-14 01:02:29 -05:00
|
|
|
|
<IconToggle
|
|
|
|
|
|
icon={ICON_BLANK_BOX}
|
|
|
|
|
|
label="Only vanilla creatures"
|
|
|
|
|
|
hint="Creatures with an empty rules box. Includes Adventure and Prepared cards whose creature half has no abilities."
|
|
|
|
|
|
on={c.vanillaOnly}
|
|
|
|
|
|
onToggle={() => onChange({ vanillaOnly: !c.vanillaOnly })}
|
|
|
|
|
|
/>
|
Spare/rarity filters, owned-only suggestions, and a Game night tab
Library filters:
- "Only spare copies" quick filter (Library): cards with more copies
owned than the decks' main boards use, the opposite of the ⚠
over-assignment warning. Rows show "N spare" while it's on.
- Rarity toggles (C/U/R/M) in the shared criteria panel, so Library,
Card Search and the set browser all get them. Any printing counts,
since the library tracks cards rather than printings; within a set
it's that set's printing. Arena-only printings don't count for a card
that also exists on paper, so a digital re-rarity can't make a paper
common read as rare.
Suggestions:
- "From my collection" toggle (remembered) narrows candidates to owned
cards before scoring and paging, so "Suggest more" keeps finding them.
- Each suggestion reports copies other decks already use, shown as
"N spare" / "all in other decks". suggest_cards now takes a
SuggestOptions struct instead of a bare `unusual` flag.
Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a
running change bubble, poison and per-opponent commander damage (which
also comes off life), out-of-the-game markers, dice and a coin, and a
random first player. A seat's deck puts its commander's art behind the
life total. On phones the board fills the screen with the far row
turned to face across the table, and asks to keep the screen awake.
The game is per-device scratch state in localStorage, not synced.
Plain filter chips now show an accent background when active; before,
"Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
|
|
|
|
{showSpares && (
|
|
|
|
|
|
<IconToggle
|
|
|
|
|
|
icon={ICON_SPARE}
|
|
|
|
|
|
label="Only spare copies"
|
|
|
|
|
|
hint="Cards you own more copies of than your decks use — free for a trade or a new deck. Maybeboards don't count."
|
|
|
|
|
|
on={c.sparesOnly}
|
|
|
|
|
|
onToggle={() => onChange({ sparesOnly: !c.sparesOnly })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-08-22 19:14:27 -05:00
|
|
|
|
{!hideNotOwned && (
|
|
|
|
|
|
<IconToggle
|
|
|
|
|
|
icon={ICON_PACKAGE}
|
|
|
|
|
|
label="Not owned"
|
|
|
|
|
|
hint="Only cards you don't have a single copy of yet."
|
|
|
|
|
|
on={c.notOwned}
|
|
|
|
|
|
onToggle={() => onChange({ notOwned: !c.notOwned })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
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
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* A single icon filter toggle, styled like [`ColorToggles`]' pips: dim when
|
|
|
|
|
|
* off, lit with a ring when on. `exclude` marks a filter that *removes* cards
|
|
|
|
|
|
* rather than selecting them, which is worth distinguishing visually since
|
|
|
|
|
|
* both live in the same row.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function IconToggle({
|
|
|
|
|
|
icon,
|
|
|
|
|
|
label,
|
|
|
|
|
|
hint,
|
|
|
|
|
|
on,
|
|
|
|
|
|
exclude,
|
|
|
|
|
|
onToggle,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
icon: ReactNode;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
hint: string;
|
|
|
|
|
|
on: boolean;
|
|
|
|
|
|
exclude?: boolean;
|
|
|
|
|
|
onToggle: () => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`icon-toggle ${exclude ? "exclude" : ""} ${on ? "on" : ""}`}
|
|
|
|
|
|
// Title carries the full explanation the old inline hint text did — the
|
|
|
|
|
|
// whole point of this row is to stop that prose eating the panel.
|
|
|
|
|
|
title={`${label}\n\n${hint}`}
|
|
|
|
|
|
aria-label={label}
|
|
|
|
|
|
aria-pressed={on}
|
|
|
|
|
|
onClick={onToggle}
|
|
|
|
|
|
>
|
|
|
|
|
|
{icon}
|
|
|
|
|
|
{/* A slash over the glyph when an exclusion is active, so "hidden" is
|
|
|
|
|
|
legible without reading the tooltip. */}
|
|
|
|
|
|
{exclude && on && <span className="icon-toggle-slash" />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
Replace folder sync with an HTTP sync server, keeping the app offline-first
Folder sync left too much to the user's sync tool: platform file access
(the Android SAF plugin), choosing the document, merging conflict copies —
and its merge base could fall out of step with the document (absorbing
conflict copies, "forget", picking a new Android folder), reading every
difference as a deletion.
The new mtgc-server crate stores one versioned library per user and accepts
a write only when it names the version it replaces. It never merges: devices
still do that with merge3, through a new sync cycle over a `Remote` trait in
mtgc-core. The base advances only on knowledge — to what was read, or to a
write the server confirmed — and is stored with the server and account it
belongs to, so a different account starts from an empty ancestor instead of
deleting. The working copy is only rewritten under the app-state lock, and
sync commands now run off the main thread.
Sign-in happens in the browser on the server's pages, via the owner's OIDC
provider and/or local username/password accounts (Argon2id, managed with
`mtgc-server user ...`), and returns a per-device revocable token to the app
by PKCE-protected code: a loopback port on desktop, the mtgc:// scheme on
Android.
The Info tab gains sign-in, the first-sync combine/replace choice, the
account's devices, and JSON export/import; the top bar shows sync state.
Includes a Dockerfile, Compose/Caddy example, and scripts to build and push
${DOCKER_REGISTRY}/mtgc/sync-server.
2026-10-08 22:32:08 -05:00
|
|
|
|
|
|
|
|
|
|
/** Compact relative time, e.g. "2h ago" — for "updated"/"synced" stamps. */
|
|
|
|
|
|
export 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`;
|
|
|
|
|
|
}
|