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>
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"
|
||||
]
|
||||
}
|
||||
}
|
||||