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>
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Rust / Cargo
|
||||
/target
|
||||
**/*.rs.bk
|
||||
|
||||
# Node / frontend
|
||||
node_modules/
|
||||
/dist/
|
||||
|
||||
# Tauri generated
|
||||
/src-tauri/gen/
|
||||
|
||||
# Local databases (reference.db is rebuildable; library.db is your synced data)
|
||||
*.db
|
||||
*.sqlite
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
4876
Cargo.lock
generated
Normal file
25
Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/mtgc-core", "src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
nucleo = "0.5"
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
|
||||
# Lean, self-contained release binary (used by `cargo tauri build`).
|
||||
# Trades a few minutes of extra build time for a smaller executable.
|
||||
[profile.release]
|
||||
strip = true # drop debug symbols
|
||||
lto = true # link-time optimization
|
||||
codegen-units = 1 # better optimization at the cost of parallelism
|
||||
panic = "abort" # no unwinding machinery
|
||||
114
README.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# mtgc — Personal Magic: The Gathering Library Catalog
|
||||
|
||||
A desktop app (Tauri v2 + React) for cataloging your MTG collection: rapid
|
||||
fuzzy card entry, rich criteria search (colors, commander color-identity
|
||||
compatibility, mana value, type/subtype/keyword), and deck organization with
|
||||
per-card deck membership. Built so the eventual mobile port is cheap — all logic
|
||||
lives in a platform-agnostic Rust core.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/ React + TypeScript UI (swappable presentation)
|
||||
src-tauri/ Tauri v2 shell — exposes the core as typed commands
|
||||
crates/mtgc-core/ Pure Rust: db, fuzzy search, filters, decks, ingester
|
||||
```
|
||||
|
||||
Two SQLite databases, joined at runtime via `ATTACH`:
|
||||
|
||||
- **`reference.db`** — read-only card data built from Scryfall bulk. Bulky,
|
||||
re-downloadable per device, **not synced**.
|
||||
- **`library.db`** — your owned cards + decks. Small, portable, **the file you
|
||||
sync** (drop it in Syncthing/Dropbox/Nextcloud). Point the app at it via
|
||||
*Choose library file…*.
|
||||
|
||||
Both default to the OS app-data dir (e.g. `~/.local/share/com.garrettmills.mtgc/`).
|
||||
|
||||
## First-time setup
|
||||
|
||||
```bash
|
||||
./scripts/devel-setup.sh # Tauri system libs (webkit2gtk/gtk3) + Tauri CLI
|
||||
npm install # frontend deps
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cargo tauri dev # launches the desktop app (dev mode)
|
||||
```
|
||||
|
||||
On first launch, click **Download card data** — it fetches the Scryfall
|
||||
*Oracle Cards* + *Default Cards* bulk (~a few hundred MB) and builds
|
||||
`reference.db`. After that the app is fully offline.
|
||||
|
||||
You can also build the card database from the command line:
|
||||
|
||||
```bash
|
||||
cargo run -p mtgc-core --bin mtgc-ingest -- /path/to/reference.db
|
||||
```
|
||||
|
||||
## Production build
|
||||
|
||||
Build an optimized app with the frontend embedded into the executable:
|
||||
|
||||
```bash
|
||||
./scripts/devel-setup.sh # once, if not already done
|
||||
cargo tauri build
|
||||
```
|
||||
|
||||
Outputs (under the workspace `target/release/`):
|
||||
|
||||
- **`target/release/mtgc-app`** — the standalone executable. The web UI and all
|
||||
app logic are baked into this one file. It dynamically links the system WebKit/GTK
|
||||
libraries (`webkit2gtk4.1`, `gtk3`), so those must be present on the machine that
|
||||
runs it — they are on any modern desktop Linux and are installed by
|
||||
`devel-setup.sh`.
|
||||
- **`target/release/bundle/appimage/*.AppImage`** — a single portable file that
|
||||
bundles the app together with its runtime. This is the closest thing to a
|
||||
download-and-run **single self-contained binary**: `chmod +x` it and run.
|
||||
**Recommended** for copying to another machine.
|
||||
- **`target/release/bundle/rpm/*.rpm`** and **`.../deb/*.deb`** — native installers
|
||||
(Fedora / Debian-based) that add a menu entry and icon.
|
||||
|
||||
Useful variants:
|
||||
|
||||
```bash
|
||||
cargo tauri build --bundles appimage # only the portable AppImage, skip installers
|
||||
cargo tauri build --no-bundle # only the bare target/release/mtgc-app binary
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- On Linux the truly portable single file is the **AppImage**; the bare `mtgc-app`
|
||||
binary is also a single file but relies on the system WebKit/GTK libs.
|
||||
- The binary does **not** embed card data — first run still downloads the Scryfall
|
||||
bulk into the OS app-data dir, and your synced `library.db` lives there by default
|
||||
(override with *Choose library file…*).
|
||||
- The `[profile.release]` settings in `Cargo.toml` (strip + LTO) make the release
|
||||
build take a few minutes but keep the binary small.
|
||||
|
||||
## Using it
|
||||
|
||||
- **Add Cards** — type a name (typos OK); ↑/↓ then Enter adds a copy.
|
||||
- **Library** — filter owned cards by name, colors, commander identity (color
|
||||
identity ⊆ chosen), mana value range, type/subtype/keyword, or deck. Hover a
|
||||
row's thumbnail for a full-size preview that tracks the cursor. Rows show which
|
||||
decks a card is in and a warning (⚠) when it's assigned to more decks than you
|
||||
own. Toggle **List / Tiles** (top-right) to switch between the table and a grid
|
||||
of full card images; the choice is remembered.
|
||||
- Deck contents offer the same List / Tiles toggle and hover previews.
|
||||
- **Decks** — create decks and assign owned cards; set per-deck quantities.
|
||||
|
||||
## Develop / test
|
||||
|
||||
```bash
|
||||
cargo test -p mtgc-core # core unit tests (db, search, filters, decks, ingest)
|
||||
npm run build # typecheck + build frontend
|
||||
cargo build # build everything incl. the desktop app
|
||||
```
|
||||
|
||||
## Mobile (future)
|
||||
|
||||
The core and frontend are platform-agnostic; `cargo tauri icon` already emitted
|
||||
iOS/Android icon sets. Porting is adding `tauri ios` / `tauri android` targets —
|
||||
only reference-data provisioning differs (download on-device).
|
||||
27
crates/mtgc-core/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "mtgc-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Platform-agnostic core for the MTG library catalog: DB, fuzzy search, filters, decks, and Scryfall ingestion."
|
||||
|
||||
[lib]
|
||||
name = "mtgc_core"
|
||||
path = "src/lib.rs"
|
||||
|
||||
# Binary that builds reference.db from Scryfall bulk data.
|
||||
[[bin]]
|
||||
name = "mtgc-ingest"
|
||||
path = "src/bin/ingest.rs"
|
||||
|
||||
[dependencies]
|
||||
rusqlite = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
nucleo = { workspace = true }
|
||||
ureq = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
28
crates/mtgc-core/src/bin/ingest.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
//! CLI: build reference.db from Scryfall bulk data.
|
||||
//!
|
||||
//! Usage: `mtgc-ingest [path/to/reference.db]` (defaults to ./reference.db)
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let dest = std::env::args()
|
||||
.nth(1)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("reference.db"));
|
||||
|
||||
eprintln!("Building reference db at {}", dest.display());
|
||||
match mtgc_core::ingest::build_reference_db(&dest, |msg| eprintln!(" {msg}")) {
|
||||
Ok(stats) => {
|
||||
eprintln!(
|
||||
"Done: {} oracle cards, {} printings.",
|
||||
stats.oracle_cards, stats.printings
|
||||
);
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Ingest failed: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
118
crates/mtgc-core/src/db.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
//! Database wiring: open the library, run migrations, ATTACH the reference db.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
/// The reference schema, applied to a freshly-created reference.db by the ingester
|
||||
/// (and by tests that build a fixture reference db).
|
||||
pub const REFERENCE_SCHEMA: &str = include_str!("sql/reference_schema.sql");
|
||||
|
||||
/// Ordered library migrations. Index + 1 is the schema version recorded in
|
||||
/// `PRAGMA user_version`. Append new migrations; never edit an applied one.
|
||||
const LIBRARY_MIGRATIONS: &[&str] = &[
|
||||
include_str!("sql/library/0001_init.sql"),
|
||||
include_str!("sql/library/0002_location.sql"),
|
||||
include_str!("sql/library/0003_deck_format.sql"),
|
||||
];
|
||||
|
||||
/// The attached alias under which the reference db is available at runtime.
|
||||
pub const REF_DB: &str = "ref";
|
||||
|
||||
/// Correlated subquery selecting one image URL for the oracle card aliased `oc`.
|
||||
/// Returns NULL when no printing has an image. Read it as `Option<String>`.
|
||||
pub fn image_subquery() -> String {
|
||||
format!(
|
||||
"(SELECT p.image_uri FROM {REF_DB}.printings p
|
||||
WHERE p.oracle_id = oc.oracle_id AND p.image_uri <> '' LIMIT 1)"
|
||||
)
|
||||
}
|
||||
|
||||
/// Create (if needed) and open a reference.db, applying the reference schema.
|
||||
/// Used by the ingester and by test fixtures.
|
||||
pub fn open_reference_for_build(path: &Path) -> Result<Connection> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute_batch(REFERENCE_SCHEMA)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Open the library db at `library_path`, run migrations, and ATTACH the
|
||||
/// reference db at `reference_path` under the [`REF_DB`] alias.
|
||||
pub fn open(library_path: &Path, reference_path: &Path) -> Result<Connection> {
|
||||
let conn = Connection::open(library_path)?;
|
||||
conn.pragma_update(None, "foreign_keys", true)?;
|
||||
migrate_library(&conn)?;
|
||||
attach_reference(&conn, reference_path)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// ATTACH a reference db to an already-open library connection.
|
||||
pub fn attach_reference(conn: &Connection, reference_path: &Path) -> Result<()> {
|
||||
conn.execute(
|
||||
&format!("ATTACH DATABASE ?1 AS {REF_DB}"),
|
||||
[reference_path.to_string_lossy()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply any library migrations newer than the recorded `user_version`.
|
||||
pub fn migrate_library(conn: &Connection) -> Result<()> {
|
||||
let current: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
|
||||
for (idx, sql) in LIBRARY_MIGRATIONS.iter().enumerate() {
|
||||
let version = (idx + 1) as i64;
|
||||
if version > current {
|
||||
conn.execute_batch(&format!("BEGIN;\n{sql}\nPRAGMA user_version = {version};\nCOMMIT;"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn migrations_are_idempotent_and_bump_version() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
migrate_library(&conn).unwrap();
|
||||
let v: i64 = conn
|
||||
.pragma_query_value(None, "user_version", |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(v, LIBRARY_MIGRATIONS.len() as i64);
|
||||
|
||||
// Running again is a no-op (no "table already exists" error).
|
||||
migrate_library(&conn).unwrap();
|
||||
|
||||
// Core tables exist.
|
||||
let n: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name IN ('decks','library_cards','deck_cards','library_card_copies')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(n, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_schema_applies_with_fts() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(REFERENCE_SCHEMA).unwrap();
|
||||
// FTS5 + trigram tokenizer must be available in the bundled SQLite.
|
||||
conn.execute(
|
||||
"INSERT INTO oracle_names_fts(name, oracle_id) VALUES ('Llanowar Elves','x')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let hit: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM oracle_names_fts WHERE oracle_names_fts MATCH 'lan'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(hit, 1);
|
||||
}
|
||||
}
|
||||
287
crates/mtgc-core/src/decks.rs
Normal file
@@ -0,0 +1,287 @@
|
||||
//! Deck operations and deck membership lookups.
|
||||
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
|
||||
use crate::db::REF_DB;
|
||||
use crate::error::Result;
|
||||
use crate::model::{CardRef, ColorSet, Deck, DeckEntry};
|
||||
use crate::Catalog;
|
||||
|
||||
impl Catalog {
|
||||
/// Create a deck. `commander_oracle_id` is optional.
|
||||
pub fn create_deck(&self, name: &str, commander_oracle_id: Option<&str>) -> Result<Deck> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO decks(name, commander_oracle_id) VALUES (?1, ?2)",
|
||||
params![name, commander_oracle_id],
|
||||
)?;
|
||||
let id = self.conn.last_insert_rowid();
|
||||
self.get_deck(id)
|
||||
.map(|d| d.expect("just-inserted deck must exist"))
|
||||
}
|
||||
|
||||
pub fn get_deck(&self, id: i64) -> Result<Option<Deck>> {
|
||||
Ok(self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT id, name, format, commander_oracle_id, notes FROM decks WHERE id = ?1",
|
||||
params![id],
|
||||
Self::map_deck,
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
pub fn list_decks(&self) -> Result<Vec<Deck>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, name, format, commander_oracle_id, notes
|
||||
FROM decks ORDER BY name COLLATE NOCASE",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], Self::map_deck)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn delete_deck(&self, id: i64) -> Result<()> {
|
||||
// ON DELETE CASCADE clears deck_cards.
|
||||
self.conn
|
||||
.execute("DELETE FROM decks WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set how many copies of a card belong to a deck. `quantity <= 0` removes
|
||||
/// the card from the deck. Upserts on (deck_id, oracle_id).
|
||||
pub fn set_deck_card(
|
||||
&self,
|
||||
deck_id: i64,
|
||||
oracle_id: &str,
|
||||
quantity: i64,
|
||||
is_commander: bool,
|
||||
) -> Result<()> {
|
||||
if quantity <= 0 {
|
||||
self.conn.execute(
|
||||
"DELETE FROM deck_cards WHERE deck_id = ?1 AND oracle_id = ?2",
|
||||
params![deck_id, oracle_id],
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
self.conn.execute(
|
||||
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(deck_id, oracle_id) DO UPDATE SET
|
||||
quantity = excluded.quantity,
|
||||
is_commander = excluded.is_commander",
|
||||
params![deck_id, oracle_id, quantity, is_commander as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// All cards in a deck — owned or not — with their in-deck quantity and how
|
||||
/// many copies are owned (0 if the card isn't in the library). Callers split
|
||||
/// this into "in your library" vs "not owned" groups.
|
||||
pub fn deck_contents(&self, deck_id: i64) -> Result<Vec<DeckEntry>> {
|
||||
let mut stmt = self.conn.prepare(&format!(
|
||||
"SELECT dc.quantity, dc.is_commander, COALESCE(lc.quantity_owned, 0),
|
||||
oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
|
||||
oc.type_line, {image}
|
||||
FROM deck_cards dc
|
||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id
|
||||
LEFT JOIN library_cards lc ON lc.oracle_id = dc.oracle_id
|
||||
WHERE dc.deck_id = ?1
|
||||
ORDER BY oc.name COLLATE NOCASE",
|
||||
image = crate::db::image_subquery()
|
||||
))?;
|
||||
let rows = stmt
|
||||
.query_map(params![deck_id], |r| {
|
||||
Ok(DeckEntry {
|
||||
quantity: r.get(0)?,
|
||||
is_commander: r.get::<_, i64>(1)? != 0,
|
||||
quantity_owned: r.get(2)?,
|
||||
card: CardRef {
|
||||
oracle_id: r.get(3)?,
|
||||
name: r.get(4)?,
|
||||
mana_value: r.get(5)?,
|
||||
colors: ColorSet(r.get::<_, i64>(6)? as u8),
|
||||
color_identity: ColorSet(r.get::<_, i64>(7)? as u8),
|
||||
type_line: r.get(8)?,
|
||||
image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
|
||||
},
|
||||
})
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// The decks a given card is currently assigned to.
|
||||
pub fn card_decks(&self, oracle_id: &str) -> Result<Vec<Deck>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT d.id, d.name, d.format, d.commander_oracle_id, d.notes
|
||||
FROM deck_cards dc JOIN decks d ON d.id = dc.deck_id
|
||||
WHERE dc.oracle_id = ?1
|
||||
ORDER BY d.name COLLATE NOCASE",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![oracle_id], Self::map_deck)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn map_deck(r: &rusqlite::Row) -> rusqlite::Result<Deck> {
|
||||
Ok(Deck {
|
||||
id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
format: r.get(2)?,
|
||||
commander_oracle_id: r.get(3)?,
|
||||
notes: r.get(4)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the deck's format (e.g. "Commander"). Empty string clears it.
|
||||
pub fn set_deck_format(&self, deck_id: i64, format: &str) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE decks SET format = ?1 WHERE id = ?2",
|
||||
params![format, deck_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set (or clear, with `None`) the deck's commander card. Also marks/unmarks
|
||||
/// the corresponding deck_cards row as the commander when present.
|
||||
pub fn set_deck_commander(&self, deck_id: i64, oracle_id: Option<&str>) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE decks SET commander_oracle_id = ?1 WHERE id = ?2",
|
||||
params![oracle_id, deck_id],
|
||||
)?;
|
||||
// Reset any existing commander flag, then set it on the chosen card.
|
||||
self.conn.execute(
|
||||
"UPDATE deck_cards SET is_commander = 0 WHERE deck_id = ?1",
|
||||
params![deck_id],
|
||||
)?;
|
||||
if let Some(oid) = oracle_id {
|
||||
self.conn.execute(
|
||||
"UPDATE deck_cards SET is_commander = 1 WHERE deck_id = ?1 AND oracle_id = ?2",
|
||||
params![deck_id, oid],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::test_support;
|
||||
|
||||
#[test]
|
||||
fn deck_membership_visible_from_library() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
|
||||
cat.add_owned("llanowar-elves", 4).unwrap();
|
||||
let elves = cat.create_deck("Elfball", Some("llanowar-elves")).unwrap();
|
||||
cat.set_deck_card(elves.id, "llanowar-elves", 4, false)
|
||||
.unwrap();
|
||||
|
||||
// card_decks reports membership.
|
||||
let decks = cat.card_decks("llanowar-elves").unwrap();
|
||||
assert_eq!(decks.len(), 1);
|
||||
assert_eq!(decks[0].name, "Elfball");
|
||||
|
||||
// list_library surfaces the deck name on the card row.
|
||||
let lib = cat.list_library().unwrap();
|
||||
let row = lib
|
||||
.iter()
|
||||
.find(|c| c.card.oracle_id == "llanowar-elves")
|
||||
.unwrap();
|
||||
assert_eq!(row.decks, vec!["Elfball".to_string()]);
|
||||
assert_eq!(row.quantity_assigned, 4);
|
||||
assert!(!row.is_over_assigned());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_assignment_detected_across_decks() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
cat.add_owned("sol-ring", 1).unwrap(); // own one physical copy
|
||||
|
||||
let a = cat.create_deck("Deck A", None).unwrap();
|
||||
let b = cat.create_deck("Deck B", None).unwrap();
|
||||
cat.set_deck_card(a.id, "sol-ring", 1, false).unwrap();
|
||||
cat.set_deck_card(b.id, "sol-ring", 1, false).unwrap();
|
||||
|
||||
let lib = cat.list_library().unwrap();
|
||||
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
|
||||
assert_eq!(sol.quantity_assigned, 2);
|
||||
assert_eq!(sol.quantity_owned, 1);
|
||||
assert!(sol.is_over_assigned());
|
||||
assert_eq!(sol.decks.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_and_commander_metadata() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
let d = cat.create_deck("EDH", None).unwrap();
|
||||
assert_eq!(d.format, "");
|
||||
|
||||
cat.set_deck_format(d.id, "Commander").unwrap();
|
||||
cat.set_deck_card(d.id, "llanowar-elves", 1, false).unwrap();
|
||||
cat.set_deck_commander(d.id, Some("llanowar-elves")).unwrap();
|
||||
|
||||
let d = cat.get_deck(d.id).unwrap().unwrap();
|
||||
assert_eq!(d.format, "Commander");
|
||||
assert_eq!(d.commander_oracle_id.as_deref(), Some("llanowar-elves"));
|
||||
|
||||
// The card is flagged as commander in the deck.
|
||||
let commander = cat
|
||||
.deck_contents(d.id)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|e| e.card.oracle_id == "llanowar-elves")
|
||||
.unwrap();
|
||||
assert!(commander.is_commander);
|
||||
|
||||
// Clearing works.
|
||||
cat.set_deck_commander(d.id, None).unwrap();
|
||||
assert_eq!(
|
||||
cat.get_deck(d.id).unwrap().unwrap().commander_oracle_id,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deck_contents_include_owned_and_unowned() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
cat.add_owned("llanowar-elves", 4).unwrap(); // owned
|
||||
// serra-angel intentionally NOT owned.
|
||||
|
||||
let d = cat.create_deck("Mixed", None).unwrap();
|
||||
cat.set_deck_card(d.id, "llanowar-elves", 4, false).unwrap();
|
||||
cat.set_deck_card(d.id, "serra-angel", 1, true).unwrap();
|
||||
|
||||
let contents = cat.deck_contents(d.id).unwrap();
|
||||
assert_eq!(contents.len(), 2);
|
||||
|
||||
let owned: Vec<_> = contents.iter().filter(|e| e.quantity_owned > 0).collect();
|
||||
let unowned: Vec<_> = contents.iter().filter(|e| e.quantity_owned == 0).collect();
|
||||
assert_eq!(owned.len(), 1);
|
||||
assert_eq!(owned[0].card.name, "Llanowar Elves");
|
||||
assert_eq!(owned[0].quantity, 4);
|
||||
assert_eq!(unowned.len(), 1);
|
||||
assert_eq!(unowned[0].card.name, "Serra Angel");
|
||||
assert!(unowned[0].is_commander);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_zero_removes_and_cascade_on_delete() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
let d = cat.create_deck("Temp", None).unwrap();
|
||||
cat.set_deck_card(d.id, "lightning-bolt", 2, false).unwrap();
|
||||
cat.set_deck_card(d.id, "lightning-bolt", 0, false).unwrap();
|
||||
assert!(cat.card_decks("lightning-bolt").unwrap().is_empty());
|
||||
|
||||
cat.set_deck_card(d.id, "lightning-bolt", 1, false).unwrap();
|
||||
cat.delete_deck(d.id).unwrap();
|
||||
assert!(cat.card_decks("lightning-bolt").unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
29
crates/mtgc-core/src/error.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
//! Core error type.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CoreError {
|
||||
#[error(transparent)]
|
||||
Sqlite(#[from] rusqlite::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("http error: {0}")]
|
||||
Http(String),
|
||||
|
||||
#[error("json error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, CoreError>;
|
||||
|
||||
impl From<ureq::Error> for CoreError {
|
||||
fn from(e: ureq::Error) -> Self {
|
||||
CoreError::Http(e.to_string())
|
||||
}
|
||||
}
|
||||
228
crates/mtgc-core/src/filters.rs
Normal file
@@ -0,0 +1,228 @@
|
||||
//! Criteria search over the owned library, compiled to SQL across the library
|
||||
//! and attached reference dbs.
|
||||
|
||||
use rusqlite::types::Value;
|
||||
|
||||
use crate::db::REF_DB;
|
||||
use crate::error::Result;
|
||||
use crate::model::{LibraryCard, LibraryFilter};
|
||||
use crate::Catalog;
|
||||
|
||||
impl Catalog {
|
||||
/// Search the owned library by the given criteria. An empty filter returns
|
||||
/// the whole owned library (same set as [`Catalog::list_library`]).
|
||||
///
|
||||
/// Semantics: within a facet, multiple `types`/`subtypes`/`keywords` values
|
||||
/// are ANDed (the card must have all of them). Commander compatibility keeps
|
||||
/// only cards whose color identity is a subset of the given identity.
|
||||
pub fn search_library(&self, filter: &LibraryFilter) -> Result<Vec<LibraryCard>> {
|
||||
let mut sql = format!(
|
||||
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
|
||||
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}
|
||||
FROM library_cards lc
|
||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
|
||||
WHERE lc.quantity_owned > 0",
|
||||
image = crate::db::image_subquery()
|
||||
);
|
||||
let mut vals: Vec<Value> = Vec::new();
|
||||
|
||||
if let Some(text) = filter.text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) {
|
||||
sql.push_str(" AND oc.name_lower LIKE ?");
|
||||
vals.push(Value::Text(format!("%{}%", text.to_lowercase())));
|
||||
}
|
||||
if let Some(c) = filter.colors_exact {
|
||||
sql.push_str(" AND oc.colors = ?");
|
||||
vals.push(Value::Integer(c.0 as i64));
|
||||
}
|
||||
if let Some(c) = filter.colors_include {
|
||||
sql.push_str(" AND (oc.colors & ?) = ?");
|
||||
vals.push(Value::Integer(c.0 as i64));
|
||||
vals.push(Value::Integer(c.0 as i64));
|
||||
}
|
||||
if let Some(id) = filter.commander_identity {
|
||||
// subset: card_identity & ~commander == 0 (see ColorSet::is_subset_of)
|
||||
sql.push_str(" AND (oc.color_identity & ?) = 0");
|
||||
vals.push(Value::Integer((!id.0 & 0b11111) as i64));
|
||||
}
|
||||
if let Some(min) = filter.cmc_min {
|
||||
sql.push_str(" AND oc.mana_value >= ?");
|
||||
vals.push(Value::Real(min));
|
||||
}
|
||||
if let Some(max) = filter.cmc_max {
|
||||
sql.push_str(" AND oc.mana_value <= ?");
|
||||
vals.push(Value::Real(max));
|
||||
}
|
||||
for t in &filter.types {
|
||||
sql.push_str(&format!(
|
||||
" AND EXISTS (SELECT 1 FROM {REF_DB}.card_types x
|
||||
WHERE x.oracle_id = oc.oracle_id AND x.type = ? COLLATE NOCASE)"
|
||||
));
|
||||
vals.push(Value::Text(t.clone()));
|
||||
}
|
||||
for s in &filter.subtypes {
|
||||
sql.push_str(&format!(
|
||||
" AND EXISTS (SELECT 1 FROM {REF_DB}.card_subtypes x
|
||||
WHERE x.oracle_id = oc.oracle_id AND x.subtype = ? COLLATE NOCASE)"
|
||||
));
|
||||
vals.push(Value::Text(s.clone()));
|
||||
}
|
||||
for k in &filter.keywords {
|
||||
sql.push_str(&format!(
|
||||
" AND EXISTS (SELECT 1 FROM {REF_DB}.card_keywords x
|
||||
WHERE x.oracle_id = oc.oracle_id AND x.keyword = ? COLLATE NOCASE)"
|
||||
));
|
||||
vals.push(Value::Text(k.clone()));
|
||||
}
|
||||
if let Some(deck_id) = filter.in_deck {
|
||||
sql.push_str(
|
||||
" AND EXISTS (SELECT 1 FROM deck_cards dc
|
||||
WHERE dc.oracle_id = oc.oracle_id AND dc.deck_id = ?)",
|
||||
);
|
||||
vals.push(Value::Integer(deck_id));
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY oc.name COLLATE NOCASE");
|
||||
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(
|
||||
rusqlite::params_from_iter(vals),
|
||||
crate::library::row_to_library_parts,
|
||||
)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
// Enrich with deck context (shared with list_library).
|
||||
let deck_names = self.deck_names_by_oracle()?;
|
||||
let assigned = self.assigned_totals_by_oracle()?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(oid, qty, location, card)| LibraryCard {
|
||||
quantity_owned: qty,
|
||||
decks: deck_names.get(&oid).cloned().unwrap_or_default(),
|
||||
quantity_assigned: *assigned.get(&oid).unwrap_or(&0),
|
||||
location,
|
||||
card,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::model::{ColorSet, LibraryFilter};
|
||||
use crate::test_support;
|
||||
|
||||
/// Own the whole fixture set so filters have something to select from.
|
||||
fn owned_fixture() -> test_support::Fixture {
|
||||
let fx = test_support::make();
|
||||
for c in test_support::seed_cards() {
|
||||
fx.catalog.add_owned(c.oracle_id, 1).unwrap();
|
||||
}
|
||||
fx
|
||||
}
|
||||
|
||||
fn names(cards: &[crate::model::LibraryCard]) -> Vec<String> {
|
||||
cards.iter().map(|c| c.card.name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_filter_returns_all_owned() {
|
||||
let fx = owned_fixture();
|
||||
let all = fx.catalog.search_library(&LibraryFilter::default()).unwrap();
|
||||
assert_eq!(all.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commander_identity_subset() {
|
||||
let fx = owned_fixture();
|
||||
|
||||
// A B/G commander: Golgari (BG) and mono-B/mono-G and colorless are legal;
|
||||
// white/red cards are not.
|
||||
let f = LibraryFilter {
|
||||
commander_identity: Some(ColorSet::parse("BG")),
|
||||
..Default::default()
|
||||
};
|
||||
let got = names(&fx.catalog.search_library(&f).unwrap());
|
||||
assert!(got.contains(&"Deathrite Shaman".to_string())); // BG
|
||||
assert!(got.contains(&"Llanowar Elves".to_string())); // G
|
||||
assert!(got.contains(&"Sol Ring".to_string())); // colorless
|
||||
assert!(!got.contains(&"Serra Angel".to_string())); // W
|
||||
assert!(!got.contains(&"Lightning Bolt".to_string())); // R
|
||||
|
||||
// Mono-white commander: the BG card is illegal.
|
||||
let f = LibraryFilter {
|
||||
commander_identity: Some(ColorSet::parse("W")),
|
||||
..Default::default()
|
||||
};
|
||||
let got = names(&fx.catalog.search_library(&f).unwrap());
|
||||
assert!(got.contains(&"Serra Angel".to_string()));
|
||||
assert!(!got.contains(&"Deathrite Shaman".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_subtype_keyword_and_cmc() {
|
||||
let fx = owned_fixture();
|
||||
|
||||
let creatures = names(&fx.catalog.search_library(&LibraryFilter {
|
||||
types: vec!["Creature".into()],
|
||||
..Default::default()
|
||||
}).unwrap());
|
||||
assert_eq!(creatures.len(), 3); // Llanowar, Deathrite, Serra
|
||||
|
||||
let elves = names(&fx.catalog.search_library(&LibraryFilter {
|
||||
subtypes: vec!["Elf".into()],
|
||||
..Default::default()
|
||||
}).unwrap());
|
||||
assert_eq!(elves, vec!["Deathrite Shaman", "Llanowar Elves"]);
|
||||
|
||||
let flyers = names(&fx.catalog.search_library(&LibraryFilter {
|
||||
keywords: vec!["Flying".into()],
|
||||
..Default::default()
|
||||
}).unwrap());
|
||||
assert_eq!(flyers, vec!["Serra Angel"]);
|
||||
|
||||
let cheap = fx.catalog.search_library(&LibraryFilter {
|
||||
cmc_max: Some(1.0),
|
||||
..Default::default()
|
||||
}).unwrap();
|
||||
assert!(cheap.iter().all(|c| c.card.mana_value <= 1.0));
|
||||
assert!(!cheap.iter().any(|c| c.card.name == "Serra Angel"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors_exact_and_include() {
|
||||
let fx = owned_fixture();
|
||||
let exact_g = names(&fx.catalog.search_library(&LibraryFilter {
|
||||
colors_exact: Some(ColorSet::parse("G")),
|
||||
..Default::default()
|
||||
}).unwrap());
|
||||
assert_eq!(exact_g, vec!["Llanowar Elves"]); // not Deathrite (BG)
|
||||
|
||||
let has_g = names(&fx.catalog.search_library(&LibraryFilter {
|
||||
colors_include: Some(ColorSet::parse("G")),
|
||||
..Default::default()
|
||||
}).unwrap());
|
||||
assert!(has_g.contains(&"Llanowar Elves".to_string()));
|
||||
assert!(has_g.contains(&"Deathrite Shaman".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_and_in_deck() {
|
||||
let fx = owned_fixture();
|
||||
let cat = &fx.catalog;
|
||||
|
||||
let bolt = names(&cat.search_library(&LibraryFilter {
|
||||
text: Some("bolt".into()),
|
||||
..Default::default()
|
||||
}).unwrap());
|
||||
assert_eq!(bolt, vec!["Lightning Bolt"]);
|
||||
|
||||
let deck = cat.create_deck("Mono-R", None).unwrap();
|
||||
cat.set_deck_card(deck.id, "lightning-bolt", 1, false).unwrap();
|
||||
let in_deck = names(&cat.search_library(&LibraryFilter {
|
||||
in_deck: Some(deck.id),
|
||||
..Default::default()
|
||||
}).unwrap());
|
||||
assert_eq!(in_deck, vec!["Lightning Bolt"]);
|
||||
}
|
||||
}
|
||||
448
crates/mtgc-core/src/ingest.rs
Normal file
@@ -0,0 +1,448 @@
|
||||
//! Build `reference.db` from Scryfall bulk data.
|
||||
//!
|
||||
//! Two passes: the *Oracle Cards* bulk (one entry per `oracle_id`) fills
|
||||
//! `oracle_cards` + the normalized type/subtype/keyword tables + the name FTS;
|
||||
//! the *Default Cards* bulk (one entry per printing) fills `printings`.
|
||||
//!
|
||||
//! Both bulk files are large JSON arrays, so we stream them element-by-element
|
||||
//! rather than loading the whole array into memory.
|
||||
|
||||
use std::io::{BufReader, Read};
|
||||
use std::marker::PhantomData;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::de::{DeserializeOwned, SeqAccess, Visitor};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::db::open_reference_for_build;
|
||||
use crate::error::{CoreError, Result};
|
||||
use crate::model::ColorSet;
|
||||
|
||||
const BULK_INDEX_URL: &str = "https://api.scryfall.com/bulk-data";
|
||||
const USER_AGENT: &str = "mtgc/0.1 (personal library catalog)";
|
||||
|
||||
/// Row counts from a build, for reporting/verification.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct IngestStats {
|
||||
pub oracle_cards: usize,
|
||||
pub printings: usize,
|
||||
}
|
||||
|
||||
/// Download the latest Scryfall bulk data and (re)build `reference.db` at `dest`.
|
||||
///
|
||||
/// `progress` is called with human-readable status lines.
|
||||
pub fn build_reference_db(dest: &Path, mut progress: impl FnMut(&str)) -> Result<IngestStats> {
|
||||
let (oracle_uri, default_uri) = fetch_bulk_uris(&mut progress)?;
|
||||
|
||||
if dest.exists() {
|
||||
std::fs::remove_file(dest)?;
|
||||
}
|
||||
let conn = open_reference_for_build(dest)?;
|
||||
// Bulk-load fast; this db is disposable/rebuildable so durability is moot.
|
||||
conn.pragma_update(None, "journal_mode", "MEMORY")?;
|
||||
conn.pragma_update(None, "synchronous", "OFF")?;
|
||||
|
||||
progress("Loading oracle cards…");
|
||||
let oracle_cards = load_oracle_cards(&conn, http_reader(&oracle_uri)?)?;
|
||||
|
||||
progress("Loading printings…");
|
||||
let printings = load_printings(&conn, http_reader(&default_uri)?)?;
|
||||
|
||||
progress("Optimizing…");
|
||||
conn.execute_batch("INSERT INTO oracle_names_fts(oracle_names_fts) VALUES('optimize');")?;
|
||||
|
||||
Ok(IngestStats {
|
||||
oracle_cards,
|
||||
printings,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Scryfall bulk index -------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BulkIndex {
|
||||
data: Vec<BulkEntry>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BulkEntry {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
download_uri: String,
|
||||
}
|
||||
|
||||
fn fetch_bulk_uris(progress: &mut impl FnMut(&str)) -> Result<(String, String)> {
|
||||
progress("Fetching Scryfall bulk index…");
|
||||
let index: BulkIndex = ureq::get(BULK_INDEX_URL)
|
||||
.set("User-Agent", USER_AGENT)
|
||||
.set("Accept", "application/json")
|
||||
.call()?
|
||||
.into_json()?;
|
||||
|
||||
let find = |kind: &str| {
|
||||
index
|
||||
.data
|
||||
.iter()
|
||||
.find(|e| e.kind == kind)
|
||||
.map(|e| e.download_uri.clone())
|
||||
.ok_or_else(|| CoreError::Other(format!("Scryfall bulk index missing '{kind}'")))
|
||||
};
|
||||
Ok((find("oracle_cards")?, find("default_cards")?))
|
||||
}
|
||||
|
||||
fn http_reader(url: &str) -> Result<impl Read> {
|
||||
let resp = ureq::get(url)
|
||||
.set("User-Agent", USER_AGENT)
|
||||
.call()?;
|
||||
Ok(BufReader::with_capacity(1 << 20, resp.into_reader()))
|
||||
}
|
||||
|
||||
// ---- Streaming array parse ----------------------------------------------
|
||||
|
||||
/// Deserialize a top-level JSON array element-by-element, invoking `f` per item
|
||||
/// without holding the whole array in memory.
|
||||
fn stream_json_array<R, T, F>(reader: R, f: F) -> Result<()>
|
||||
where
|
||||
R: Read,
|
||||
T: DeserializeOwned,
|
||||
F: FnMut(T) -> Result<()>,
|
||||
{
|
||||
struct SeqVisitor<T, F> {
|
||||
f: F,
|
||||
_marker: PhantomData<fn() -> T>,
|
||||
}
|
||||
|
||||
impl<'de, T, F> Visitor<'de> for SeqVisitor<T, F>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
F: FnMut(T) -> Result<()>,
|
||||
{
|
||||
type Value = ();
|
||||
|
||||
fn expecting(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
fmt.write_str("a JSON array of objects")
|
||||
}
|
||||
|
||||
fn visit_seq<A>(mut self, mut seq: A) -> std::result::Result<(), A::Error>
|
||||
where
|
||||
A: SeqAccess<'de>,
|
||||
{
|
||||
while let Some(item) = seq.next_element::<T>()? {
|
||||
(self.f)(item).map_err(serde::de::Error::custom)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let mut de = serde_json::Deserializer::from_reader(reader);
|
||||
serde::de::Deserializer::deserialize_seq(
|
||||
&mut de,
|
||||
SeqVisitor {
|
||||
f,
|
||||
_marker: PhantomData,
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Oracle pass ---------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ScryOracle {
|
||||
oracle_id: Option<String>,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
cmc: f64,
|
||||
#[serde(default)]
|
||||
colors: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
color_identity: Vec<String>,
|
||||
#[serde(default)]
|
||||
type_line: Option<String>,
|
||||
#[serde(default)]
|
||||
oracle_text: Option<String>,
|
||||
#[serde(default)]
|
||||
keywords: Vec<String>,
|
||||
#[serde(default)]
|
||||
card_faces: Option<Vec<ScryFace>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ScryFace {
|
||||
#[serde(default)]
|
||||
type_line: Option<String>,
|
||||
#[serde(default)]
|
||||
colors: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
fn load_oracle_cards(conn: &Connection, reader: impl Read) -> Result<usize> {
|
||||
conn.execute_batch("BEGIN")?;
|
||||
let mut count = 0usize;
|
||||
{
|
||||
let mut ins_card = conn.prepare(
|
||||
"INSERT OR IGNORE INTO oracle_cards
|
||||
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
|
||||
)?;
|
||||
let mut ins_fts =
|
||||
conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?;
|
||||
let mut ins_type =
|
||||
conn.prepare("INSERT INTO card_types(oracle_id,type) VALUES (?1,?2)")?;
|
||||
let mut ins_sub =
|
||||
conn.prepare("INSERT INTO card_subtypes(oracle_id,subtype) VALUES (?1,?2)")?;
|
||||
let mut ins_kw =
|
||||
conn.prepare("INSERT INTO card_keywords(oracle_id,keyword) VALUES (?1,?2)")?;
|
||||
|
||||
stream_json_array::<_, ScryOracle, _>(reader, |c| {
|
||||
let Some(oracle_id) = c.oracle_id.clone() else {
|
||||
return Ok(()); // rare face-only records without a top-level oracle_id
|
||||
};
|
||||
|
||||
// Colors: prefer top-level, else union of faces.
|
||||
let colors = match &c.colors {
|
||||
Some(cs) => ColorSet::from_letters(cs),
|
||||
None => {
|
||||
let mut m = 0u8;
|
||||
if let Some(faces) = &c.card_faces {
|
||||
for f in faces {
|
||||
if let Some(cs) = &f.colors {
|
||||
m |= ColorSet::from_letters(cs).0;
|
||||
}
|
||||
}
|
||||
}
|
||||
ColorSet(m)
|
||||
}
|
||||
};
|
||||
let identity = ColorSet::from_letters(&c.color_identity);
|
||||
|
||||
// Type line: top-level, else join faces.
|
||||
let type_line = c.type_line.clone().unwrap_or_else(|| {
|
||||
c.card_faces
|
||||
.as_ref()
|
||||
.map(|faces| {
|
||||
faces
|
||||
.iter()
|
||||
.filter_map(|f| f.type_line.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" // ")
|
||||
})
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
let changed = ins_card.execute(params![
|
||||
oracle_id,
|
||||
c.name,
|
||||
c.name.to_lowercase(),
|
||||
c.cmc,
|
||||
colors.0 as i64,
|
||||
identity.0 as i64,
|
||||
type_line,
|
||||
c.oracle_text.clone().unwrap_or_default(),
|
||||
])?;
|
||||
// Only emit satellite rows the first time we see this oracle_id.
|
||||
if changed == 1 {
|
||||
ins_fts.execute(params![c.name, oracle_id])?;
|
||||
let (types, subtypes) = parse_type_line(&type_line);
|
||||
for t in types {
|
||||
ins_type.execute(params![oracle_id, t])?;
|
||||
}
|
||||
for s in subtypes {
|
||||
ins_sub.execute(params![oracle_id, s])?;
|
||||
}
|
||||
let mut kws = c.keywords.clone();
|
||||
kws.sort();
|
||||
kws.dedup();
|
||||
for k in kws {
|
||||
ins_kw.execute(params![oracle_id, k])?;
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
conn.execute_batch("COMMIT")?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Split a Scryfall type line into (types+supertypes, subtypes). Handles split /
|
||||
/// double-faced lines joined by " // ". The separator is the em dash '—'.
|
||||
pub fn parse_type_line(type_line: &str) -> (Vec<String>, Vec<String>) {
|
||||
let mut types = Vec::new();
|
||||
let mut subtypes = Vec::new();
|
||||
for part in type_line.split("//") {
|
||||
let part = part.trim();
|
||||
let mut halves = part.splitn(2, '—');
|
||||
let left = halves.next().unwrap_or("").trim();
|
||||
let right = halves.next().unwrap_or("").trim();
|
||||
for t in left.split_whitespace() {
|
||||
types.push(t.to_string());
|
||||
}
|
||||
for s in right.split_whitespace() {
|
||||
subtypes.push(s.to_string());
|
||||
}
|
||||
}
|
||||
types.sort();
|
||||
types.dedup();
|
||||
subtypes.sort();
|
||||
subtypes.dedup();
|
||||
(types, subtypes)
|
||||
}
|
||||
|
||||
// ---- Printings pass ------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ScryPrinting {
|
||||
id: String,
|
||||
oracle_id: Option<String>,
|
||||
#[serde(default)]
|
||||
set: String,
|
||||
#[serde(default)]
|
||||
collector_number: String,
|
||||
#[serde(default)]
|
||||
finishes: Vec<String>,
|
||||
#[serde(default)]
|
||||
rarity: String,
|
||||
#[serde(default)]
|
||||
image_uris: Option<ImageUris>,
|
||||
#[serde(default)]
|
||||
card_faces: Option<Vec<PrintFace>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ImageUris {
|
||||
#[serde(default)]
|
||||
normal: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PrintFace {
|
||||
#[serde(default)]
|
||||
image_uris: Option<ImageUris>,
|
||||
}
|
||||
|
||||
fn load_printings(conn: &Connection, reader: impl Read) -> Result<usize> {
|
||||
conn.execute_batch("BEGIN")?;
|
||||
let mut count = 0usize;
|
||||
{
|
||||
let mut ins = conn.prepare(
|
||||
"INSERT OR IGNORE INTO printings
|
||||
(scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7)",
|
||||
)?;
|
||||
stream_json_array::<_, ScryPrinting, _>(reader, |p| {
|
||||
let Some(oracle_id) = p.oracle_id.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
let image = p
|
||||
.image_uris
|
||||
.as_ref()
|
||||
.and_then(|i| i.normal.clone())
|
||||
.or_else(|| {
|
||||
p.card_faces
|
||||
.as_ref()
|
||||
.and_then(|f| f.first())
|
||||
.and_then(|f| f.image_uris.as_ref())
|
||||
.and_then(|i| i.normal.clone())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
ins.execute(params![
|
||||
p.id,
|
||||
oracle_id,
|
||||
p.set,
|
||||
p.collector_number,
|
||||
p.finishes.join(","),
|
||||
p.rarity,
|
||||
image,
|
||||
])?;
|
||||
count += 1;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
conn.execute_batch("COMMIT")?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_type_lines() {
|
||||
let (t, s) = parse_type_line("Legendary Creature — Elf Druid");
|
||||
assert_eq!(t, vec!["Creature", "Legendary"]);
|
||||
assert_eq!(s, vec!["Druid", "Elf"]);
|
||||
|
||||
let (t, s) = parse_type_line("Instant");
|
||||
assert_eq!(t, vec!["Instant"]);
|
||||
assert!(s.is_empty());
|
||||
|
||||
// Split card.
|
||||
let (t, s) = parse_type_line("Sorcery // Sorcery");
|
||||
assert_eq!(t, vec!["Sorcery"]);
|
||||
assert!(s.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streams_array_of_objects() {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Item {
|
||||
n: i32,
|
||||
}
|
||||
let json = br#"[{"n":1},{"n":2},{"n":3}]"#;
|
||||
let mut sum = 0;
|
||||
stream_json_array::<_, Item, _>(&json[..], |it| {
|
||||
sum += it.n;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(sum, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_and_printing_load_from_fixture_json() {
|
||||
// Minimal Scryfall-shaped fixtures exercising the insert paths.
|
||||
let oracle = r#"[
|
||||
{"oracle_id":"oid-1","name":"Llanowar Elves","cmc":1.0,
|
||||
"colors":["G"],"color_identity":["G"],
|
||||
"type_line":"Creature — Elf Druid","keywords":[]},
|
||||
{"oracle_id":"oid-2","name":"Serra Angel","cmc":5.0,
|
||||
"colors":["W"],"color_identity":["W"],
|
||||
"type_line":"Creature — Angel","keywords":["Flying","Vigilance"]}
|
||||
]"#;
|
||||
let printings = r#"[
|
||||
{"id":"p-1","oracle_id":"oid-1","set":"m19","collector_number":"310",
|
||||
"finishes":["nonfoil","foil"],"rarity":"common",
|
||||
"image_uris":{"normal":"http://img/1"}}
|
||||
]"#;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dest = dir.path().join("reference.db");
|
||||
let conn = open_reference_for_build(&dest).unwrap();
|
||||
|
||||
let oc = load_oracle_cards(&conn, oracle.as_bytes()).unwrap();
|
||||
let pc = load_printings(&conn, printings.as_bytes()).unwrap();
|
||||
assert_eq!(oc, 2);
|
||||
assert_eq!(pc, 1);
|
||||
|
||||
// Keyword table populated.
|
||||
let flyers: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM card_keywords WHERE keyword='Flying'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(flyers, 1);
|
||||
|
||||
// Bitmask colors stored.
|
||||
let g: i64 = conn
|
||||
.query_row(
|
||||
"SELECT colors FROM oracle_cards WHERE oracle_id='oid-1'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(g as u8, crate::model::G);
|
||||
}
|
||||
}
|
||||
217
crates/mtgc-core/src/lib.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
//! mtgc-core — platform-agnostic core for the MTG library catalog.
|
||||
//!
|
||||
//! Everything that isn't UI lives here: the SQLite schema/migrations, the
|
||||
//! in-memory fuzzy name search, the criteria filters, deck operations, and the
|
||||
//! Scryfall ingester. It has no Tauri (or any GUI) dependency so it stays
|
||||
//! unit-testable and reusable when the app is ported to mobile.
|
||||
|
||||
pub mod db;
|
||||
pub mod decks;
|
||||
pub mod error;
|
||||
pub mod filters;
|
||||
pub mod ingest;
|
||||
pub mod library;
|
||||
pub mod model;
|
||||
pub mod search;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
pub use error::{CoreError, Result};
|
||||
pub use model::{CardRef, ColorSet, Deck, DeckEntry, LibraryCard, LibraryFilter};
|
||||
pub use search::NameIndex;
|
||||
|
||||
/// A handle to an open catalog: the library db with the reference db attached.
|
||||
///
|
||||
/// All read/write operations go through this. It owns the SQLite connection;
|
||||
/// the fuzzy [`NameIndex`] is built separately (see [`Catalog::build_name_index`])
|
||||
/// so the caller can cache it in app state.
|
||||
pub struct Catalog {
|
||||
pub(crate) conn: Connection,
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// Open the library at `library_path` (running migrations) with the
|
||||
/// reference db at `reference_path` attached.
|
||||
pub fn open(library_path: &Path, reference_path: &Path) -> Result<Self> {
|
||||
let conn = db::open(library_path, reference_path)?;
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
/// Build (or rebuild) the in-memory fuzzy name index from the attached
|
||||
/// reference db. Cheap enough to hold in app state and rebuild after a
|
||||
/// reference-data update.
|
||||
pub fn build_name_index(&self) -> Result<NameIndex> {
|
||||
NameIndex::build(&self.conn)
|
||||
}
|
||||
|
||||
/// Borrow the underlying connection (used by tests and advanced callers).
|
||||
pub fn connection(&self) -> &Connection {
|
||||
&self.conn
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support {
|
||||
//! Builds a tiny library+reference catalog in temp files for tests.
|
||||
|
||||
use super::*;
|
||||
use crate::model::ColorSet;
|
||||
|
||||
pub struct Fixture {
|
||||
pub catalog: Catalog,
|
||||
// Keep the temp dir alive for the lifetime of the fixture.
|
||||
_dir: tempfile::TempDir,
|
||||
}
|
||||
|
||||
/// (oracle_id, name, mana_value, colors, color_identity, type_line,
|
||||
/// types, subtypes, keywords)
|
||||
pub struct SeedCard {
|
||||
pub oracle_id: &'static str,
|
||||
pub name: &'static str,
|
||||
pub mv: f64,
|
||||
pub colors: &'static str,
|
||||
pub identity: &'static str,
|
||||
pub type_line: &'static str,
|
||||
pub types: &'static [&'static str],
|
||||
pub subtypes: &'static [&'static str],
|
||||
pub keywords: &'static [&'static str],
|
||||
}
|
||||
|
||||
pub fn seed_cards() -> Vec<SeedCard> {
|
||||
vec![
|
||||
SeedCard {
|
||||
oracle_id: "llanowar-elves",
|
||||
name: "Llanowar Elves",
|
||||
mv: 1.0,
|
||||
colors: "G",
|
||||
identity: "G",
|
||||
type_line: "Creature — Elf Druid",
|
||||
types: &["Creature"],
|
||||
subtypes: &["Elf", "Druid"],
|
||||
keywords: &[],
|
||||
},
|
||||
SeedCard {
|
||||
oracle_id: "deathrite-shaman",
|
||||
name: "Deathrite Shaman",
|
||||
mv: 1.0,
|
||||
colors: "BG",
|
||||
identity: "BG",
|
||||
type_line: "Creature — Elf Shaman",
|
||||
types: &["Creature"],
|
||||
subtypes: &["Elf", "Shaman"],
|
||||
keywords: &[],
|
||||
},
|
||||
SeedCard {
|
||||
oracle_id: "serra-angel",
|
||||
name: "Serra Angel",
|
||||
mv: 5.0,
|
||||
colors: "W",
|
||||
identity: "W",
|
||||
type_line: "Creature — Angel",
|
||||
types: &["Creature"],
|
||||
subtypes: &["Angel"],
|
||||
keywords: &["Flying", "Vigilance"],
|
||||
},
|
||||
SeedCard {
|
||||
oracle_id: "lightning-bolt",
|
||||
name: "Lightning Bolt",
|
||||
mv: 1.0,
|
||||
colors: "R",
|
||||
identity: "R",
|
||||
type_line: "Instant",
|
||||
types: &["Instant"],
|
||||
subtypes: &[],
|
||||
keywords: &[],
|
||||
},
|
||||
SeedCard {
|
||||
oracle_id: "sol-ring",
|
||||
name: "Sol Ring",
|
||||
mv: 1.0,
|
||||
colors: "",
|
||||
identity: "",
|
||||
type_line: "Artifact",
|
||||
types: &["Artifact"],
|
||||
subtypes: &[],
|
||||
keywords: &[],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn make() -> Fixture {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ref_path = dir.path().join("reference.db");
|
||||
let lib_path = dir.path().join("library.db");
|
||||
|
||||
// Build reference.db with fixture cards.
|
||||
{
|
||||
let rconn = db::open_reference_for_build(&ref_path).unwrap();
|
||||
for c in seed_cards() {
|
||||
let colors = ColorSet::parse(c.colors).0 as i64;
|
||||
let identity = ColorSet::parse(c.identity).0 as i64;
|
||||
rconn
|
||||
.execute(
|
||||
"INSERT INTO oracle_cards
|
||||
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7)",
|
||||
rusqlite::params![
|
||||
c.oracle_id,
|
||||
c.name,
|
||||
c.name.to_lowercase(),
|
||||
c.mv,
|
||||
colors,
|
||||
identity,
|
||||
c.type_line
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
rconn
|
||||
.execute(
|
||||
"INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)",
|
||||
rusqlite::params![c.name, c.oracle_id],
|
||||
)
|
||||
.unwrap();
|
||||
for t in c.types {
|
||||
rconn
|
||||
.execute(
|
||||
"INSERT INTO card_types(oracle_id,type) VALUES (?1,?2)",
|
||||
rusqlite::params![c.oracle_id, t],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
for s in c.subtypes {
|
||||
rconn
|
||||
.execute(
|
||||
"INSERT INTO card_subtypes(oracle_id,subtype) VALUES (?1,?2)",
|
||||
rusqlite::params![c.oracle_id, s],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
for k in c.keywords {
|
||||
rconn
|
||||
.execute(
|
||||
"INSERT INTO card_keywords(oracle_id,keyword) VALUES (?1,?2)",
|
||||
rusqlite::params![c.oracle_id, k],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
// One printing per card, carrying an image URL.
|
||||
rconn
|
||||
.execute(
|
||||
"INSERT INTO printings(scryfall_id,oracle_id,image_uri)
|
||||
VALUES (?1,?2,?3)",
|
||||
rusqlite::params![
|
||||
format!("print-{}", c.oracle_id),
|
||||
c.oracle_id,
|
||||
format!("http://img/{}", c.oracle_id)
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let catalog = Catalog::open(&lib_path, &ref_path).unwrap();
|
||||
Fixture { catalog, _dir: dir }
|
||||
}
|
||||
}
|
||||
192
crates/mtgc-core/src/library.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
//! Ownership operations: what cards the user has and how many.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
|
||||
use crate::db::REF_DB;
|
||||
use crate::error::Result;
|
||||
use crate::model::{CardRef, ColorSet, LibraryCard};
|
||||
use crate::Catalog;
|
||||
|
||||
/// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value,
|
||||
/// colors, color_identity, type_line, image_uri)` into its library parts. Shared
|
||||
/// by [`Catalog::list_library`] and the criteria search.
|
||||
pub(crate) fn row_to_library_parts(
|
||||
r: &rusqlite::Row,
|
||||
) -> rusqlite::Result<(String, i64, String, CardRef)> {
|
||||
let oracle_id: String = r.get(0)?;
|
||||
Ok((
|
||||
oracle_id.clone(),
|
||||
r.get::<_, i64>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
CardRef {
|
||||
oracle_id,
|
||||
name: r.get(3)?,
|
||||
mana_value: r.get(4)?,
|
||||
colors: ColorSet(r.get::<_, i64>(5)? as u8),
|
||||
color_identity: ColorSet(r.get::<_, i64>(6)? as u8),
|
||||
type_line: r.get(7)?,
|
||||
image_uri: r.get::<_, Option<String>>(8)?.unwrap_or_default(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// Add `delta` copies of a card to the library (increment, creating the row
|
||||
/// if needed). `delta` may be negative to decrement; the quantity floors at 0.
|
||||
/// Returns the new owned quantity.
|
||||
pub fn add_owned(&self, oracle_id: &str, delta: i64) -> Result<i64> {
|
||||
let current: i64 = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT quantity_owned FROM library_cards WHERE oracle_id = ?1",
|
||||
params![oracle_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.unwrap_or(0);
|
||||
self.set_owned(oracle_id, current + delta)
|
||||
}
|
||||
|
||||
/// Set the absolute owned quantity for a card (0 removes ownership but keeps
|
||||
/// any deck assignments referencing it).
|
||||
pub fn set_owned(&self, oracle_id: &str, quantity: i64) -> Result<i64> {
|
||||
let q = quantity.max(0);
|
||||
self.conn.execute(
|
||||
"INSERT INTO library_cards(oracle_id, quantity_owned) VALUES (?1, ?2)
|
||||
ON CONFLICT(oracle_id) DO UPDATE SET quantity_owned = excluded.quantity_owned",
|
||||
params![oracle_id, q],
|
||||
)?;
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
/// List every owned card (quantity_owned > 0) with deck context, ordered by
|
||||
/// name. This is the "browse my library" view without extra criteria.
|
||||
pub fn list_library(&self) -> Result<Vec<LibraryCard>> {
|
||||
let deck_names = self.deck_names_by_oracle()?;
|
||||
let assigned = self.assigned_totals_by_oracle()?;
|
||||
|
||||
let mut stmt = self.conn.prepare(&format!(
|
||||
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
|
||||
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}
|
||||
FROM library_cards lc
|
||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
|
||||
WHERE lc.quantity_owned > 0
|
||||
ORDER BY oc.name COLLATE NOCASE",
|
||||
image = crate::db::image_subquery()
|
||||
))?;
|
||||
let rows = stmt
|
||||
.query_map([], row_to_library_parts)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(oid, qty, location, card)| LibraryCard {
|
||||
quantity_owned: qty,
|
||||
decks: deck_names.get(&oid).cloned().unwrap_or_default(),
|
||||
quantity_assigned: *assigned.get(&oid).unwrap_or(&0),
|
||||
location,
|
||||
card,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Set the free-text storage location for a card (creates the library row if
|
||||
/// absent). Empty string clears it.
|
||||
pub fn set_location(&self, oracle_id: &str, location: &str) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO library_cards(oracle_id, location) VALUES (?1, ?2)
|
||||
ON CONFLICT(oracle_id) DO UPDATE SET location = excluded.location",
|
||||
params![oracle_id, location],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Map each owned/assigned oracle_id to the deck names it appears in.
|
||||
pub(crate) fn deck_names_by_oracle(&self) -> Result<HashMap<String, Vec<String>>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT dc.oracle_id, d.name
|
||||
FROM deck_cards dc JOIN decks d ON d.id = dc.deck_id
|
||||
ORDER BY d.name COLLATE NOCASE",
|
||||
)?;
|
||||
let mut map: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
|
||||
for row in rows {
|
||||
let (oid, name) = row?;
|
||||
map.entry(oid).or_default().push(name);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
pub(crate) fn assigned_totals_by_oracle(&self) -> Result<HashMap<String, i64>> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT oracle_id, SUM(quantity) FROM deck_cards GROUP BY oracle_id")?;
|
||||
let mut map = HashMap::new();
|
||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
|
||||
for row in rows {
|
||||
let (oid, total) = row?;
|
||||
map.insert(oid, total);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::test_support;
|
||||
|
||||
#[test]
|
||||
fn add_increment_and_floor() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
|
||||
assert_eq!(cat.add_owned("sol-ring", 1).unwrap(), 1);
|
||||
assert_eq!(cat.add_owned("sol-ring", 2).unwrap(), 3);
|
||||
assert_eq!(cat.add_owned("sol-ring", -10).unwrap(), 0); // floors at 0
|
||||
assert_eq!(cat.set_owned("sol-ring", 4).unwrap(), 4);
|
||||
|
||||
let lib = cat.list_library().unwrap();
|
||||
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
|
||||
assert_eq!(sol.quantity_owned, 4);
|
||||
assert!(sol.decks.is_empty());
|
||||
// Image URL is plumbed through from the printings table.
|
||||
assert_eq!(sol.card.image_uri, "http://img/sol-ring");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn location_is_stored_and_listed() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
cat.add_owned("sol-ring", 1).unwrap();
|
||||
cat.set_location("sol-ring", "Box 1").unwrap();
|
||||
|
||||
let lib = cat.list_library().unwrap();
|
||||
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
|
||||
assert_eq!(sol.location, "Box 1");
|
||||
|
||||
// Clearing works.
|
||||
cat.set_location("sol-ring", "").unwrap();
|
||||
let lib = cat.list_library().unwrap();
|
||||
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
|
||||
assert_eq!(sol.location, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_is_ordered_and_excludes_zero() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
cat.add_owned("serra-angel", 1).unwrap();
|
||||
cat.add_owned("lightning-bolt", 1).unwrap();
|
||||
cat.set_owned("sol-ring", 0).unwrap();
|
||||
|
||||
let names: Vec<_> = cat
|
||||
.list_library()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|c| c.card.name)
|
||||
.collect();
|
||||
assert_eq!(names, vec!["Lightning Bolt", "Serra Angel"]);
|
||||
}
|
||||
}
|
||||
165
crates/mtgc-core/src/model.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
//! Shared domain types used across the core.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A card's color identity / colors, encoded as a 5-bit WUBRG mask.
|
||||
///
|
||||
/// Bit layout (little end first): W=1, U=2, B=4, R=8, G=16.
|
||||
/// Storing colors this way turns commander color-identity compatibility into a
|
||||
/// single integer predicate (see [`ColorSet::is_subset_of`]).
|
||||
pub const W: u8 = 1 << 0;
|
||||
pub const U: u8 = 1 << 1;
|
||||
pub const B: u8 = 1 << 2;
|
||||
pub const R: u8 = 1 << 3;
|
||||
pub const G: u8 = 1 << 4;
|
||||
|
||||
/// The five colors in canonical WUBRG order, paired with their single-letter code.
|
||||
pub const WUBRG: [(u8, char); 5] = [(W, 'W'), (U, 'U'), (B, 'B'), (R, 'R'), (G, 'G')];
|
||||
|
||||
/// A set of colors as a WUBRG bitmask.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct ColorSet(pub u8);
|
||||
|
||||
impl ColorSet {
|
||||
pub const COLORLESS: ColorSet = ColorSet(0);
|
||||
|
||||
/// Build from an iterator of single-letter color codes (e.g. Scryfall's
|
||||
/// `["B", "G"]`). Unknown letters are ignored.
|
||||
pub fn from_letters<I, S>(letters: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let mut mask = 0u8;
|
||||
for l in letters {
|
||||
match l.as_ref() {
|
||||
"W" | "w" => mask |= W,
|
||||
"U" | "u" => mask |= U,
|
||||
"B" | "b" => mask |= B,
|
||||
"R" | "r" => mask |= R,
|
||||
"G" | "g" => mask |= G,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
ColorSet(mask)
|
||||
}
|
||||
|
||||
/// Parse from a compact string like "BG" or "wubrg". Order-insensitive.
|
||||
pub fn parse(s: &str) -> Self {
|
||||
Self::from_letters(s.chars().map(|c| c.to_string()))
|
||||
}
|
||||
|
||||
/// Canonical WUBRG string, e.g. `ColorSet(B|G)` -> "BG". Colorless -> "".
|
||||
pub fn to_letters(self) -> String {
|
||||
WUBRG
|
||||
.iter()
|
||||
.filter(|(bit, _)| self.0 & bit != 0)
|
||||
.map(|(_, ch)| *ch)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn is_colorless(self) -> bool {
|
||||
self.0 == 0
|
||||
}
|
||||
|
||||
pub fn contains(self, bit: u8) -> bool {
|
||||
self.0 & bit != 0
|
||||
}
|
||||
|
||||
/// True if every color in `self` is also in `other`.
|
||||
///
|
||||
/// This is the commander-legality rule: a card is playable in a commander of
|
||||
/// color identity `C` iff `card_identity.is_subset_of(C)`.
|
||||
pub fn is_subset_of(self, other: ColorSet) -> bool {
|
||||
self.0 & !other.0 == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// A card as returned by search / library queries. Oracle-level (one per
|
||||
/// `oracle_id`), enriched with the caller's ownership + deck context where relevant.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CardRef {
|
||||
pub oracle_id: String,
|
||||
pub name: String,
|
||||
pub mana_value: f64,
|
||||
pub colors: ColorSet,
|
||||
pub color_identity: ColorSet,
|
||||
pub type_line: String,
|
||||
/// URL of a card image (Scryfall "normal" size) for one of its printings,
|
||||
/// or empty if none is available.
|
||||
pub image_uri: String,
|
||||
}
|
||||
|
||||
/// A library row: a card the user owns, with quantity and its deck memberships.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LibraryCard {
|
||||
#[serde(flatten)]
|
||||
pub card: CardRef,
|
||||
pub quantity_owned: i64,
|
||||
/// Names of decks this card is currently assigned to.
|
||||
pub decks: Vec<String>,
|
||||
/// Total copies assigned across all decks. If it exceeds `quantity_owned`,
|
||||
/// the physical copies are over-committed.
|
||||
pub quantity_assigned: i64,
|
||||
/// Free-text physical storage location (e.g. "Box 1"). Meaningful mainly for
|
||||
/// cards not currently in a deck.
|
||||
pub location: String,
|
||||
}
|
||||
|
||||
/// A card in a deck, whether or not the user owns it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeckEntry {
|
||||
#[serde(flatten)]
|
||||
pub card: CardRef,
|
||||
/// Copies of this card in the deck.
|
||||
pub quantity: i64,
|
||||
pub is_commander: bool,
|
||||
/// Copies owned in the library (0 if the card isn't in the library).
|
||||
pub quantity_owned: i64,
|
||||
}
|
||||
|
||||
impl LibraryCard {
|
||||
pub fn is_over_assigned(&self) -> bool {
|
||||
self.quantity_assigned > self.quantity_owned
|
||||
}
|
||||
}
|
||||
|
||||
/// A deck.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Deck {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
/// Free-text format, e.g. "Commander", "Modern" (empty if unset).
|
||||
pub format: String,
|
||||
/// The commander card's oracle id (meaningful for Commander/Brawl formats).
|
||||
pub commander_oracle_id: Option<String>,
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
/// Criteria for searching the owned library. All fields are optional; an empty
|
||||
/// filter matches every owned card.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct LibraryFilter {
|
||||
/// Fuzzy-ish name text (applied as a case-insensitive substring here; the
|
||||
/// fast fuzzy path is [`crate::search`]).
|
||||
pub text: Option<String>,
|
||||
/// Cards whose `colors` exactly equal this set.
|
||||
pub colors_exact: Option<ColorSet>,
|
||||
/// Cards whose `colors` include at least these.
|
||||
pub colors_include: Option<ColorSet>,
|
||||
/// Commander compatibility: only cards whose color identity is a subset of
|
||||
/// this set (playable under a commander of this identity).
|
||||
pub commander_identity: Option<ColorSet>,
|
||||
pub cmc_min: Option<f64>,
|
||||
pub cmc_max: Option<f64>,
|
||||
/// Card supertype/type, e.g. "Creature", "Instant" (matched against the
|
||||
/// normalized `card_types` table).
|
||||
pub types: Vec<String>,
|
||||
/// Subtype, e.g. "Elf", "Equipment".
|
||||
pub subtypes: Vec<String>,
|
||||
/// Keyword ability, e.g. "Flying", "Deathtouch".
|
||||
pub keywords: Vec<String>,
|
||||
/// Restrict to cards assigned to this deck id.
|
||||
pub in_deck: Option<i64>,
|
||||
}
|
||||
108
crates/mtgc-core/src/search.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
//! In-memory fuzzy search over oracle card names (the "rapid entry" path).
|
||||
//!
|
||||
//! Names are loaded once from the reference db into a [`NameIndex`]; each query
|
||||
//! is ranked with nucleo (fzf-quality), giving typo-tolerant ordering far better
|
||||
//! than SQL `LIKE`. ~30k names rank in well under a millisecond.
|
||||
|
||||
use nucleo::pattern::{CaseMatching, Normalization, Pattern};
|
||||
use nucleo::{Config, Matcher};
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::db::REF_DB;
|
||||
use crate::error::Result;
|
||||
use crate::model::{CardRef, ColorSet};
|
||||
|
||||
/// One searchable entry, carrying the full [`CardRef`] so a hit needs no extra
|
||||
/// DB round-trip. `AsRef<str>` yields the name that nucleo matches against.
|
||||
struct Entry {
|
||||
card: CardRef,
|
||||
}
|
||||
|
||||
impl AsRef<str> for Entry {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.card.name
|
||||
}
|
||||
}
|
||||
|
||||
/// An immutable, in-memory index of all oracle card names for fuzzy search.
|
||||
pub struct NameIndex {
|
||||
entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
impl NameIndex {
|
||||
/// Load every oracle card from the attached reference db.
|
||||
pub fn build(conn: &Connection) -> Result<Self> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
|
||||
oc.type_line, {image}
|
||||
FROM {REF_DB}.oracle_cards oc",
|
||||
image = crate::db::image_subquery()
|
||||
))?;
|
||||
let entries = stmt
|
||||
.query_map([], |r| {
|
||||
Ok(Entry {
|
||||
card: CardRef {
|
||||
oracle_id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
mana_value: r.get(2)?,
|
||||
colors: ColorSet(r.get::<_, i64>(3)? as u8),
|
||||
color_identity: ColorSet(r.get::<_, i64>(4)? as u8),
|
||||
type_line: r.get(5)?,
|
||||
image_uri: r.get::<_, Option<String>>(6)?.unwrap_or_default(),
|
||||
},
|
||||
})
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
Ok(Self { entries })
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Rank cards by fuzzy match against `query`, returning up to `limit` of the
|
||||
/// best matches (highest score first). An empty query returns nothing.
|
||||
pub fn search(&self, query: &str, limit: usize) -> Vec<CardRef> {
|
||||
if query.trim().is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut matcher = Matcher::new(Config::DEFAULT);
|
||||
let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
|
||||
let mut matches = pattern.match_list(self.entries.iter(), &mut matcher);
|
||||
matches.truncate(limit);
|
||||
matches.into_iter().map(|(e, _score)| e.card.clone()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::test_support;
|
||||
|
||||
#[test]
|
||||
fn typo_still_ranks_target_first() {
|
||||
let fx = test_support::make();
|
||||
let idx = fx.catalog.build_name_index().unwrap();
|
||||
assert_eq!(idx.len(), 5);
|
||||
|
||||
// Misspelled "Llanowar Elves".
|
||||
let hits = idx.search("lanowar elvs", 5);
|
||||
assert!(!hits.is_empty(), "typo query should still match");
|
||||
assert_eq!(hits[0].name, "Llanowar Elves");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_and_limit() {
|
||||
let fx = test_support::make();
|
||||
let idx = fx.catalog.build_name_index().unwrap();
|
||||
|
||||
let hits = idx.search("light", 5);
|
||||
assert_eq!(hits[0].name, "Lightning Bolt");
|
||||
|
||||
assert!(idx.search("bolt", 1).len() <= 1);
|
||||
assert!(idx.search("", 10).is_empty());
|
||||
}
|
||||
}
|
||||
41
crates/mtgc-core/src/sql/library/0001_init.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- library.db v1 — the small, portable, synced file.
|
||||
-- References reference cards by stable oracle_id / scryfall_id.
|
||||
|
||||
CREATE TABLE decks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
commander_oracle_id TEXT,
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Oracle-level ownership: one row per distinct card.
|
||||
CREATE TABLE library_cards (
|
||||
id INTEGER PRIMARY KEY,
|
||||
oracle_id TEXT NOT NULL UNIQUE,
|
||||
quantity_owned INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Optional per-physical-copy printing detail (the "hybrid" identity).
|
||||
CREATE TABLE library_card_copies (
|
||||
id INTEGER PRIMARY KEY,
|
||||
library_card_id INTEGER NOT NULL REFERENCES library_cards(id) ON DELETE CASCADE,
|
||||
scryfall_id TEXT,
|
||||
set_code TEXT,
|
||||
collector_number TEXT,
|
||||
finish TEXT,
|
||||
condition TEXT
|
||||
);
|
||||
CREATE INDEX idx_copies_card ON library_card_copies(library_card_id);
|
||||
|
||||
-- Deck membership at the oracle level.
|
||||
CREATE TABLE deck_cards (
|
||||
id INTEGER PRIMARY KEY,
|
||||
deck_id INTEGER NOT NULL REFERENCES decks(id) ON DELETE CASCADE,
|
||||
oracle_id TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
is_commander INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(deck_id, oracle_id)
|
||||
);
|
||||
CREATE INDEX idx_deck_cards_oid ON deck_cards(oracle_id);
|
||||
CREATE INDEX idx_deck_cards_deck ON deck_cards(deck_id);
|
||||
3
crates/mtgc-core/src/sql/library/0002_location.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- v2 — physical storage location for owned cards (e.g. "Box 1", "Binder A").
|
||||
-- Most relevant for cards not assigned to any deck.
|
||||
ALTER TABLE library_cards ADD COLUMN location TEXT NOT NULL DEFAULT '';
|
||||
3
crates/mtgc-core/src/sql/library/0003_deck_format.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- v3 — deck format metadata (e.g. "Commander", "Modern"). The commander card
|
||||
-- itself is stored in the existing decks.commander_oracle_id column.
|
||||
ALTER TABLE decks ADD COLUMN format TEXT NOT NULL DEFAULT '';
|
||||
56
crates/mtgc-core/src/sql/reference_schema.sql
Normal file
@@ -0,0 +1,56 @@
|
||||
-- Schema for reference.db: read-only card data built from Scryfall bulk.
|
||||
-- Applied against a connection whose `main` database IS the reference db
|
||||
-- (the ingester opens reference.db directly; at runtime it is ATTACHed as `ref`).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oracle_cards (
|
||||
oracle_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
name_lower TEXT NOT NULL, -- for cheap case-insensitive substring
|
||||
mana_value REAL NOT NULL DEFAULT 0,
|
||||
colors INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask
|
||||
color_identity INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask
|
||||
type_line TEXT NOT NULL DEFAULT '',
|
||||
oracle_text TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_name_lower ON oracle_cards(name_lower);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_ci ON oracle_cards(color_identity);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_mv ON oracle_cards(mana_value);
|
||||
|
||||
-- Normalized attribute tables for fast type/subtype/keyword filtering.
|
||||
CREATE TABLE IF NOT EXISTS card_types (
|
||||
oracle_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_card_types_type ON card_types(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_card_types_oid ON card_types(oracle_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS card_subtypes (
|
||||
oracle_id TEXT NOT NULL,
|
||||
subtype TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_card_subtypes_sub ON card_subtypes(subtype);
|
||||
CREATE INDEX IF NOT EXISTS idx_card_subtypes_oid ON card_subtypes(oracle_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS card_keywords (
|
||||
oracle_id TEXT NOT NULL,
|
||||
keyword TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_card_keywords_kw ON card_keywords(keyword);
|
||||
CREATE INDEX IF NOT EXISTS idx_card_keywords_oid ON card_keywords(oracle_id);
|
||||
|
||||
-- One row per printing (default-language bulk).
|
||||
CREATE TABLE IF NOT EXISTS printings (
|
||||
scryfall_id TEXT PRIMARY KEY,
|
||||
oracle_id TEXT NOT NULL,
|
||||
set_code TEXT NOT NULL DEFAULT '',
|
||||
collector_number TEXT NOT NULL DEFAULT '',
|
||||
finishes TEXT NOT NULL DEFAULT '', -- comma-joined, e.g. "nonfoil,foil"
|
||||
rarity TEXT NOT NULL DEFAULT '',
|
||||
image_uri TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_printings_oid ON printings(oracle_id);
|
||||
|
||||
-- Secondary trigram FTS index over names (typo-tolerant fallback; the primary
|
||||
-- interactive search is the in-memory nucleo matcher).
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS oracle_names_fts
|
||||
USING fts5(name, oracle_id UNINDEXED, tokenize = 'trigram');
|
||||
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MTG Library Catalog</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1971
package-lock.json
generated
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "mtgc",
|
||||
"version": "0.1.0",
|
||||
"description": "Personal Magic: The Gathering library catalog",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
80
scripts/devel-setup.sh
Executable file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# devel-setup.sh — one-time developer environment setup for mtgc.
|
||||
#
|
||||
# Installs the Tauri v2 Linux system libraries and the Tauri CLI, and verifies
|
||||
# the toolchain (Rust, Node, pkg-config libs) needed to build the desktop shell.
|
||||
#
|
||||
# Safe to re-run: package installs are idempotent and the CLI install is skipped
|
||||
# if already present.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/devel-setup.sh
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*"; }
|
||||
err() { printf '\033[1;31m[err]\033[0m %s\n' "$*" >&2; }
|
||||
|
||||
# --- 1. Prerequisites already expected on the machine ----------------------
|
||||
|
||||
if ! command -v rustc >/dev/null 2>&1; then
|
||||
err "Rust not found. Install from https://rustup.rs and re-run."
|
||||
exit 1
|
||||
fi
|
||||
info "Rust: $(rustc --version)"
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
warn "Node.js not found — needed for the Vite/React frontend."
|
||||
warn "Install Node 18+ (e.g. 'sudo dnf install nodejs') before running the app."
|
||||
else
|
||||
info "Node: $(node --version)"
|
||||
fi
|
||||
|
||||
# --- 2. Tauri v2 system libraries (Fedora / dnf) ---------------------------
|
||||
|
||||
FEDORA_PKGS=(
|
||||
webkit2gtk4.1-devel
|
||||
gtk3-devel
|
||||
libappindicator-gtk3-devel
|
||||
librsvg2-devel
|
||||
file
|
||||
openssl-devel
|
||||
)
|
||||
|
||||
if command -v dnf >/dev/null 2>&1; then
|
||||
info "Installing Tauri system libraries via dnf (sudo required)…"
|
||||
sudo dnf install -y "${FEDORA_PKGS[@]}"
|
||||
else
|
||||
warn "dnf not found — this script targets Fedora."
|
||||
warn "Install the equivalents of: ${FEDORA_PKGS[*]}"
|
||||
warn "See https://tauri.app/start/prerequisites/ for your distro."
|
||||
fi
|
||||
|
||||
# --- 3. Verify the libraries pkg-config will look for ----------------------
|
||||
|
||||
check_pkg() {
|
||||
if pkg-config --exists "$1" 2>/dev/null; then
|
||||
info "found $1"
|
||||
else
|
||||
warn "missing $1 (Tauri build may fail)"
|
||||
fi
|
||||
}
|
||||
if command -v pkg-config >/dev/null 2>&1; then
|
||||
check_pkg webkit2gtk-4.1
|
||||
check_pkg gtk+-3.0
|
||||
else
|
||||
warn "pkg-config not found; cannot verify libraries."
|
||||
fi
|
||||
|
||||
# --- 4. Tauri v2 CLI -------------------------------------------------------
|
||||
|
||||
if cargo tauri --version >/dev/null 2>&1; then
|
||||
info "Tauri CLI: $(cargo tauri --version 2>&1 | head -1)"
|
||||
else
|
||||
info "Installing Tauri v2 CLI (cargo install tauri-cli --version '^2')…"
|
||||
cargo install tauri-cli --version '^2'
|
||||
fi
|
||||
|
||||
info "Setup complete. Next: scaffold + run the desktop shell with 'cargo tauri dev'."
|
||||
21
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "mtgc-app"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "MTG Library Catalog desktop app"
|
||||
|
||||
[lib]
|
||||
# The Tauri app logic lives in a lib so the same entry point can serve desktop
|
||||
# and (later) mobile targets.
|
||||
name = "mtgc_app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
mtgc-core = { path = "../crates/mtgc-core" }
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
7
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capabilities for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default", "dialog:default"]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 730 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
163
src-tauri/src/commands.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
//! Tauri commands: the typed IPC surface bridging the web UI to `mtgc-core`.
|
||||
//!
|
||||
//! JS passes camelCase argument keys; Tauri maps them to these snake_case params.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use mtgc_core::{CardRef, Deck, DeckEntry, LibraryCard, LibraryFilter};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
type Db<'a> = State<'a, Mutex<AppState>>;
|
||||
type Res<T> = Result<T, String>;
|
||||
|
||||
/// High-level status for the top bar / first-run gating.
|
||||
#[derive(Serialize)]
|
||||
pub struct Status {
|
||||
pub library_path: String,
|
||||
pub reference_path: String,
|
||||
pub reference_ready: bool,
|
||||
pub card_count: usize,
|
||||
}
|
||||
|
||||
fn status_of(s: &AppState) -> Status {
|
||||
Status {
|
||||
library_path: s.library_path.display().to_string(),
|
||||
reference_path: s.reference_path.display().to_string(),
|
||||
reference_ready: s.catalog.is_some(),
|
||||
card_count: s.index.as_ref().map(|i| i.len()).unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_status(db: Db) -> Res<Status> {
|
||||
Ok(status_of(&db.lock().unwrap()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_library_path(db: Db, path: String) -> Res<Status> {
|
||||
let mut s = db.lock().unwrap();
|
||||
s.library_path = PathBuf::from(path);
|
||||
s.reopen()?;
|
||||
Ok(status_of(&s))
|
||||
}
|
||||
|
||||
/// Download Scryfall bulk data and (re)build reference.db, then reopen.
|
||||
/// Runs on a blocking thread so the UI stays responsive and progress events flow.
|
||||
#[tauri::command]
|
||||
pub async fn update_card_data(app: AppHandle, db: Db<'_>) -> Res<Status> {
|
||||
let reference_path = db.lock().unwrap().reference_path.clone();
|
||||
let app_for_progress = app.clone();
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
mtgc_core::ingest::build_reference_db(&reference_path, |msg| {
|
||||
let _ = app_for_progress.emit("ingest-progress", msg.to_string());
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut s = db.lock().unwrap();
|
||||
s.reopen()?;
|
||||
Ok(status_of(&s))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn search_cards(db: Db, query: String, limit: usize) -> Res<Vec<CardRef>> {
|
||||
let s = db.lock().unwrap();
|
||||
Ok(s.index()?.search(&query, limit))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_owned(db: Db, oracle_id: String, delta: i64) -> Res<i64> {
|
||||
let s = db.lock().unwrap();
|
||||
s.catalog()?.add_owned(&oracle_id, delta).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_owned(db: Db, oracle_id: String, quantity: i64) -> Res<i64> {
|
||||
let s = db.lock().unwrap();
|
||||
s.catalog()?
|
||||
.set_owned(&oracle_id, quantity)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_location(db: Db, oracle_id: String, location: String) -> Res<()> {
|
||||
let s = db.lock().unwrap();
|
||||
s.catalog()?
|
||||
.set_location(&oracle_id, &location)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn deck_contents(db: Db, deck_id: i64) -> Res<Vec<DeckEntry>> {
|
||||
let s = db.lock().unwrap();
|
||||
s.catalog()?.deck_contents(deck_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn search_library(db: Db, filter: LibraryFilter) -> Res<Vec<LibraryCard>> {
|
||||
let s = db.lock().unwrap();
|
||||
s.catalog()?.search_library(&filter).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_decks(db: Db) -> Res<Vec<Deck>> {
|
||||
let s = db.lock().unwrap();
|
||||
s.catalog()?.list_decks().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_deck(db: Db, name: String, commander_oracle_id: Option<String>) -> Res<Deck> {
|
||||
let s = db.lock().unwrap();
|
||||
s.catalog()?
|
||||
.create_deck(&name, commander_oracle_id.as_deref())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_deck(db: Db, deck_id: i64) -> Res<()> {
|
||||
let s = db.lock().unwrap();
|
||||
s.catalog()?.delete_deck(deck_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_deck_format(db: Db, deck_id: i64, format: String) -> Res<()> {
|
||||
let s = db.lock().unwrap();
|
||||
s.catalog()?
|
||||
.set_deck_format(deck_id, &format)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_deck_commander(db: Db, deck_id: i64, oracle_id: Option<String>) -> Res<()> {
|
||||
let s = db.lock().unwrap();
|
||||
s.catalog()?
|
||||
.set_deck_commander(deck_id, oracle_id.as_deref())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_deck_card(
|
||||
db: Db,
|
||||
deck_id: i64,
|
||||
oracle_id: String,
|
||||
quantity: i64,
|
||||
is_commander: bool,
|
||||
) -> Res<()> {
|
||||
let s = db.lock().unwrap();
|
||||
s.catalog()?
|
||||
.set_deck_card(deck_id, &oracle_id, quantity, is_commander)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn card_decks(db: Db, oracle_id: String) -> Res<Vec<Deck>> {
|
||||
let s = db.lock().unwrap();
|
||||
s.catalog()?.card_decks(&oracle_id).map_err(|e| e.to_string())
|
||||
}
|
||||
43
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
mod commands;
|
||||
mod state;
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
use state::AppState;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.setup(|app| {
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.expect("resolve app data dir");
|
||||
std::fs::create_dir_all(&data_dir).ok();
|
||||
app.manage(Mutex::new(AppState::new(data_dir)));
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_status,
|
||||
commands::set_library_path,
|
||||
commands::update_card_data,
|
||||
commands::search_cards,
|
||||
commands::add_owned,
|
||||
commands::set_owned,
|
||||
commands::set_location,
|
||||
commands::search_library,
|
||||
commands::list_decks,
|
||||
commands::create_deck,
|
||||
commands::delete_deck,
|
||||
commands::set_deck_format,
|
||||
commands::set_deck_commander,
|
||||
commands::set_deck_card,
|
||||
commands::deck_contents,
|
||||
commands::card_decks,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevent an extra console window on Windows in release.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
mtgc_app_lib::run()
|
||||
}
|
||||
58
src-tauri/src/state.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the db paths.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use mtgc_core::{Catalog, NameIndex};
|
||||
|
||||
/// Managed application state, guarded by a `Mutex` in Tauri's state store.
|
||||
pub struct AppState {
|
||||
pub library_path: PathBuf,
|
||||
pub reference_path: PathBuf,
|
||||
pub catalog: Option<Catalog>,
|
||||
pub index: Option<NameIndex>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Initialize with default paths under the app data dir and open the catalog
|
||||
/// if a reference db already exists.
|
||||
pub fn new(data_dir: PathBuf) -> Self {
|
||||
let mut s = Self {
|
||||
library_path: data_dir.join("library.db"),
|
||||
reference_path: data_dir.join("reference.db"),
|
||||
catalog: None,
|
||||
index: None,
|
||||
};
|
||||
// Best-effort open; failures just leave the catalog unloaded.
|
||||
let _ = s.reopen();
|
||||
s
|
||||
}
|
||||
|
||||
/// (Re)open the catalog + rebuild the name index. Only opens when a reference
|
||||
/// db exists — attaching a non-existent file would create an empty (tableless)
|
||||
/// db and make every query fail.
|
||||
pub fn reopen(&mut self) -> Result<(), String> {
|
||||
if self.reference_path.exists() {
|
||||
let catalog = Catalog::open(&self.library_path, &self.reference_path)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let index = catalog.build_name_index().map_err(|e| e.to_string())?;
|
||||
self.index = Some(index);
|
||||
self.catalog = Some(catalog);
|
||||
} else {
|
||||
self.catalog = None;
|
||||
self.index = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn catalog(&self) -> Result<&Catalog, String> {
|
||||
self.catalog
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Card data not loaded yet — click 'Download card data'.".to_string())
|
||||
}
|
||||
|
||||
pub fn index(&self) -> Result<&NameIndex, String> {
|
||||
self.index
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Card data not loaded yet — click 'Download card data'.".to_string())
|
||||
}
|
||||
}
|
||||
37
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "MTG Library",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.garrettmills.mtgc",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "MTG Library",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
170
src/App.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useEffect, useState, useCallback, ReactNode } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { api, Status } from "./api";
|
||||
import { HoverPreviewProvider, usePersistedState } from "./components";
|
||||
import { CardEntry } from "./views/CardEntry";
|
||||
import { LibraryBrowser } from "./views/LibraryBrowser";
|
||||
import { DeckView } from "./views/DeckView";
|
||||
|
||||
type Tab = "add" | "library" | "decks";
|
||||
|
||||
const svg = (children: ReactNode) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
const NAV: { id: Tab; label: string; icon: ReactNode }[] = [
|
||||
{ id: "add", label: "Add Cards", icon: svg(<path d="M12 5v14M5 12h14" />) },
|
||||
{
|
||||
id: "library",
|
||||
label: "Library",
|
||||
icon: svg(
|
||||
<>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "decks",
|
||||
label: "Decks",
|
||||
icon: svg(
|
||||
<>
|
||||
<path d="M12 2.5l8.5 4.5-8.5 4.5L3.5 7z" />
|
||||
<path d="M3.5 12l8.5 4.5 8.5-4.5" />
|
||||
<path d="M3.5 16.5l8.5 4.5 8.5-4.5" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [tab, setTab] = usePersistedState<Tab>("active-tab", "add");
|
||||
const [progress, setProgress] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
setStatus(await api.getStatus());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshStatus();
|
||||
const un = listen<string>("ingest-progress", (e) => setProgress(e.payload));
|
||||
return () => {
|
||||
un.then((f) => f());
|
||||
};
|
||||
}, [refreshStatus]);
|
||||
|
||||
async function updateCardData() {
|
||||
setBusy(true);
|
||||
setProgress("Starting…");
|
||||
try {
|
||||
const s = await api.updateCardData();
|
||||
setStatus(s);
|
||||
setProgress(`Loaded ${s.card_count.toLocaleString()} cards.`);
|
||||
} catch (err) {
|
||||
setProgress(`Failed: ${String(err)}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseLibrary() {
|
||||
const path = await open({
|
||||
title: "Choose or create library.db (put it in a synced folder)",
|
||||
filters: [{ name: "SQLite", extensions: ["db", "sqlite"] }],
|
||||
});
|
||||
if (typeof path === "string") {
|
||||
setStatus(await api.setLibraryPath(path));
|
||||
}
|
||||
}
|
||||
|
||||
const ready = status?.reference_ready ?? false;
|
||||
|
||||
return (
|
||||
<HoverPreviewProvider>
|
||||
<div className="app">
|
||||
<nav className="nav-rail">
|
||||
<div className="brand">
|
||||
<span className="brand-mark">M</span>
|
||||
<span className="brand-text">MTG Library</span>
|
||||
</div>
|
||||
|
||||
<div className="nav-items">
|
||||
{NAV.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
className={`nav-item ${tab === n.id ? "active" : ""}`}
|
||||
onClick={() => setTab(n.id)}
|
||||
>
|
||||
<span className="nav-icon">{n.icon}</span>
|
||||
{n.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="nav-foot">
|
||||
<span className={`status-dot ${ready ? "on" : "off"}`} />
|
||||
{ready ? `${status?.card_count.toLocaleString()} cards` : "No card data"}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="main">
|
||||
<header className="topbar">
|
||||
{status && (
|
||||
<span className="path" title={status.library_path}>
|
||||
{status.library_path}
|
||||
</span>
|
||||
)}
|
||||
<span className="spacer" />
|
||||
{progress && <span className="progress">{progress}</span>}
|
||||
<button onClick={chooseLibrary} disabled={busy}>
|
||||
Choose library…
|
||||
</button>
|
||||
<button onClick={updateCardData} disabled={busy} className="primary">
|
||||
{ready ? "Update card data" : "Download card data"}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="content">
|
||||
{!ready ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-mark">✦</div>
|
||||
<h2>Welcome to your MTG library</h2>
|
||||
<p className="hint">
|
||||
No card database yet. Click <strong>Download card data</strong> to fetch
|
||||
the latest cards from Scryfall (a few hundred MB, about a minute). The
|
||||
reference data stays on this device — only your library file is meant to
|
||||
be synced.
|
||||
</p>
|
||||
<button className="primary big" onClick={updateCardData} disabled={busy}>
|
||||
{busy ? "Downloading…" : "Download card data"}
|
||||
</button>
|
||||
</div>
|
||||
) : tab === "add" ? (
|
||||
<CardEntry />
|
||||
) : tab === "library" ? (
|
||||
<LibraryBrowser />
|
||||
) : (
|
||||
<DeckView />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HoverPreviewProvider>
|
||||
);
|
||||
}
|
||||
109
src/api.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
// Typed wrappers over the Tauri command bridge into mtgc-core.
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
// --- Types (mirror the serde-serialized core structs) ---------------------
|
||||
|
||||
export interface CardRef {
|
||||
oracle_id: string;
|
||||
name: string;
|
||||
mana_value: number;
|
||||
colors: number; // WUBRG bitmask
|
||||
color_identity: number; // WUBRG bitmask
|
||||
type_line: string;
|
||||
image_uri: string; // Scryfall "normal" image URL, or "" if unavailable
|
||||
}
|
||||
|
||||
export interface LibraryCard extends CardRef {
|
||||
quantity_owned: number;
|
||||
decks: string[];
|
||||
quantity_assigned: number;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export interface DeckEntry extends CardRef {
|
||||
quantity: number;
|
||||
is_commander: boolean;
|
||||
quantity_owned: number;
|
||||
}
|
||||
|
||||
export interface Deck {
|
||||
id: number;
|
||||
name: string;
|
||||
format: string;
|
||||
commander_oracle_id: string | null;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface LibraryFilter {
|
||||
text?: string | null;
|
||||
colors_exact?: number | null;
|
||||
colors_include?: number | null;
|
||||
commander_identity?: number | null;
|
||||
cmc_min?: number | null;
|
||||
cmc_max?: number | null;
|
||||
types?: string[];
|
||||
subtypes?: string[];
|
||||
keywords?: string[];
|
||||
in_deck?: number | null;
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
library_path: string;
|
||||
reference_path: string;
|
||||
reference_ready: boolean;
|
||||
card_count: number;
|
||||
}
|
||||
|
||||
// --- WUBRG bitmask helpers ------------------------------------------------
|
||||
|
||||
export const COLORS: { bit: number; code: string; label: string }[] = [
|
||||
{ bit: 1, code: "W", label: "White" },
|
||||
{ bit: 2, code: "U", label: "Blue" },
|
||||
{ bit: 4, code: "B", label: "Black" },
|
||||
{ bit: 8, code: "R", label: "Red" },
|
||||
{ bit: 16, code: "G", label: "Green" },
|
||||
];
|
||||
|
||||
export function maskToLetters(mask: number): string {
|
||||
return COLORS.filter((c) => mask & c.bit)
|
||||
.map((c) => c.code)
|
||||
.join("");
|
||||
}
|
||||
|
||||
// --- Commands -------------------------------------------------------------
|
||||
|
||||
export const api = {
|
||||
getStatus: () => invoke<Status>("get_status"),
|
||||
setLibraryPath: (path: string) => invoke<Status>("set_library_path", { path }),
|
||||
updateCardData: () => invoke<Status>("update_card_data"),
|
||||
|
||||
searchCards: (query: string, limit = 20) =>
|
||||
invoke<CardRef[]>("search_cards", { query, limit }),
|
||||
|
||||
addOwned: (oracleId: string, delta: number) =>
|
||||
invoke<number>("add_owned", { oracleId, delta }),
|
||||
setOwned: (oracleId: string, quantity: number) =>
|
||||
invoke<number>("set_owned", { oracleId, quantity }),
|
||||
setLocation: (oracleId: string, location: string) =>
|
||||
invoke<void>("set_location", { oracleId, location }),
|
||||
|
||||
searchLibrary: (filter: LibraryFilter) =>
|
||||
invoke<LibraryCard[]>("search_library", { filter }),
|
||||
|
||||
listDecks: () => invoke<Deck[]>("list_decks"),
|
||||
createDeck: (name: string, commanderOracleId: string | null) =>
|
||||
invoke<Deck>("create_deck", { name, commanderOracleId }),
|
||||
deleteDeck: (deckId: number) => invoke<void>("delete_deck", { deckId }),
|
||||
setDeckFormat: (deckId: number, format: string) =>
|
||||
invoke<void>("set_deck_format", { deckId, format }),
|
||||
setDeckCommander: (deckId: number, oracleId: string | null) =>
|
||||
invoke<void>("set_deck_commander", { deckId, oracleId }),
|
||||
setDeckCard: (
|
||||
deckId: number,
|
||||
oracleId: string,
|
||||
quantity: number,
|
||||
isCommander = false,
|
||||
) => invoke<void>("set_deck_card", { deckId, oracleId, quantity, isCommander }),
|
||||
deckContents: (deckId: number) => invoke<DeckEntry[]>("deck_contents", { deckId }),
|
||||
cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }),
|
||||
};
|
||||
455
src/components.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { api, CardRef, COLORS, maskToLetters } from "./api";
|
||||
|
||||
/**
|
||||
* Inline editor for a card's free-text storage location (e.g. "Box 1").
|
||||
* Saves on blur; used for owned cards that aren't in any deck.
|
||||
*/
|
||||
export function LocationField({
|
||||
oracleId,
|
||||
location,
|
||||
onSaved,
|
||||
}: {
|
||||
oracleId: string;
|
||||
location: string;
|
||||
onSaved?: () => void;
|
||||
}) {
|
||||
const [val, setVal] = useState(location);
|
||||
useEffect(() => setVal(location), [location]);
|
||||
|
||||
const save = () => {
|
||||
if (val !== location) api.setLocation(oracleId, val).then(() => onSaved?.());
|
||||
};
|
||||
return (
|
||||
<input
|
||||
className="location-input"
|
||||
placeholder="Location…"
|
||||
value={val}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
onBlur={save}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Render a color-identity/colors bitmask as WUBRG pips (colorless = a "C" pip). */
|
||||
export function ColorPips({ mask }: { mask: number }) {
|
||||
const letters = maskToLetters(mask);
|
||||
if (letters.length === 0) {
|
||||
return (
|
||||
<span className="pips">
|
||||
<span className="pip C">C</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="pips">
|
||||
{letters.split("").map((c) => (
|
||||
<span key={c} className={`pip ${c}`}>
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small card-image thumbnail. Hovering it shows the full-size preview. */
|
||||
export function CardThumb({ card }: { card: CardRef }) {
|
||||
const { handlers } = useHoverPreview();
|
||||
if (!card.image_uri) {
|
||||
return (
|
||||
<span className="thumb-empty" title="No image">
|
||||
🂠
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
className="card-thumb"
|
||||
src={card.image_uri}
|
||||
alt={card.name}
|
||||
loading="lazy"
|
||||
{...handlers(card)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A search input plus a results dropdown with keyboard navigation:
|
||||
* ↑/↓ move the highlight, Enter picks, mouse hover/click also work.
|
||||
* `value`/`onChange` are controlled by the caller (which runs the search).
|
||||
*/
|
||||
export function CardSearchBox({
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
results,
|
||||
onPick,
|
||||
autoFocus,
|
||||
big,
|
||||
}: {
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
results: CardRef[];
|
||||
onPick: (card: CardRef) => void;
|
||||
autoFocus?: boolean;
|
||||
/** Larger, more prominent input (used for the primary Add Cards field). */
|
||||
big?: boolean;
|
||||
}) {
|
||||
const [sel, setSel] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => setSel(0), [results]);
|
||||
|
||||
// Keep focus in the box after a pick so cards can be added in a rapid streak.
|
||||
function pick(card: CardRef) {
|
||||
onPick(card);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSel((s) => Math.min(s + 1, results.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSel((s) => Math.max(s - 1, 0));
|
||||
} else if (e.key === "Enter" && results[sel]) {
|
||||
e.preventDefault();
|
||||
pick(results[sel]);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus={autoFocus}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom: 8,
|
||||
...(big ? { fontSize: 16, padding: "10px 12px" } : {}),
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{results.length > 0 && (
|
||||
<ul className="result-list" style={{ marginBottom: 14 }}>
|
||||
{results.map((c, i) => (
|
||||
<li
|
||||
key={c.oracle_id}
|
||||
style={i === sel ? { background: "var(--panel-2)" } : undefined}
|
||||
onMouseEnter={() => setSel(i)}
|
||||
onClick={() => pick(c)}
|
||||
>
|
||||
<ColorPips mask={c.color_identity} />
|
||||
<span className="card-name">{c.name}</span>
|
||||
<span className="card-type">{c.type_line}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Hover preview --------------------------------------------------------
|
||||
|
||||
interface HoverApi {
|
||||
enter: (card: CardRef, e: React.MouseEvent) => void;
|
||||
move: (e: React.MouseEvent) => void;
|
||||
leave: () => void;
|
||||
}
|
||||
const HoverCtx = createContext<HoverApi | null>(null);
|
||||
|
||||
/**
|
||||
* Provides a single floating card-image preview that appears while hovering a
|
||||
* card and tracks the cursor. Wrap the app once; attach `handlers(card)` from
|
||||
* [`useHoverPreview`] to any element that should trigger it.
|
||||
*/
|
||||
export function HoverPreviewProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<{ card: CardRef; x: number; y: number } | null>(null);
|
||||
|
||||
const enter = useCallback((card: CardRef, e: React.MouseEvent) => {
|
||||
if (card.image_uri) setState({ card, x: e.clientX, y: e.clientY });
|
||||
}, []);
|
||||
const move = useCallback((e: React.MouseEvent) => {
|
||||
setState((s) => (s ? { ...s, x: e.clientX, y: e.clientY } : s));
|
||||
}, []);
|
||||
const leave = useCallback(() => setState(null), []);
|
||||
|
||||
return (
|
||||
<HoverCtx.Provider value={{ enter, move, leave }}>
|
||||
{children}
|
||||
{state && <FloatingCard card={state.card} x={state.x} y={state.y} />}
|
||||
</HoverCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns a `handlers(card)` factory to spread onto a row/cell for hover preview. */
|
||||
export function useHoverPreview() {
|
||||
const api = useContext(HoverCtx);
|
||||
return {
|
||||
handlers: (card: CardRef) =>
|
||||
api
|
||||
? {
|
||||
onMouseEnter: (e: React.MouseEvent) => api.enter(card, e),
|
||||
onMouseMove: (e: React.MouseEvent) => api.move(e),
|
||||
onMouseLeave: () => api.leave(),
|
||||
}
|
||||
: {},
|
||||
};
|
||||
}
|
||||
|
||||
const PREVIEW_W = 320;
|
||||
const PREVIEW_H = 446; // ~ card aspect for the normal image
|
||||
const GAP = 24;
|
||||
const PAD = 12;
|
||||
|
||||
function FloatingCard({ card, x, y }: { card: CardRef; x: number; y: number }) {
|
||||
// Prefer the right of the cursor; flip left if it would overflow.
|
||||
let left = x + GAP;
|
||||
if (left + PREVIEW_W + PAD > window.innerWidth) left = x - PREVIEW_W - GAP;
|
||||
const top = Math.max(PAD, Math.min(y - PREVIEW_H / 2, window.innerHeight - PREVIEW_H - PAD));
|
||||
return (
|
||||
<img
|
||||
className="hover-card"
|
||||
src={card.image_uri}
|
||||
alt={card.name}
|
||||
style={{ left, top, width: PREVIEW_W }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Grouping -------------------------------------------------------------
|
||||
|
||||
export type GroupMode = "none" | "type" | "subtype" | "cmc" | "colors";
|
||||
|
||||
export const GROUP_OPTIONS: { value: GroupMode; label: string }[] = [
|
||||
{ value: "none", label: "No grouping" },
|
||||
{ value: "type", label: "Card type" },
|
||||
{ value: "subtype", label: "Subtype" },
|
||||
{ value: "cmc", label: "Mana value" },
|
||||
{ value: "colors", label: "Color identity" },
|
||||
];
|
||||
|
||||
// Priority for choosing a card's primary type from its (multi-type) type line.
|
||||
const TYPE_PRIORITY = [
|
||||
"Creature",
|
||||
"Planeswalker",
|
||||
"Battle",
|
||||
"Land",
|
||||
"Artifact",
|
||||
"Enchantment",
|
||||
"Instant",
|
||||
"Sorcery",
|
||||
];
|
||||
|
||||
function primaryType(typeLine: string): string {
|
||||
const left = typeLine.split("—")[0];
|
||||
for (const t of TYPE_PRIORITY) if (left.includes(t)) return t;
|
||||
const words = left.trim().split(/\s+/);
|
||||
return words[words.length - 1] || "Other";
|
||||
}
|
||||
|
||||
function firstSubtype(typeLine: string): string {
|
||||
const parts = typeLine.split("—");
|
||||
if (parts.length < 2) return "No subtype";
|
||||
return parts[1].trim().split(/\s+/)[0] || "No subtype";
|
||||
}
|
||||
|
||||
function colorLabel(mask: number): string {
|
||||
const letters = maskToLetters(mask);
|
||||
return letters === "" ? "Colorless" : letters;
|
||||
}
|
||||
|
||||
/** Group cards by the chosen attribute, returning ordered groups. */
|
||||
export function groupCards<T extends CardRef>(
|
||||
cards: T[],
|
||||
mode: GroupMode,
|
||||
): { label: string; cards: T[] }[] {
|
||||
if (mode === "none") return [{ label: "", cards }];
|
||||
|
||||
const keyOf = (c: T) =>
|
||||
mode === "type"
|
||||
? primaryType(c.type_line)
|
||||
: mode === "subtype"
|
||||
? firstSubtype(c.type_line)
|
||||
: mode === "cmc"
|
||||
? `Mana value ${c.mana_value}`
|
||||
: colorLabel(c.color_identity);
|
||||
|
||||
const map = new Map<string, T[]>();
|
||||
for (const c of cards) {
|
||||
const k = keyOf(c);
|
||||
const arr = map.get(k);
|
||||
if (arr) arr.push(c);
|
||||
else map.set(k, [c]);
|
||||
}
|
||||
|
||||
const groups = [...map.entries()].map(([label, cs]) => ({ label, cards: cs }));
|
||||
if (mode === "type") {
|
||||
const rank = (l: string) => {
|
||||
const i = TYPE_PRIORITY.indexOf(l);
|
||||
return i === -1 ? TYPE_PRIORITY.length : i;
|
||||
};
|
||||
groups.sort((a, b) => rank(a.label) - rank(b.label) || a.label.localeCompare(b.label));
|
||||
} else if (mode === "cmc") {
|
||||
groups.sort((a, b) => a.cards[0].mana_value - b.cards[0].mana_value);
|
||||
} else {
|
||||
groups.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Dropdown for choosing a [`GroupMode`]. */
|
||||
export function GroupBySelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: GroupMode;
|
||||
onChange: (m: GroupMode) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="group-by">
|
||||
<span className="muted">Group by</span>
|
||||
<select value={value} onChange={(e) => onChange(e.target.value as GroupMode)}>
|
||||
{GROUP_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Persistence ----------------------------------------------------------
|
||||
|
||||
/** useState that mirrors its value to localStorage (JSON), so it survives
|
||||
* remounts and app restarts. */
|
||||
export function usePersistedState<T>(key: string, initial: T): [T, (v: T) => void] {
|
||||
const [val, setVal] = useState<T>(() => {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) return initial;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return initial;
|
||||
}
|
||||
});
|
||||
const set = useCallback(
|
||||
(v: T) => {
|
||||
localStorage.setItem(key, JSON.stringify(v));
|
||||
setVal(v);
|
||||
},
|
||||
[key],
|
||||
);
|
||||
return [val, set];
|
||||
}
|
||||
|
||||
// --- View mode (list vs. image tiles) ------------------------------------
|
||||
|
||||
export type ViewMode = "list" | "tiles";
|
||||
|
||||
/** View-mode state persisted to localStorage so it survives tab switches. */
|
||||
export function useViewMode(key: string): [ViewMode, (m: ViewMode) => void] {
|
||||
const [mode, setMode] = useState<ViewMode>(
|
||||
() => (localStorage.getItem(key) as ViewMode) || "list",
|
||||
);
|
||||
const set = useCallback(
|
||||
(m: ViewMode) => {
|
||||
localStorage.setItem(key, m);
|
||||
setMode(m);
|
||||
},
|
||||
[key],
|
||||
);
|
||||
return [mode, set];
|
||||
}
|
||||
|
||||
/** Segmented control switching between the list and image-tile layouts. */
|
||||
export function ViewToggle({
|
||||
mode,
|
||||
onChange,
|
||||
}: {
|
||||
mode: ViewMode;
|
||||
onChange: (m: ViewMode) => void;
|
||||
}) {
|
||||
return (
|
||||
<span className="view-toggle">
|
||||
<button
|
||||
className={mode === "list" ? "active" : ""}
|
||||
onClick={() => onChange("list")}
|
||||
title="List view"
|
||||
>
|
||||
☰ List
|
||||
</button>
|
||||
<button
|
||||
className={mode === "tiles" ? "active" : ""}
|
||||
onClick={() => onChange("tiles")}
|
||||
title="Image tiles"
|
||||
>
|
||||
▦ Tiles
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single card rendered as a full image tile, with optional corner + footer. */
|
||||
export function CardTile({
|
||||
card,
|
||||
corner,
|
||||
footer,
|
||||
}: {
|
||||
card: CardRef;
|
||||
corner?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="tile">
|
||||
<div className="tile-img-wrap">
|
||||
{card.image_uri ? (
|
||||
<img className="tile-img" src={card.image_uri} alt={card.name} loading="lazy" />
|
||||
) : (
|
||||
<div className="tile-noimg">{card.name}</div>
|
||||
)}
|
||||
{corner && <div className="tile-corner">{corner}</div>}
|
||||
</div>
|
||||
{footer && <div className="tile-footer">{footer}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Clickable WUBRG toggles that edit a bitmask value. */
|
||||
export function ColorToggles({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (mask: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<span className="color-toggles">
|
||||
{COLORS.map((c) => (
|
||||
<span
|
||||
key={c.code}
|
||||
title={c.label}
|
||||
className={`pip ${c.code} ${value & c.bit ? "on" : ""}`}
|
||||
onClick={() => onChange(value ^ c.bit)}
|
||||
>
|
||||
{c.code}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
715
src/styles.css
Normal file
@@ -0,0 +1,715 @@
|
||||
/* ============================================================
|
||||
mtgc — design system
|
||||
Terracotta accent (#C8553D) on a layered, Discord-inspired dark UI.
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
/* Layered surfaces (darkest → lightest) */
|
||||
--bg-rail: #16171a;
|
||||
--bg-app: #1a1b1f;
|
||||
--bg-topbar: #1e1f24;
|
||||
--bg-content: #212228;
|
||||
--bg-panel: #282a31;
|
||||
--bg-input: #2f313a;
|
||||
--bg-hover: #363841;
|
||||
--bg-elev: #3a3c46;
|
||||
|
||||
--border: #34363e;
|
||||
--border-soft: rgba(255, 255, 255, 0.055);
|
||||
|
||||
--text: #dbdee4;
|
||||
--text-strong: #f5f6f8;
|
||||
--muted: #9a9ea8;
|
||||
--faint: #71757f;
|
||||
|
||||
/* Terracotta accent */
|
||||
--accent: #c8553d;
|
||||
--accent-hover: #d86e56;
|
||||
--accent-active: #a8412d;
|
||||
--accent-subtle: rgba(200, 85, 61, 0.15);
|
||||
--accent-glow: rgba(200, 85, 61, 0.35);
|
||||
|
||||
/* Complementary / status */
|
||||
--info: #46a6bd; /* teal complement */
|
||||
--gold: #e0b24a;
|
||||
--success: #4bb073;
|
||||
--warn: #e0a34a;
|
||||
--danger: #d8503f;
|
||||
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 6px 20px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 14px 44px rgba(0, 0, 0, 0.55);
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Inter", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
|
||||
background: var(--bg-app);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
/* Thin, unobtrusive scrollbars */
|
||||
*::-webkit-scrollbar {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-radius: 8px;
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* -------- Controls -------- */
|
||||
|
||||
button {
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 7px 13px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
transition: background 0.12s, border-color 0.12s, transform 0.05s, filter 0.12s;
|
||||
}
|
||||
button:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--bg-elev);
|
||||
}
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
transform: none;
|
||||
}
|
||||
button.primary {
|
||||
background: linear-gradient(180deg, var(--accent-hover), var(--accent));
|
||||
border: none;
|
||||
color: #fff;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
button.primary:hover {
|
||||
filter: brightness(1.07);
|
||||
background: linear-gradient(180deg, var(--accent-hover), var(--accent));
|
||||
}
|
||||
button.primary:active {
|
||||
background: var(--accent-active);
|
||||
}
|
||||
button.big {
|
||||
padding: 11px 22px;
|
||||
font-size: 15px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 11px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
input::placeholder {
|
||||
color: var(--faint);
|
||||
}
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-subtle);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
App shell: nav rail + main
|
||||
============================================================ */
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-rail {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-rail);
|
||||
border-right: 1px solid var(--border-soft);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px 10px 10px;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 4px 8px 16px;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(150deg, var(--accent-hover), var(--accent-active));
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.brand-text {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
color: var(--text-strong);
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.nav-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.nav-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 9px 12px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-weight: 550;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
}
|
||||
.nav-item.active {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.nav-item.active::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -10px;
|
||||
top: 9px;
|
||||
bottom: 9px;
|
||||
width: 4px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: var(--accent);
|
||||
}
|
||||
.nav-icon {
|
||||
display: inline-flex;
|
||||
color: inherit;
|
||||
}
|
||||
.nav-foot {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 10px 6px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
border-top: 1px solid var(--border-soft);
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.status-dot.on {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 6px var(--success);
|
||||
}
|
||||
.status-dot.off {
|
||||
background: var(--faint);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-content);
|
||||
}
|
||||
.topbar {
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 18px;
|
||||
background: var(--bg-topbar);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.path {
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
color: var(--faint);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
max-width: 460px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.progress {
|
||||
color: var(--accent-hover);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 22px 26px;
|
||||
}
|
||||
|
||||
/* Empty / welcome state */
|
||||
.empty-state {
|
||||
max-width: 480px;
|
||||
margin: 9vh auto 0;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.empty-mark {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 34px;
|
||||
color: #fff;
|
||||
background: linear-gradient(150deg, var(--accent-hover), var(--accent-active));
|
||||
box-shadow: 0 10px 30px var(--accent-glow);
|
||||
}
|
||||
.empty-state h2 {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Typography helpers
|
||||
============================================================ */
|
||||
|
||||
h2 {
|
||||
margin: 2px 0 14px;
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
color: var(--text-strong);
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
h3 {
|
||||
margin: 18px 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--faint);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
h4 {
|
||||
margin: 14px 0 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-hover);
|
||||
border-radius: 999px;
|
||||
padding: 1px 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Lists (search results, deck list)
|
||||
============================================================ */
|
||||
|
||||
.result-list {
|
||||
list-style: none;
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
.result-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.result-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.result-list li:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.card-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.card-type {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Color pips (WUBRG)
|
||||
============================================================ */
|
||||
|
||||
.pips {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
}
|
||||
.pip {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.pip.W { background: #f4efdd; color: #4a3f2a; }
|
||||
.pip.U { background: #3a8fd0; color: #fff; }
|
||||
.pip.B { background: #38343d; color: #cbc3d4; }
|
||||
.pip.R { background: var(--accent); color: #fff; }
|
||||
.pip.G { background: #479b63; color: #fff; }
|
||||
.pip.C { background: #9aa0ab; color: #23242a; }
|
||||
|
||||
.color-toggles {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
.color-toggles .pip {
|
||||
cursor: pointer;
|
||||
opacity: 0.32;
|
||||
transition: opacity 0.1s, box-shadow 0.1s;
|
||||
}
|
||||
.color-toggles .pip.on {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 2px var(--accent-hover);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Tables
|
||||
============================================================ */
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--faint);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-content);
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 1;
|
||||
}
|
||||
td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
vertical-align: middle;
|
||||
}
|
||||
tbody tr {
|
||||
transition: background 0.08s;
|
||||
}
|
||||
tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.022);
|
||||
}
|
||||
.over-assigned {
|
||||
color: var(--warn);
|
||||
font-weight: 600;
|
||||
}
|
||||
.deck-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Filters panel
|
||||
============================================================ */
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: 14px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.field > label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--faint);
|
||||
}
|
||||
.location-input {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Card images: thumbnails, hover preview, tiles
|
||||
============================================================ */
|
||||
|
||||
.card-thumb {
|
||||
width: 40px;
|
||||
height: 56px;
|
||||
object-fit: cover;
|
||||
border-radius: 5px;
|
||||
display: block;
|
||||
background: var(--bg-elev);
|
||||
cursor: zoom-in;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 0.1s, box-shadow 0.1s;
|
||||
}
|
||||
.card-thumb:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 0 2px var(--accent-hover), var(--shadow-md);
|
||||
}
|
||||
.thumb-empty {
|
||||
display: inline-flex;
|
||||
width: 40px;
|
||||
height: 56px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--faint);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 5px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.hover-card {
|
||||
position: fixed;
|
||||
z-index: 200;
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
.tile-img-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.tile-img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
.tile:hover .tile-img {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.tile-noimg {
|
||||
aspect-ratio: 488 / 680;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-panel);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
.tile-corner {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.tile-badge {
|
||||
background: rgba(0, 0, 0, 0.78);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 2px 9px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.tile-badge.warn {
|
||||
background: var(--warn);
|
||||
color: #241700;
|
||||
}
|
||||
.tile-remove {
|
||||
background: rgba(0, 0, 0, 0.78);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
font-size: 13px;
|
||||
}
|
||||
.tile-remove:hover {
|
||||
background: var(--danger);
|
||||
}
|
||||
.tile-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Toolbar controls: group-by + view toggle
|
||||
============================================================ */
|
||||
|
||||
.group-by {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.view-toggle {
|
||||
display: inline-flex;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.view-toggle button {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
padding: 5px 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.view-toggle button:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.view-toggle button.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Deck metadata (format + commander)
|
||||
============================================================ */
|
||||
|
||||
.deck-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.commander-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 280px;
|
||||
flex: 1;
|
||||
}
|
||||
83
src/views/CardEntry.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, CardRef } from "../api";
|
||||
import { CardSearchBox } from "../components";
|
||||
|
||||
/** Rapid card entry: fuzzy search-as-you-type, ↑/↓ + Enter (or click) adds one copy. */
|
||||
export function CardEntry() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<CardRef[]>([]);
|
||||
const [recent, setRecent] = useState<{ name: string; qty: number; location: string }[]>([]);
|
||||
// Persisted so a "box" location sticks while entering a stack of cards.
|
||||
const [location, setLocation] = useState(() => localStorage.getItem("add-location") || "");
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("add-location", location);
|
||||
}, [location]);
|
||||
|
||||
// Debounced fuzzy search.
|
||||
useEffect(() => {
|
||||
const q = query.trim();
|
||||
if (!q) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
setResults(await api.searchCards(q, 20));
|
||||
} catch {
|
||||
setResults([]);
|
||||
}
|
||||
}, 80);
|
||||
return () => clearTimeout(t);
|
||||
}, [query]);
|
||||
|
||||
async function add(card: CardRef) {
|
||||
const qty = await api.addOwned(card.oracle_id, 1);
|
||||
const loc = location.trim();
|
||||
if (loc) await api.setLocation(card.oracle_id, loc);
|
||||
setRecent((r) => [{ name: card.name, qty, location: loc }, ...r].slice(0, 12));
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Add Cards</h2>
|
||||
|
||||
<div className="field" style={{ maxWidth: 260, marginBottom: 12 }}>
|
||||
<label>Location for added cards (optional)</label>
|
||||
<input
|
||||
placeholder="e.g. Box 1 — prefilled onto each card"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardSearchBox
|
||||
big
|
||||
autoFocus
|
||||
placeholder="Type a card name… (typos are fine — ↑/↓ then Enter to add)"
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
results={results}
|
||||
onPick={add}
|
||||
/>
|
||||
|
||||
{recent.length > 0 && (
|
||||
<>
|
||||
<h2 style={{ marginTop: 24 }}>Just added</h2>
|
||||
<ul className="result-list">
|
||||
{recent.map((r, i) => (
|
||||
<li key={i} style={{ cursor: "default" }}>
|
||||
<span className="card-name">{r.name}</span>
|
||||
{r.location && <span className="deck-chip">{r.location}</span>}
|
||||
<span className="spacer" />
|
||||
<span className="badge">owned ×{r.qty}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
377
src/views/DeckView.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { api, CardRef, Deck, DeckEntry } from "../api";
|
||||
import {
|
||||
CardSearchBox,
|
||||
CardThumb,
|
||||
CardTile,
|
||||
ColorPips,
|
||||
GroupBySelect,
|
||||
GroupMode,
|
||||
ViewToggle,
|
||||
groupCards,
|
||||
usePersistedState,
|
||||
useViewMode,
|
||||
} from "../components";
|
||||
|
||||
const FORMATS = [
|
||||
"",
|
||||
"Commander",
|
||||
"Brawl",
|
||||
"Standard",
|
||||
"Pioneer",
|
||||
"Modern",
|
||||
"Legacy",
|
||||
"Vintage",
|
||||
"Pauper",
|
||||
"Other",
|
||||
];
|
||||
const COMMANDER_FORMATS = ["Commander", "Brawl"];
|
||||
|
||||
/**
|
||||
* Deck editor. A deck's 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. Deck-level format + commander metadata sit at the top.
|
||||
*/
|
||||
export function DeckView() {
|
||||
const [decks, setDecks] = useState<Deck[]>([]);
|
||||
const [selected, setSelected] = usePersistedState<number | null>("deck-selected", null);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [contents, setContents] = useState<DeckEntry[]>([]);
|
||||
const [view, setView] = useViewMode("deck-view");
|
||||
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("deck-group", "none");
|
||||
|
||||
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[]>([]);
|
||||
|
||||
const deck = decks.find((d) => d.id === selected) ?? null;
|
||||
|
||||
const loadDecks = useCallback(async () => {
|
||||
const d = await api.listDecks();
|
||||
setDecks(d);
|
||||
// Keep the persisted selection if it still exists, else fall back to the first.
|
||||
if (d.length && (selected === null || !d.some((x) => x.id === selected))) {
|
||||
setSelected(d[0].id);
|
||||
}
|
||||
}, [selected, setSelected]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDecks();
|
||||
}, [loadDecks]);
|
||||
|
||||
const loadContents = useCallback(async () => {
|
||||
if (selected === null) {
|
||||
setContents([]);
|
||||
return;
|
||||
}
|
||||
setContents(await api.deckContents(selected));
|
||||
}, [selected]);
|
||||
|
||||
useEffect(() => {
|
||||
loadContents();
|
||||
}, [loadContents]);
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
const q = cmdQuery.trim();
|
||||
if (!q) return setCmdResults([]);
|
||||
const t = setTimeout(() => api.searchCards(q, 12).then(setCmdResults), 80);
|
||||
return () => clearTimeout(t);
|
||||
}, [cmdQuery]);
|
||||
|
||||
async function createDeck() {
|
||||
if (!newName.trim()) return;
|
||||
const d = await api.createDeck(newName.trim(), null);
|
||||
setNewName("");
|
||||
await loadDecks();
|
||||
setSelected(d.id);
|
||||
}
|
||||
|
||||
async function addToDeck(card: CardRef) {
|
||||
if (selected === null) return;
|
||||
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
|
||||
const qty = existing ? existing.quantity + 1 : 1;
|
||||
await api.setDeckCard(selected, card.oracle_id, qty, false);
|
||||
setLibQuery("");
|
||||
setLibResults([]);
|
||||
setAnyQuery("");
|
||||
setAnyResults([]);
|
||||
await loadContents();
|
||||
}
|
||||
|
||||
async function setQty(oracleId: string, qty: number) {
|
||||
if (selected === null) return;
|
||||
await api.setDeckCard(selected, oracleId, qty, false);
|
||||
await loadContents();
|
||||
}
|
||||
|
||||
async function removeDeck(id: number) {
|
||||
await api.deleteDeck(id);
|
||||
if (selected === id) setSelected(null);
|
||||
await loadDecks();
|
||||
}
|
||||
|
||||
async function changeFormat(format: string) {
|
||||
if (selected === null) return;
|
||||
await api.setDeckFormat(selected, format);
|
||||
await loadDecks();
|
||||
}
|
||||
|
||||
async function setCommander(card: CardRef) {
|
||||
if (selected === null) return;
|
||||
// Ensure the commander is in the deck, then flag it.
|
||||
if (!contents.find((e) => e.oracle_id === card.oracle_id)) {
|
||||
await api.setDeckCard(selected, card.oracle_id, 1, true);
|
||||
}
|
||||
await api.setDeckCommander(selected, card.oracle_id);
|
||||
setCmdQuery("");
|
||||
setCmdResults([]);
|
||||
await loadContents();
|
||||
await loadDecks();
|
||||
}
|
||||
|
||||
async function clearCommander() {
|
||||
if (selected === null) return;
|
||||
await api.setDeckCommander(selected, null);
|
||||
await loadContents();
|
||||
await loadDecks();
|
||||
}
|
||||
|
||||
const owned = contents.filter((e) => e.quantity_owned > 0);
|
||||
const unowned = contents.filter((e) => e.quantity_owned === 0);
|
||||
const commanderName =
|
||||
deck?.commander_oracle_id &&
|
||||
(contents.find((e) => e.oracle_id === deck.commander_oracle_id)?.name ??
|
||||
deck.commander_oracle_id);
|
||||
|
||||
function deckTiles(entries: DeckEntry[]) {
|
||||
return (
|
||||
<div className="tile-grid">
|
||||
{entries.map((e) => (
|
||||
<CardTile
|
||||
key={e.oracle_id}
|
||||
card={e}
|
||||
corner={
|
||||
<button
|
||||
className="tile-remove"
|
||||
title="Remove from deck"
|
||||
onClick={() => setQty(e.oracle_id, 0)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
}
|
||||
footer={
|
||||
<span className="tile-badge">
|
||||
{e.is_commander ? "★ " : ""}in deck ×{e.quantity}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function deckTable(entries: DeckEntry[]) {
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Colors</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>In deck</th>
|
||||
<th>Owned</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.oracle_id}>
|
||||
<td>
|
||||
<CardThumb card={e} />
|
||||
</td>
|
||||
<td>
|
||||
<ColorPips mask={e.color_identity} />
|
||||
</td>
|
||||
<td className="card-name">
|
||||
{e.is_commander && <span title="Commander">★ </span>}
|
||||
{e.name}
|
||||
</td>
|
||||
<td className="card-type">{e.type_line}</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
style={{ width: 60 }}
|
||||
key={e.quantity}
|
||||
defaultValue={e.quantity}
|
||||
onBlur={(ev) => setQty(e.oracle_id, Number(ev.target.value))}
|
||||
/>
|
||||
</td>
|
||||
<td>{e.quantity_owned}</td>
|
||||
<td>
|
||||
<button onClick={() => setQty(e.oracle_id, 0)}>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function cardsGroup(entries: DeckEntry[]) {
|
||||
if (entries.length === 0) return <p className="hint">None yet.</p>;
|
||||
return groupCards(entries, groupBy).map((g) => (
|
||||
<div key={g.label || "all"} style={{ marginBottom: g.label ? 14 : 0 }}>
|
||||
{g.label && (
|
||||
<h4>
|
||||
{g.label} <span className="badge">{g.cards.length}</span>
|
||||
</h4>
|
||||
)}
|
||||
{view === "tiles" ? deckTiles(g.cards) : deckTable(g.cards)}
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 20 }}>
|
||||
<div style={{ width: 240, flexShrink: 0 }}>
|
||||
<h2>Decks</h2>
|
||||
<div className="row" style={{ marginBottom: 10 }}>
|
||||
<input
|
||||
value={newName}
|
||||
placeholder="New deck name"
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && createDeck()}
|
||||
/>
|
||||
<button className="primary" onClick={createDeck}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<ul className="result-list">
|
||||
{decks.map((d) => (
|
||||
<li
|
||||
key={d.id}
|
||||
style={d.id === selected ? { background: "var(--panel-2)" } : undefined}
|
||||
onClick={() => setSelected(d.id)}
|
||||
>
|
||||
<span className="card-name">{d.name}</span>
|
||||
{d.format && <span className="deck-chip">{d.format}</span>}
|
||||
<span className="spacer" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeDeck(d.id);
|
||||
}}
|
||||
title="Delete deck"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{decks.length === 0 && (
|
||||
<li style={{ cursor: "default" }} className="hint">
|
||||
No decks yet.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
{selected === null || !deck ? (
|
||||
<p className="hint">Select or create a deck.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||
<h2>{deck.name}</h2>
|
||||
<div className="row" style={{ gap: 16 }}>
|
||||
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
||||
<ViewToggle mode={view} onChange={setView} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
|
||||
<div className="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>
|
||||
|
||||
{COMMANDER_FORMATS.includes(deck.format) && (
|
||||
<div className="commander-box">
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<span className="muted">Commander:</span>
|
||||
<strong>{commanderName || "none"}</strong>
|
||||
{commanderName && (
|
||||
<button onClick={clearCommander} title="Clear commander">
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<CardSearchBox
|
||||
placeholder="Search to set the commander…"
|
||||
value={cmdQuery}
|
||||
onChange={setCmdQuery}
|
||||
results={cmdResults}
|
||||
onPick={setCommander}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section style={{ marginTop: 18 }}>
|
||||
<h3>
|
||||
In your library <span className="badge">{owned.length}</span>
|
||||
</h3>
|
||||
<CardSearchBox
|
||||
placeholder="Search your library to add owned cards…"
|
||||
value={libQuery}
|
||||
onChange={setLibQuery}
|
||||
results={libResults}
|
||||
onPick={addToDeck}
|
||||
/>
|
||||
{cardsGroup(owned)}
|
||||
</section>
|
||||
|
||||
<section style={{ marginTop: 26 }}>
|
||||
<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}
|
||||
/>
|
||||
{cardsGroup(unowned)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
235
src/views/LibraryBrowser.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, Deck, LibraryCard, LibraryFilter } from "../api";
|
||||
import {
|
||||
CardThumb,
|
||||
CardTile,
|
||||
ColorPips,
|
||||
ColorToggles,
|
||||
GroupBySelect,
|
||||
GroupMode,
|
||||
LocationField,
|
||||
ViewToggle,
|
||||
groupCards,
|
||||
usePersistedState,
|
||||
useViewMode,
|
||||
} from "../components";
|
||||
|
||||
/** Browse & filter the owned library by name and card criteria. */
|
||||
export function LibraryBrowser() {
|
||||
const [text, setText] = useState("");
|
||||
const [commander, setCommander] = useState(0);
|
||||
const [colorsInclude, setColorsInclude] = useState(0);
|
||||
const [cmcMin, setCmcMin] = useState("");
|
||||
const [cmcMax, setCmcMax] = useState("");
|
||||
const [type, setType] = useState("");
|
||||
const [subtype, setSubtype] = useState("");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [inDeck, setInDeck] = useState<number | "">("");
|
||||
|
||||
const [decks, setDecks] = useState<Deck[]>([]);
|
||||
const [rows, setRows] = useState<LibraryCard[]>([]);
|
||||
const [view, setView] = useViewMode("library-view");
|
||||
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("library-group", "none");
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
api.listDecks().then(setDecks);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const filter: LibraryFilter = {
|
||||
text: text.trim() || null,
|
||||
commander_identity: commander || null,
|
||||
colors_include: colorsInclude || null,
|
||||
cmc_min: cmcMin === "" ? null : Number(cmcMin),
|
||||
cmc_max: cmcMax === "" ? null : Number(cmcMax),
|
||||
types: splitCsv(type),
|
||||
subtypes: splitCsv(subtype),
|
||||
keywords: splitCsv(keyword),
|
||||
in_deck: inDeck === "" ? null : Number(inDeck),
|
||||
};
|
||||
const t = setTimeout(() => {
|
||||
api.searchLibrary(filter).then(setRows).catch(() => setRows([]));
|
||||
}, 120);
|
||||
return () => clearTimeout(t);
|
||||
}, [text, commander, colorsInclude, cmcMin, cmcMax, type, subtype, keyword, inDeck, refreshKey]);
|
||||
|
||||
const deckOrLocation = (c: LibraryCard) =>
|
||||
c.decks.length === 0 ? (
|
||||
<LocationField
|
||||
oracleId={c.oracle_id}
|
||||
location={c.location}
|
||||
onSaved={() => setRefreshKey((k) => k + 1)}
|
||||
/>
|
||||
) : (
|
||||
c.decks.map((d) => (
|
||||
<span key={d} className="deck-chip">
|
||||
{d}
|
||||
</span>
|
||||
))
|
||||
);
|
||||
|
||||
function libTable(cards: LibraryCard[]) {
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Colors</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>MV</th>
|
||||
<th>Owned</th>
|
||||
<th>Decks / Location</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cards.map((c) => (
|
||||
<tr key={c.oracle_id}>
|
||||
<td>
|
||||
<CardThumb card={c} />
|
||||
</td>
|
||||
<td>
|
||||
<ColorPips mask={c.color_identity} />
|
||||
</td>
|
||||
<td className="card-name">{c.name}</td>
|
||||
<td className="card-type">{c.type_line}</td>
|
||||
<td>{c.mana_value}</td>
|
||||
<td className={c.quantity_assigned > c.quantity_owned ? "over-assigned" : ""}>
|
||||
{c.quantity_owned}
|
||||
{c.quantity_assigned > c.quantity_owned && (
|
||||
<span title="Assigned to more decks than you own">
|
||||
{" "}
|
||||
⚠ {c.quantity_assigned} assigned
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{deckOrLocation(c)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function libTiles(cards: LibraryCard[]) {
|
||||
return (
|
||||
<div className="tile-grid">
|
||||
{cards.map((c) => (
|
||||
<CardTile
|
||||
key={c.oracle_id}
|
||||
card={c}
|
||||
corner={
|
||||
<span
|
||||
className={`tile-badge ${c.quantity_assigned > c.quantity_owned ? "warn" : ""}`}
|
||||
title="Copies owned"
|
||||
>
|
||||
×{c.quantity_owned}
|
||||
{c.quantity_assigned > c.quantity_owned ? " ⚠" : ""}
|
||||
</span>
|
||||
}
|
||||
footer={deckOrLocation(c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const groups = groupCards(rows, groupBy);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="row" style={{ justifyContent: "space-between", marginBottom: 12 }}>
|
||||
<h2 style={{ margin: 0 }}>
|
||||
Library <span className="badge">{rows.length}</span>
|
||||
</h2>
|
||||
<div className="row" style={{ gap: 16 }}>
|
||||
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
||||
<ViewToggle mode={view} onChange={setView} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filters">
|
||||
<div className="field">
|
||||
<label>Name contains</label>
|
||||
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="e.g. bolt" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Playable in commander (color identity ⊆)</label>
|
||||
<ColorToggles value={commander} onChange={setCommander} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Includes colors</label>
|
||||
<ColorToggles value={colorsInclude} onChange={setColorsInclude} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Mana value</label>
|
||||
<div className="row">
|
||||
<input
|
||||
type="number"
|
||||
style={{ width: 70 }}
|
||||
placeholder="min"
|
||||
value={cmcMin}
|
||||
onChange={(e) => setCmcMin(e.target.value)}
|
||||
/>
|
||||
<span className="muted">–</span>
|
||||
<input
|
||||
type="number"
|
||||
style={{ width: 70 }}
|
||||
placeholder="max"
|
||||
value={cmcMax}
|
||||
onChange={(e) => setCmcMax(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Type(s)</label>
|
||||
<input value={type} onChange={(e) => setType(e.target.value)} placeholder="Creature, Legendary" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Subtype(s)</label>
|
||||
<input value={subtype} onChange={(e) => setSubtype(e.target.value)} placeholder="Elf" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Keyword(s)</label>
|
||||
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="Flying" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>In deck</label>
|
||||
<select value={inDeck} onChange={(e) => setInDeck(e.target.value === "" ? "" : Number(e.target.value))}>
|
||||
<option value="">Any</option>
|
||||
{decks.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="hint">
|
||||
No matching cards owned. Add some in the Add Cards tab, or loosen the filters.
|
||||
</p>
|
||||
) : (
|
||||
groups.map((g) => (
|
||||
<div key={g.label || "all"} style={{ marginBottom: g.label ? 20 : 0 }}>
|
||||
{g.label && (
|
||||
<h3>
|
||||
{g.label} <span className="badge">{g.cards.length}</span>
|
||||
</h3>
|
||||
)}
|
||||
{view === "list" ? libTable(g.cards) : libTiles(g.cards)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function splitCsv(s: string): string[] {
|
||||
return s
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
16
vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// Vite config tuned for Tauri: fixed port, no clearing the terminal Tauri owns.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
watch: {
|
||||
// Don't watch the Rust side; Tauri handles that.
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
});
|
||||