diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f728103..2032de3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use std::sync::Mutex; -use mtgc_core::{CardRef, Deck, DeckEntry, LibraryCard, LibraryFilter}; +use mtgc_core::{CardRef, Deck, DeckEntry, LibraryCard, LibraryFilter, LibraryStats}; use serde::Serialize; use tauri::{AppHandle, Emitter, State}; @@ -127,6 +127,12 @@ pub fn search_library(db: Db, filter: LibraryFilter) -> Res> { s.catalog()?.search_library(&filter).map_err(|e| e.to_string()) } +#[tauri::command] +pub fn get_library_stats(db: Db) -> Res { + let s = db.lock().unwrap(); + s.catalog()?.library_stats().map_err(|e| e.to_string()) +} + #[tauri::command] pub fn list_decks(db: Db) -> Res> { let s = db.lock().unwrap(); @@ -147,6 +153,22 @@ pub fn delete_deck(db: Db, deck_id: i64) -> Res<()> { s.catalog()?.delete_deck(deck_id).map_err(|e| e.to_string()) } +#[tauri::command] +pub fn rename_deck(db: Db, deck_id: i64, name: String) -> Res<()> { + let s = db.lock().unwrap(); + s.catalog()? + .rename_deck(deck_id, &name) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn duplicate_deck(db: Db, deck_id: i64, new_name: String) -> Res { + let s = db.lock().unwrap(); + s.catalog()? + .duplicate_deck(deck_id, &new_name) + .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(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 77a08ff..c0b8247 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -30,9 +30,12 @@ pub fn run() { commands::set_owned, commands::set_location, commands::search_library, + commands::get_library_stats, commands::list_decks, commands::create_deck, commands::delete_deck, + commands::rename_deck, + commands::duplicate_deck, commands::set_deck_format, commands::set_deck_commander, commands::set_deck_card, diff --git a/src/App.tsx b/src/App.tsx index 179418a..8b0687d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,8 @@ import { useEffect, useRef, useState, useCallback, ReactNode } from "react"; import { listen } from "@tauri-apps/api/event"; import { open, save } from "@tauri-apps/plugin-dialog"; -import { api, IngestProgress, IngestStageInfo, Status } from "./api"; -import { HoverPreviewProvider, usePersistedState } from "./components"; +import { api, IngestProgress, IngestStageInfo, LibraryStats, Status } from "./api"; +import { HoverPreviewProvider, IconButton, Icons, usePersistedState } from "./components"; import { CardEntry } from "./views/CardEntry"; import { LibraryBrowser } from "./views/LibraryBrowser"; import { DeckView } from "./views/DeckView"; @@ -86,6 +86,7 @@ function segmentFill(stages: IngestStageInfo[], index: number, overallPercent: n export default function App() { const [status, setStatus] = useState(null); + const [libStats, setLibStats] = useState(null); const [tab, setTab] = usePersistedState("active-tab", "add"); const [progress, setProgress] = useState(null); const [busy, setBusy] = useState(false); @@ -106,6 +107,25 @@ export default function App() { }; }, [refreshStatus]); + const ready = status?.reference_ready ?? false; + + // Keep the sidebar's library summary fresh: fetch once card data is ready, + // then poll (cards/decks can change from any tab without a shared event bus). + useEffect(() => { + if (!ready) { + setLibStats(null); + return; + } + let cancelled = false; + const refresh = () => api.getLibraryStats().then((s) => !cancelled && setLibStats(s)); + refresh(); + const interval = setInterval(refresh, 5000); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [ready]); + async function updateCardData() { setBusy(true); try { @@ -175,8 +195,6 @@ export default function App() { if (typeof path === "string") await switchLibrary(path); } - const ready = status?.reference_ready ?? false; - return (
@@ -200,8 +218,71 @@ export default function App() {
- - {ready ? `${status?.card_count.toLocaleString()} cards` : "No card data"} + {!ready ? ( +
+ + No card data +
+ ) : ( +
+
+ + Your library +
+ {!libStats ? ( +
Loading…
+ ) : ( + <> +
+
+ + {libStats.unique_cards.toLocaleString()} + + unique +
+
+ + {libStats.total_copies.toLocaleString()} + + copies +
+
+ + {libStats.deck_count.toLocaleString()} + + decks +
+
+ {libStats.total_copies > 0 && ( +
+ {( + [ + ["W", libStats.color_counts[0]], + ["U", libStats.color_counts[1]], + ["B", libStats.color_counts[2]], + ["R", libStats.color_counts[3]], + ["G", libStats.color_counts[4]], + ["C", libStats.colorless_count], + ] as [string, number][] + ).map(([code, count]) => + count > 0 ? ( + + ) : null, + )} +
+ )} + + )} +
+ {status?.card_count.toLocaleString()} cards in database +
+
+ )}
@@ -238,13 +319,7 @@ export default function App() { (progress.stage === "done" || progress.stage === "error" || progress.stage === "cancelled") && ( - + setProgress(null)} /> )}
diff --git a/src/api.ts b/src/api.ts index a64fdba..e877248 100644 --- a/src/api.ts +++ b/src/api.ts @@ -55,6 +55,14 @@ export interface Status { card_count: number; } +export interface LibraryStats { + unique_cards: number; + total_copies: number; + deck_count: number; + color_counts: [number, number, number, number, number]; // W, U, B, R, G + colorless_count: number; +} + export interface IngestStageInfo { id: string; label: string; @@ -104,11 +112,15 @@ export const api = { searchLibrary: (filter: LibraryFilter) => invoke("search_library", { filter }), + getLibraryStats: () => invoke("get_library_stats"), listDecks: () => invoke("list_decks"), createDeck: (name: string, commanderOracleId: string | null) => invoke("create_deck", { name, commanderOracleId }), deleteDeck: (deckId: number) => invoke("delete_deck", { deckId }), + renameDeck: (deckId: number, name: string) => invoke("rename_deck", { deckId, name }), + duplicateDeck: (deckId: number, newName: string) => + invoke("duplicate_deck", { deckId, newName }), setDeckFormat: (deckId: number, format: string) => invoke("set_deck_format", { deckId, format }), setDeckCommander: (deckId: number, oracleId: string | null) => diff --git a/src/components.tsx b/src/components.tsx index 92588a4..54b7be3 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -9,6 +9,200 @@ import { } from "react"; import { api, CardRef, COLORS, maskToLetters } from "./api"; +// --- Icons ------------------------------------------------------------ + +const icon = (children: ReactNode) => ( + + {children} + +); + +const ICON_TRASH = icon( + <> + + + + + + , +); +const ICON_COPY = icon( + <> + + + , +); +const ICON_PENCIL = icon(); +const ICON_CLOSE = icon( + <> + + + , +); + +/** + * A small round icon-only button, used consistently for lightweight actions + * (rename, duplicate, delete, dismiss, close) throughout the app. + * - `overlay`: dark translucent variant for sitting on top of card art. + * - `danger`: tints red on hover, for destructive actions. + */ +export function IconButton({ + icon, + label, + onClick, + overlay, + danger, + disabled, +}: { + icon: ReactNode; + label: string; + onClick: (e: React.MouseEvent) => void; + overlay?: boolean; + danger?: boolean; + disabled?: boolean; +}) { + return ( + + ); +} + +export const Icons = { + trash: ICON_TRASH, + copy: ICON_COPY, + pencil: ICON_PENCIL, + close: ICON_CLOSE, +}; + +// --- Dialogs ------------------------------------------------------------ + +/** + * A centered modal dialog (backdrop + card) shared by [`ConfirmDialog`] and + * [`PromptDialog`]. Dismisses on Escape or backdrop click. + */ +function DialogFrame({ + title, + children, + onCancel, +}: { + title: string; + children: ReactNode; + onCancel: () => void; +}) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === "Escape" && onCancel(); + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onCancel]); + + return ( +
+
e.stopPropagation()}> +

