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";
|
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,
|
|
|
|
|
|
LibraryFilter,
|
|
|
|
|
|
legalStatusFor,
|
|
|
|
|
|
maskToLetters,
|
|
|
|
|
|
PreviewCard,
|
|
|
|
|
|
} 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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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).
|
|
|
|
|
|
*/
|
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 CardThumb({ card }: { card: CardRef }) {
|
|
|
|
|
|
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();
|
|
|
|
|
|
open(card);
|
|
|
|
|
|
}}
|
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 {
|
2026-07-22 23:11:26 -05:00
|
|
|
|
open: (card: PreviewCard) => 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
|
|
|
|
}
|
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);
|
2026-07-22 23:11:26 -05:00
|
|
|
|
const [clicked, setClicked] = useState<{ card: PreviewCard; showBack: boolean } | 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), []);
|
|
|
|
|
|
|
2026-07-22 23:11:26 -05:00
|
|
|
|
const open = useCallback((card: PreviewCard) => {
|
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);
|
|
|
|
|
|
setClicked({ card, 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(),
|
|
|
|
|
|
}
|
|
|
|
|
|
: {},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/** Returns `open(card)` to trigger the larger click-to-preview modal. */
|
|
|
|
|
|
export function useClickPreview() {
|
|
|
|
|
|
const api = useContext(ClickCtx);
|
2026-07-22 23:11:26 -05:00
|
|
|
|
return { open: (card: PreviewCard) => api?.open(card) };
|
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. */
|
2026-10-14 20:35:50 -05:00
|
|
|
|
const CardInfoTile = forwardRef<HTMLDivElement, { card: PreviewCard }>(function CardInfoTile({ card }, ref) {
|
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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{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
|
|
|
|
|
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,
|
|
|
|
|
|
}: {
|
2026-07-22 23:11:26 -05:00
|
|
|
|
state: { card: PreviewCard; showBack: boolean };
|
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;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const { card, showBack } = state;
|
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>
|
2026-10-14 20:35:50 -05:00
|
|
|
|
<CardInfoTile ref={infoRef} card={card} />
|
|
|
|
|
|
{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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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,
|
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: CardRef;
|
|
|
|
|
|
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;
|
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"
|
|
|
|
|
|
onClick={() => open(card)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<img
|
|
|
|
|
|
className="tile-img"
|
|
|
|
|
|
src={card.image_uri}
|
|
|
|
|
|
alt={card.name}
|
|
|
|
|
|
loading="lazy"
|
|
|
|
|
|
onClick={() => open(card)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
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" />
|
|
|
|
|
|
</>,
|
|
|
|
|
|
);
|
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;
|
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,
|
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: "",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
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 ||
|
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,
|
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;
|
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 (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<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 })}
|
|
|
|
|
|
/>
|
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`;
|
|
|
|
|
|
}
|