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>
This commit is contained in:
163
src-tauri/src/commands.rs
Normal file
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
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
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
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user