{title}

+ {children} +
+
+ ); +} + +/** A confirm/cancel dialog for destructive or otherwise consequential actions. */ +export function ConfirmDialog({ + title, + message, + confirmLabel = "Confirm", + danger, + onConfirm, + onCancel, +}: { + title: string; + message: ReactNode; + confirmLabel?: string; + danger?: boolean; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( + +

{message}

+
+ + +
+
+ ); +} + +/** A single-text-field dialog (rename, duplicate-as, etc.), Enter to confirm. */ +export function PromptDialog({ + title, + label, + defaultValue, + confirmLabel = "Save", + error, + onConfirm, + onCancel, +}: { + title: string; + label: string; + defaultValue: string; + confirmLabel?: string; + error?: string | null; + onConfirm: (value: string) => void; + onCancel: () => void; +}) { + const [value, setValue] = useState(defaultValue); + const inputRef = useRef(null); + useEffect(() => inputRef.current?.select(), []); + + const confirm = () => { + const v = value.trim(); + if (v) onConfirm(v); + }; + + return ( + + + {error &&

{error}

} +
+ + +
+
+ ); +} + /** * 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. @@ -332,15 +526,34 @@ function ClickPreviewModal({ onFlip: () => void; }) { const { card, showBack } = state; - const src = showBack && card.back_image_uri ? card.back_image_uri : card.image_uri; + const hasBack = !!card.back_image_uri; + // Both faces are always in the DOM when the card has one; a wide-viewport + // media query shows them side by side (no flip needed), while a narrow one + // shows only the "active" face and the flip button toggles between them. return (
-
e.stopPropagation()}> - {card.name} - - {card.back_image_uri && ( +
e.stopPropagation()} + > +
+ {card.name} + {hasBack && ( + {`${card.name} + )} +
+ + + + {hasBack && ( diff --git a/src/styles.css b/src/styles.css index d44571c..ac506f8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -36,6 +36,14 @@ --warn: #e0a34a; --danger: #d8503f; + /* WUBRG mana colors, shared by pips + the library color-distribution bar */ + --mtg-w: #f4efdd; + --mtg-u: #3a8fd0; + --mtg-b: #38343d; + --mtg-r: var(--accent); + --mtg-g: #479b63; + --mtg-c: #9aa0ab; + --radius: 8px; --radius-lg: 12px; --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); @@ -133,6 +141,18 @@ button.primary:hover { button.primary:active { background: var(--accent-active); } +button.danger { + background: var(--danger); + border: none; + color: #fff; + box-shadow: var(--shadow-sm); +} +button.danger:hover { + filter: brightness(1.1); +} +button.danger:active { + filter: brightness(0.92); +} button.big { padding: 11px 22px; font-size: 15px; @@ -160,6 +180,29 @@ select:focus { box-shadow: 0 0 0 3px var(--accent-subtle); } +/* Native