diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 2032de3..3abcaa1 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -21,14 +21,23 @@ pub struct Status { pub reference_path: String, pub reference_ready: bool, pub card_count: usize, + /// Unix timestamp (seconds) of reference.db's last modification — i.e. + /// when "Update card data" last completed. `None` if unavailable. + pub reference_updated_at: Option, } fn status_of(s: &AppState) -> Status { + let reference_updated_at = std::fs::metadata(&s.reference_path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64); 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), + reference_updated_at, } } diff --git a/src/App.tsx b/src/App.tsx index 8b0687d..5b02b85 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -72,6 +72,21 @@ const NAV: { id: Tab; label: string; icon: ReactNode }[] = [ }, ]; +/** Compact relative time, e.g. "2h ago" — for the "database updated" stamp. */ +function formatRelativeTime(epochSeconds: number): string { + const diffSec = Math.round((Date.now() - epochSeconds * 1000) / 1000); + if (diffSec < 30) return "just now"; + const mins = Math.round(diffSec / 60); + if (mins < 60) return `${mins}m ago`; + const hours = Math.round(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.round(hours / 24); + if (days < 30) return `${days}d ago`; + const months = Math.round(days / 30); + if (months < 12) return `${months}mo ago`; + return `${Math.round(months / 12)}y ago`; +} + /** Percent-fill (0..100) of one segment, given overall cumulative percent. */ function segmentFill(stages: IngestStageInfo[], index: number, overallPercent: number): number { let start = 0; @@ -107,6 +122,14 @@ export default function App() { }; }, [refreshStatus]); + // Force a re-render every minute so the "Updated Xh ago" stamp stays fresh + // over a long session without needing an actual status refetch. + const [, tick] = useState(0); + useEffect(() => { + const interval = setInterval(() => tick((n) => n + 1), 60000); + return () => clearInterval(interval); + }, []); + const ready = status?.reference_ready ?? false; // Keep the sidebar's library summary fresh: fetch once card data is ready, @@ -279,7 +302,12 @@ export default function App() { )}
- {status?.card_count.toLocaleString()} cards in database +
{status?.card_count.toLocaleString()} cards in database
+ {status?.reference_updated_at != null && ( +
+ Updated {formatRelativeTime(status.reference_updated_at)} +
+ )}
)} diff --git a/src/api.ts b/src/api.ts index e877248..f29d118 100644 --- a/src/api.ts +++ b/src/api.ts @@ -46,6 +46,7 @@ export interface LibraryFilter { subtypes?: string[]; keywords?: string[]; in_deck?: number | null; + location?: string | null; } export interface Status { @@ -53,6 +54,7 @@ export interface Status { reference_path: string; reference_ready: boolean; card_count: number; + reference_updated_at: number | null; // unix seconds } export interface LibraryStats { diff --git a/src/styles.css b/src/styles.css index ac506f8..eb6791a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -68,7 +68,7 @@ body { font-family: "Inter", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif; background: var(--bg-app); color: var(--text); - font-size: 14px; + font-size: 15px; line-height: 1.5; -webkit-font-smoothing: antialiased; } @@ -108,7 +108,7 @@ button { border-radius: var(--radius); padding: 7px 13px; cursor: pointer; - font-size: 13px; + font-size: 14px; font-weight: 500; font-family: inherit; transition: background 0.12s, border-color 0.12s, transform 0.05s, filter 0.12s; @@ -155,7 +155,7 @@ button.danger:active { } button.big { padding: 11px 22px; - font-size: 15px; + font-size: 16px; border-radius: 10px; } @@ -166,7 +166,7 @@ select { border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 11px; - font-size: 13px; + font-size: 14px; font-family: inherit; transition: border-color 0.12s, box-shadow 0.12s; } @@ -235,7 +235,7 @@ select option { background: linear-gradient(150deg, var(--accent-hover), var(--accent-active)); color: #fff; font-weight: 800; - font-size: 18px; + font-size: 19px; display: flex; align-items: center; justify-content: center; @@ -243,7 +243,7 @@ select option { } .brand-text { font-weight: 700; - font-size: 15px; + font-size: 16px; color: var(--text-strong); letter-spacing: 0.2px; } @@ -264,7 +264,7 @@ select option { border-radius: var(--radius); background: transparent; color: var(--muted); - font-size: 14px; + font-size: 15px; font-weight: 550; } .nav-item:hover { @@ -309,7 +309,7 @@ select option { display: flex; align-items: center; gap: 8px; - font-size: 12px; + font-size: 13px; color: var(--muted); } .status-dot { @@ -334,14 +334,14 @@ select option { margin-bottom: 10px; } .lib-stats-title { - font-size: 11px; + font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--faint); } .lib-stats-loading { - font-size: 11px; + font-size: 12px; color: var(--faint); padding-bottom: 10px; } @@ -368,14 +368,14 @@ select option { transform: translateY(-1px); } .lib-stat-value { - font-size: 16px; + font-size: 17px; font-weight: 800; color: var(--text-strong); font-variant-numeric: tabular-nums; text-shadow: 0 0 14px var(--accent-glow); } .lib-stat-label { - font-size: 9px; + font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; @@ -400,7 +400,10 @@ select option { .lib-color-seg.G { background: var(--mtg-g); } .lib-color-seg.C { background: var(--mtg-c); } .lib-stats-sub { - font-size: 10px; + display: flex; + flex-direction: column; + gap: 2px; + font-size: 11px; color: var(--faint); } @@ -424,7 +427,7 @@ select option { .path { flex: 1 1 auto; min-width: 0; - font-size: 12px; + font-size: 13px; font-family: ui-monospace, "SF Mono", Menlo, monospace; color: var(--faint); background: rgba(0, 0, 0, 0.25); @@ -444,7 +447,7 @@ select option { padding: 8px 18px 10px; background: var(--bg-elev); border-bottom: 1px solid var(--border-soft); - font-size: 12px; + font-size: 13px; } .ingest-bar-head { display: flex; @@ -462,7 +465,7 @@ select option { .ingest-bar-cancel { flex-shrink: 0; padding: 3px 10px; - font-size: 11px; + font-size: 12px; color: var(--muted); } .ingest-bar-cancel:hover { @@ -494,7 +497,7 @@ select option { .ingest-segment-label { display: block; margin-top: 3px; - font-size: 10px; + font-size: 11px; color: var(--faint); white-space: nowrap; overflow: hidden; @@ -546,7 +549,7 @@ select option { display: flex; align-items: center; justify-content: center; - font-size: 34px; + font-size: 36px; color: #fff; background: linear-gradient(150deg, var(--accent-hover), var(--accent-active)); box-shadow: 0 10px 30px var(--accent-glow); @@ -561,14 +564,14 @@ select option { h2 { margin: 2px 0 14px; - font-size: 19px; + font-size: 20px; font-weight: 700; color: var(--text-strong); letter-spacing: -0.2px; } h3 { margin: 18px 0 10px; - font-size: 12px; + font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; @@ -578,7 +581,7 @@ h3 { } h4 { margin: 14px 0 7px; - font-size: 13px; + font-size: 14px; font-weight: 600; color: var(--accent-hover); } @@ -587,7 +590,7 @@ h4 { } .hint { color: var(--muted); - font-size: 13px; + font-size: 14px; line-height: 1.65; } .row { @@ -602,7 +605,7 @@ h4 { color: var(--accent-hover); border-radius: 999px; padding: 1px 9px; - font-size: 11px; + font-size: 12px; font-weight: 700; } @@ -631,7 +634,7 @@ h4 { border: none; border-radius: 0; color: var(--muted); - font-size: 15px; + font-size: 16px; font-weight: 600; line-height: 1; cursor: pointer; @@ -654,7 +657,7 @@ h4 { min-width: 22px; text-align: center; padding: 0 2px; - font-size: 13px; + font-size: 14px; font-weight: 700; color: var(--text-strong); font-variant-numeric: tabular-nums; @@ -792,7 +795,7 @@ li:focus-within .row-actions { } .dialog-title { margin: 0 0 10px; - font-size: 15px; + font-size: 16px; font-weight: 600; text-transform: none; letter-spacing: normal; @@ -803,7 +806,7 @@ li:focus-within .row-actions { .dialog-message { margin: 0 0 18px; color: var(--muted); - font-size: 13px; + font-size: 14px; line-height: 1.5; } .dialog-field { @@ -818,7 +821,7 @@ li:focus-within .row-actions { .dialog-error { margin: -6px 0 14px; color: var(--danger); - font-size: 12px; + font-size: 13px; } .dialog-actions { display: flex; @@ -860,7 +863,7 @@ li:focus-within .row-actions { } .card-type { color: var(--muted); - font-size: 12px; + font-size: 13px; } /* ============================================================ @@ -875,7 +878,7 @@ li:focus-within .row-actions { width: 18px; height: 18px; border-radius: 50%; - font-size: 10px; + font-size: 11px; font-weight: 800; display: inline-flex; align-items: center; @@ -914,7 +917,7 @@ table { th { text-align: left; padding: 8px 12px; - font-size: 11px; + font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; @@ -947,7 +950,7 @@ tbody tr:hover { border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px; - font-size: 11px; + font-size: 12px; color: var(--text); margin-right: 4px; } @@ -972,7 +975,7 @@ tbody tr:hover { gap: 6px; } .field > label { - font-size: 11px; + font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; @@ -980,7 +983,7 @@ tbody tr:hover { } .location-input { padding: 4px 8px; - font-size: 12px; + font-size: 13px; max-width: 160px; } @@ -1012,7 +1015,7 @@ tbody tr:hover { color: var(--faint); border: 1px dashed var(--border); border-radius: 5px; - font-size: 18px; + font-size: 19px; } .hover-card { position: fixed; @@ -1098,7 +1101,7 @@ tbody tr:hover { background: rgba(20, 20, 24, 0.75); color: #fff; border: 1px solid rgba(255, 255, 255, 0.15); - font-size: 12px; + font-size: 13px; font-weight: 600; backdrop-filter: blur(4px); } @@ -1151,13 +1154,21 @@ tbody tr:hover { right: 8px; display: flex; gap: 6px; + /* The card art's own mana cost prints in this corner — only cover it while + the tile is actually being interacted with. */ + opacity: 0; + transition: opacity 0.12s; +} +.tile:hover .tile-corner, +.tile:focus-within .tile-corner { + opacity: 1; } .tile-badge { background: rgba(0, 0, 0, 0.78); color: #fff; border-radius: 8px; padding: 2px 9px; - font-size: 12px; + font-size: 13px; font-weight: 700; backdrop-filter: blur(2px); } @@ -1180,7 +1191,7 @@ tbody tr:hover { display: inline-flex; align-items: center; gap: 7px; - font-size: 12px; + font-size: 13px; color: var(--muted); } .view-toggle { @@ -1194,7 +1205,7 @@ tbody tr:hover { border: none; border-radius: 0; background: transparent; - font-size: 12px; + font-size: 13px; padding: 5px 12px; color: var(--muted); } diff --git a/src/views/LibraryBrowser.tsx b/src/views/LibraryBrowser.tsx index 1e8d58d..f0223ed 100644 --- a/src/views/LibraryBrowser.tsx +++ b/src/views/LibraryBrowser.tsx @@ -31,6 +31,7 @@ export function LibraryBrowser() { const [subtype, setSubtype] = useState(""); const [keyword, setKeyword] = useState(""); const [inDeck, setInDeck] = useState(""); + const [location, setLocation] = useState(""); const [decks, setDecks] = useState([]); const [rows, setRows] = useState([]); @@ -59,12 +60,25 @@ export function LibraryBrowser() { subtypes: splitCsv(subtype), keywords: splitCsv(keyword), in_deck: inDeck === "" ? null : Number(inDeck), + location: location.trim() || null, }; const t = setTimeout(() => { api.searchLibrary(filter).then(setRows).catch(() => setRows([])); }, 120); return () => clearTimeout(t); - }, [text, commander, colorsInclude, cmcMin, cmcMax, type, subtype, keyword, inDeck, refreshKey]); + }, [ + text, + commander, + colorsInclude, + cmcMin, + cmcMax, + type, + subtype, + keyword, + inDeck, + location, + refreshKey, + ]); async function adjust(oracleId: string, delta: number) { const qty = await api.addOwned(oracleId, delta); @@ -261,6 +275,14 @@ export function LibraryBrowser() { ))} +
+ + setLocation(e.target.value)} + placeholder="e.g. Box 1" + /> +
{rows.length === 0 ? (