2026-07-20 00:21:22 -05:00
|
|
|
import { open, save } from "@tauri-apps/plugin-dialog";
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react";
|
2026-10-26 22:35:41 +00:00
|
|
|
import {
|
|
|
|
|
api,
|
|
|
|
|
CardRef,
|
|
|
|
|
Deck,
|
|
|
|
|
DECK_STATUSES,
|
|
|
|
|
DeckEntry,
|
|
|
|
|
DeckHolding,
|
|
|
|
|
DeckImportResult,
|
|
|
|
|
DeckStatus,
|
|
|
|
|
isPhysical,
|
|
|
|
|
libraryKey,
|
2026-10-26 21:31:28 -05:00
|
|
|
LibraryFacets,
|
2026-10-26 22:35:41 +00:00
|
|
|
PreviewContext,
|
|
|
|
|
} from "../api";
|
2026-10-26 21:31:28 -05:00
|
|
|
import { CardFilterPanel, CardFilterState, NO_FACETS, useCardFilter } from "../cardFilter";
|
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 {
|
|
|
|
|
CardSearchBox,
|
|
|
|
|
CardThumb,
|
|
|
|
|
CardTile,
|
2026-07-08 23:54:47 -05:00
|
|
|
ColorDistributionBar,
|
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
|
|
|
ColorPips,
|
2026-10-26 20:35:21 -05:00
|
|
|
ColumnDef,
|
|
|
|
|
ColumnsMenu,
|
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
|
|
|
ConfirmDialog,
|
2026-10-26 21:25:15 -05:00
|
|
|
DECK_SORT_OPTIONS,
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
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
|
|
|
GroupBySelect,
|
|
|
|
|
GroupMode,
|
Deck actions UI, consistent icon buttons, native select fix, sidebar stats
- DeckView: rename/duplicate/delete deck via row-hover icon actions, with
a ConfirmDialog for delete and PromptDialog (with friendly name-
collision errors) for rename/duplicate.
- Add a shared IconButton (plus overlay/danger variants) and replace
every ad hoc close/remove button in the app with it: deck actions,
remove-card-from-deck (list and tile), clear-commander, the card-
preview close, and the ingest-progress dismiss.
- Fix native <select> (Group by, Format, In deck): only color/border were
overridden, leaving native OS chrome (height, arrow, popup) in place.
Drop appearance and draw a custom chevron so they match text inputs.
- Click-to-preview: show both faces of a double-faced card side by side
on wide viewports instead of requiring Flip, and size the preview
images larger overall.
- Sidebar footer: replace the plain "N cards" line with a "Your library"
panel (unique/copies/decks tiles + a WUBRG color-distribution bar),
polling get_library_stats while card data is loaded.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 21:21:47 -05:00
|
|
|
IconButton,
|
|
|
|
|
Icons,
|
2026-07-20 00:21:22 -05:00
|
|
|
InfoDialog,
|
2026-07-08 23:54:47 -05:00
|
|
|
ManaCurve,
|
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
|
|
|
PromptDialog,
|
2026-07-08 08:20:20 -05:00
|
|
|
QtyStepper,
|
2026-07-20 00:21:22 -05:00
|
|
|
SampleHandModal,
|
2026-10-26 21:25:15 -05:00
|
|
|
SortBySelect,
|
|
|
|
|
SortDir,
|
|
|
|
|
SortMode,
|
2026-07-08 23:54:47 -05:00
|
|
|
StatTile,
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
TagInput,
|
2026-10-16 21:14:43 -05:00
|
|
|
TileColumnsControl,
|
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
|
|
|
ViewToggle,
|
|
|
|
|
groupCards,
|
2026-10-26 21:25:15 -05:00
|
|
|
sortCards,
|
2026-07-08 23:54:47 -05:00
|
|
|
useClickPreview,
|
|
|
|
|
useHoverPreview,
|
2026-10-26 20:35:21 -05:00
|
|
|
useColumns,
|
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
|
|
|
usePersistedState,
|
2026-10-16 21:14:43 -05:00
|
|
|
useTileColumns,
|
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
|
|
|
useViewMode,
|
2026-10-23 23:05:19 -05:00
|
|
|
fold,
|
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
|
|
|
} from "../components";
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
import {
|
|
|
|
|
COMMANDER_FORMATS,
|
|
|
|
|
computeDeckLegality,
|
|
|
|
|
computeDeckStats,
|
|
|
|
|
formatCost,
|
|
|
|
|
FORMATS,
|
2026-10-26 21:44:16 +00:00
|
|
|
illegalSelection,
|
|
|
|
|
LEGALITY_CATEGORIES,
|
|
|
|
|
LegalityCategory,
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
pairsWith,
|
|
|
|
|
partnerPlaceholder,
|
|
|
|
|
shortBy,
|
|
|
|
|
} from "../deckAnalysis";
|
2026-07-21 21:04:36 -05:00
|
|
|
import { SuggestionsModal } from "../SuggestionsModal";
|
2026-10-14 21:14:50 -05:00
|
|
|
import { useBackButton } from "../backButton";
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
import { DeckList } from "./DeckList";
|
2026-10-23 22:56:59 -05:00
|
|
|
|
|
|
|
|
function libraryAddLabel(e: DeckEntry): string {
|
|
|
|
|
const n = shortBy(e);
|
|
|
|
|
return n === 1 ? "Add to library" : `Add ${n} copies to library`;
|
|
|
|
|
}
|
|
|
|
|
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
/** Tooltip/label + icon for the per-card mainboard/maybeboard toggle. */
|
2026-07-14 21:32:25 -05:00
|
|
|
function boardToggleProps(e: DeckEntry) {
|
|
|
|
|
return e.is_maybeboard
|
|
|
|
|
? { icon: Icons.arrowLeftCircle, label: "Move to deck" }
|
|
|
|
|
: { icon: Icons.helpCircle, label: "Move to maybeboard" };
|
|
|
|
|
}
|
|
|
|
|
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
type DeckColumn = "image" | "colors" | "type" | "qty" | "owned" | "price" | "where" | "tags" | "actions";
|
2026-10-26 20:35:21 -05:00
|
|
|
|
|
|
|
|
// The List view's optional columns; the checkbox and name always show.
|
|
|
|
|
const DECK_COLUMNS: ColumnDef<DeckColumn>[] = [
|
|
|
|
|
{ id: "image", label: "Image" },
|
|
|
|
|
{ id: "colors", label: "Colors" },
|
|
|
|
|
{ id: "type", label: "Type" },
|
|
|
|
|
{ id: "qty", label: "In deck" },
|
|
|
|
|
{ id: "owned", label: "Owned" },
|
2026-10-26 21:25:15 -05:00
|
|
|
{ id: "price", label: "Price" },
|
2026-10-26 20:35:21 -05:00
|
|
|
{ id: "where", label: "Where" },
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
{ id: "tags", label: "Tags" },
|
2026-10-26 20:35:21 -05:00
|
|
|
{ id: "actions", label: "Actions" },
|
|
|
|
|
];
|
|
|
|
|
|
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
|
|
|
type DeckDialog =
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
| { kind: "create" }
|
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
|
|
|
| { kind: "delete"; deck: Deck }
|
|
|
|
|
| { kind: "rename"; deck: Deck }
|
|
|
|
|
| { kind: "duplicate"; deck: Deck }
|
2026-07-20 00:21:22 -05:00
|
|
|
| { kind: "import-name"; suggestedName: string; path: string }
|
|
|
|
|
| { kind: "import-result"; result: DeckImportResult }
|
2026-10-28 22:48:36 -05:00
|
|
|
| { kind: "export-failed"; message: string }
|
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
|
|
|
| null;
|
|
|
|
|
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
/** Name a new deck and, optionally, pick its format up front. */
|
|
|
|
|
function NewDeckDialog({
|
|
|
|
|
defaultFormat,
|
|
|
|
|
error,
|
|
|
|
|
onConfirm,
|
|
|
|
|
onCancel,
|
|
|
|
|
}: {
|
|
|
|
|
defaultFormat: string;
|
|
|
|
|
error: string | null;
|
|
|
|
|
onConfirm: (name: string, format: string) => void;
|
|
|
|
|
onCancel: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const [name, setName] = useState("");
|
|
|
|
|
const [format, setFormat] = useState(FORMATS.includes(defaultFormat) ? defaultFormat : "");
|
|
|
|
|
const confirm = () => {
|
|
|
|
|
if (name.trim()) onConfirm(name.trim(), format);
|
2026-07-08 23:54:47 -05:00
|
|
|
};
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
return (
|
|
|
|
|
<DialogFrame title="New deck" onCancel={onCancel}>
|
|
|
|
|
<label className="dialog-field">
|
|
|
|
|
<span className="muted">Deck name</span>
|
|
|
|
|
<input
|
|
|
|
|
autoFocus
|
|
|
|
|
value={name}
|
|
|
|
|
onChange={(e) => setName(e.target.value)}
|
|
|
|
|
onKeyDown={(e) => e.key === "Enter" && confirm()}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="dialog-field">
|
|
|
|
|
<span className="muted">Format</span>
|
|
|
|
|
<select value={format} onChange={(e) => setFormat(e.target.value)}>
|
|
|
|
|
{FORMATS.map((f) => (
|
|
|
|
|
<option key={f} value={f}>
|
|
|
|
|
{f || "—"}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
{error && <p className="dialog-error">{error}</p>}
|
|
|
|
|
<div className="dialog-actions">
|
|
|
|
|
<button onClick={onCancel}>Cancel</button>
|
|
|
|
|
<button className="primary" onClick={confirm} disabled={!name.trim()}>
|
|
|
|
|
Create
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</DialogFrame>
|
|
|
|
|
);
|
2026-07-08 23:54:47 -05:00
|
|
|
}
|
|
|
|
|
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
function friendlyDeckError(err: unknown): string {
|
|
|
|
|
const msg = String(err);
|
|
|
|
|
return msg.includes("UNIQUE constraint failed") ? "A deck with that name already exists." : msg;
|
2026-10-23 23:18:27 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-08 23:54:47 -05:00
|
|
|
/** A prominent, art-forward display for a deck's commander (Commander/Brawl). */
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
function CommanderHero({
|
|
|
|
|
card,
|
|
|
|
|
onClear,
|
|
|
|
|
preview,
|
|
|
|
|
}: {
|
|
|
|
|
card: DeckEntry;
|
|
|
|
|
onClear: () => void;
|
|
|
|
|
preview?: PreviewContext;
|
|
|
|
|
}) {
|
2026-07-08 23:54:47 -05:00
|
|
|
const { handlers } = useHoverPreview();
|
|
|
|
|
const { open } = useClickPreview();
|
|
|
|
|
return (
|
Cohesive styling pass: design tokens, shared primitives, responsive groundwork
The UI had a good terracotta-on-dark system that wasn't applied
consistently — magic-number spacing, ad-hoc page headers, ~6 copied
panel surfaces, ~8 drifting label variants, and scattered accent use.
- Tokens: warm the neutral ramp, add spacing/radius/type/motion scales,
--accent-quiet + panel highlight. Terracotta hue unchanged.
- Primitives: .panel, .section-label, .page-header/.toolbar — collapse
the copied surfaces and label variants onto shared classes.
- Unify each filter-heavy page's display controls (group/sort/view,
select-all, count) into a workspace toolbar inside the filter panel,
so they read as one surface instead of floating above it.
- Labels: sentence-case, higher-contrast form labels; uppercase section
markers bumped faint -> muted.
- Accent hierarchy: reserve full terracotta for primary actions/active
state; demote decorative labels and the group-header underline.
- Responsive groundwork: reflow classes for the deck two-pane and nav
rail (stacks / icon-collapse at breakpoints), touch-target sizing.
Desktop unchanged in kind.
2026-07-22 23:44:21 -05:00
|
|
|
<div className="panel commander-hero">
|
2026-07-08 23:54:47 -05:00
|
|
|
{card.image_uri ? (
|
|
|
|
|
<img
|
|
|
|
|
className="commander-hero-img"
|
|
|
|
|
src={card.image_uri}
|
|
|
|
|
alt={card.name}
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
onClick={() => open(card, preview)}
|
2026-07-08 23:54:47 -05:00
|
|
|
{...handlers(card)}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="commander-hero-noimg">{card.name}</div>
|
|
|
|
|
)}
|
|
|
|
|
<div className="commander-hero-info">
|
|
|
|
|
<span className="commander-hero-label">Commander</span>
|
|
|
|
|
<span className="commander-hero-name">{card.name}</span>
|
|
|
|
|
<span className="commander-hero-type">{card.type_line}</span>
|
|
|
|
|
<ColorPips mask={card.color_identity} />
|
|
|
|
|
</div>
|
2026-10-23 22:29:56 -05:00
|
|
|
<IconButton icon={Icons.close} label={`Clear ${card.name} as commander`} onClick={onClear} />
|
2026-07-08 23:54:47 -05:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 08:20:20 -05:00
|
|
|
// How long a card decremented to 0 copies lingers (dimmed, still adjustable)
|
|
|
|
|
// before actually dropping out of the deck — same grace-period pattern as
|
|
|
|
|
// the Library tab, so a mistaken removal can be undone with +.
|
|
|
|
|
const REMOVAL_GRACE_MS = 5000;
|
|
|
|
|
|
2026-10-26 21:44:16 +00:00
|
|
|
const extraCopiesLabel = (n: number) => `${n} extra ${n === 1 ? "copy" : "copies"}`;
|
|
|
|
|
|
2026-10-26 22:35:41 +00:00
|
|
|
const copies = (n: number) => `${n} ${n === 1 ? "copy" : "copies"}`;
|
|
|
|
|
|
|
|
|
|
const statusLabel = (s: DeckStatus) => DECK_STATUSES.find((d) => d.value === s)?.label ?? s;
|
|
|
|
|
|
|
|
|
|
/** One line under the status select saying what the status means. */
|
|
|
|
|
const STATUS_HINTS: Record<DeckStatus, string> = {
|
|
|
|
|
idea: "Just a list — it claims none of your cards.",
|
|
|
|
|
building: "Being assembled: pull its cards as you go.",
|
|
|
|
|
built: "Assembled: its cards are in the deck box.",
|
|
|
|
|
retired: "Taken apart, kept for reference.",
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-20 00:21:22 -05:00
|
|
|
const STARTING_HAND_SIZE = 7;
|
|
|
|
|
|
2026-10-26 21:13:29 -05:00
|
|
|
interface NavSection {
|
|
|
|
|
id: string;
|
|
|
|
|
label: string;
|
|
|
|
|
count: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The deck builder's sticky section bar: jumps between its card sections,
|
|
|
|
|
* marks the one in view, and holds the filter. Inline tabs where they fit;
|
|
|
|
|
* on a narrow bar, one button naming the current section opens a menu of
|
|
|
|
|
* them all instead (the CSS picks by the bar's own width).
|
|
|
|
|
*/
|
|
|
|
|
function SectionNav({
|
|
|
|
|
sections,
|
|
|
|
|
active,
|
|
|
|
|
onJump,
|
|
|
|
|
onTop,
|
|
|
|
|
filter,
|
|
|
|
|
onFilterChange,
|
2026-10-26 21:31:28 -05:00
|
|
|
onAdvanced,
|
|
|
|
|
advancedActive,
|
2026-10-26 21:13:29 -05:00
|
|
|
}: {
|
|
|
|
|
sections: NavSection[];
|
|
|
|
|
active: string;
|
|
|
|
|
onJump: (id: string) => void;
|
|
|
|
|
onTop: () => void;
|
|
|
|
|
filter: string;
|
|
|
|
|
onFilterChange: (q: string) => void;
|
2026-10-26 21:31:28 -05:00
|
|
|
onAdvanced: () => void;
|
|
|
|
|
/** An advanced filter is narrowing the cards. */
|
|
|
|
|
advancedActive: boolean;
|
2026-10-26 21:13:29 -05:00
|
|
|
}) {
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
const jumpRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const current = sections.find((s) => s.id === active) ?? sections[0];
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!open) return;
|
|
|
|
|
const onDown = (e: MouseEvent) => {
|
|
|
|
|
if (jumpRef.current && !jumpRef.current.contains(e.target as Node)) setOpen(false);
|
|
|
|
|
};
|
|
|
|
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
|
|
|
|
document.addEventListener("mousedown", onDown);
|
|
|
|
|
window.addEventListener("keydown", onKey);
|
|
|
|
|
return () => {
|
|
|
|
|
document.removeEventListener("mousedown", onDown);
|
|
|
|
|
window.removeEventListener("keydown", onKey);
|
|
|
|
|
};
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="section-nav">
|
|
|
|
|
<button className="section-nav-top" onClick={onTop} title="Back to the deck overview" aria-label="Back to the deck overview">
|
|
|
|
|
{Icons.arrowUp}
|
|
|
|
|
</button>
|
|
|
|
|
<nav className="section-nav-tabs" aria-label="Deck sections">
|
|
|
|
|
{sections.map((s) => (
|
|
|
|
|
<button
|
|
|
|
|
key={s.id}
|
|
|
|
|
className={[s.id === active ? "active" : "", s.count === 0 ? "empty" : ""].filter(Boolean).join(" ")}
|
|
|
|
|
aria-current={s.id === active ? "location" : undefined}
|
|
|
|
|
onClick={() => onJump(s.id)}
|
|
|
|
|
>
|
|
|
|
|
{s.label} <span className="badge">{s.count}</span>
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</nav>
|
|
|
|
|
<div className="section-nav-jump" ref={jumpRef}>
|
|
|
|
|
<button onClick={() => setOpen(!open)} aria-haspopup="menu" aria-expanded={open} title="Jump to a section">
|
|
|
|
|
<span className="section-nav-jump-label">{current.label}</span>
|
|
|
|
|
<span className="badge">{current.count}</span> ▾
|
|
|
|
|
</button>
|
|
|
|
|
{open && (
|
|
|
|
|
<div className="library-menu section-nav-menu" role="menu">
|
|
|
|
|
{sections.map((s) => (
|
|
|
|
|
<button
|
|
|
|
|
key={s.id}
|
|
|
|
|
role="menuitemradio"
|
|
|
|
|
aria-checked={s.id === active}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setOpen(false);
|
|
|
|
|
onJump(s.id);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<span className="library-menu-check">{s.id === active ? "✓" : ""}</span>
|
|
|
|
|
{s.label}
|
|
|
|
|
<span className="hint">{s.count}</span>
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<input
|
|
|
|
|
className="section-nav-filter"
|
|
|
|
|
value={filter}
|
|
|
|
|
onChange={(e) => onFilterChange(e.target.value)}
|
|
|
|
|
placeholder="Filter cards in deck…"
|
|
|
|
|
/>
|
2026-10-26 21:31:28 -05:00
|
|
|
<button
|
|
|
|
|
className={advancedActive ? "section-nav-adv active" : "section-nav-adv"}
|
|
|
|
|
onClick={onAdvanced}
|
|
|
|
|
title={advancedActive ? "Advanced filter (on)" : "Advanced filter"}
|
|
|
|
|
aria-label={advancedActive ? "Advanced filter (on)" : "Advanced filter"}
|
|
|
|
|
>
|
|
|
|
|
{Icons.filter}
|
|
|
|
|
</button>
|
2026-10-26 21:13:29 -05:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
/** Narrowing the deck's cards by their tags. Matched in the browser, unlike
|
|
|
|
|
* the rest of the advanced filter. */
|
|
|
|
|
interface TagFilter {
|
|
|
|
|
tags: string[];
|
|
|
|
|
/** Whether a card needs any of `tags`, or all of them. */
|
|
|
|
|
mode: "any" | "all";
|
|
|
|
|
/** Only cards with no tags at all; `tags` is then ignored. */
|
|
|
|
|
untagged: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const NO_TAG_FILTER: TagFilter = { tags: [], mode: "any", untagged: false };
|
|
|
|
|
|
|
|
|
|
const tagFilterActive = (f: TagFilter) => f.untagged || f.tags.length > 0;
|
|
|
|
|
|
|
|
|
|
/** Tag comparison ignores case, like the backend's. */
|
|
|
|
|
const hasTag = (e: DeckEntry, tag: string) => e.tags.some((t) => t.toLowerCase() === tag.toLowerCase());
|
|
|
|
|
|
|
|
|
|
function matchesTagFilter(e: DeckEntry, f: TagFilter): boolean {
|
|
|
|
|
if (f.untagged) return e.tags.length === 0;
|
|
|
|
|
if (f.tags.length === 0) return true;
|
|
|
|
|
return f.mode === "all" ? f.tags.every((t) => hasTag(e, t)) : f.tags.some((t) => hasTag(e, t));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** A card's tags as chips; clicking one filters to it (or, if the filter
|
|
|
|
|
* already has it, takes it back out). */
|
|
|
|
|
function TagChips({
|
|
|
|
|
tags,
|
|
|
|
|
filter,
|
|
|
|
|
onToggle,
|
|
|
|
|
}: {
|
|
|
|
|
tags: string[];
|
|
|
|
|
filter: TagFilter;
|
|
|
|
|
onToggle: (tag: string) => void;
|
|
|
|
|
}) {
|
|
|
|
|
if (tags.length === 0) return null;
|
|
|
|
|
return (
|
|
|
|
|
<span className="tag-filter-chips">
|
|
|
|
|
{tags.map((t) => {
|
|
|
|
|
const active = !filter.untagged && filter.tags.some((f) => f.toLowerCase() === t.toLowerCase());
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={t}
|
|
|
|
|
className={`deck-chip deck-chip-link tag-filter-chip ${active ? "active" : ""}`}
|
|
|
|
|
onClick={() => onToggle(t)}
|
|
|
|
|
title={active ? `Stop filtering by "${t}"` : `Show only cards tagged "${t}"`}
|
|
|
|
|
>
|
|
|
|
|
{t}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Add and remove tags across the selected cards. Each change saves at once. */
|
|
|
|
|
function TagCardsDialog({
|
|
|
|
|
entries,
|
|
|
|
|
suggestions,
|
|
|
|
|
onChange,
|
|
|
|
|
onClose,
|
|
|
|
|
}: {
|
|
|
|
|
entries: DeckEntry[];
|
|
|
|
|
suggestions: string[];
|
|
|
|
|
onChange: (add: string[], remove: string[]) => Promise<void>;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const counts = new Map<string, { tag: string; n: number }>();
|
|
|
|
|
for (const e of entries) {
|
|
|
|
|
for (const t of e.tags) {
|
|
|
|
|
const c = counts.get(t.toLowerCase());
|
|
|
|
|
if (c) c.n++;
|
|
|
|
|
else counts.set(t.toLowerCase(), { tag: t, n: 1 });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const used = [...counts.values()].sort((a, b) => a.tag.localeCompare(b.tag));
|
|
|
|
|
|
|
|
|
|
async function apply(add: string[], remove: string[]) {
|
|
|
|
|
setBusy(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
try {
|
|
|
|
|
await onChange(add, remove);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
setError(String(e));
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<DialogFrame title={`Tag ${entries.length} ${entries.length === 1 ? "card" : "cards"}`} onCancel={onClose}>
|
|
|
|
|
{used.length > 0 && (
|
|
|
|
|
<ul className="tag-cards-list">
|
|
|
|
|
{used.map(({ tag, n }) => (
|
|
|
|
|
<li key={tag}>
|
|
|
|
|
<span className="deck-chip">{tag}</span>
|
|
|
|
|
<span className="muted">
|
|
|
|
|
{n} of {entries.length}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="spacer" />
|
|
|
|
|
<button onClick={() => apply([tag], [])} disabled={busy || n === entries.length}>
|
|
|
|
|
Add to all
|
|
|
|
|
</button>
|
|
|
|
|
<button onClick={() => apply([], [tag])} disabled={busy}>
|
|
|
|
|
Remove
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
<label className="dialog-field">
|
|
|
|
|
<span className="muted">Add to every selected card</span>
|
|
|
|
|
<TagInput
|
|
|
|
|
values={[]}
|
|
|
|
|
onChange={(next) => next.length > 0 && apply(next, [])}
|
|
|
|
|
suggestions={suggestions}
|
|
|
|
|
placeholder="New tag…"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
{error && <p className="dialog-error">{error}</p>}
|
|
|
|
|
<div className="dialog-actions">
|
|
|
|
|
<button className="primary" onClick={onClose}>
|
|
|
|
|
Done
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</DialogFrame>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-26 21:31:28 -05:00
|
|
|
/** The advanced filter as a popup, so it doesn't take over the builder.
|
|
|
|
|
* Changes apply as they're made; `matched` is null until a filter is on. */
|
|
|
|
|
function AdvancedFilterDialog({
|
|
|
|
|
state,
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
tagFilter,
|
|
|
|
|
onTagFilterChange,
|
|
|
|
|
deckTags,
|
2026-10-26 21:31:28 -05:00
|
|
|
matched,
|
|
|
|
|
total,
|
|
|
|
|
error,
|
|
|
|
|
onClose,
|
|
|
|
|
}: {
|
|
|
|
|
state: CardFilterState;
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
tagFilter: TagFilter;
|
|
|
|
|
onTagFilterChange: (f: TagFilter) => void;
|
|
|
|
|
/** Every tag used in the deck, the only ones worth filtering by. */
|
|
|
|
|
deckTags: string[];
|
2026-10-26 21:31:28 -05:00
|
|
|
matched: number | null;
|
|
|
|
|
total: number;
|
|
|
|
|
error: string | null;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
api.getReferenceFacets().then(setFacets).catch(() => {});
|
|
|
|
|
}, []);
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
// TagInput accepts anything typed; keep only tags the deck actually uses.
|
|
|
|
|
const known = (tags: string[]) =>
|
|
|
|
|
tags.flatMap((t) => deckTags.find((d) => d.toLowerCase() === t.trim().toLowerCase()) ?? []);
|
2026-10-26 21:31:28 -05:00
|
|
|
return (
|
|
|
|
|
<DialogFrame title="Filter deck cards" onCancel={onClose} wide>
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
<div className="tag-filter-field">
|
|
|
|
|
<span className="muted">Tags</span>
|
|
|
|
|
{deckTags.length === 0 ? (
|
|
|
|
|
<span className="hint">No cards in this deck are tagged yet.</span>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div className={tagFilter.untagged ? "tag-filter-input disabled" : "tag-filter-input"}>
|
|
|
|
|
<TagInput
|
|
|
|
|
values={tagFilter.tags}
|
|
|
|
|
onChange={(tags) => onTagFilterChange({ ...tagFilter, tags: known(tags) })}
|
|
|
|
|
suggestions={deckTags}
|
|
|
|
|
placeholder="Filter by tag…"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="row" style={{ gap: 12, flexWrap: "wrap" }}>
|
|
|
|
|
<label className="row" style={{ gap: 6 }}>
|
|
|
|
|
<span className="muted">Match</span>
|
|
|
|
|
<select
|
|
|
|
|
value={tagFilter.mode}
|
|
|
|
|
onChange={(e) => onTagFilterChange({ ...tagFilter, mode: e.target.value as TagFilter["mode"] })}
|
|
|
|
|
disabled={tagFilter.untagged}
|
|
|
|
|
>
|
|
|
|
|
<option value="any">any of these</option>
|
|
|
|
|
<option value="all">all of these</option>
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="row" style={{ gap: 6, cursor: "pointer" }}>
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={tagFilter.untagged}
|
|
|
|
|
onChange={(e) => onTagFilterChange({ ...tagFilter, untagged: e.target.checked })}
|
|
|
|
|
/>
|
|
|
|
|
Untagged only
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-10-26 21:31:28 -05:00
|
|
|
<CardFilterPanel state={state} facets={facets} decks={[]} hideName hideInDeck alwaysExpanded />
|
|
|
|
|
{error && <p className="sync-bad">{error}</p>}
|
|
|
|
|
<div className="dialog-actions">
|
|
|
|
|
<span className="muted" style={{ marginRight: "auto" }}>
|
|
|
|
|
{matched === null ? `All ${total} cards shown` : `${matched} of ${total} cards match`}
|
|
|
|
|
</span>
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
state.reset();
|
|
|
|
|
onTagFilterChange(NO_TAG_FILTER);
|
|
|
|
|
}}
|
|
|
|
|
disabled={!state.active && !tagFilterActive(tagFilter)}
|
|
|
|
|
>
|
2026-10-26 21:31:28 -05:00
|
|
|
Clear filters
|
|
|
|
|
</button>
|
|
|
|
|
<button className="primary" onClick={onClose}>
|
|
|
|
|
Done
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</DialogFrame>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 00:21:22 -05:00
|
|
|
/**
|
|
|
|
|
* Deck editor. A deck's mainboard cards are split into two groups — "In your
|
|
|
|
|
* library" (owned) and "Not owned" — each with its own search box and each
|
|
|
|
|
* groupable by card attribute; "Combine sections" merges these two into one
|
|
|
|
|
* list. The maybeboard is always shown as its own separate section. Deck-level
|
|
|
|
|
* format + commander metadata sit at the top.
|
2026-07-21 21:04:36 -05:00
|
|
|
*
|
|
|
|
|
* `refreshSignal` is bumped by `App` when something outside this view's own
|
|
|
|
|
* control changes deck contents — e.g. undoing a Library bulk add-to-deck
|
|
|
|
|
* from the command palette while this view is already mounted — so the
|
|
|
|
|
* card list, mana curve, and legality tile recompute immediately instead of
|
|
|
|
|
* only on the next mount.
|
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
|
|
|
*/
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
function DeckBuilder({
|
|
|
|
|
deck,
|
2026-10-23 23:10:40 -05:00
|
|
|
refreshSignal,
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
onBack,
|
|
|
|
|
onDecksChanged,
|
|
|
|
|
onRename,
|
|
|
|
|
onDuplicate,
|
|
|
|
|
onDelete,
|
|
|
|
|
onExport,
|
2026-10-23 23:10:40 -05:00
|
|
|
}: {
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
deck: Deck;
|
2026-10-23 23:10:40 -05:00
|
|
|
refreshSignal?: number;
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
onBack: () => void;
|
|
|
|
|
/** Refetch the deck list after a change to the deck's own fields. */
|
|
|
|
|
onDecksChanged: () => Promise<void>;
|
|
|
|
|
onRename: () => void;
|
|
|
|
|
onDuplicate: () => void;
|
|
|
|
|
onDelete: () => void;
|
|
|
|
|
onExport: () => void;
|
2026-10-23 23:10:40 -05:00
|
|
|
}) {
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
const deckId = deck.id;
|
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 [contents, setContents] = useState<DeckEntry[]>([]);
|
|
|
|
|
const [view, setView] = useViewMode("deck-view");
|
2026-10-16 21:14:43 -05:00
|
|
|
const tiles = useTileColumns();
|
2026-10-26 20:35:21 -05:00
|
|
|
const columns = useColumns("deck-columns", DECK_COLUMNS);
|
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 [groupBy, setGroupBy] = usePersistedState<GroupMode>("deck-group", "none");
|
2026-10-26 21:25:15 -05:00
|
|
|
const [sortBy, setSortBy] = usePersistedState<SortMode>("deck-sort", "name");
|
|
|
|
|
const [sortDir, setSortDir] = usePersistedState<SortDir>("deck-sort-dir", "asc");
|
2026-07-20 00:21:22 -05:00
|
|
|
const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false);
|
2026-07-21 21:04:36 -05:00
|
|
|
const [filterQuery, setFilterQuery] = useState("");
|
2026-10-26 21:31:28 -05:00
|
|
|
// The Library's full card filter, narrowing the sections to the deck cards
|
|
|
|
|
// it matches. Its criteria (rarity, price, …) persist like the Library's.
|
|
|
|
|
const advFilter = useCardFilter("deck-criteria");
|
|
|
|
|
const [advOpen, setAdvOpen] = useState(false);
|
|
|
|
|
const [advMatches, setAdvMatches] = useState<Set<string> | null>(null);
|
|
|
|
|
const [advError, setAdvError] = useState<string | null>(null);
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
const [tagFilter, setTagFilter] = useState<TagFilter>(NO_TAG_FILTER);
|
|
|
|
|
const [tagDialogOpen, setTagDialogOpen] = useState(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
|
|
|
|
|
|
|
|
const [libQuery, setLibQuery] = useState("");
|
|
|
|
|
const [libResults, setLibResults] = useState<CardRef[]>([]);
|
|
|
|
|
const [anyQuery, setAnyQuery] = useState("");
|
|
|
|
|
const [anyResults, setAnyResults] = useState<CardRef[]>([]);
|
|
|
|
|
const [cmdQuery, setCmdQuery] = useState("");
|
|
|
|
|
const [cmdResults, setCmdResults] = useState<CardRef[]>([]);
|
2026-10-23 22:29:56 -05:00
|
|
|
const [partnerQuery, setPartnerQuery] = useState("");
|
|
|
|
|
const [partnerResults, setPartnerResults] = useState<CardRef[]>([]);
|
2026-07-14 21:32:25 -05:00
|
|
|
const [maybeQuery, setMaybeQuery] = useState("");
|
|
|
|
|
const [maybeResults, setMaybeResults] = useState<CardRef[]>([]);
|
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 08:20:20 -05:00
|
|
|
const [pendingRemoval, setPendingRemoval] = useState<Set<string>>(new Set());
|
|
|
|
|
const removalTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
|
|
|
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set());
|
2026-10-26 21:44:16 +00:00
|
|
|
// Selected cards where only some copies are marked for removal (the extras
|
|
|
|
|
// over a format's copy limit), keyed by oracle id → copies to remove.
|
|
|
|
|
const [partialRemoval, setPartialRemoval] = useState<Map<string, number>>(new Map());
|
2026-07-08 08:20:20 -05:00
|
|
|
const [bulkBusy, setBulkBusy] = useState(false);
|
|
|
|
|
const selectAllRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
2026-07-20 00:21:22 -05:00
|
|
|
const [handOpen, setHandOpen] = useState(false);
|
|
|
|
|
const [hand, setHand] = useState<DeckEntry[]>([]);
|
|
|
|
|
const [libraryStack, setLibraryStack] = useState<DeckEntry[]>([]);
|
2026-07-21 21:04:36 -05:00
|
|
|
const [suggestOpen, setSuggestOpen] = useState(false);
|
2026-10-26 22:35:41 +00:00
|
|
|
const [statusDialog, setStatusDialog] = useState<{ to: DeckStatus; kind: "assemble" | "release" } | null>(
|
|
|
|
|
null,
|
|
|
|
|
);
|
2026-07-20 00:21:22 -05:00
|
|
|
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
// Keyed by deck id in `DeckView`, so switching decks remounts with fresh
|
|
|
|
|
// state rather than resetting it here.
|
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 loadContents = useCallback(async () => {
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
setContents(await api.deckContents(deckId));
|
|
|
|
|
}, [deckId]);
|
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(() => {
|
|
|
|
|
loadContents();
|
|
|
|
|
}, [loadContents]);
|
|
|
|
|
|
2026-10-26 21:31:28 -05:00
|
|
|
// Which of the deck's cards the advanced filter matches: the same search
|
|
|
|
|
// the Library runs, scoped to this deck (main board and maybeboard). Null
|
|
|
|
|
// when no filter is on, or the search failed, so nothing is hidden.
|
|
|
|
|
const contentIds = contents.map((e) => e.oracle_id).join(",");
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!advFilter.active) {
|
|
|
|
|
setAdvMatches(null);
|
|
|
|
|
setAdvError(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let live = true;
|
|
|
|
|
const t = setTimeout(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const cards = await api.searchReference(
|
|
|
|
|
{ ...advFilter.filter, in_deck: deckId },
|
|
|
|
|
Math.max(1, contentIds.split(",").length),
|
|
|
|
|
);
|
|
|
|
|
if (!live) return;
|
|
|
|
|
setAdvMatches(new Set(cards.map((c) => c.oracle_id)));
|
|
|
|
|
setAdvError(null);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (!live) return;
|
|
|
|
|
setAdvMatches(null);
|
|
|
|
|
setAdvError(String(e));
|
|
|
|
|
}
|
|
|
|
|
}, 150);
|
|
|
|
|
return () => {
|
|
|
|
|
live = false;
|
|
|
|
|
clearTimeout(t);
|
|
|
|
|
};
|
|
|
|
|
}, [advFilter.filter, advFilter.active, deckId, contentIds]);
|
|
|
|
|
|
2026-07-21 21:04:36 -05:00
|
|
|
// Refetch on an external refresh signal without resetting the UI state
|
|
|
|
|
// above (unlike a deck switch, this shouldn't clear the user's filter,
|
|
|
|
|
// selection, or open sample hand). Skips the first render so it doesn't
|
|
|
|
|
// duplicate the mount-time fetch above.
|
|
|
|
|
const skipFirstRefresh = useRef(true);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (skipFirstRefresh.current) {
|
|
|
|
|
skipFirstRefresh.current = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
loadContents();
|
|
|
|
|
}, [refreshSignal]);
|
|
|
|
|
|
2026-07-08 08:20:20 -05:00
|
|
|
// Cancel any in-flight removal timers on unmount so they don't fire against
|
|
|
|
|
// an unmounted component.
|
|
|
|
|
useEffect(() => () => removalTimers.current.forEach(clearTimeout), []);
|
|
|
|
|
|
|
|
|
|
// Keep selection in sync with what's actually visible — drop ids that fall
|
|
|
|
|
// out of the deck (deck switched, or a card's qty hit 0 and its grace
|
|
|
|
|
// period expired).
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setSelectedCards((s) => {
|
|
|
|
|
const visible = new Set(contents.map((e) => e.oracle_id));
|
|
|
|
|
const next = new Set([...s].filter((id) => visible.has(id)));
|
|
|
|
|
return next.size === s.size ? s : next;
|
|
|
|
|
});
|
2026-10-26 21:44:16 +00:00
|
|
|
setPartialRemoval((m) => {
|
|
|
|
|
const visible = new Set(contents.map((e) => e.oracle_id));
|
|
|
|
|
const next = new Map([...m].filter(([id]) => visible.has(id)));
|
|
|
|
|
return next.size === m.size ? m : next;
|
|
|
|
|
});
|
2026-07-08 08:20:20 -05:00
|
|
|
}, [contents]);
|
|
|
|
|
|
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(() => {
|
|
|
|
|
const q = libQuery.trim();
|
|
|
|
|
if (!q) return setLibResults([]);
|
|
|
|
|
const t = setTimeout(() => api.searchLibrary({ text: q }).then(setLibResults), 100);
|
|
|
|
|
return () => clearTimeout(t);
|
|
|
|
|
}, [libQuery]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const q = anyQuery.trim();
|
|
|
|
|
if (!q) return setAnyResults([]);
|
|
|
|
|
const t = setTimeout(() => api.searchCards(q, 12).then(setAnyResults), 80);
|
|
|
|
|
return () => clearTimeout(t);
|
|
|
|
|
}, [anyQuery]);
|
|
|
|
|
|
2026-07-14 21:32:25 -05:00
|
|
|
useEffect(() => {
|
|
|
|
|
const q = maybeQuery.trim();
|
|
|
|
|
if (!q) return setMaybeResults([]);
|
|
|
|
|
const t = setTimeout(() => api.searchCards(q, 12).then(setMaybeResults), 80);
|
|
|
|
|
return () => clearTimeout(t);
|
|
|
|
|
}, [maybeQuery]);
|
|
|
|
|
|
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(() => {
|
|
|
|
|
const q = cmdQuery.trim();
|
|
|
|
|
if (!q) return setCmdResults([]);
|
|
|
|
|
const t = setTimeout(() => api.searchCards(q, 12).then(setCmdResults), 80);
|
|
|
|
|
return () => clearTimeout(t);
|
|
|
|
|
}, [cmdQuery]);
|
|
|
|
|
|
2026-10-23 22:29:56 -05:00
|
|
|
// The primary commander, whose partner ability the second search box serves.
|
|
|
|
|
const partnerOf =
|
|
|
|
|
deck?.commander_oracle_id ?? contents.find((e) => e.is_commander && !e.is_maybeboard)?.oracle_id ?? null;
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const q = partnerQuery.trim();
|
|
|
|
|
if (!q || !partnerOf) return setPartnerResults([]);
|
|
|
|
|
const t = setTimeout(() => api.searchPartners(partnerOf, q, 12).then(setPartnerResults), 80);
|
|
|
|
|
return () => clearTimeout(t);
|
|
|
|
|
}, [partnerQuery, partnerOf]);
|
|
|
|
|
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
async function addToDeck(card: CardRef) {
|
|
|
|
|
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
|
|
|
|
|
const qty = existing ? existing.quantity + 1 : 1;
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
await api.setDeckCard(deckId, card.oracle_id, qty, false, 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
|
|
|
setLibQuery("");
|
|
|
|
|
setLibResults([]);
|
|
|
|
|
setAnyQuery("");
|
|
|
|
|
setAnyResults([]);
|
|
|
|
|
await loadContents();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 21:32:25 -05:00
|
|
|
async function addToMaybeboard(card: CardRef) {
|
|
|
|
|
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
|
|
|
|
|
const qty = existing ? existing.quantity + 1 : 1;
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
await api.setDeckCard(deckId, card.oracle_id, qty, false, true);
|
2026-07-14 21:32:25 -05:00
|
|
|
setMaybeQuery("");
|
|
|
|
|
setMaybeResults([]);
|
|
|
|
|
await loadContents();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Move a card between the mainboard and the maybeboard. Clears commander
|
2026-10-23 22:29:56 -05:00
|
|
|
* status (dropping it from the deck's commanders) when maybeboarding. */
|
2026-07-14 21:32:25 -05:00
|
|
|
async function setMaybeboard(entry: DeckEntry, isMaybeboard: boolean) {
|
|
|
|
|
const isCommander = isMaybeboard ? false : entry.is_commander;
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
await api.setDeckCard(deckId, entry.oracle_id, entry.quantity, isCommander, isMaybeboard);
|
2026-10-23 22:29:56 -05:00
|
|
|
if (isMaybeboard && entry.is_commander) {
|
|
|
|
|
await api.setDeckCommanders(
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
deckId,
|
2026-10-23 22:29:56 -05:00
|
|
|
commanders.filter((e) => e.oracle_id !== entry.oracle_id).map((e) => e.oracle_id),
|
|
|
|
|
);
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
await onDecksChanged();
|
2026-07-14 21:32:25 -05:00
|
|
|
}
|
|
|
|
|
await loadContents();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 00:21:22 -05:00
|
|
|
/** Every mainboard copy (commander/maybeboard excluded), one entry per
|
|
|
|
|
* physical copy so a card at quantity N is N times as likely to be drawn,
|
|
|
|
|
* shuffled in place (Fisher-Yates). */
|
|
|
|
|
function shuffledLibrary(): DeckEntry[] {
|
|
|
|
|
const pool: DeckEntry[] = [];
|
|
|
|
|
for (const e of contents) {
|
|
|
|
|
if (e.is_commander || e.is_maybeboard) continue;
|
|
|
|
|
for (let i = 0; i < e.quantity; i++) pool.push(e);
|
|
|
|
|
}
|
|
|
|
|
for (let i = pool.length - 1; i > 0; i--) {
|
|
|
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
|
|
|
[pool[i], pool[j]] = [pool[j], pool[i]];
|
|
|
|
|
}
|
|
|
|
|
return pool;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function drawNewHand() {
|
|
|
|
|
const shuffled = shuffledLibrary();
|
|
|
|
|
setHand(shuffled.slice(0, STARTING_HAND_SIZE));
|
|
|
|
|
setLibraryStack(shuffled.slice(STARTING_HAND_SIZE));
|
|
|
|
|
setHandOpen(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function drawOneMore() {
|
|
|
|
|
if (libraryStack.length === 0) return;
|
|
|
|
|
setHand((h) => [...h, libraryStack[0]]);
|
|
|
|
|
setLibraryStack((stack) => stack.slice(1));
|
|
|
|
|
}
|
|
|
|
|
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
async function setQty(oracleId: string, qty: number) {
|
2026-07-08 08:20:20 -05:00
|
|
|
const clamped = Math.max(0, qty);
|
2026-07-14 21:32:25 -05:00
|
|
|
const existing = contents.find((e) => e.oracle_id === oracleId);
|
|
|
|
|
await api.setDeckCard(
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
deckId,
|
2026-07-14 21:32:25 -05:00
|
|
|
oracleId,
|
|
|
|
|
clamped,
|
|
|
|
|
existing?.is_commander ?? false,
|
|
|
|
|
existing?.is_maybeboard ?? false,
|
|
|
|
|
);
|
2026-07-08 08:20:20 -05:00
|
|
|
setContents((cs) => cs.map((e) => (e.oracle_id === oracleId ? { ...e, quantity: clamped } : e)));
|
|
|
|
|
|
|
|
|
|
// Re-adding during the grace period below cancels the pending removal.
|
|
|
|
|
const timer = removalTimers.current.get(oracleId);
|
|
|
|
|
if (timer) {
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
removalTimers.current.delete(oracleId);
|
|
|
|
|
setPendingRemoval((p) => {
|
|
|
|
|
const next = new Set(p);
|
|
|
|
|
next.delete(oracleId);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (clamped <= 0) {
|
|
|
|
|
// deck_contents wouldn't include this card on a fresh fetch anymore —
|
|
|
|
|
// rather than dropping the row immediately, dim it and give the user a
|
|
|
|
|
// window to undo a mistaken decrement (via +) before it disappears.
|
|
|
|
|
setPendingRemoval((p) => new Set(p).add(oracleId));
|
|
|
|
|
removalTimers.current.set(
|
|
|
|
|
oracleId,
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
setContents((cs) => cs.filter((e) => e.oracle_id !== oracleId));
|
|
|
|
|
setPendingRemoval((p) => {
|
|
|
|
|
const next = new Set(p);
|
|
|
|
|
next.delete(oracleId);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
removalTimers.current.delete(oracleId);
|
|
|
|
|
}, REMOVAL_GRACE_MS),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleCard(oracleId: string) {
|
2026-10-26 21:44:16 +00:00
|
|
|
// A manual (de)select always means the whole entry.
|
|
|
|
|
dropPartial(oracleId);
|
2026-07-08 08:20:20 -05:00
|
|
|
setSelectedCards((s) => {
|
|
|
|
|
const next = new Set(s);
|
|
|
|
|
if (next.has(oracleId)) next.delete(oracleId);
|
|
|
|
|
else next.add(oracleId);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-26 21:44:16 +00:00
|
|
|
function dropPartial(oracleId: string) {
|
|
|
|
|
setPartialRemoval((m) => {
|
|
|
|
|
if (!m.has(oracleId)) return m;
|
|
|
|
|
const next = new Map(m);
|
|
|
|
|
next.delete(oracleId);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearSelection() {
|
|
|
|
|
setSelectedCards(new Set());
|
|
|
|
|
setPartialRemoval(new Map());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Replace the selection with the cards behind the deck's card-level
|
|
|
|
|
* legality issues (optionally only some categories). Over-limit copies
|
|
|
|
|
* select just the extras, so removing them leaves the legal count. */
|
|
|
|
|
function selectIllegal(categories?: LegalityCategory[]) {
|
|
|
|
|
const sel = illegalSelection(deckLegality.issues, categories);
|
|
|
|
|
setSelectedCards(new Set(sel.keys()));
|
|
|
|
|
setPartialRemoval(
|
|
|
|
|
new Map([...sel].filter((kv): kv is [string, number] => kv[1] !== null)),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
/** Select every card currently shown (so a filtered view selects just
|
|
|
|
|
* what passes the filter), or clear the selection if they all are. */
|
2026-07-08 08:20:20 -05:00
|
|
|
function toggleSelectAllCards() {
|
2026-10-26 21:44:16 +00:00
|
|
|
setPartialRemoval(new Map());
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
setSelectedCards(allShownSelected ? new Set() : new Set(shownCards.map((e) => e.oracle_id)));
|
2026-07-08 08:20:20 -05:00
|
|
|
}
|
|
|
|
|
|
2026-10-23 22:29:56 -05:00
|
|
|
/** Remove every selected card outright. Unlike stepping one card down to 0,
|
|
|
|
|
* this is a deliberate action, so there's no grace period to linger in. */
|
2026-07-08 08:20:20 -05:00
|
|
|
async function bulkRemoveFromDeck() {
|
2026-10-26 21:44:16 +00:00
|
|
|
// Entries with only some copies selected are trimmed rather than removed.
|
|
|
|
|
const remaining = new Map<string, number>();
|
|
|
|
|
for (const id of selectedCards) {
|
|
|
|
|
const e = contents.find((c) => c.oracle_id === id);
|
|
|
|
|
const n = partialRemoval.get(id);
|
|
|
|
|
remaining.set(id, e && n !== undefined ? Math.max(e.quantity - n, 0) : 0);
|
|
|
|
|
}
|
2026-07-08 08:20:20 -05:00
|
|
|
setBulkBusy(true);
|
|
|
|
|
try {
|
2026-10-23 22:29:56 -05:00
|
|
|
await Promise.all(
|
2026-10-26 21:44:16 +00:00
|
|
|
[...remaining].map(([id, qty]) => {
|
2026-10-23 22:29:56 -05:00
|
|
|
const e = contents.find((c) => c.oracle_id === id);
|
2026-10-26 21:44:16 +00:00
|
|
|
return api.setDeckCard(deckId, id, qty, e?.is_commander ?? false, e?.is_maybeboard ?? false);
|
2026-10-23 22:29:56 -05:00
|
|
|
}),
|
|
|
|
|
);
|
2026-10-26 21:44:16 +00:00
|
|
|
const removed = new Set([...remaining].filter(([, qty]) => qty === 0).map(([id]) => id));
|
|
|
|
|
for (const id of removed) {
|
2026-10-23 22:29:56 -05:00
|
|
|
const timer = removalTimers.current.get(id);
|
|
|
|
|
if (timer) clearTimeout(timer);
|
|
|
|
|
removalTimers.current.delete(id);
|
|
|
|
|
}
|
2026-10-26 21:44:16 +00:00
|
|
|
setPendingRemoval((p) => new Set([...p].filter((id) => !removed.has(id))));
|
|
|
|
|
setContents((cs) =>
|
|
|
|
|
cs
|
|
|
|
|
.filter((e) => !removed.has(e.oracle_id))
|
|
|
|
|
.map((e) => (remaining.has(e.oracle_id) ? { ...e, quantity: remaining.get(e.oracle_id)! } : e)),
|
|
|
|
|
);
|
|
|
|
|
clearSelection();
|
2026-07-08 08:20:20 -05:00
|
|
|
} finally {
|
|
|
|
|
setBulkBusy(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
|
|
|
}
|
|
|
|
|
|
2026-10-23 22:56:59 -05:00
|
|
|
/** Record cards as owned: as many copies as the deck uses beyond those
|
|
|
|
|
* already owned, so an acquired card moves to "In your library". */
|
|
|
|
|
async function addToLibrary(entries: DeckEntry[]) {
|
|
|
|
|
await Promise.all(entries.filter((e) => shortBy(e) > 0).map((e) => api.addOwned(e.oracle_id, shortBy(e))));
|
|
|
|
|
await loadContents();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function bulkAddToLibrary() {
|
|
|
|
|
setBulkBusy(true);
|
|
|
|
|
try {
|
|
|
|
|
await addToLibrary(contents.filter((e) => selectedCards.has(e.oracle_id)));
|
2026-10-26 21:44:16 +00:00
|
|
|
clearSelection();
|
2026-10-23 22:56:59 -05:00
|
|
|
} finally {
|
|
|
|
|
setBulkBusy(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
async function changeTags(oracleIds: string[], add: string[], remove: string[]) {
|
|
|
|
|
await api.setDeckCardTags(deckId, oracleIds, add, remove);
|
|
|
|
|
await loadContents();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Clicking a tag chip adds it to the tag filter, or takes it back out. */
|
|
|
|
|
function toggleTagFilter(tag: string) {
|
|
|
|
|
setTagFilter((f) => {
|
|
|
|
|
const has = !f.untagged && f.tags.some((t) => t.toLowerCase() === tag.toLowerCase());
|
|
|
|
|
return {
|
|
|
|
|
...f,
|
|
|
|
|
untagged: false,
|
|
|
|
|
tags: has ? f.tags.filter((t) => t.toLowerCase() !== tag.toLowerCase()) : [...(f.untagged ? [] : f.tags), tag],
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
async function changeFormat(format: string) {
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
await api.setDeckFormat(deckId, format);
|
|
|
|
|
await onDecksChanged();
|
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-10-26 22:35:41 +00:00
|
|
|
async function applyStatus(status: DeckStatus, assembled = false) {
|
|
|
|
|
setStatusDialog(null);
|
|
|
|
|
await api.setDeckStatus(deckId, status, assembled);
|
|
|
|
|
await onDecksChanged();
|
|
|
|
|
await loadContents();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Change status, first asking how to treat the cards when the deck starts
|
|
|
|
|
* or stops physically holding them. */
|
|
|
|
|
function changeStatus(status: DeckStatus) {
|
|
|
|
|
if (status === deck.status) return;
|
|
|
|
|
if (isPhysical(status) && !isPhysical(deck.status) && contents.some((e) => !e.is_maybeboard)) {
|
|
|
|
|
setStatusDialog({ to: status, kind: "assemble" });
|
|
|
|
|
} else if (!isPhysical(status) && pulledCopies > 0) {
|
|
|
|
|
setStatusDialog({ to: status, kind: "release" });
|
|
|
|
|
} else {
|
|
|
|
|
applyStatus(status);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Mark as many copies pulled as the deck needs (the backend clamps to
|
|
|
|
|
* what's actually in the box). */
|
|
|
|
|
async function pullCard(e: DeckEntry) {
|
|
|
|
|
await api.setDeckCardPulled(deckId, e.oracle_id, e.quantity);
|
|
|
|
|
await loadContents();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function takeCard(e: DeckEntry, from: DeckHolding) {
|
|
|
|
|
await api.takeDeckCardCopy(from.deck_id, deckId, e.oracle_id);
|
|
|
|
|
await loadContents();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function bulkSetPulled(pulled: boolean) {
|
|
|
|
|
setBulkBusy(true);
|
|
|
|
|
try {
|
|
|
|
|
await Promise.all(
|
|
|
|
|
contents
|
|
|
|
|
.filter((e) => selectedCards.has(e.oracle_id) && !e.is_maybeboard)
|
|
|
|
|
.map((e) => api.setDeckCardPulled(deckId, e.oracle_id, pulled ? e.quantity : 0)),
|
|
|
|
|
);
|
|
|
|
|
clearSelection();
|
|
|
|
|
await loadContents();
|
|
|
|
|
} finally {
|
|
|
|
|
setBulkBusy(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
|
|
|
async function setCommander(card: CardRef) {
|
2026-07-14 21:32:25 -05:00
|
|
|
// Ensure the card is in the deck (mainboard, not maybeboard) and flagged
|
|
|
|
|
// commander — covers both "not in the deck yet" and "was maybeboarded".
|
|
|
|
|
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
await api.setDeckCard(deckId, card.oracle_id, existing?.quantity ?? 1, true, false);
|
2026-10-23 22:29:56 -05:00
|
|
|
// Keep the partner only if it can still pair with the new commander; if
|
|
|
|
|
// not, it stays in the deck as an ordinary card.
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
const picked = (await api.deckContents(deckId)).find((e) => e.oracle_id === card.oracle_id);
|
2026-10-23 22:29:56 -05:00
|
|
|
const keepPartner =
|
|
|
|
|
partnerEntry && partnerEntry.oracle_id !== card.oracle_id && picked && pairsWith(picked, partnerEntry);
|
|
|
|
|
await api.setDeckCommanders(
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
deckId,
|
2026-10-23 22:29:56 -05:00
|
|
|
keepPartner ? [card.oracle_id, partnerEntry.oracle_id] : [card.oracle_id],
|
|
|
|
|
);
|
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
|
|
|
setCmdQuery("");
|
|
|
|
|
setCmdResults([]);
|
|
|
|
|
await loadContents();
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
await onDecksChanged();
|
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-10-23 22:29:56 -05:00
|
|
|
async function addPartner(card: CardRef) {
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
if (!commanderEntry) return;
|
2026-10-23 22:29:56 -05:00
|
|
|
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
await api.setDeckCard(deckId, card.oracle_id, existing?.quantity ?? 1, true, false);
|
|
|
|
|
await api.setDeckCommanders(deckId, [commanderEntry.oracle_id, card.oracle_id]);
|
2026-10-23 22:29:56 -05:00
|
|
|
setPartnerQuery("");
|
|
|
|
|
setPartnerResults([]);
|
|
|
|
|
await loadContents();
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
await onDecksChanged();
|
2026-10-23 22:29:56 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Stop treating a card as a commander; it stays in the deck. Clearing the
|
|
|
|
|
* primary commander promotes its partner. */
|
|
|
|
|
async function clearCommander(entry: DeckEntry) {
|
|
|
|
|
await api.setDeckCommanders(
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
deckId,
|
2026-10-23 22:29:56 -05:00
|
|
|
commanders.filter((e) => e.oracle_id !== entry.oracle_id).map((e) => e.oracle_id),
|
|
|
|
|
);
|
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
|
|
|
await loadContents();
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
await onDecksChanged();
|
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-21 21:04:36 -05:00
|
|
|
// Quick-search over cards already in the deck/maybeboard — a display
|
|
|
|
|
// filter only, distinct from the CardSearchBox boxes below (which search
|
|
|
|
|
// the library/reference db to find NEW cards to add).
|
|
|
|
|
function matchesFilter(e: DeckEntry): boolean {
|
2026-10-26 21:31:28 -05:00
|
|
|
if (advMatches && !advMatches.has(e.oracle_id)) return false;
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
if (!matchesTagFilter(e, tagFilter)) return false;
|
2026-10-23 23:05:19 -05:00
|
|
|
const q = fold(filterQuery.trim());
|
2026-07-21 21:04:36 -05:00
|
|
|
if (!q) return true;
|
2026-10-23 23:05:19 -05:00
|
|
|
return fold(e.name).includes(q) || fold(e.type_line).includes(q);
|
2026-07-21 21:04:36 -05:00
|
|
|
}
|
|
|
|
|
|
2026-10-26 22:35:41 +00:00
|
|
|
// Building/built decks split by what's physically in them; ideas and
|
|
|
|
|
// retired decks by whether the cards are free to use.
|
|
|
|
|
const physical = isPhysical(deck.status);
|
|
|
|
|
const mainboard = contents.filter((e) => !e.is_maybeboard && matchesFilter(e));
|
|
|
|
|
const toPull = mainboard.filter((e) => e.quantity_pulled < e.quantity);
|
|
|
|
|
const inDeck = mainboard.filter((e) => e.quantity_pulled >= e.quantity);
|
|
|
|
|
const available = mainboard.filter((e) => e.quantity_owned > 0 && e.quantity_spare >= e.quantity);
|
|
|
|
|
const elsewhere = mainboard.filter((e) => e.quantity_owned > 0 && e.quantity_spare < e.quantity);
|
|
|
|
|
const unowned = mainboard.filter((e) => e.quantity_owned === 0);
|
|
|
|
|
const pulledCopies = contents.reduce((n, e) => n + (e.is_maybeboard ? 0 : e.quantity_pulled), 0);
|
|
|
|
|
const leftToPull = contents.filter((e) => !e.is_maybeboard && e.quantity_pulled < e.quantity).length;
|
2026-07-21 21:04:36 -05:00
|
|
|
const maybeboard = contents.filter((e) => e.is_maybeboard && matchesFilter(e));
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
const shownCards = [...mainboard, ...maybeboard];
|
|
|
|
|
const allShownSelected = shownCards.length > 0 && shownCards.every((e) => selectedCards.has(e.oracle_id));
|
|
|
|
|
// Every tag used in the deck, in the casing first met, for autocomplete
|
|
|
|
|
// and the tag filter.
|
|
|
|
|
const deckTags = useMemo(() => {
|
|
|
|
|
const seen = new Map<string, string>();
|
|
|
|
|
for (const e of contents) for (const t of e.tags) if (!seen.has(t.toLowerCase())) seen.set(t.toLowerCase(), t);
|
|
|
|
|
return [...seen.values()].sort((a, b) => a.localeCompare(b));
|
|
|
|
|
}, [contents]);
|
|
|
|
|
const tagsOn = tagFilterActive(tagFilter);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (selectAllRef.current) {
|
|
|
|
|
selectAllRef.current.indeterminate = selectedCards.size > 0 && !allShownSelected;
|
|
|
|
|
}
|
|
|
|
|
}, [selectedCards, allShownSelected]);
|
|
|
|
|
// The popup's "N of M match": both the backend-matched criteria and tags.
|
|
|
|
|
const advMatchedCount =
|
|
|
|
|
advMatches || tagsOn
|
|
|
|
|
? contents.filter((e) => (!advMatches || advMatches.has(e.oracle_id)) && matchesTagFilter(e, tagFilter)).length
|
|
|
|
|
: null;
|
2026-10-23 22:29:56 -05:00
|
|
|
// The primary commander first, then its partner, if any.
|
|
|
|
|
const isPrimary = (e: DeckEntry) => Number(e.oracle_id === deck?.commander_oracle_id);
|
|
|
|
|
const commanders = contents
|
|
|
|
|
.filter((e) => e.is_commander && !e.is_maybeboard)
|
|
|
|
|
.sort((a, b) => isPrimary(b) - isPrimary(a));
|
|
|
|
|
const commanderEntry: DeckEntry | undefined = commanders[0];
|
|
|
|
|
const partnerEntry: DeckEntry | undefined = commanders[1];
|
2026-07-08 23:54:47 -05:00
|
|
|
const deckStats = useMemo(() => computeDeckStats(contents), [contents]);
|
2026-07-20 00:21:22 -05:00
|
|
|
const deckLegality = useMemo(() => computeDeckLegality(deck, contents), [deck, contents]);
|
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-10-26 21:13:29 -05:00
|
|
|
// The card sections below, in order, for the section bar to jump between.
|
|
|
|
|
// Mirrors the branches that render them.
|
|
|
|
|
const sections: NavSection[] = [
|
|
|
|
|
...(combineSections
|
|
|
|
|
? [{ id: "all", label: "All cards", count: mainboard.length }]
|
|
|
|
|
: physical
|
|
|
|
|
? [
|
|
|
|
|
{ id: "pull", label: "To pull", count: toPull.length },
|
|
|
|
|
{ id: "in-deck", label: "In deck", count: inDeck.length },
|
|
|
|
|
]
|
|
|
|
|
: [
|
|
|
|
|
{ id: "available", label: "Available", count: available.length },
|
|
|
|
|
...(elsewhere.length > 0 ? [{ id: "elsewhere", label: "In other decks", count: elsewhere.length }] : []),
|
|
|
|
|
{ id: "unowned", label: "Not owned", count: unowned.length },
|
|
|
|
|
]),
|
|
|
|
|
{ id: "maybe", label: "Maybeboard", count: maybeboard.length },
|
|
|
|
|
];
|
|
|
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
|
|
|
|
|
const sectionRef = (id: string) => (el: HTMLElement | null) => {
|
|
|
|
|
sectionRefs.current[id] = el;
|
|
|
|
|
};
|
|
|
|
|
const [activeSection, setActiveSection] = useState(sections[0].id);
|
|
|
|
|
const sectionIds = sections.map((s) => s.id).join(",");
|
|
|
|
|
|
|
|
|
|
// Scroll-spy: the current section is the last one whose top has reached
|
|
|
|
|
// where a jump lands it (just under the bar, per its scroll-margin) — or
|
|
|
|
|
// the last one outright once scrolled to the bottom, so a short
|
|
|
|
|
// Maybeboard can still become current.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const scroller = rootRef.current?.closest(".content");
|
|
|
|
|
if (!scroller) return;
|
|
|
|
|
let frame = 0;
|
|
|
|
|
const update = () => {
|
|
|
|
|
frame = 0;
|
|
|
|
|
const ids = sectionIds.split(",");
|
|
|
|
|
const top = scroller.getBoundingClientRect().top;
|
|
|
|
|
let current = ids[0];
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
const el = sectionRefs.current[id];
|
|
|
|
|
if (el && el.getBoundingClientRect().top <= top + parseFloat(getComputedStyle(el).scrollMarginTop) + 1) {
|
|
|
|
|
current = id;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2) current = ids[ids.length - 1];
|
|
|
|
|
setActiveSection(current);
|
|
|
|
|
};
|
|
|
|
|
const onScroll = () => {
|
|
|
|
|
if (!frame) frame = requestAnimationFrame(update);
|
|
|
|
|
};
|
|
|
|
|
update();
|
|
|
|
|
scroller.addEventListener("scroll", onScroll, { passive: true });
|
|
|
|
|
return () => {
|
|
|
|
|
scroller.removeEventListener("scroll", onScroll);
|
|
|
|
|
cancelAnimationFrame(frame);
|
|
|
|
|
};
|
|
|
|
|
}, [sectionIds]);
|
|
|
|
|
|
|
|
|
|
const scrollBehavior = (): ScrollBehavior =>
|
|
|
|
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
|
|
|
|
|
const jumpTo = (id: string) => sectionRefs.current[id]?.scrollIntoView({ behavior: scrollBehavior(), block: "start" });
|
|
|
|
|
const jumpToTop = () => rootRef.current?.closest(".content")?.scrollTo({ top: 0, behavior: scrollBehavior() });
|
|
|
|
|
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
// Lets the click preview track copies and choose each slot's printing.
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
const preview: PreviewContext = {
|
|
|
|
|
deckId,
|
|
|
|
|
coverOracleId: deck.cover_oracle_id,
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
tagSuggestions: deckTags,
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
onChanged: () => {
|
|
|
|
|
loadContents();
|
|
|
|
|
onDecksChanged();
|
|
|
|
|
},
|
|
|
|
|
};
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
2026-10-26 22:35:41 +00:00
|
|
|
/** Where a card's copies are, when that's worth saying: for a deck being
|
|
|
|
|
* built, where to pull what's missing from; for an idea, which real decks
|
|
|
|
|
* already use the copies. Null when there's nothing to act on. */
|
|
|
|
|
function whereabouts(e: DeckEntry, compact = false) {
|
|
|
|
|
if (e.is_maybeboard) return null;
|
|
|
|
|
if (physical) {
|
|
|
|
|
if (e.quantity_pulled >= e.quantity) return null;
|
|
|
|
|
const progress = e.quantity_pulled > 0 && (
|
|
|
|
|
<span className="muted">
|
|
|
|
|
{e.quantity_pulled}/{e.quantity} pulled
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
const holders = e.claimed_by.filter((h) => h.pulled > 0);
|
|
|
|
|
let source;
|
|
|
|
|
if (e.quantity_in_box > 0) {
|
|
|
|
|
source = (
|
|
|
|
|
<>
|
|
|
|
|
<span className="pull-source">In {e.location || "your library"}</span>
|
|
|
|
|
<button className="pull-action" onClick={() => pullCard(e)}>
|
|
|
|
|
Pulled
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
} else if (holders.length > 0) {
|
|
|
|
|
source = holders.map((h) => (
|
|
|
|
|
<span key={h.deck_id} className="row" style={{ gap: 4 }}>
|
|
|
|
|
<span className="pull-source">In {h.name}</span>
|
|
|
|
|
<button className="pull-action" onClick={() => takeCard(e, h)} title={`Move a copy from ${h.name}`}>
|
|
|
|
|
Take
|
|
|
|
|
</button>
|
|
|
|
|
</span>
|
|
|
|
|
));
|
|
|
|
|
} else if (e.quantity_owned === 0) {
|
|
|
|
|
source = <span className="pull-source muted">Not owned</span>;
|
|
|
|
|
} else {
|
|
|
|
|
source = (
|
|
|
|
|
<span className="pull-source muted">Need {copies(e.quantity - e.quantity_pulled)} more</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<span className={`whereabouts ${compact ? "compact" : ""}`}>
|
|
|
|
|
{progress}
|
|
|
|
|
{source}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (e.quantity_owned === 0 || e.quantity_spare >= e.quantity) return null;
|
|
|
|
|
const names = e.claimed_by.map((h) => h.name).join(", ");
|
|
|
|
|
return (
|
|
|
|
|
<span className={`whereabouts ${compact ? "compact" : ""}`}>
|
|
|
|
|
<span className="pull-source" title={`Copies claimed by: ${names}`}>
|
|
|
|
|
In {names}
|
|
|
|
|
</span>
|
|
|
|
|
{e.quantity_spare > 0 && (
|
|
|
|
|
<span className="muted">
|
|
|
|
|
{e.quantity_spare} of {e.quantity} free
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</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
|
|
|
function deckTiles(entries: DeckEntry[]) {
|
|
|
|
|
return (
|
2026-10-16 21:14:43 -05:00
|
|
|
<div {...tiles.gridProps}>
|
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
|
|
|
{entries.map((e) => (
|
|
|
|
|
<CardTile
|
|
|
|
|
key={e.oracle_id}
|
|
|
|
|
card={e}
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
preview={preview}
|
2026-07-08 08:20:20 -05:00
|
|
|
selected={selectedCards.has(e.oracle_id)}
|
|
|
|
|
cornerLeft={
|
|
|
|
|
<span className="tile-select">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={selectedCards.has(e.oracle_id)}
|
|
|
|
|
onChange={() => toggleCard(e.oracle_id)}
|
|
|
|
|
/>
|
|
|
|
|
</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
|
|
|
corner={
|
2026-07-08 08:20:20 -05:00
|
|
|
<QtyStepper
|
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
|
|
|
overlay
|
2026-07-08 08:20:20 -05:00
|
|
|
value={e.quantity}
|
|
|
|
|
onDecrement={() => setQty(e.oracle_id, e.quantity - 1)}
|
|
|
|
|
onIncrement={() => setQty(e.oracle_id, e.quantity + 1)}
|
|
|
|
|
warn={e.quantity_owned > 0 && e.quantity > e.quantity_owned}
|
|
|
|
|
pending={pendingRemoval.has(e.oracle_id)}
|
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
|
|
|
/>
|
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-20 00:21:22 -05:00
|
|
|
cornerBottomLeft={
|
2026-10-23 22:56:59 -05:00
|
|
|
<span className="row" style={{ gap: 4 }}>
|
|
|
|
|
<IconButton overlay {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
|
|
|
|
|
{e.quantity_owned === 0 && (
|
|
|
|
|
<IconButton
|
|
|
|
|
overlay
|
|
|
|
|
icon={Icons.libraryAdd}
|
|
|
|
|
label={libraryAddLabel(e)}
|
|
|
|
|
onClick={() => addToLibrary([e])}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
2026-07-14 21:32:25 -05:00
|
|
|
}
|
2026-10-26 21:44:16 +00:00
|
|
|
footer={
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
e.is_commander || partialRemoval.has(e.oracle_id) || e.tags.length > 0 || whereabouts(e) ? (
|
2026-10-26 22:35:41 +00:00
|
|
|
<span className="row" style={{ gap: 4, flexWrap: "wrap" }}>
|
2026-10-26 21:44:16 +00:00
|
|
|
{e.is_commander && <span className="tile-badge">★ commander</span>}
|
|
|
|
|
{partialRemoval.has(e.oracle_id) && (
|
|
|
|
|
<span className="tile-badge">{extraCopiesLabel(partialRemoval.get(e.oracle_id)!)}</span>
|
|
|
|
|
)}
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
{e.tags.map((t) => (
|
|
|
|
|
<span key={t} className="tile-badge tile-tag">
|
|
|
|
|
{t}
|
|
|
|
|
</span>
|
|
|
|
|
))}
|
2026-10-26 22:35:41 +00:00
|
|
|
{whereabouts(e, true)}
|
2026-10-26 21:44:16 +00:00
|
|
|
</span>
|
|
|
|
|
) : 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
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function deckTable(entries: DeckEntry[]) {
|
|
|
|
|
return (
|
|
|
|
|
<table>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
2026-07-08 08:20:20 -05:00
|
|
|
<th></th>
|
2026-10-26 20:35:21 -05:00
|
|
|
{columns.shown("image") && <th></th>}
|
|
|
|
|
{columns.shown("colors") && <th>Colors</th>}
|
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
|
|
|
<th>Name</th>
|
2026-10-26 20:35:21 -05:00
|
|
|
{columns.shown("type") && <th>Type</th>}
|
|
|
|
|
{columns.shown("qty") && <th>In deck</th>}
|
|
|
|
|
{columns.shown("owned") && <th>Owned</th>}
|
2026-10-26 21:25:15 -05:00
|
|
|
{columns.shown("price") && <th>Price</th>}
|
2026-10-26 20:35:21 -05:00
|
|
|
{columns.shown("where") && <th>Where</th>}
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
{columns.shown("tags") && <th>Tags</th>}
|
2026-10-26 20:35:21 -05:00
|
|
|
{columns.shown("actions") && <th></th>}
|
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
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{entries.map((e) => (
|
|
|
|
|
<tr key={e.oracle_id}>
|
2026-08-02 16:10:25 -05:00
|
|
|
<td className="cell-check">
|
2026-07-08 08:20:20 -05:00
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={selectedCards.has(e.oracle_id)}
|
|
|
|
|
onChange={() => toggleCard(e.oracle_id)}
|
|
|
|
|
/>
|
|
|
|
|
</td>
|
2026-10-26 20:35:21 -05:00
|
|
|
{columns.shown("image") && (
|
|
|
|
|
<td className="cell-thumb">
|
|
|
|
|
<CardThumb card={e} preview={preview} />
|
|
|
|
|
</td>
|
|
|
|
|
)}
|
|
|
|
|
{columns.shown("colors") && (
|
|
|
|
|
<td>
|
|
|
|
|
<ColorPips mask={e.color_identity} />
|
|
|
|
|
</td>
|
|
|
|
|
)}
|
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
|
|
|
<td className="card-name">
|
|
|
|
|
{e.is_commander && <span title="Commander">★ </span>}
|
|
|
|
|
{e.name}
|
2026-10-26 21:44:16 +00:00
|
|
|
{partialRemoval.has(e.oracle_id) && (
|
|
|
|
|
<span className="badge" style={{ marginLeft: 6 }}>
|
|
|
|
|
{extraCopiesLabel(partialRemoval.get(e.oracle_id)!)}
|
|
|
|
|
</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
|
|
|
</td>
|
2026-10-26 20:35:21 -05:00
|
|
|
{columns.shown("type") && <td className="card-type">{e.type_line}</td>}
|
|
|
|
|
{columns.shown("qty") && (
|
|
|
|
|
<td data-label="In deck">
|
|
|
|
|
<QtyStepper
|
|
|
|
|
value={e.quantity}
|
|
|
|
|
onDecrement={() => setQty(e.oracle_id, e.quantity - 1)}
|
|
|
|
|
onIncrement={() => setQty(e.oracle_id, e.quantity + 1)}
|
|
|
|
|
warn={e.quantity_owned > 0 && e.quantity > e.quantity_owned}
|
|
|
|
|
pending={pendingRemoval.has(e.oracle_id)}
|
|
|
|
|
/>
|
|
|
|
|
</td>
|
|
|
|
|
)}
|
|
|
|
|
{columns.shown("owned") && <td data-label="Owned">{e.quantity_owned}</td>}
|
2026-10-26 21:25:15 -05:00
|
|
|
{columns.shown("price") && (
|
|
|
|
|
<td
|
|
|
|
|
data-label="Price"
|
|
|
|
|
className="cell-price"
|
|
|
|
|
title={
|
|
|
|
|
e.price_usd > 0 && e.quantity > 1
|
|
|
|
|
? `${formatCost(e.price_usd * e.quantity)} for ${e.quantity} copies`
|
|
|
|
|
: undefined
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{e.price_usd > 0 ? `$${e.price_usd.toFixed(2)}` : "—"}
|
|
|
|
|
</td>
|
|
|
|
|
)}
|
2026-10-26 20:35:21 -05:00
|
|
|
{columns.shown("where") && <td data-label="Where">{whereabouts(e)}</td>}
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
{columns.shown("tags") && (
|
|
|
|
|
<td data-label="Tags">
|
|
|
|
|
<TagChips tags={e.tags} filter={tagFilter} onToggle={toggleTagFilter} />
|
|
|
|
|
</td>
|
|
|
|
|
)}
|
2026-10-26 20:35:21 -05:00
|
|
|
{columns.shown("actions") && (
|
|
|
|
|
<td>
|
|
|
|
|
<span className="row" style={{ gap: 4 }}>
|
|
|
|
|
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
|
|
|
|
|
{e.quantity_owned === 0 && (
|
|
|
|
|
<IconButton icon={Icons.libraryAdd} label={libraryAddLabel(e)} onClick={() => addToLibrary([e])} />
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
)}
|
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
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cardsGroup(entries: DeckEntry[]) {
|
|
|
|
|
if (entries.length === 0) return <p className="hint">None yet.</p>;
|
2026-10-26 21:25:15 -05:00
|
|
|
return groupCards(sortCards(entries, sortBy, sortDir), groupBy).map((g) => (
|
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 key={g.label || "all"} style={{ marginBottom: g.label ? 14 : 0 }}>
|
|
|
|
|
{g.label && (
|
2026-07-20 00:21:22 -05:00
|
|
|
<h4 className="group-header">
|
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
|
|
|
{g.label} <span className="badge">{g.cards.length}</span>
|
|
|
|
|
</h4>
|
|
|
|
|
)}
|
|
|
|
|
{view === "tiles" ? deckTiles(g.cards) : deckTable(g.cards)}
|
|
|
|
|
</div>
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-10-26 21:13:29 -05:00
|
|
|
<div className="deck-builder" ref={rootRef}>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
<button className="deck-builder-back" onClick={onBack}>
|
|
|
|
|
{Icons.arrowLeft}
|
|
|
|
|
All decks
|
|
|
|
|
</button>
|
|
|
|
|
<div className="row" style={{ justifyContent: "space-between" }}>
|
|
|
|
|
<div className="row deck-builder-title">
|
|
|
|
|
<h2>{deck.name}</h2>
|
|
|
|
|
<IconButton icon={Icons.pencil} label="Rename deck" onClick={onRename} />
|
|
|
|
|
<IconButton icon={Icons.copy} label="Duplicate deck" onClick={onDuplicate} />
|
|
|
|
|
<IconButton icon={Icons.trash} label="Delete deck" danger onClick={onDelete} />
|
2026-07-20 00:21:22 -05:00
|
|
|
</div>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
<div className="row" style={{ gap: 8 }}>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setSuggestOpen(true)}
|
|
|
|
|
disabled={contents.filter((e) => !e.is_maybeboard).length === 0}
|
|
|
|
|
>
|
|
|
|
|
{Icons.cards}
|
|
|
|
|
Suggest cards
|
|
|
|
|
</button>
|
|
|
|
|
<button onClick={onExport}>
|
|
|
|
|
{Icons.download}
|
|
|
|
|
Export deck
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={drawNewHand}
|
|
|
|
|
disabled={!contents.some((e) => !e.is_commander && !e.is_maybeboard)}
|
|
|
|
|
>
|
|
|
|
|
{Icons.cards}
|
|
|
|
|
Sample hand
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
</button>
|
|
|
|
|
</div>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="deck-top-grid">
|
|
|
|
|
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
|
|
|
|
|
<div className="deck-top-commander">
|
|
|
|
|
{commanders.map((c) => (
|
|
|
|
|
<CommanderHero key={c.oracle_id} card={c} onClear={() => clearCommander(c)} preview={preview} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
|
|
|
|
|
<div className="deck-top-meta">
|
|
|
|
|
<div className="panel deck-meta">
|
|
|
|
|
<label className="row" style={{ gap: 6 }}>
|
|
|
|
|
<span className="muted">Format</span>
|
|
|
|
|
<select value={deck.format} onChange={(e) => changeFormat(e.target.value)}>
|
|
|
|
|
{FORMATS.map((f) => (
|
|
|
|
|
<option key={f} value={f}>
|
|
|
|
|
{f || "—"}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
2026-10-26 22:35:41 +00:00
|
|
|
<label className="row" style={{ gap: 6 }}>
|
|
|
|
|
<span className="muted">Status</span>
|
|
|
|
|
<select value={deck.status} onChange={(e) => changeStatus(e.target.value as DeckStatus)}>
|
|
|
|
|
{DECK_STATUSES.map((s) => (
|
|
|
|
|
<option key={s.value} value={s.value}>
|
|
|
|
|
{s.label}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
<span className="muted deck-status-hint">
|
|
|
|
|
{deck.status === "building" && leftToPull > 0
|
|
|
|
|
? `Being assembled: ${leftToPull} card${leftToPull === 1 ? "" : "s"} left to pull.`
|
|
|
|
|
: deck.status === "built" && leftToPull > 0
|
|
|
|
|
? `Assembled, with ${leftToPull} card${leftToPull === 1 ? "" : "s"} still to pull.`
|
|
|
|
|
: STATUS_HINTS[deck.status]}
|
|
|
|
|
</span>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
|
|
|
|
|
{COMMANDER_FORMATS.includes(deck.format) && (
|
|
|
|
|
<div className="commander-box">
|
|
|
|
|
{!commanderEntry && <span className="muted">No commander set yet.</span>}
|
|
|
|
|
<CardSearchBox
|
|
|
|
|
placeholder={
|
|
|
|
|
commanderEntry
|
|
|
|
|
? "Search to change the commander…"
|
|
|
|
|
: "Search to set the commander…"
|
|
|
|
|
}
|
|
|
|
|
value={cmdQuery}
|
|
|
|
|
onChange={setCmdQuery}
|
|
|
|
|
results={cmdResults}
|
|
|
|
|
onPick={setCommander}
|
|
|
|
|
/>
|
|
|
|
|
{commanderEntry?.partner && !partnerEntry && (
|
|
|
|
|
<CardSearchBox
|
|
|
|
|
placeholder={partnerPlaceholder(commanderEntry.partner)}
|
|
|
|
|
value={partnerQuery}
|
|
|
|
|
onChange={setPartnerQuery}
|
|
|
|
|
results={partnerResults}
|
|
|
|
|
onPick={addPartner}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-07-08 23:54:47 -05:00
|
|
|
</div>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{contents.length > 0 && (
|
|
|
|
|
<div className="deck-top-stats">
|
|
|
|
|
<div className="panel deck-stats">
|
|
|
|
|
<div className="deck-stats-head section-label">Deck analysis</div>
|
|
|
|
|
<div className="stat-tiles-row">
|
|
|
|
|
<StatTile value={deckStats.totalCards} label="cards" />
|
|
|
|
|
<StatTile value={deckStats.uniqueCards} label="unique" />
|
|
|
|
|
<StatTile value={deckStats.landCount} label="lands" />
|
|
|
|
|
<StatTile value={deckStats.avgManaValue.toFixed(2)} label="avg MV" />
|
|
|
|
|
<StatTile value={formatCost(deckStats.totalCost)} label="cost" />
|
2026-10-28 22:03:45 -05:00
|
|
|
{(deck.status === "idea" || deck.status === "building") && (
|
|
|
|
|
<>
|
|
|
|
|
<StatTile value={formatCost(deckStats.unownedCost)} label="not owned" />
|
|
|
|
|
<StatTile value={formatCost(deckStats.elsewhereCost)} label="in other decks" />
|
|
|
|
|
</>
|
|
|
|
|
)}
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
</div>
|
|
|
|
|
<div className="deck-stats-body">
|
|
|
|
|
<div className="deck-stats-numbers">
|
|
|
|
|
<div className="deck-stats-curve-label">Color identity</div>
|
|
|
|
|
<ColorDistributionBar
|
|
|
|
|
colorCounts={deckStats.colorCounts}
|
|
|
|
|
colorlessCount={deckStats.colorlessCount}
|
2026-07-08 23:54:47 -05:00
|
|
|
/>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
<div className="deck-stats-curve-label">Mana production</div>
|
|
|
|
|
<ColorDistributionBar
|
|
|
|
|
colorCounts={deckStats.manaProduction}
|
|
|
|
|
colorlessCount={deckStats.colorlessProduction}
|
2026-07-08 23:54:47 -05:00
|
|
|
/>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
<div className="deck-stats-curve-label">Mana consumption</div>
|
|
|
|
|
<ColorDistributionBar
|
|
|
|
|
colorCounts={deckStats.manaConsumption}
|
|
|
|
|
colorlessCount={deckStats.colorlessConsumption}
|
2026-07-08 23:54:47 -05:00
|
|
|
/>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
</div>
|
|
|
|
|
<div className="deck-stats-curve">
|
|
|
|
|
<div className="deck-stats-curve-label">Mana curve (nonland)</div>
|
|
|
|
|
<ManaCurve buckets={deckStats.curve} />
|
|
|
|
|
</div>
|
2026-07-08 23:54:47 -05:00
|
|
|
</div>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -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
|
|
|
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
{deck.format !== "Other" && (
|
|
|
|
|
<div className="deck-top-legality">
|
|
|
|
|
<div className={`legality-tile ${deckLegality.status}`}>
|
|
|
|
|
<div className="legality-tile-head">
|
|
|
|
|
<span className="legality-tile-icon">
|
|
|
|
|
{deckLegality.status === "legal" ? "✓" : deckLegality.status === "illegal" ? "✗" : "—"}
|
2026-08-02 16:10:25 -05:00
|
|
|
</span>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
<div className="legality-tile-info">
|
|
|
|
|
<span className="legality-tile-status">
|
|
|
|
|
{deckLegality.status === "legal"
|
|
|
|
|
? "Legal"
|
|
|
|
|
: deckLegality.status === "illegal"
|
|
|
|
|
? "Illegal"
|
|
|
|
|
: "Not checked"}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="legality-tile-detail">
|
|
|
|
|
{deckLegality.status === "legal"
|
|
|
|
|
? `Meets all ${deck.format} deck-building rules.`
|
|
|
|
|
: deckLegality.status === "illegal"
|
2026-10-26 21:44:16 +00:00
|
|
|
? `${deckLegality.issues.length} issue${deckLegality.issues.length === 1 ? "" : "s"} found:`
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
: deck.format
|
|
|
|
|
? `Legality isn't checked for "${deck.format}".`
|
|
|
|
|
: "Set a format to check legality."}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-10-26 21:44:16 +00:00
|
|
|
{illegalSelection(deckLegality.issues).size > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<span className="spacer" />
|
|
|
|
|
<button className="legality-tile-select" onClick={() => selectIllegal()}>
|
|
|
|
|
Select illegal cards
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-07-08 08:20:20 -05:00
|
|
|
</div>
|
2026-10-26 21:44:16 +00:00
|
|
|
{deckLegality.status === "illegal" &&
|
|
|
|
|
LEGALITY_CATEGORIES.map((cat) => {
|
|
|
|
|
const issues = deckLegality.issues.filter((i) => i.category === cat.key);
|
|
|
|
|
if (issues.length === 0) return null;
|
|
|
|
|
const cardCount = illegalSelection(issues).size;
|
|
|
|
|
return (
|
|
|
|
|
<div key={cat.key} className="legality-tile-group">
|
|
|
|
|
<div className="legality-tile-group-head">
|
|
|
|
|
<span>{cat.label}</span>
|
|
|
|
|
{cat.selectable && cardCount > 0 && (
|
|
|
|
|
<button className="legality-tile-select" onClick={() => selectIllegal([cat.key])}>
|
|
|
|
|
Select ({cardCount})
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<ul className="legality-tile-reasons">
|
|
|
|
|
{issues.map((i, n) => (
|
|
|
|
|
<li key={n}>{i.message}</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-07-20 00:21:22 -05:00
|
|
|
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
<hr className="deck-section-divider" />
|
2026-07-08 23:54:47 -05:00
|
|
|
|
2026-10-26 21:25:15 -05:00
|
|
|
<div className="row" style={{ gap: 16, flexWrap: "wrap" }}>
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
<label className="row" style={{ gap: 6, cursor: "pointer" }} title="Select all cards shown">
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
<input
|
|
|
|
|
ref={selectAllRef}
|
|
|
|
|
type="checkbox"
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
checked={allShownSelected}
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
onChange={toggleSelectAllCards}
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
disabled={shownCards.length === 0}
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
/>
|
|
|
|
|
<span className="muted" style={{ fontSize: 12 }}>
|
|
|
|
|
Select all
|
|
|
|
|
</span>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="row" style={{ gap: 6, cursor: "pointer" }} title="Combine owned/unowned into one section">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={combineSections}
|
|
|
|
|
onChange={(e) => setCombineSections(e.target.checked)}
|
|
|
|
|
/>
|
|
|
|
|
<span className="muted" style={{ fontSize: 12 }}>
|
|
|
|
|
Combine sections
|
|
|
|
|
</span>
|
|
|
|
|
</label>
|
|
|
|
|
<span className="spacer" />
|
Deck builder: group cards by role
A Role option in the deck builder's Group by sorts cards into Ramp, Card
draw, Removal, Board wipe, Counterspell and Tutor, then the synergy
archetypes Suggest Cards already detects (Mill, Tokens, …), then Lands
and No role. A card with several roles shows in each group.
Roles come from the new roles module, which reads each deck card's
oracle text as the deck loads, so no re-ingest is needed. Reminder text
is ignored, and exiling or bouncing your own things isn't removal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:03:50 -05:00
|
|
|
<GroupBySelect value={groupBy} onChange={setGroupBy} roles />
|
2026-10-26 21:25:15 -05:00
|
|
|
<SortBySelect
|
|
|
|
|
value={sortBy}
|
|
|
|
|
dir={sortDir}
|
|
|
|
|
onChangeValue={setSortBy}
|
|
|
|
|
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
|
|
|
|
|
options={DECK_SORT_OPTIONS}
|
|
|
|
|
/>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
<ViewToggle mode={view} onChange={setView} />
|
2026-10-26 20:35:21 -05:00
|
|
|
{view === "list" && <ColumnsMenu columns={columns} />}
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
{view === "tiles" && <TileColumnsControl tiles={tiles} />}
|
|
|
|
|
</div>
|
2026-07-21 21:04:36 -05:00
|
|
|
|
2026-10-26 21:13:29 -05:00
|
|
|
<SectionNav
|
|
|
|
|
sections={sections}
|
|
|
|
|
active={activeSection}
|
|
|
|
|
onJump={jumpTo}
|
|
|
|
|
onTop={jumpToTop}
|
|
|
|
|
filter={filterQuery}
|
|
|
|
|
onFilterChange={setFilterQuery}
|
2026-10-26 21:31:28 -05:00
|
|
|
onAdvanced={() => setAdvOpen(true)}
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
advancedActive={advFilter.active || tagsOn}
|
2026-10-26 21:13:29 -05:00
|
|
|
/>
|
|
|
|
|
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
{selectedCards.size > 0 && (
|
|
|
|
|
<div className="bulk-bar">
|
2026-10-26 21:44:16 +00:00
|
|
|
<span className="bulk-bar-count">
|
|
|
|
|
{selectedCards.size} selected
|
|
|
|
|
{partialRemoval.size > 0 && ` (${partialRemoval.size} only extra copies)`}
|
|
|
|
|
</span>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
<div className="bulk-bar-actions">
|
|
|
|
|
<button className="danger" onClick={bulkRemoveFromDeck} disabled={bulkBusy}>
|
|
|
|
|
Remove from deck
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={bulkAddToLibrary}
|
|
|
|
|
disabled={bulkBusy || !contents.some((e) => selectedCards.has(e.oracle_id) && shortBy(e) > 0)}
|
|
|
|
|
title="Add the copies these cards are missing to your library"
|
|
|
|
|
>
|
|
|
|
|
Add to library
|
|
|
|
|
</button>
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
<button onClick={() => setTagDialogOpen(true)} disabled={bulkBusy}>
|
|
|
|
|
Tag…
|
|
|
|
|
</button>
|
2026-10-26 22:35:41 +00:00
|
|
|
{physical && (
|
|
|
|
|
<>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => bulkSetPulled(true)}
|
|
|
|
|
disabled={
|
|
|
|
|
bulkBusy ||
|
|
|
|
|
!contents.some(
|
|
|
|
|
(e) =>
|
|
|
|
|
selectedCards.has(e.oracle_id) &&
|
|
|
|
|
!e.is_maybeboard &&
|
|
|
|
|
e.quantity_pulled < e.quantity &&
|
|
|
|
|
e.quantity_in_box > 0,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
title="Record that these cards' copies have been taken from their boxes"
|
|
|
|
|
>
|
|
|
|
|
Mark pulled
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => bulkSetPulled(false)}
|
|
|
|
|
disabled={
|
|
|
|
|
bulkBusy || !contents.some((e) => selectedCards.has(e.oracle_id) && e.quantity_pulled > 0)
|
|
|
|
|
}
|
|
|
|
|
title="Record that these cards' copies are back in their boxes"
|
|
|
|
|
>
|
|
|
|
|
Mark not pulled
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
</div>
|
|
|
|
|
<span className="spacer" />
|
2026-10-26 21:44:16 +00:00
|
|
|
<button onClick={clearSelection} disabled={bulkBusy}>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
Clear
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-07-20 00:21:22 -05:00
|
|
|
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
{combineSections ? (
|
2026-10-26 21:13:29 -05:00
|
|
|
<section className="deck-section" ref={sectionRef("all")} style={{ marginTop: 10 }}>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
<h3>
|
2026-10-26 22:35:41 +00:00
|
|
|
All cards <span className="badge">{mainboard.length}</span>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
</h3>
|
|
|
|
|
<CardSearchBox
|
|
|
|
|
placeholder="Search all cards to add to your deck…"
|
|
|
|
|
value={anyQuery}
|
|
|
|
|
onChange={setAnyQuery}
|
|
|
|
|
results={anyResults}
|
|
|
|
|
onPick={addToDeck}
|
|
|
|
|
/>
|
2026-10-26 22:35:41 +00:00
|
|
|
{cardsGroup(physical ? [...toPull, ...inDeck] : [...available, ...elsewhere, ...unowned])}
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
</section>
|
2026-10-26 22:35:41 +00:00
|
|
|
) : physical ? (
|
|
|
|
|
<>
|
2026-10-26 21:13:29 -05:00
|
|
|
<section className="deck-section" ref={sectionRef("pull")} style={{ marginTop: 10 }}>
|
2026-10-26 22:35:41 +00:00
|
|
|
<h3>
|
|
|
|
|
To pull <span className="badge">{toPull.length}</span>
|
|
|
|
|
</h3>
|
|
|
|
|
<p className="hint" style={{ marginTop: -4 }}>
|
|
|
|
|
In the deck's list, but not physically in it yet. New cards land here.
|
|
|
|
|
</p>
|
|
|
|
|
<CardSearchBox
|
|
|
|
|
placeholder="Search all cards to add to your deck…"
|
|
|
|
|
value={anyQuery}
|
|
|
|
|
onChange={setAnyQuery}
|
|
|
|
|
results={anyResults}
|
|
|
|
|
onPick={addToDeck}
|
|
|
|
|
/>
|
|
|
|
|
{toPull.length === 0 && deck.status === "building" && mainboard.length > 0 ? (
|
|
|
|
|
<p className="hint row" style={{ gap: 8 }}>
|
|
|
|
|
Everything's pulled.
|
|
|
|
|
<button className="primary" onClick={() => applyStatus("built")}>
|
|
|
|
|
Mark as Built
|
|
|
|
|
</button>
|
|
|
|
|
</p>
|
|
|
|
|
) : (
|
|
|
|
|
cardsGroup(toPull)
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-10-26 21:13:29 -05:00
|
|
|
<section className="deck-section" ref={sectionRef("in-deck")} style={{ marginTop: 26 }}>
|
2026-10-26 22:35:41 +00:00
|
|
|
<h3>
|
|
|
|
|
In deck <span className="badge">{inDeck.length}</span>
|
|
|
|
|
</h3>
|
|
|
|
|
{cardsGroup(inDeck)}
|
|
|
|
|
</section>
|
|
|
|
|
</>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
) : (
|
|
|
|
|
<>
|
2026-10-26 21:13:29 -05:00
|
|
|
<section className="deck-section" ref={sectionRef("available")} style={{ marginTop: 10 }}>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
<h3>
|
2026-10-26 22:35:41 +00:00
|
|
|
Available <span className="badge">{available.length}</span>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
</h3>
|
2026-10-26 22:35:41 +00:00
|
|
|
<p className="hint" style={{ marginTop: -4 }}>
|
|
|
|
|
Owned, with copies no built deck is using.
|
|
|
|
|
</p>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
<CardSearchBox
|
|
|
|
|
placeholder="Search your library to add owned cards…"
|
|
|
|
|
value={libQuery}
|
|
|
|
|
onChange={setLibQuery}
|
|
|
|
|
results={libResults}
|
|
|
|
|
onPick={addToDeck}
|
|
|
|
|
/>
|
2026-10-26 22:35:41 +00:00
|
|
|
{cardsGroup(available)}
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
</section>
|
|
|
|
|
|
2026-10-26 22:35:41 +00:00
|
|
|
{elsewhere.length > 0 && (
|
2026-10-26 21:13:29 -05:00
|
|
|
<section className="deck-section" ref={sectionRef("elsewhere")} style={{ marginTop: 26 }}>
|
2026-10-26 22:35:41 +00:00
|
|
|
<h3>
|
|
|
|
|
In other decks <span className="badge">{elsewhere.length}</span>
|
|
|
|
|
</h3>
|
|
|
|
|
<p className="hint" style={{ marginTop: -4 }}>
|
|
|
|
|
Owned, but your copies are used by decks you're building or have built.
|
|
|
|
|
</p>
|
|
|
|
|
{cardsGroup(elsewhere)}
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-10-26 21:13:29 -05:00
|
|
|
<section className="deck-section" ref={sectionRef("unowned")} style={{ marginTop: 26 }}>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
<h3>
|
|
|
|
|
Not owned <span className="badge">{unowned.length}</span>
|
|
|
|
|
</h3>
|
|
|
|
|
<CardSearchBox
|
|
|
|
|
placeholder="Search all cards to add ones you don't own…"
|
|
|
|
|
value={anyQuery}
|
|
|
|
|
onChange={setAnyQuery}
|
|
|
|
|
results={anyResults}
|
|
|
|
|
onPick={addToDeck}
|
2026-10-26 21:39:39 -05:00
|
|
|
showPrice
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
/>
|
|
|
|
|
{cardsGroup(unowned)}
|
|
|
|
|
</section>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-07-20 00:21:22 -05:00
|
|
|
|
2026-10-26 21:13:29 -05:00
|
|
|
<section className="deck-section" ref={sectionRef("maybe")} style={{ marginTop: 26 }}>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
<h3>
|
|
|
|
|
Maybeboard <span className="badge">{maybeboard.length}</span>
|
|
|
|
|
</h3>
|
|
|
|
|
<p className="hint" style={{ marginTop: -4 }}>
|
|
|
|
|
Cards under consideration — not counted in deck analysis.
|
|
|
|
|
</p>
|
|
|
|
|
<CardSearchBox
|
|
|
|
|
placeholder="Search all cards to add one to the maybeboard…"
|
|
|
|
|
value={maybeQuery}
|
|
|
|
|
onChange={setMaybeQuery}
|
|
|
|
|
results={maybeResults}
|
|
|
|
|
onPick={addToMaybeboard}
|
|
|
|
|
/>
|
|
|
|
|
{cardsGroup(maybeboard)}
|
|
|
|
|
</section>
|
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-10-26 21:31:28 -05:00
|
|
|
{advOpen && (
|
|
|
|
|
<AdvancedFilterDialog
|
|
|
|
|
state={advFilter}
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
tagFilter={tagFilter}
|
|
|
|
|
onTagFilterChange={setTagFilter}
|
|
|
|
|
deckTags={deckTags}
|
|
|
|
|
matched={advMatchedCount}
|
2026-10-26 21:31:28 -05:00
|
|
|
total={contents.length}
|
|
|
|
|
error={advError}
|
|
|
|
|
onClose={() => setAdvOpen(false)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00
|
|
|
{tagDialogOpen && selectedCards.size > 0 && (
|
|
|
|
|
<TagCardsDialog
|
|
|
|
|
entries={contents.filter((e) => selectedCards.has(e.oracle_id))}
|
|
|
|
|
suggestions={deckTags}
|
|
|
|
|
onChange={(add, remove) => changeTags([...selectedCards], add, remove)}
|
|
|
|
|
onClose={() => setTagDialogOpen(false)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
{handOpen && (
|
|
|
|
|
<SampleHandModal
|
|
|
|
|
hand={hand}
|
|
|
|
|
libraryCount={libraryStack.length}
|
|
|
|
|
onDrawOne={drawOneMore}
|
|
|
|
|
onNewHand={drawNewHand}
|
|
|
|
|
onClose={() => setHandOpen(false)}
|
|
|
|
|
/>
|
2026-10-26 22:35:41 +00:00
|
|
|
)}
|
|
|
|
|
{statusDialog?.kind === "assemble" && (
|
|
|
|
|
<DialogFrame title={`Mark as ${statusLabel(statusDialog.to)}`} onCancel={() => setStatusDialog(null)}>
|
|
|
|
|
<p className="dialog-message">
|
|
|
|
|
Are this deck's cards already together? If so, the copies in your library are recorded as in the
|
|
|
|
|
deck. Otherwise, they're listed under "To pull" until you take them out of their boxes.
|
|
|
|
|
</p>
|
|
|
|
|
<div className="dialog-actions">
|
|
|
|
|
<button onClick={() => setStatusDialog(null)}>Cancel</button>
|
|
|
|
|
<button onClick={() => applyStatus(statusDialog.to, false)}>Start pulling</button>
|
|
|
|
|
<button className="primary" onClick={() => applyStatus(statusDialog.to, true)} autoFocus>
|
|
|
|
|
Already assembled
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</DialogFrame>
|
|
|
|
|
)}
|
|
|
|
|
{statusDialog?.kind === "release" && (
|
|
|
|
|
<ConfirmDialog
|
|
|
|
|
title={`Mark as ${statusLabel(statusDialog.to)}`}
|
|
|
|
|
message={`${copies(pulledCopies)} in this deck will be recorded as back in ${
|
|
|
|
|
pulledCopies === 1 ? "its box" : "their boxes"
|
|
|
|
|
}.`}
|
|
|
|
|
confirmLabel={`Mark as ${statusLabel(statusDialog.to)}`}
|
|
|
|
|
onConfirm={() => applyStatus(statusDialog.to)}
|
|
|
|
|
onCancel={() => setStatusDialog(null)}
|
|
|
|
|
/>
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
)}
|
|
|
|
|
{suggestOpen && (
|
|
|
|
|
<SuggestionsModal
|
|
|
|
|
deckId={deck.id}
|
|
|
|
|
onClose={() => setSuggestOpen(false)}
|
|
|
|
|
onCardAdded={loadContents}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</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
|
|
|
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
// Which screen the Decks tab was last on. Module-level rather than component
|
|
|
|
|
// state so hopping to another tab and back returns to the deck being built,
|
|
|
|
|
// while a fresh launch starts on the deck list.
|
|
|
|
|
let lastScreen: "list" | "builder" = "list";
|
|
|
|
|
// Where the deck list was scrolled to when a deck was opened, so coming back
|
|
|
|
|
// lands where you left off rather than at the top.
|
|
|
|
|
let listScrollTop = 0;
|
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
|
|
|
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
/** The app's scrolling page area (the window itself doesn't scroll). */
|
|
|
|
|
const scroller = () => document.querySelector<HTMLElement>(".content");
|
2026-07-14 21:32:25 -05:00
|
|
|
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
/**
|
|
|
|
|
* The Decks tab: the deck list, or one deck's builder, as separate screens.
|
|
|
|
|
* Owns the deck list itself and every whole-deck action (create, import,
|
|
|
|
|
* rename, duplicate, export, delete), which both screens offer.
|
|
|
|
|
*/
|
|
|
|
|
export function DeckView({
|
|
|
|
|
refreshSignal,
|
|
|
|
|
openDeckId,
|
|
|
|
|
onOpenedDeck,
|
|
|
|
|
}: {
|
|
|
|
|
refreshSignal?: number;
|
|
|
|
|
/** A deck to open, e.g. from a deck chip elsewhere; `onOpenedDeck` clears it. */
|
|
|
|
|
openDeckId?: number | null;
|
|
|
|
|
onOpenedDeck?: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const [decks, setDecks] = useState<Deck[] | null>(null);
|
|
|
|
|
const [selected, setSelected] = usePersistedState<number | null>(libraryKey("deck-selected"), null);
|
|
|
|
|
// When each deck was last opened, for the list's "Recently opened" sort.
|
|
|
|
|
const [openedAt, setOpenedAt] = usePersistedState<Record<number, number>>(libraryKey("deck-opened"), {});
|
|
|
|
|
// The format the last new deck was created with; null until one has been.
|
|
|
|
|
const [lastNewFormat, setLastNewFormat] = usePersistedState<string | null>("new-deck-format", null);
|
|
|
|
|
const [screen, setScreenState] = useState(lastScreen);
|
|
|
|
|
const [dialog, setDialog] = useState<DeckDialog>(null);
|
|
|
|
|
const [dialogError, setDialogError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const setScreen = useCallback((next: "list" | "builder") => {
|
|
|
|
|
if (lastScreen === "list" && next === "builder") listScrollTop = scroller()?.scrollTop ?? 0;
|
|
|
|
|
lastScreen = next;
|
|
|
|
|
setScreenState(next);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// A fresh screen starts at the top; the list at wherever it was left.
|
|
|
|
|
useLayoutEffect(() => {
|
|
|
|
|
scroller()?.scrollTo(0, screen === "list" ? listScrollTop : 0);
|
|
|
|
|
}, [screen, selected]);
|
|
|
|
|
|
|
|
|
|
const loadDecks = useCallback(async () => {
|
|
|
|
|
setDecks(await api.listDecks());
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadDecks();
|
|
|
|
|
}, [loadDecks]);
|
|
|
|
|
|
|
|
|
|
function openDeck(id: number) {
|
|
|
|
|
setSelected(id);
|
|
|
|
|
setOpenedAt({ ...openedAt, [id]: Date.now() });
|
|
|
|
|
setScreen("builder");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Open a deck asked for from outside — straight to its building screen.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (openDeckId == null) return;
|
|
|
|
|
openDeck(openDeckId);
|
|
|
|
|
onOpenedDeck?.();
|
|
|
|
|
}, [openDeckId]);
|
|
|
|
|
|
|
|
|
|
// Android back from the builder returns to the deck list, like its ← button.
|
|
|
|
|
useBackButton(() => setScreen("list"), screen === "builder");
|
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
|
|
|
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
const deck = decks?.find((d) => d.id === selected) ?? null;
|
|
|
|
|
|
|
|
|
|
// A new deck starts in the format the last one was created with — or,
|
|
|
|
|
// before any has been, the format of the deck opened most recently.
|
|
|
|
|
const newDeckFormat =
|
|
|
|
|
lastNewFormat ??
|
|
|
|
|
(decks ?? []).reduce<Deck | null>(
|
|
|
|
|
(latest, d) => ((openedAt[d.id] ?? 0) > (latest ? openedAt[latest.id] ?? 0 : 0) ? d : latest),
|
|
|
|
|
null,
|
|
|
|
|
)?.format ??
|
|
|
|
|
"";
|
|
|
|
|
|
|
|
|
|
function showDialog(next: DeckDialog) {
|
|
|
|
|
setDialogError(null);
|
|
|
|
|
setDialog(next);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function confirmCreate(name: string, format: string) {
|
|
|
|
|
try {
|
|
|
|
|
const d = await api.createDeck(name, null);
|
|
|
|
|
if (format) await api.setDeckFormat(d.id, format);
|
|
|
|
|
setLastNewFormat(format);
|
|
|
|
|
setDialog(null);
|
|
|
|
|
await loadDecks();
|
|
|
|
|
openDeck(d.id);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setDialogError(friendlyDeckError(err));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function exportDeck(d: Deck) {
|
|
|
|
|
const path = await save({
|
|
|
|
|
title: "Export deck",
|
|
|
|
|
defaultPath: `${d.name}.txt`,
|
|
|
|
|
filters: [{ name: "Decklist", extensions: ["txt"] }],
|
|
|
|
|
});
|
2026-10-28 22:48:36 -05:00
|
|
|
if (typeof path !== "string") return;
|
|
|
|
|
try {
|
|
|
|
|
await api.exportDeck(d.id, path);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setDialog({ kind: "export-failed", message: String(err) });
|
|
|
|
|
}
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function pickImportFile() {
|
|
|
|
|
const path = await open({
|
|
|
|
|
title: "Import deck",
|
|
|
|
|
filters: [{ name: "Decklist", extensions: ["txt"] }],
|
|
|
|
|
});
|
|
|
|
|
if (typeof path !== "string") return;
|
2026-10-28 22:48:36 -05:00
|
|
|
// On Android the path is a content:// URI, so ask for the file's name.
|
|
|
|
|
const base = (await api.pickedFileName(path).catch(() => null)) || "Imported deck";
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -05:00
|
|
|
const suggestedName = base.replace(/\.txt$/i, "");
|
|
|
|
|
showDialog({ kind: "import-name", suggestedName, path });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function confirmImportName(name: string) {
|
|
|
|
|
if (dialog?.kind !== "import-name") return;
|
|
|
|
|
try {
|
|
|
|
|
const result = await api.importDeck(dialog.path, name);
|
|
|
|
|
setDialog({ kind: "import-result", result });
|
|
|
|
|
setDialogError(null);
|
|
|
|
|
await loadDecks();
|
|
|
|
|
openDeck(result.deck.id);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setDialogError(friendlyDeckError(err));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function confirmDelete() {
|
|
|
|
|
if (dialog?.kind !== "delete") return;
|
|
|
|
|
await api.deleteDeck(dialog.deck.id);
|
|
|
|
|
if (selected === dialog.deck.id) {
|
|
|
|
|
setSelected(null);
|
|
|
|
|
setScreen("list");
|
|
|
|
|
}
|
|
|
|
|
setDialog(null);
|
|
|
|
|
await loadDecks();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function confirmRename(name: string) {
|
|
|
|
|
if (dialog?.kind !== "rename") return;
|
|
|
|
|
try {
|
|
|
|
|
await api.renameDeck(dialog.deck.id, name);
|
|
|
|
|
setDialog(null);
|
|
|
|
|
setDialogError(null);
|
|
|
|
|
await loadDecks();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setDialogError(friendlyDeckError(err));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function confirmDuplicate(newName: string) {
|
|
|
|
|
if (dialog?.kind !== "duplicate") return;
|
|
|
|
|
try {
|
|
|
|
|
const d = await api.duplicateDeck(dialog.deck.id, newName);
|
|
|
|
|
setDialog(null);
|
|
|
|
|
setDialogError(null);
|
|
|
|
|
await loadDecks();
|
|
|
|
|
// From the builder, carry on in the copy; from the list, it just appears.
|
|
|
|
|
if (screen === "builder") openDeck(d.id);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setDialogError(friendlyDeckError(err));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Nothing until the first fetch, so a builder left open doesn't flash the
|
|
|
|
|
// list on the way back in.
|
|
|
|
|
if (decks === null) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{screen === "builder" && deck ? (
|
|
|
|
|
<DeckBuilder
|
|
|
|
|
key={deck.id}
|
|
|
|
|
deck={deck}
|
|
|
|
|
refreshSignal={refreshSignal}
|
|
|
|
|
onBack={() => setScreen("list")}
|
|
|
|
|
onDecksChanged={loadDecks}
|
|
|
|
|
onRename={() => showDialog({ kind: "rename", deck })}
|
|
|
|
|
onDuplicate={() => showDialog({ kind: "duplicate", deck })}
|
|
|
|
|
onDelete={() => showDialog({ kind: "delete", deck })}
|
|
|
|
|
onExport={() => exportDeck(deck)}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<DeckList
|
|
|
|
|
decks={decks}
|
|
|
|
|
refreshSignal={refreshSignal}
|
|
|
|
|
openedAt={openedAt}
|
|
|
|
|
onOpen={openDeck}
|
|
|
|
|
onNew={() => showDialog({ kind: "create" })}
|
|
|
|
|
onImport={pickImportFile}
|
|
|
|
|
onRename={(d) => showDialog({ kind: "rename", deck: d })}
|
|
|
|
|
onDuplicate={(d) => showDialog({ kind: "duplicate", deck: d })}
|
|
|
|
|
onDelete={(d) => showDialog({ kind: "delete", deck: d })}
|
|
|
|
|
onExport={exportDeck}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{dialog?.kind === "create" && (
|
|
|
|
|
<NewDeckDialog
|
|
|
|
|
defaultFormat={newDeckFormat}
|
|
|
|
|
error={dialogError}
|
|
|
|
|
onConfirm={confirmCreate}
|
|
|
|
|
onCancel={() => setDialog(null)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
Deck actions UI, consistent icon buttons, native select fix, sidebar stats
- DeckView: rename/duplicate/delete deck via row-hover icon actions, with
a ConfirmDialog for delete and PromptDialog (with friendly name-
collision errors) for rename/duplicate.
- Add a shared IconButton (plus overlay/danger variants) and replace
every ad hoc close/remove button in the app with it: deck actions,
remove-card-from-deck (list and tile), clear-commander, the card-
preview close, and the ingest-progress dismiss.
- Fix native <select> (Group by, Format, In deck): only color/border were
overridden, leaving native OS chrome (height, arrow, popup) in place.
Drop appearance and draw a custom chevron so they match text inputs.
- Click-to-preview: show both faces of a double-faced card side by side
on wide viewports instead of requiring Flip, and size the preview
images larger overall.
- Sidebar footer: replace the plain "N cards" line with a "Your library"
panel (unique/copies/decks tiles + a WUBRG color-distribution bar),
polling get_library_stats while card data is loaded.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 21:21:47 -05:00
|
|
|
{dialog?.kind === "delete" && (
|
|
|
|
|
<ConfirmDialog
|
|
|
|
|
title="Delete deck"
|
|
|
|
|
message={`Delete "${dialog.deck.name}"? This can't be undone.`}
|
|
|
|
|
confirmLabel="Delete"
|
|
|
|
|
danger
|
|
|
|
|
onConfirm={confirmDelete}
|
|
|
|
|
onCancel={() => setDialog(null)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{dialog?.kind === "rename" && (
|
|
|
|
|
<PromptDialog
|
|
|
|
|
title="Rename deck"
|
|
|
|
|
label="Deck name"
|
|
|
|
|
defaultValue={dialog.deck.name}
|
|
|
|
|
confirmLabel="Rename"
|
|
|
|
|
error={dialogError}
|
|
|
|
|
onConfirm={confirmRename}
|
|
|
|
|
onCancel={() => setDialog(null)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{dialog?.kind === "duplicate" && (
|
|
|
|
|
<PromptDialog
|
|
|
|
|
title="Duplicate deck"
|
|
|
|
|
label="Name for the copy"
|
|
|
|
|
defaultValue={`${dialog.deck.name} (copy)`}
|
|
|
|
|
confirmLabel="Duplicate"
|
|
|
|
|
error={dialogError}
|
|
|
|
|
onConfirm={confirmDuplicate}
|
|
|
|
|
onCancel={() => setDialog(null)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-07-20 00:21:22 -05:00
|
|
|
{dialog?.kind === "import-name" && (
|
|
|
|
|
<PromptDialog
|
|
|
|
|
title="Import deck"
|
|
|
|
|
label="Deck name"
|
|
|
|
|
defaultValue={dialog.suggestedName}
|
|
|
|
|
confirmLabel="Import"
|
|
|
|
|
error={dialogError}
|
|
|
|
|
onConfirm={confirmImportName}
|
|
|
|
|
onCancel={() => setDialog(null)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{dialog?.kind === "import-result" && (
|
|
|
|
|
<InfoDialog
|
|
|
|
|
title="Deck imported"
|
|
|
|
|
message={
|
|
|
|
|
dialog.result.unresolved.length > 0
|
|
|
|
|
? `Imported "${dialog.result.deck.name}". ${dialog.result.unresolved.length} card name${dialog.result.unresolved.length === 1 ? "" : "s"} couldn't be matched:`
|
|
|
|
|
: `Imported "${dialog.result.deck.name}" — every card matched.`
|
|
|
|
|
}
|
|
|
|
|
items={dialog.result.unresolved}
|
|
|
|
|
onClose={() => setDialog(null)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-10-28 22:48:36 -05:00
|
|
|
{dialog?.kind === "export-failed" && (
|
|
|
|
|
<InfoDialog
|
|
|
|
|
title="Couldn't export deck"
|
|
|
|
|
message={dialog.message}
|
|
|
|
|
onClose={() => setDialog(null)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.
A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.
Closes #9
Closes #11
Closes #12
2026-10-25 15:04:41 -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
|
|
|
);
|
|
|
|
|
}
|