Files
mtgc/src/components.tsx

1907 lines
58 KiB
TypeScript
Raw Normal View History

import {
createContext,
forwardRef,
ReactNode,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
Rework library sync around a JSON document; add card filters Sync The previous design put the merge inside SQL: timestamp-stamping triggers, a tombstone table, per-device SQLite snapshots, a device id in the database, and row merges written as correlated-subquery UPDATEs. All of that schema was inherited by desktop, and the merge was very hard to verify. Replace it with a single JSON document plus a three-way merge in plain Rust. Storing the last-read remote document as the merge base makes "who changed what" and "what was deleted" derivable, which removes the tombstone table, the updated_at columns and all eight triggers. It also makes a single shared file safe: if a publish is lost the remote is unchanged, so the next merge re-detects the local edits and republishes rather than reading their absence as a deletion. That removes the need for per-device files and for device identity entirely. Schema impact drops to one column, decks.uuid. Conflicts resolve to "theirs", which is required for convergence rather than chosen for taste — preferring ours has each device re-asserting its own value forever. Every such choice is reported to the user. SQLite becomes invisible: the working copy lives in the app data dir and the user only ever names a .json file. There is always a document, defaulting to private storage, so edits are never stranded with nowhere to be saved. Pointing at a file that doesn't exist yet moves the current library there; pointing at one that does adopts it. A converter brings a pre-document library.db across. Android reaches the folder through the Storage Access Framework rather than MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all. Filters Track card origin in reference.db: Universes Beyond, digital-only and Un-set cards, plus numeric power/toughness. Each origin flag is derived as "every printing", not "any printing" — ordinary cards get reprinted into those sets, and Lightning Bolt appearing in Marvel Commander must not hide it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range filters instead of being compared as zero. Also wire up filters over data already present: price range, format legality (restricted counts as legal) and "can be a commander". All of these degrade silently against an older reference.db rather than failing every query. The shared criteria live in one component used by both filter panels, as a compact row of icon toggles. Card Search gains the Library tab's bulk selection: checkboxes in both views, select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
import {
api,
CardRef,
COLORS,
LEGALITY_FORMATS,
LibraryFilter,
legalStatusFor,
maskToLetters,
PreviewCard,
} from "./api";
import { useBackButton } from "./backButton";
// --- Icons ------------------------------------------------------------
const icon = (children: ReactNode) => (
<svg
viewBox="0 0 24 24"
width="15"
height="15"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
);
const ICON_TRASH = icon(
<>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</>,
);
const ICON_COPY = icon(
<>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</>,
);
const ICON_PENCIL = icon(<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />);
const ICON_CLOSE = icon(
<>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</>,
);
const ICON_HELP_CIRCLE = icon(
<>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</>,
);
const ICON_ARROW_LEFT_CIRCLE = icon(
<>
<circle cx="12" cy="12" r="10" />
<polyline points="12 8 8 12 12 16" />
<line x1="16" y1="12" x2="8" y2="12" />
</>,
);
const ICON_CARDS = icon(
<>
<rect x="4" y="2" width="12" height="17" rx="2" />
<rect x="8" y="5" width="12" height="17" rx="2" />
</>,
);
const ICON_DOWNLOAD = icon(
<>
<path d="M12 3v12" />
<polyline points="7 11 12 16 17 11" />
<path d="M4 18v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-1" />
</>,
);
const ICON_UPLOAD = icon(
<>
<path d="M12 21V9" />
<polyline points="7 13 12 8 17 13" />
<path d="M4 18v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-1" />
</>,
);
/**
* 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 (
<button
type="button"
className={["icon-btn", overlay ? "icon-btn-overlay" : "", danger ? "icon-btn-danger" : ""]
.filter(Boolean)
.join(" ")}
onClick={onClick}
title={label}
aria-label={label}
disabled={disabled}
>
{icon}
</button>
);
}
export const Icons = {
trash: ICON_TRASH,
copy: ICON_COPY,
pencil: ICON_PENCIL,
close: ICON_CLOSE,
helpCircle: ICON_HELP_CIRCLE,
arrowLeftCircle: ICON_ARROW_LEFT_CIRCLE,
cards: ICON_CARDS,
download: ICON_DOWNLOAD,
upload: ICON_UPLOAD,
};
// --- Dialogs ------------------------------------------------------------
/**
* A centered modal dialog (backdrop + card) shared by [`ConfirmDialog`],
* [`PromptDialog`], and other dialogs built outside this module (e.g. the
* command palette). Dismisses on Escape, Android back, backdrop click, or the
* title-bar close button — the explicit button matters most on mobile, where
* there's no Escape key and a `wide` dialog can leave little to no backdrop
* to tap.
*/
export function DialogFrame({
title,
children,
onCancel,
wide,
}: {
title: string;
children: ReactNode;
onCancel: () => void;
/** Wider variant for content that needs more room than a typical confirm/prompt box. */
wide?: boolean;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onCancel();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
useBackButton(onCancel);
return (
<div className="dialog-backdrop" onClick={onCancel}>
<div className={`dialog ${wide ? "dialog-wide" : ""}`} onClick={(e) => e.stopPropagation()}>
<div className="dialog-title-row">
<h3 className="dialog-title">{title}</h3>
<IconButton icon={Icons.close} label="Close" onClick={onCancel} />
</div>
{children}
</div>
</div>
);
}
/** 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 (
<DialogFrame title={title} onCancel={onCancel}>
<p className="dialog-message">{message}</p>
<div className="dialog-actions">
<button onClick={onCancel}>Cancel</button>
<button className={danger ? "danger" : "primary"} onClick={onConfirm} autoFocus>
{confirmLabel}
</button>
</div>
</DialogFrame>
);
}
/** 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<HTMLInputElement>(null);
useEffect(() => inputRef.current?.select(), []);
const confirm = () => {
const v = value.trim();
if (v) onConfirm(v);
};
return (
<DialogFrame title={title} onCancel={onCancel}>
<label className="dialog-field">
<span className="muted">{label}</span>
<input
ref={inputRef}
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && confirm()}
/>
</label>
{error && <p className="dialog-error">{error}</p>}
<div className="dialog-actions">
<button onClick={onCancel}>Cancel</button>
<button className="primary" onClick={confirm} disabled={!value.trim()}>
{confirmLabel}
</button>
</div>
</DialogFrame>
);
}
/** Read-only informational dialog: a message plus an optional bulleted list
* (e.g. unresolved card names after an import). Single "OK" dismiss button. */
export function InfoDialog({
title,
message,
items,
onClose,
}: {
title: string;
message?: ReactNode;
items?: string[];
onClose: () => void;
}) {
return (
<DialogFrame title={title} onCancel={onClose} wide={!!items?.length}>
{message && <p className="dialog-message">{message}</p>}
{items && items.length > 0 && (
<ul className="dialog-list">
{items.map((it, i) => (
<li key={i}>{it}</li>
))}
</ul>
)}
<div className="dialog-actions">
<button className="primary" onClick={onClose} autoFocus>
OK
</button>
</div>
</DialogFrame>
);
}
/** Floating snackbar offering to reverse the caller's last action. Auto-
* dismisses on a timer managed by the caller (this component is purely
* presentational) — pass `null`/omit rendering it once the window lapses. */
export function UndoBar({
message,
onUndo,
onDismiss,
}: {
message: string;
onUndo: () => void;
onDismiss: () => void;
}) {
return (
<div className="undo-bar">
<span>{message}</span>
<button className="primary" onClick={onUndo}>
Undo
</button>
<button className="undo-bar-close" onClick={onDismiss} title="Dismiss">
×
</button>
</div>
);
}
/** Modal shuffle-and-draw sample hand: shows the drawn cards and lets the
* caller draw more or reshuffle, on top of a click/Escape-to-close backdrop. */
export function SampleHandModal({
hand,
libraryCount,
onDrawOne,
onNewHand,
onClose,
}: {
hand: CardRef[];
libraryCount: number;
onDrawOne: () => void;
onNewHand: () => void;
onClose: () => void;
}) {
return (
<DialogFrame title="Sample hand" onCancel={onClose} wide>
<div className="row" style={{ gap: 10, marginBottom: 12 }}>
<span className="muted" style={{ fontSize: 12 }}>
{libraryCount} card{libraryCount === 1 ? "" : "s"} left in library
</span>
<span className="spacer" />
<button onClick={onDrawOne} disabled={libraryCount === 0}>
Draw a card
</button>
<button onClick={onNewHand}>New hand</button>
</div>
{hand.length > 0 ? (
<div className="tile-grid">
{hand.map((c, i) => (
<CardTile key={`${c.oracle_id}-${i}`} card={c} />
))}
</div>
) : (
<p className="hint">Your deck has no cards to draw yet.</p>
)}
<div className="dialog-actions">
<button className="primary" onClick={onClose} autoFocus>
Close
</button>
</div>
</DialogFrame>
);
}
/**
* 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()}
/>
);
}
/**
* A small pill-shaped [− N +] quantity stepper, used consistently for owned-copy
* counts on the Add Cards page and both Library views.
*
* - `overlay`: dark translucent variant for use atop card art (tile corners),
* instead of the default panel-toned variant used in rows/lists.
* - `warn`: tints the pill amber (e.g. "assigned to more decks than owned").
* - `pending`: dims the pill (e.g. mid grace-period before a 0-qty row is removed).
*/
export function QtyStepper({
value,
onDecrement,
onIncrement,
overlay,
warn,
pending,
}: {
value: number;
onDecrement: () => void;
onIncrement: () => void;
overlay?: boolean;
warn?: boolean;
pending?: boolean;
}) {
return (
<span
className={[
"qty-stepper",
overlay ? "qty-stepper-overlay" : "",
warn ? "warn" : "",
pending ? "pending" : "",
]
.filter(Boolean)
.join(" ")}
>
<button
type="button"
className="qty-stepper-btn"
onClick={onDecrement}
disabled={value <= 0}
title="Remove a copy"
>
−
</button>
<span className="qty-stepper-value">{value}</span>
<button type="button" className="qty-stepper-btn" onClick={onIncrement} title="Add a copy">
+
</button>
</span>
);
}
/** 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 a cursor-tracking preview;
* clicking it opens a larger, centered preview (with a flip button for
* double-faced cards).
*/
export function CardThumb({ card }: { card: CardRef }) {
const { handlers } = useHoverPreview();
const { open } = useClickPreview();
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"
onClick={(e) => {
e.stopPropagation();
open(card);
}}
{...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).
* Forwards a ref to the underlying `<input>` so callers can focus it
* imperatively (e.g. redirecting a stray keystroke into the search box).
*/
export const CardSearchBox = forwardRef<
HTMLInputElement,
{
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;
}
>(function CardSearchBox(
{ placeholder, value, onChange, results, onPick, autoFocus, big },
forwardedRef,
) {
const [sel, setSel] = useState(0);
const inputRef = useRef<HTMLInputElement | null>(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={(el) => {
inputRef.current = el;
if (typeof forwardedRef === "function") forwardedRef(el);
else if (forwardedRef) (forwardedRef as React.MutableRefObject<HTMLInputElement | null>).current = el;
}}
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(--accent-subtle)" } : 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>
)}
</>
);
});
/**
* A chip-based multi-value text filter with autocomplete: selected values
* render as removable chips, and typing filters `suggestions` into a
* dropdown (↑/↓ to move, Enter to pick — or Enter with no match adds the
* typed text verbatim, so it's not limited to only-suggested values).
* Backspace on an empty input quick-removes the last chip.
*/
export function TagInput({
values,
onChange,
suggestions,
placeholder,
}: {
values: string[];
onChange: (values: string[]) => void;
suggestions: string[];
placeholder?: string;
}) {
const [text, setText] = useState("");
const [sel, setSel] = useState(0);
const query = text.trim().toLowerCase();
const filtered = query
? suggestions
.filter((s) => !values.some((v) => v.toLowerCase() === s.toLowerCase()))
.filter((s) => s.toLowerCase().includes(query))
.slice(0, 8)
: [];
useEffect(() => setSel(0), [text]);
function addValue(v: string) {
const trimmed = v.trim();
if (!trimmed) return;
if (!values.some((x) => x.toLowerCase() === trimmed.toLowerCase())) {
onChange([...values, trimmed]);
}
setText("");
}
function removeValue(v: string) {
onChange(values.filter((x) => x !== v));
}
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === "ArrowDown" && filtered.length > 0) {
e.preventDefault();
setSel((s) => Math.min(s + 1, filtered.length - 1));
} else if (e.key === "ArrowUp" && filtered.length > 0) {
e.preventDefault();
setSel((s) => Math.max(s - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (filtered[sel]) addValue(filtered[sel]);
else if (text.trim()) addValue(text);
} else if (e.key === "Backspace" && text === "" && values.length > 0) {
onChange(values.slice(0, -1));
} else if (e.key === "Escape") {
setText("");
}
}
return (
<div className="tag-input">
<div className="tag-chips">
{values.map((v) => (
<span key={v} className="tag-chip">
{v}
<button
type="button"
className="tag-chip-remove"
onClick={() => removeValue(v)}
title={`Remove ${v}`}
>
×
</button>
</span>
))}
<input
className="tag-chip-input"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={onKeyDown}
placeholder={values.length === 0 ? placeholder : ""}
/>
</div>
{filtered.length > 0 && (
<ul className="result-list tag-suggestions">
{filtered.map((s, i) => (
<li
key={s}
style={i === sel ? { background: "var(--accent-subtle)" } : undefined}
onMouseEnter={() => setSel(i)}
onClick={() => addValue(s)}
>
{s}
</li>
))}
</ul>
)}
</div>
);
}
// --- Hover + click preview -------------------------------------------------
interface HoverApi {
enter: (card: CardRef, e: React.MouseEvent) => void;
move: (e: React.MouseEvent) => void;
leave: () => void;
}
interface ClickApi {
open: (card: PreviewCard) => void;
}
const HoverCtx = createContext<HoverApi | null>(null);
const ClickCtx = createContext<ClickApi | null>(null);
/**
* Provides card-image previews app-wide: a small floating preview that
* tracks the cursor while hovering (see [`useHoverPreview`]), and a larger
* centered modal on click (see [`useClickPreview`]) — with a flip button for
* double-faced cards. Wrap the app once.
*/
export function HoverPreviewProvider({ children }: { children: ReactNode }) {
const [hover, setHover] = useState<{ card: CardRef; x: number; y: number } | null>(null);
const [clicked, setClicked] = useState<{ card: PreviewCard; showBack: boolean } | null>(null);
const enter = useCallback((card: CardRef, e: React.MouseEvent) => {
if (card.image_uri) setHover({ card, x: e.clientX, y: e.clientY });
}, []);
const move = useCallback((e: React.MouseEvent) => {
setHover((s) => (s ? { ...s, x: e.clientX, y: e.clientY } : s));
}, []);
const leave = useCallback(() => setHover(null), []);
const open = useCallback((card: PreviewCard) => {
if (!card.image_uri) return;
setHover(null);
setClicked({ card, showBack: false });
}, []);
const close = useCallback(() => setClicked(null), []);
const flip = useCallback(() => setClicked((c) => (c ? { ...c, showBack: !c.showBack } : c)), []);
useEffect(() => {
if (!clicked) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [clicked, close]);
useBackButton(close, !!clicked);
return (
<HoverCtx.Provider value={{ enter, move, leave }}>
<ClickCtx.Provider value={{ open }}>
{children}
{!clicked && hover && <FloatingCard card={hover.card} x={hover.x} y={hover.y} />}
{clicked && <ClickPreviewModal state={clicked} onClose={close} onFlip={flip} />}
</ClickCtx.Provider>
</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(),
}
: {},
};
}
/** Returns `open(card)` to trigger the larger click-to-preview modal. */
export function useClickPreview() {
const api = useContext(ClickCtx);
return { open: (card: PreviewCard) => api?.open(card) };
}
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 }) {
// Battles/Planes/Phenomena store their image portrait-shaped with the
// content itself rotated, so the box footprint swaps (landscape) and the
// image inside gets rotated 90° back into place — see `CardRef.horizontal`.
const boxW = card.horizontal ? PREVIEW_H : PREVIEW_W;
const boxH = card.horizontal ? PREVIEW_W : PREVIEW_H;
// Prefer the right of the cursor; flip left if it would overflow.
let left = x + GAP;
if (left + boxW + PAD > window.innerWidth) left = x - boxW - GAP;
const top = Math.max(PAD, Math.min(y - boxH / 2, window.innerHeight - boxH - PAD));
return (
<div className="hover-card" style={{ left, top, width: boxW, height: boxH }}>
<img
className="hover-card-img"
src={card.image_uri}
alt={card.name}
style={
card.horizontal
? {
position: "absolute",
top: "50%",
left: "50%",
width: PREVIEW_W,
height: PREVIEW_H,
transform: "translate(-50%, -50%) rotate(90deg)",
}
: undefined
}
/>
</div>
);
}
// Landscape (post-rotation) aspect ratio of a Scryfall "normal" card image —
// the raw file is portrait (488x680); rotated 90° it reads as 680x488.
const ROTATED_ASPECT = 680 / 488;
/** The largest landscape box (matching `.preview-modal-img`'s own
* `max-width: min(720px, 90vw)` / `max-height: 90vh` bounds) for a rotated
* horizontal-card preview. Recomputed on resize so it stays live like the
* CSS-only sizing used for normal (portrait) cards.
*
* `dfc` (when wide enough for side-by-side faces) shrinks the budget to
* roughly match a DFC back face's own `min(430px, 43vw)` box (see the `.preview-modal-dfc .preview-modal-img`
* rule) and leaves headroom below for the info tile — pass it whenever the
* horizontal front face has a sibling back face, so a transformed Battle
* doesn't render at full single-card size next to a much smaller back face
* and push the info tile off-screen.
*
* `fitH` (compact mode, see `ClickPreviewModal`) overrides both with the
* exact art height budget left over after the pane toolbar. */
function useRotatedPreviewBox(dfc: boolean, fitH: number | null) {
const compute = () => {
// Faces only sit side by side at the `.preview-modal-dfc` ≥860px
// breakpoint; below it they flip one at a time, so use the full budget.
const sideBySide = dfc && fitH === null && window.innerWidth >= 860;
const boundW = sideBySide ? Math.min(430, window.innerWidth * 0.43) : Math.min(720, window.innerWidth * 0.9);
const boundH = fitH ?? (sideBySide ? window.innerHeight * 0.55 : window.innerHeight * 0.9);
const w = Math.min(boundW, boundH * ROTATED_ASPECT);
return { w, h: w / ROTATED_ASPECT };
};
const [box, setBox] = useState(compute);
useEffect(() => {
setBox(compute());
const onResize = () => setBox(compute());
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [dfc, fitH]);
return box;
}
/** Legality status pills for every tracked format, e.g. inside the
* click-preview modal's info tile. Not-legal formats are shown too (dimmed)
* so the list reads as a complete checklist rather than a partial one. */
function LegalityPills({ legalities }: { legalities: number }) {
return (
<div className="preview-info-legal-grid">
{LEGALITY_FORMATS.map((f) => {
const status = legalStatusFor(legalities, f.label);
return (
<span key={f.key} className={`legal-pill legal-${status}`}>
{f.label}
</span>
);
})}
</div>
);
}
/** Details panel shown alongside the card art in the click-to-preview modal:
* type/mana/color, price, ownership context (when the caller had a
* `LibraryCard`/`DeckEntry` to hand — see `PreviewCard`), and per-format
* legality. */
const CardInfoTile = forwardRef<HTMLDivElement, { card: PreviewCard }>(function CardInfoTile({ card }, ref) {
return (
<div className="preview-info" ref={ref}>
<div className="preview-info-name">{card.name}</div>
<div className="preview-info-type">{card.type_line}</div>
<div className="preview-info-row">
<span className="preview-info-mv">Mana value {card.mana_value}</span>
<ColorPips mask={card.colors} />
</div>
{card.price_usd > 0 && (
<div className="preview-info-price">${card.price_usd.toFixed(2)}</div>
)}
{card.quantity_owned !== undefined && (
<div className="preview-info-section">
<span className="preview-info-label">Owned</span>
<span>
{card.quantity_owned} cop{card.quantity_owned === 1 ? "y" : "ies"}
</span>
</div>
)}
{card.decks && card.decks.length > 0 && (
<div className="preview-info-section">
<span className="preview-info-label">In decks</span>
<div className="preview-info-deck-chips">
{card.decks.map((d) => (
<span key={d} className="badge">
{d}
</span>
))}
</div>
</div>
)}
<div className="preview-info-section">
<span className="preview-info-label">Legalities</span>
<LegalityPills legalities={card.legalities} />
</div>
</div>
);
});
type PreviewPane = "card" | "info";
// Compact-mode toolbar height and the modal's flex gap — keep in sync with
// `.preview-modal-bar` / `.preview-modal` in styles.css.
const PREVIEW_BAR_H = 36;
const PREVIEW_GAP = 16;
/** Segmented control for the compact (one-pane-at-a-time) click preview:
* front face / back face (double-faced cards only) / info tile. */
function PreviewPaneToggle({
hasBack,
showBack,
pane,
onSelect,
}: {
hasBack: boolean;
showBack: boolean;
pane: PreviewPane;
onSelect: (pane: PreviewPane, back: boolean) => void;
}) {
const btn = (label: string, active: boolean, onClick: () => void) => (
<button className={active ? "active" : ""} onClick={onClick}>
{label}
</button>
);
return (
<span className="view-toggle">
{btn(hasBack ? "Front" : "Card", pane === "card" && !showBack, () => onSelect("card", false))}
{hasBack && btn("Back", pane === "card" && showBack, () => onSelect("card", true))}
{btn("Info", pane === "info", () => onSelect("info", showBack))}
</span>
);
}
function ClickPreviewModal({
state,
onClose,
onFlip,
}: {
state: { card: PreviewCard; showBack: boolean };
onClose: () => void;
onFlip: () => void;
}) {
const { card, showBack } = state;
const hasBack = !!card.back_image_uri;
const backdropRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const infoRef = useRef<HTMLDivElement>(null);
// Compact mode: when the full layout (art + info tile) would be taller than
// the viewport — i.e. on phones, where the tile wraps below the art — show
// one pane at a time above a Front/Back/Info toggle instead of making the
// user scroll. Decided by measuring the full layout itself: every resize
// drops back to it, and the layout effect below re-measures and switches
// before paint, so there's no flash and no feedback loop (compact mode is
// never what gets measured). The art's size doesn't depend on the image
// having loaded (see `.preview-modal-img`), so measuring on mount is exact.
// `fit` holds the measurements compact mode sizes itself from; null = full.
const [fit, setFit] = useState<{ avail: number; infoH: number } | null>(null);
const [pane, setPane] = useState<PreviewPane>("card");
const [measureKey, setMeasureKey] = useState(0);
const artBudget = fit && fit.avail - PREVIEW_BAR_H - PREVIEW_GAP;
const rotatedBox = useRotatedPreviewBox(hasBack, artBudget);
useEffect(() => {
const onResize = () => {
setFit(null);
setMeasureKey((k) => k + 1);
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
useLayoutEffect(() => {
const backdrop = backdropRef.current;
const modal = modalRef.current;
if (fit || !backdrop || !modal) return;
const cs = getComputedStyle(backdrop);
const avail = backdrop.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
if (modal.getBoundingClientRect().height > avail) {
setFit({ avail, infoH: infoRef.current?.getBoundingClientRect().height ?? 0 });
}
}, [fit, measureKey, card, showBack]);
// In compact mode the modal gets a fixed box — as wide and tall as the
// largest pane (capped to the screen), toolbar pinned to its bottom — so
// switching panes never moves the toolbar. Mirrors the CSS sizing of the
// art (`.preview-modal-img`) and info tile (`.preview-info`).
let compactStyle: React.CSSProperties | undefined;
if (fit && artBudget !== null) {
const vw = window.innerWidth;
const portraitW = Math.min(488, vw * 0.9, (artBudget * 488) / 680);
const faces = [
card.horizontal ? rotatedBox : { w: portraitW, h: (portraitW * 680) / 488 },
...(hasBack ? [{ w: portraitW, h: (portraitW * 680) / 488 }] : []),
];
const paneW = Math.max(Math.min(320, vw * 0.9), ...faces.map((f) => f.w));
const paneH = Math.min(artBudget, Math.max(fit.infoH, ...faces.map((f) => f.h)));
compactStyle = {
width: paneW,
height: paneH + PREVIEW_GAP + PREVIEW_BAR_H,
["--preview-img-h" as string]: `${artBudget}px`,
};
}
const compact = !!fit;
const selectPane = (next: PreviewPane, back: boolean) => {
setPane(next);
if (back !== showBack) onFlip();
};
// 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 (
<div className="preview-modal-backdrop" ref={backdropRef} onClick={onClose}>
<div
ref={modalRef}
style={compactStyle}
className={[
"preview-modal",
hasBack ? "preview-modal-dfc" : "",
compact ? "preview-modal-compact" : "",
compact && pane === "info" ? "preview-modal-show-info" : "",
]
.filter(Boolean)
.join(" ")}
onClick={(e) => e.stopPropagation()}
>
<div className="preview-modal-faces">
{/* Only the front face can be horizontal (Battles) — a transformed
Battle's back face is always a normal, correctly-oriented
creature card, per `CardRef.horizontal`'s doc comment. */}
{card.horizontal ? (
<div
className={`preview-modal-img preview-modal-img-rotated ${showBack ? "" : "active"}`}
style={{ width: rotatedBox.w, height: rotatedBox.h }}
>
<img
className="preview-modal-rotate-img"
src={card.image_uri}
alt={card.name}
style={{ width: rotatedBox.h, height: rotatedBox.w }}
/>
</div>
) : (
<img
className={`preview-modal-img ${showBack ? "" : "active"}`}
src={card.image_uri}
alt={card.name}
/>
)}
{hasBack && (
<img
className={`preview-modal-img ${showBack ? "active" : ""}`}
src={card.back_image_uri}
alt={`${card.name} (back)`}
/>
)}
<span className="preview-modal-close">
<IconButton icon={Icons.close} label="Close (Esc)" onClick={onClose} overlay />
</span>
{hasBack && (
<button className="preview-modal-flip" onClick={onFlip} title="Show the other side">
⟲ Flip
</button>
)}
</div>
<CardInfoTile ref={infoRef} card={card} />
{compact && (
<div className="preview-modal-bar">
<PreviewPaneToggle hasBack={hasBack} showBack={showBack} pane={pane} onSelect={selectPane} />
<IconButton icon={Icons.close} label="Close (Esc)" onClick={onClose} overlay />
</div>
)}
</div>
</div>
);
}
// --- 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";
}
/** Where a primary type falls in [`TYPE_PRIORITY`] — used both to order
* type groups and to sort cards by type. */
function typeRank(primary: string): number {
const i = TYPE_PRIORITY.indexOf(primary);
return i === -1 ? TYPE_PRIORITY.length : i;
}
/** e.g. "Creature — Chimera" — prefixed with the primary type so cards group
* by (type, subtype) rather than colliding on a subtype shared across types. */
function subtypeLabel(typeLine: string): string {
const parts = typeLine.split("—");
const subtype = parts.length < 2 ? "No subtype" : parts[1].trim().split(/\s+/)[0] || "No subtype";
return `${primaryType(typeLine)} — ${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"
? subtypeLabel(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") {
groups.sort((a, b) => typeRank(a.label) - typeRank(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>
);
}
// --- Sorting ----------------------------------------------------------
export type SortMode = "name" | "mv" | "color" | "type" | "qty" | "price";
export const SORT_OPTIONS: { value: SortMode; label: string }[] = [
{ value: "name", label: "Name" },
{ value: "mv", label: "Mana value" },
{ value: "color", label: "Color identity" },
{ value: "type", label: "Card type" },
{ value: "qty", label: "Quantity owned" },
{ value: "price", label: "Price" },
];
export type SortDir = "asc" | "desc";
/** Sort cards by the chosen attribute; ties always break by name so the
* order stays stable and predictable regardless of `mode`. Grouping (see
* [`groupCards`]) determines section order independently — sort this array
* first, then group it, and each group keeps this order internally. */
export function sortCards<T extends CardRef & { quantity_owned: number }>(
cards: T[],
mode: SortMode,
dir: SortDir,
): T[] {
const sorted = [...cards].sort((a, b) => {
const cmp =
mode === "name"
? a.name.localeCompare(b.name)
: mode === "mv"
? a.mana_value - b.mana_value || a.name.localeCompare(b.name)
: mode === "color"
? colorLabel(a.color_identity).localeCompare(colorLabel(b.color_identity)) ||
a.name.localeCompare(b.name)
: mode === "type"
? typeRank(primaryType(a.type_line)) - typeRank(primaryType(b.type_line)) ||
a.name.localeCompare(b.name)
: mode === "qty"
? a.quantity_owned - b.quantity_owned || a.name.localeCompare(b.name)
: a.price_usd - b.price_usd || a.name.localeCompare(b.name);
return dir === "asc" ? cmp : -cmp;
});
return sorted;
}
/** Dropdown + ascending/descending toggle for choosing a [`SortMode`]. */
export function SortBySelect({
value,
dir,
onChangeValue,
onToggleDir,
}: {
value: SortMode;
dir: SortDir;
onChangeValue: (m: SortMode) => void;
onToggleDir: () => void;
}) {
return (
<span className="row" style={{ gap: 4 }}>
<label className="group-by">
<span className="muted">Sort by</span>
<select value={value} onChange={(e) => onChangeValue(e.target.value as SortMode)}>
{SORT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
<button
type="button"
className="sort-dir-btn"
onClick={onToggleDir}
title={dir === "asc" ? "Ascending — click for descending" : "Descending — click for ascending"}
>
{dir === "asc" ? "↑" : "↓"}
</button>
</span>
);
}
// --- Stats widgets (sidebar "Your library" panel, per-deck analysis) ------
/** A single numeric stat in a small panel tile, e.g. "42 / unique". */
export function StatTile({ value, label }: { value: string | number; label: string }) {
return (
<div className="stat-tile">
<span className="stat-tile-value">{value}</span>
<span className="stat-tile-label">{label}</span>
</div>
);
}
/** A segmented WUBRG(+colorless) bar sized proportionally to `colorCounts`. */
export function ColorDistributionBar({
colorCounts,
colorlessCount,
}: {
colorCounts: [number, number, number, number, number]; // W, U, B, R, G
colorlessCount: number;
}) {
const segs: [string, number][] = [
["W", colorCounts[0]],
["U", colorCounts[1]],
["B", colorCounts[2]],
["R", colorCounts[3]],
["G", colorCounts[4]],
["C", colorlessCount],
];
if (segs.every(([, count]) => count === 0)) return null;
return (
<div className="color-bar">
{segs.map(([code, count]) =>
count > 0 ? (
<span
key={code}
className={`color-bar-seg ${code}`}
style={{ flexGrow: count }}
title={`${code === "C" ? "Colorless" : code}: ${count.toLocaleString()}`}
/>
) : null,
)}
</div>
);
}
/** A mana-value histogram (bar height proportional to the largest bucket),
* each bar segmented by color like {@link ColorDistributionBar}. */
export function ManaCurve({
buckets,
}: {
buckets: {
label: string;
count: number;
colorCounts: [number, number, number, number, number]; // W, U, B, R, G
colorlessCount: number;
}[];
}) {
const max = Math.max(1, ...buckets.map((b) => b.count));
return (
<div className="mana-curve">
{buckets.map((b) => {
const segs: [string, number][] = [
["W", b.colorCounts[0]],
["U", b.colorCounts[1]],
["B", b.colorCounts[2]],
["R", b.colorCounts[3]],
["G", b.colorCounts[4]],
["C", b.colorlessCount],
];
return (
<div key={b.label} className="mana-curve-col">
<span className="mana-curve-count">{b.count || ""}</span>
<div className="mana-curve-bar" style={{ height: `${(b.count / max) * 100}%` }}>
{segs.map(([code, count]) =>
count > 0 ? (
<span
key={code}
className={`mana-curve-seg ${code}`}
style={{ flexGrow: count }}
title={`${code === "C" ? "Colorless" : code}: ${count.toLocaleString()}`}
/>
) : null,
)}
</div>
<span className="mana-curve-label">{b.label}</span>
</div>
);
})}
</div>
);
}
// --- 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.
*
* With nothing persisted yet, the default itself is width-aware: List's wide
* multi-column table forces horizontal scrolling on a phone, so narrow
* screens start on Tiles (a reflowing grid, no horizontal scroll) instead.
* This only picks the *default* — once a mode is explicitly chosen it's
* remembered and used everywhere, same as before. */
export function useViewMode(key: string): [ViewMode, (m: ViewMode) => void] {
const [mode, setMode] = useState<ViewMode>(() => {
const saved = localStorage.getItem(key) as ViewMode | null;
if (saved) return saved;
return typeof window !== "undefined" && window.innerWidth <= 720 ? "tiles" : "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.
* Clicking the image opens a larger click-to-preview modal.
*/
export function CardTile({
card,
corner,
cornerLeft,
cornerBottomLeft,
footer,
selected,
rotated,
}: {
card: CardRef;
corner?: ReactNode;
/** Rendered top-left, e.g. a selection checkbox (see `selected`). */
cornerLeft?: ReactNode;
/** Rendered bottom-left, e.g. a secondary per-card action button. */
cornerBottomLeft?: ReactNode;
footer?: ReactNode;
/** Highlights the tile and keeps `cornerLeft` visible without hovering. */
selected?: boolean;
/** Caller wants Horizontal view — only actually rotates when `card.horizontal`
* is also true, so mixed results (e.g. a set with both Sieges and normal
* cards) don't spin the ones that are already right-side up. */
rotated?: boolean;
}) {
const { open } = useClickPreview();
const showRotated = !!rotated && card.horizontal;
return (
<div className={`tile ${selected ? "tile-selected" : ""} ${showRotated ? "tile-rotated" : ""}`}>
<div className={showRotated ? "tile-img-wrap-rotated" : "tile-img-wrap"}>
{card.image_uri ? (
showRotated ? (
<img
className="tile-img-rotated"
src={card.image_uri}
alt={card.name}
loading="lazy"
onClick={() => open(card)}
/>
) : (
<img
className="tile-img"
src={card.image_uri}
alt={card.name}
loading="lazy"
onClick={() => open(card)}
/>
)
) : (
<div className="tile-noimg">{card.name}</div>
)}
{cornerLeft && <div className="tile-corner-left">{cornerLeft}</div>}
{corner && <div className="tile-corner">{corner}</div>}
{cornerBottomLeft && <div className="tile-corner-bottom-left">{cornerBottomLeft}</div>}
{card.price_usd > 0 && (
<div className="tile-corner-bottom-right">
<span className="tile-badge">${card.price_usd.toFixed(2)}</span>
</div>
)}
</div>
{footer && <div className="tile-footer">{footer}</div>}
</div>
);
}
/** Clickable WUBRG toggles that edit a bitmask value. */
export function ColorToggles({
value,
onChange,
Add colorless filters, and make set listings filterable Colorless There was no way to ask for colorless cards — the colour toggles are a 5-bit WUBRG mask where 0 already means "don't filter by colour", leaving nothing to express "no colours at all". That's exactly what you need to find a colorless commander's card pool. Adds a "C" pip to both colour rows, matching how ColorPips already renders colorless cards, backed by two explicit booleans rather than a sixth bit — a filter mask and a card's real colour mask staying the same shape is worth keeping. The two rows mean different things and both are useful: on the commander row C filters colour *identity*, which is the Commander rule; on the includes row it filters the card's own colours. A card printed colorless can still carry a coloured identity through mana symbols in its text, and it's the identity that bars it from a colorless commander's deck. Set listings News > Releases had its own card path and so couldn't be filtered. A new set_code filter (matched against printings.set_code, already ingested) lets the set browser run through search_reference instead, so every criterion works there identically rather than being reimplemented over a live Scryfall response. That alone would have cost the ability to browse an upcoming set, since search_reference only knows downloaded card data — and upcoming sets are half the point of the tab. So the browser picks a source per set: downloaded data where it exists, with the full filter panel; otherwise Scryfall directly, with the panel hidden and a line explaining why. A panel that silently does nothing would be worse than no panel. The source is decided by an *unfiltered* probe. Keying off whether the current criteria match anything would flip a properly indexed set to "live" the moment a filter excluded everything, dropping the panel exactly when it was in use.
2026-08-10 22:34:46 -05:00
colorless,
onColorlessChange,
colorlessTitle,
}: {
value: number;
onChange: (mask: number) => void;
Add colorless filters, and make set listings filterable Colorless There was no way to ask for colorless cards — the colour toggles are a 5-bit WUBRG mask where 0 already means "don't filter by colour", leaving nothing to express "no colours at all". That's exactly what you need to find a colorless commander's card pool. Adds a "C" pip to both colour rows, matching how ColorPips already renders colorless cards, backed by two explicit booleans rather than a sixth bit — a filter mask and a card's real colour mask staying the same shape is worth keeping. The two rows mean different things and both are useful: on the commander row C filters colour *identity*, which is the Commander rule; on the includes row it filters the card's own colours. A card printed colorless can still carry a coloured identity through mana symbols in its text, and it's the identity that bars it from a colorless commander's deck. Set listings News > Releases had its own card path and so couldn't be filtered. A new set_code filter (matched against printings.set_code, already ingested) lets the set browser run through search_reference instead, so every criterion works there identically rather than being reimplemented over a live Scryfall response. That alone would have cost the ability to browse an upcoming set, since search_reference only knows downloaded card data — and upcoming sets are half the point of the tab. So the browser picks a source per set: downloaded data where it exists, with the full filter panel; otherwise Scryfall directly, with the panel hidden and a line explaining why. A panel that silently does nothing would be worse than no panel. The source is decided by an *unfiltered* probe. Keying off whether the current criteria match anything would flip a properly indexed set to "live" the moment a filter excluded everything, dropping the panel exactly when it was in use.
2026-08-10 22:34:46 -05:00
/** Renders a "C" pip alongside the colours when provided.
*
* Colourlessness can't live in the bitmask: 0 there already means "don't
* filter by colour", so there'd be no way to say "no colours at all" — which
* is exactly what you need to find a colourless commander's card pool. */
colorless?: boolean;
onColorlessChange?: (v: boolean) => void;
colorlessTitle?: string;
}) {
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>
))}
Add colorless filters, and make set listings filterable Colorless There was no way to ask for colorless cards — the colour toggles are a 5-bit WUBRG mask where 0 already means "don't filter by colour", leaving nothing to express "no colours at all". That's exactly what you need to find a colorless commander's card pool. Adds a "C" pip to both colour rows, matching how ColorPips already renders colorless cards, backed by two explicit booleans rather than a sixth bit — a filter mask and a card's real colour mask staying the same shape is worth keeping. The two rows mean different things and both are useful: on the commander row C filters colour *identity*, which is the Commander rule; on the includes row it filters the card's own colours. A card printed colorless can still carry a coloured identity through mana symbols in its text, and it's the identity that bars it from a colorless commander's deck. Set listings News > Releases had its own card path and so couldn't be filtered. A new set_code filter (matched against printings.set_code, already ingested) lets the set browser run through search_reference instead, so every criterion works there identically rather than being reimplemented over a live Scryfall response. That alone would have cost the ability to browse an upcoming set, since search_reference only knows downloaded card data — and upcoming sets are half the point of the tab. So the browser picks a source per set: downloaded data where it exists, with the full filter panel; otherwise Scryfall directly, with the panel hidden and a line explaining why. A panel that silently does nothing would be worse than no panel. The source is decided by an *unfiltered* probe. Keying off whether the current criteria match anything would flip a properly indexed set to "live" the moment a filter excluded everything, dropping the panel exactly when it was in use.
2026-08-10 22:34:46 -05:00
{onColorlessChange && (
<span
title={colorlessTitle ?? "Colorless"}
className={`pip C ${colorless ? "on" : ""}`}
onClick={() => onColorlessChange(!colorless)}
>
C
</span>
)}
</span>
);
}
Rework library sync around a JSON document; add card filters Sync The previous design put the merge inside SQL: timestamp-stamping triggers, a tombstone table, per-device SQLite snapshots, a device id in the database, and row merges written as correlated-subquery UPDATEs. All of that schema was inherited by desktop, and the merge was very hard to verify. Replace it with a single JSON document plus a three-way merge in plain Rust. Storing the last-read remote document as the merge base makes "who changed what" and "what was deleted" derivable, which removes the tombstone table, the updated_at columns and all eight triggers. It also makes a single shared file safe: if a publish is lost the remote is unchanged, so the next merge re-detects the local edits and republishes rather than reading their absence as a deletion. That removes the need for per-device files and for device identity entirely. Schema impact drops to one column, decks.uuid. Conflicts resolve to "theirs", which is required for convergence rather than chosen for taste — preferring ours has each device re-asserting its own value forever. Every such choice is reported to the user. SQLite becomes invisible: the working copy lives in the app data dir and the user only ever names a .json file. There is always a document, defaulting to private storage, so edits are never stranded with nowhere to be saved. Pointing at a file that doesn't exist yet moves the current library there; pointing at one that does adopts it. A converter brings a pre-document library.db across. Android reaches the folder through the Storage Access Framework rather than MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all. Filters Track card origin in reference.db: Universes Beyond, digital-only and Un-set cards, plus numeric power/toughness. Each origin flag is derived as "every printing", not "any printing" — ordinary cards get reprinted into those sets, and Lightning Bolt appearing in Marvel Commander must not hide it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range filters instead of being compared as zero. Also wire up filters over data already present: price range, format legality (restricted counts as legal) and "can be a commander". All of these degrade silently against an older reference.db rather than failing every query. The shared criteria live in one component used by both filter panels, as a compact row of icon toggles. Card Search gains the Library tab's bulk selection: checkboxes in both views, select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
// --- Shared card criteria -------------------------------------------------
/** Ringed planet — a card from another world/property (Universes Beyond). */
const ICON_CROSSOVER = icon(
<>
<circle cx="12" cy="11" r="6" />
<ellipse cx="12" cy="11" rx="11" ry="4" transform="rotate(-20 12 11)" />
</>,
);
/** Monitor — exists only on Arena/MTGO. */
const ICON_SCREEN = icon(
<>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M9 20h6" />
<path d="M12 16v4" />
</>,
);
/** Smiley — the joke sets. */
const ICON_SMILEY = icon(
<>
<circle cx="12" cy="12" r="9" />
<path d="M8.5 14.5a4.5 4.5 0 0 0 7 0" />
<line x1="9" y1="9.5" x2="9.01" y2="9.5" />
<line x1="15" y1="9.5" x2="15.01" y2="9.5" />
</>,
);
/** Crown — can head a Commander deck. */
const ICON_CROWN = icon(
<>
<path d="M3 8l3.5 3L12 5l5.5 6L21 8l-1.5 10h-15z" />
<line x1="5" y1="21" x2="19" y2="21" />
</>,
);
/** An empty rules box — a vanilla creature, all body and no text. */
const ICON_BLANK_BOX = icon(
<>
<rect x="3" y="4" width="18" height="16" rx="2" />
<line x1="3" y1="9" x2="21" y2="9" />
</>,
);
/** An open package — cards you don't have a copy of yet. */
const ICON_PACKAGE = icon(
<>
<path d="M21 8l-9-5-9 5 9 5 9-5z" />
<path d="M3 8v8l9 5 9-5V8" />
<path d="M12 13v8" />
</>,
);
Rework library sync around a JSON document; add card filters Sync The previous design put the merge inside SQL: timestamp-stamping triggers, a tombstone table, per-device SQLite snapshots, a device id in the database, and row merges written as correlated-subquery UPDATEs. All of that schema was inherited by desktop, and the merge was very hard to verify. Replace it with a single JSON document plus a three-way merge in plain Rust. Storing the last-read remote document as the merge base makes "who changed what" and "what was deleted" derivable, which removes the tombstone table, the updated_at columns and all eight triggers. It also makes a single shared file safe: if a publish is lost the remote is unchanged, so the next merge re-detects the local edits and republishes rather than reading their absence as a deletion. That removes the need for per-device files and for device identity entirely. Schema impact drops to one column, decks.uuid. Conflicts resolve to "theirs", which is required for convergence rather than chosen for taste — preferring ours has each device re-asserting its own value forever. Every such choice is reported to the user. SQLite becomes invisible: the working copy lives in the app data dir and the user only ever names a .json file. There is always a document, defaulting to private storage, so edits are never stranded with nowhere to be saved. Pointing at a file that doesn't exist yet moves the current library there; pointing at one that does adopts it. A converter brings a pre-document library.db across. Android reaches the folder through the Storage Access Framework rather than MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all. Filters Track card origin in reference.db: Universes Beyond, digital-only and Un-set cards, plus numeric power/toughness. Each origin flag is derived as "every printing", not "any printing" — ordinary cards get reprinted into those sets, and Lightning Bolt appearing in Marvel Commander must not hide it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range filters instead of being compared as zero. Also wire up filters over data already present: price range, format legality (restricted counts as legal) and "can be a commander". All of these degrade silently against an older reference.db rather than failing every query. The shared criteria live in one component used by both filter panels, as a compact row of icon toggles. Card Search gains the Library tab's bulk selection: checkboxes in both views, select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
/**
* Card-origin and numeric criteria shared by the Library and Card Search
* filter panels. Kept here rather than duplicated in both views, since they
* must stay in step: a filter that behaves differently between browsing what
* you own and searching everything would be a bug, not a feature.
*
* Numeric fields are strings so the inputs can be empty (meaning "no bound")
* rather than coercing a blank to 0.
*/
export type CardCriteria = {
excludeUb: boolean;
excludeDigital: boolean;
excludeFunny: boolean;
priceMin: string;
priceMax: string;
legalIn: string;
canBeCommander: boolean;
vanillaOnly: boolean;
notOwned: boolean;
Rework library sync around a JSON document; add card filters Sync The previous design put the merge inside SQL: timestamp-stamping triggers, a tombstone table, per-device SQLite snapshots, a device id in the database, and row merges written as correlated-subquery UPDATEs. All of that schema was inherited by desktop, and the merge was very hard to verify. Replace it with a single JSON document plus a three-way merge in plain Rust. Storing the last-read remote document as the merge base makes "who changed what" and "what was deleted" derivable, which removes the tombstone table, the updated_at columns and all eight triggers. It also makes a single shared file safe: if a publish is lost the remote is unchanged, so the next merge re-detects the local edits and republishes rather than reading their absence as a deletion. That removes the need for per-device files and for device identity entirely. Schema impact drops to one column, decks.uuid. Conflicts resolve to "theirs", which is required for convergence rather than chosen for taste — preferring ours has each device re-asserting its own value forever. Every such choice is reported to the user. SQLite becomes invisible: the working copy lives in the app data dir and the user only ever names a .json file. There is always a document, defaulting to private storage, so edits are never stranded with nowhere to be saved. Pointing at a file that doesn't exist yet moves the current library there; pointing at one that does adopts it. A converter brings a pre-document library.db across. Android reaches the folder through the Storage Access Framework rather than MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all. Filters Track card origin in reference.db: Universes Beyond, digital-only and Un-set cards, plus numeric power/toughness. Each origin flag is derived as "every printing", not "any printing" — ordinary cards get reprinted into those sets, and Lightning Bolt appearing in Marvel Commander must not hide it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range filters instead of being compared as zero. Also wire up filters over data already present: price range, format legality (restricted counts as legal) and "can be a commander". All of these degrade silently against an older reference.db rather than failing every query. The shared criteria live in one component used by both filter panels, as a compact row of icon toggles. Card Search gains the Library tab's bulk selection: checkboxes in both views, select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
powerMin: string;
powerMax: string;
toughMin: string;
toughMax: string;
};
export const EMPTY_CRITERIA: CardCriteria = {
excludeUb: false,
excludeDigital: false,
excludeFunny: false,
priceMin: "",
priceMax: "",
legalIn: "",
canBeCommander: false,
vanillaOnly: false,
notOwned: false,
Rework library sync around a JSON document; add card filters Sync The previous design put the merge inside SQL: timestamp-stamping triggers, a tombstone table, per-device SQLite snapshots, a device id in the database, and row merges written as correlated-subquery UPDATEs. All of that schema was inherited by desktop, and the merge was very hard to verify. Replace it with a single JSON document plus a three-way merge in plain Rust. Storing the last-read remote document as the merge base makes "who changed what" and "what was deleted" derivable, which removes the tombstone table, the updated_at columns and all eight triggers. It also makes a single shared file safe: if a publish is lost the remote is unchanged, so the next merge re-detects the local edits and republishes rather than reading their absence as a deletion. That removes the need for per-device files and for device identity entirely. Schema impact drops to one column, decks.uuid. Conflicts resolve to "theirs", which is required for convergence rather than chosen for taste — preferring ours has each device re-asserting its own value forever. Every such choice is reported to the user. SQLite becomes invisible: the working copy lives in the app data dir and the user only ever names a .json file. There is always a document, defaulting to private storage, so edits are never stranded with nowhere to be saved. Pointing at a file that doesn't exist yet moves the current library there; pointing at one that does adopts it. A converter brings a pre-document library.db across. Android reaches the folder through the Storage Access Framework rather than MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all. Filters Track card origin in reference.db: Universes Beyond, digital-only and Un-set cards, plus numeric power/toughness. Each origin flag is derived as "every printing", not "any printing" — ordinary cards get reprinted into those sets, and Lightning Bolt appearing in Marvel Commander must not hide it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range filters instead of being compared as zero. Also wire up filters over data already present: price range, format legality (restricted counts as legal) and "can be a commander". All of these degrade silently against an older reference.db rather than failing every query. The shared criteria live in one component used by both filter panels, as a compact row of icon toggles. Card Search gains the Library tab's bulk selection: checkboxes in both views, select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
powerMin: "",
powerMax: "",
toughMin: "",
toughMax: "",
};
export function useCardCriteria(key: string): [CardCriteria, (patch: Partial<CardCriteria>) => void] {
const [value, setValue] = usePersistedState<CardCriteria>(key, EMPTY_CRITERIA);
// Merged over the defaults so a stored object written before a field existed
// doesn't leave it undefined.
const merged = { ...EMPTY_CRITERIA, ...value };
const patch = useCallback(
(p: Partial<CardCriteria>) => setValue({ ...EMPTY_CRITERIA, ...value, ...p }),
[setValue, value],
);
return [merged, patch];
}
const num = (s: string): number | null => (s.trim() === "" ? null : Number(s));
/** Fold criteria into the filter sent to the backend. */
export function applyCardCriteria(c: CardCriteria): Partial<LibraryFilter> {
return {
exclude_universes_beyond: c.excludeUb,
exclude_digital_only: c.excludeDigital,
exclude_funny: c.excludeFunny,
price_min: num(c.priceMin),
price_max: num(c.priceMax),
legal_in: c.legalIn || null,
can_be_commander: c.canBeCommander,
vanilla_only: c.vanillaOnly,
not_owned: c.notOwned,
Rework library sync around a JSON document; add card filters Sync The previous design put the merge inside SQL: timestamp-stamping triggers, a tombstone table, per-device SQLite snapshots, a device id in the database, and row merges written as correlated-subquery UPDATEs. All of that schema was inherited by desktop, and the merge was very hard to verify. Replace it with a single JSON document plus a three-way merge in plain Rust. Storing the last-read remote document as the merge base makes "who changed what" and "what was deleted" derivable, which removes the tombstone table, the updated_at columns and all eight triggers. It also makes a single shared file safe: if a publish is lost the remote is unchanged, so the next merge re-detects the local edits and republishes rather than reading their absence as a deletion. That removes the need for per-device files and for device identity entirely. Schema impact drops to one column, decks.uuid. Conflicts resolve to "theirs", which is required for convergence rather than chosen for taste — preferring ours has each device re-asserting its own value forever. Every such choice is reported to the user. SQLite becomes invisible: the working copy lives in the app data dir and the user only ever names a .json file. There is always a document, defaulting to private storage, so edits are never stranded with nowhere to be saved. Pointing at a file that doesn't exist yet moves the current library there; pointing at one that does adopts it. A converter brings a pre-document library.db across. Android reaches the folder through the Storage Access Framework rather than MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all. Filters Track card origin in reference.db: Universes Beyond, digital-only and Un-set cards, plus numeric power/toughness. Each origin flag is derived as "every printing", not "any printing" — ordinary cards get reprinted into those sets, and Lightning Bolt appearing in Marvel Commander must not hide it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range filters instead of being compared as zero. Also wire up filters over data already present: price range, format legality (restricted counts as legal) and "can be a commander". All of these degrade silently against an older reference.db rather than failing every query. The shared criteria live in one component used by both filter panels, as a compact row of icon toggles. Card Search gains the Library tab's bulk selection: checkboxes in both views, select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
power_min: num(c.powerMin),
power_max: num(c.powerMax),
toughness_min: num(c.toughMin),
toughness_max: num(c.toughMax),
};
}
/**
* Whether these criteria amount to a search in their own right.
*
* The three "exclude" toggles deliberately don't count: they refine a result
* set rather than select one, so ticking "hide Un-sets" on its own shouldn't
* make Card Search pull every card in the database. Choosing a format, a
* power range, or "not owned" is a real query, and does.
Rework library sync around a JSON document; add card filters Sync The previous design put the merge inside SQL: timestamp-stamping triggers, a tombstone table, per-device SQLite snapshots, a device id in the database, and row merges written as correlated-subquery UPDATEs. All of that schema was inherited by desktop, and the merge was very hard to verify. Replace it with a single JSON document plus a three-way merge in plain Rust. Storing the last-read remote document as the merge base makes "who changed what" and "what was deleted" derivable, which removes the tombstone table, the updated_at columns and all eight triggers. It also makes a single shared file safe: if a publish is lost the remote is unchanged, so the next merge re-detects the local edits and republishes rather than reading their absence as a deletion. That removes the need for per-device files and for device identity entirely. Schema impact drops to one column, decks.uuid. Conflicts resolve to "theirs", which is required for convergence rather than chosen for taste — preferring ours has each device re-asserting its own value forever. Every such choice is reported to the user. SQLite becomes invisible: the working copy lives in the app data dir and the user only ever names a .json file. There is always a document, defaulting to private storage, so edits are never stranded with nowhere to be saved. Pointing at a file that doesn't exist yet moves the current library there; pointing at one that does adopts it. A converter brings a pre-document library.db across. Android reaches the folder through the Storage Access Framework rather than MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all. Filters Track card origin in reference.db: Universes Beyond, digital-only and Un-set cards, plus numeric power/toughness. Each origin flag is derived as "every printing", not "any printing" — ordinary cards get reprinted into those sets, and Lightning Bolt appearing in Marvel Commander must not hide it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range filters instead of being compared as zero. Also wire up filters over data already present: price range, format legality (restricted counts as legal) and "can be a commander". All of these degrade silently against an older reference.db rather than failing every query. The shared criteria live in one component used by both filter panels, as a compact row of icon toggles. Card Search gains the Library tab's bulk selection: checkboxes in both views, select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
*/
export function criteriaActive(c: CardCriteria): boolean {
return (
c.canBeCommander ||
c.vanillaOnly ||
c.notOwned ||
Rework library sync around a JSON document; add card filters Sync The previous design put the merge inside SQL: timestamp-stamping triggers, a tombstone table, per-device SQLite snapshots, a device id in the database, and row merges written as correlated-subquery UPDATEs. All of that schema was inherited by desktop, and the merge was very hard to verify. Replace it with a single JSON document plus a three-way merge in plain Rust. Storing the last-read remote document as the merge base makes "who changed what" and "what was deleted" derivable, which removes the tombstone table, the updated_at columns and all eight triggers. It also makes a single shared file safe: if a publish is lost the remote is unchanged, so the next merge re-detects the local edits and republishes rather than reading their absence as a deletion. That removes the need for per-device files and for device identity entirely. Schema impact drops to one column, decks.uuid. Conflicts resolve to "theirs", which is required for convergence rather than chosen for taste — preferring ours has each device re-asserting its own value forever. Every such choice is reported to the user. SQLite becomes invisible: the working copy lives in the app data dir and the user only ever names a .json file. There is always a document, defaulting to private storage, so edits are never stranded with nowhere to be saved. Pointing at a file that doesn't exist yet moves the current library there; pointing at one that does adopts it. A converter brings a pre-document library.db across. Android reaches the folder through the Storage Access Framework rather than MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all. Filters Track card origin in reference.db: Universes Beyond, digital-only and Un-set cards, plus numeric power/toughness. Each origin flag is derived as "every printing", not "any printing" — ordinary cards get reprinted into those sets, and Lightning Bolt appearing in Marvel Commander must not hide it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range filters instead of being compared as zero. Also wire up filters over data already present: price range, format legality (restricted counts as legal) and "can be a commander". All of these degrade silently against an older reference.db rather than failing every query. The shared criteria live in one component used by both filter panels, as a compact row of icon toggles. Card Search gains the Library tab's bulk selection: checkboxes in both views, select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
c.legalIn !== "" ||
[c.priceMin, c.priceMax, c.powerMin, c.powerMax, c.toughMin, c.toughMax].some(
(v) => v.trim() !== "",
)
);
}
function RangePair({
label,
min,
max,
onMin,
onMax,
step,
prefix,
}: {
label: string;
min: string;
max: string;
onMin: (v: string) => void;
onMax: (v: string) => void;
step?: string;
prefix?: string;
}) {
return (
<div className="field">
<label>{label}</label>
<div className="row">
{prefix && <span className="muted">{prefix}</span>}
<input
type="number"
step={step}
style={{ width: 70 }}
placeholder="min"
value={min}
onChange={(e) => onMin(e.target.value)}
/>
<span className="muted">–</span>
{prefix && <span className="muted">{prefix}</span>}
<input
type="number"
step={step}
style={{ width: 70 }}
placeholder="max"
value={max}
onChange={(e) => onMax(e.target.value)}
/>
</div>
</div>
);
}
export function CardCriteriaFields({
value: c,
onChange,
hideNotOwned,
Rework library sync around a JSON document; add card filters Sync The previous design put the merge inside SQL: timestamp-stamping triggers, a tombstone table, per-device SQLite snapshots, a device id in the database, and row merges written as correlated-subquery UPDATEs. All of that schema was inherited by desktop, and the merge was very hard to verify. Replace it with a single JSON document plus a three-way merge in plain Rust. Storing the last-read remote document as the merge base makes "who changed what" and "what was deleted" derivable, which removes the tombstone table, the updated_at columns and all eight triggers. It also makes a single shared file safe: if a publish is lost the remote is unchanged, so the next merge re-detects the local edits and republishes rather than reading their absence as a deletion. That removes the need for per-device files and for device identity entirely. Schema impact drops to one column, decks.uuid. Conflicts resolve to "theirs", which is required for convergence rather than chosen for taste — preferring ours has each device re-asserting its own value forever. Every such choice is reported to the user. SQLite becomes invisible: the working copy lives in the app data dir and the user only ever names a .json file. There is always a document, defaulting to private storage, so edits are never stranded with nowhere to be saved. Pointing at a file that doesn't exist yet moves the current library there; pointing at one that does adopts it. A converter brings a pre-document library.db across. Android reaches the folder through the Storage Access Framework rather than MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all. Filters Track card origin in reference.db: Universes Beyond, digital-only and Un-set cards, plus numeric power/toughness. Each origin flag is derived as "every printing", not "any printing" — ordinary cards get reprinted into those sets, and Lightning Bolt appearing in Marvel Commander must not hide it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range filters instead of being compared as zero. Also wire up filters over data already present: price range, format legality (restricted counts as legal) and "can be a commander". All of these degrade silently against an older reference.db rather than failing every query. The shared criteria live in one component used by both filter panels, as a compact row of icon toggles. Card Search gains the Library tab's bulk selection: checkboxes in both views, select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
}: {
value: CardCriteria;
onChange: (patch: Partial<CardCriteria>) => void;
/** Omit the "Not owned" toggle — for the Library tab, which already shows
* nothing but owned cards, so the filter would just be a no-op there. */
hideNotOwned?: boolean;
Rework library sync around a JSON document; add card filters Sync The previous design put the merge inside SQL: timestamp-stamping triggers, a tombstone table, per-device SQLite snapshots, a device id in the database, and row merges written as correlated-subquery UPDATEs. All of that schema was inherited by desktop, and the merge was very hard to verify. Replace it with a single JSON document plus a three-way merge in plain Rust. Storing the last-read remote document as the merge base makes "who changed what" and "what was deleted" derivable, which removes the tombstone table, the updated_at columns and all eight triggers. It also makes a single shared file safe: if a publish is lost the remote is unchanged, so the next merge re-detects the local edits and republishes rather than reading their absence as a deletion. That removes the need for per-device files and for device identity entirely. Schema impact drops to one column, decks.uuid. Conflicts resolve to "theirs", which is required for convergence rather than chosen for taste — preferring ours has each device re-asserting its own value forever. Every such choice is reported to the user. SQLite becomes invisible: the working copy lives in the app data dir and the user only ever names a .json file. There is always a document, defaulting to private storage, so edits are never stranded with nowhere to be saved. Pointing at a file that doesn't exist yet moves the current library there; pointing at one that does adopts it. A converter brings a pre-document library.db across. Android reaches the folder through the Storage Access Framework rather than MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all. Filters Track card origin in reference.db: Universes Beyond, digital-only and Un-set cards, plus numeric power/toughness. Each origin flag is derived as "every printing", not "any printing" — ordinary cards get reprinted into those sets, and Lightning Bolt appearing in Marvel Commander must not hide it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range filters instead of being compared as zero. Also wire up filters over data already present: price range, format legality (restricted counts as legal) and "can be a commander". All of these degrade silently against an older reference.db rather than failing every query. The shared criteria live in one component used by both filter panels, as a compact row of icon toggles. Card Search gains the Library tab's bulk selection: checkboxes in both views, select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
}) {
return (
<>
<div className="field">
<label>Legal in</label>
<select value={c.legalIn} onChange={(e) => onChange({ legalIn: e.target.value })}>
<option value="">Any format</option>
{LEGALITY_FORMATS.map((f) => (
<option key={f.key} value={f.key}>
{f.label}
</option>
))}
</select>
</div>
<RangePair
label="Price (USD)"
prefix="$"
step="0.01"
min={c.priceMin}
max={c.priceMax}
onMin={(v) => onChange({ priceMin: v })}
onMax={(v) => onChange({ priceMax: v })}
/>
<RangePair
label="Power"
min={c.powerMin}
max={c.powerMax}
onMin={(v) => onChange({ powerMin: v })}
onMax={(v) => onChange({ powerMax: v })}
/>
<RangePair
label="Toughness"
min={c.toughMin}
max={c.toughMax}
onMin={(v) => onChange({ toughMin: v })}
onMax={(v) => onChange({ toughMax: v })}
/>
<div className="field">
<label>Quick filters</label>
<div className="icon-toggles">
{/* Exclusions read as "no X" — tinted with the danger colour when
active, so removing things looks different from selecting them. */}
<IconToggle
icon={ICON_CROSSOVER}
label="Hide Universes Beyond"
hint="Cards that only exist as another property — Lord of the Rings, Warhammer 40,000, Marvel. Normal cards reprinted in those sets stay."
exclude
on={c.excludeUb}
onToggle={() => onChange({ excludeUb: !c.excludeUb })}
/>
<IconToggle
icon={ICON_SCREEN}
label="Hide digital-only"
hint="Arena and MTGO cards such as Alchemy rebalances, which don't exist on paper."
exclude
on={c.excludeDigital}
onToggle={() => onChange({ excludeDigital: !c.excludeDigital })}
/>
<IconToggle
icon={ICON_SMILEY}
label="Hide Un-set cards"
hint="Unglued, Unhinged, Unstable and Unfinity joke cards."
exclude
on={c.excludeFunny}
onToggle={() => onChange({ excludeFunny: !c.excludeFunny })}
/>
<span className="icon-toggles-sep" />
<IconToggle
icon={ICON_CROWN}
label="Only cards that can be a commander"
hint="Legendary creatures, plus cards whose rules text says they can be your commander."
on={c.canBeCommander}
onToggle={() => onChange({ canBeCommander: !c.canBeCommander })}
/>
<IconToggle
icon={ICON_BLANK_BOX}
label="Only vanilla creatures"
hint="Creatures with an empty rules box. Includes Adventure and Prepared cards whose creature half has no abilities."
on={c.vanillaOnly}
onToggle={() => onChange({ vanillaOnly: !c.vanillaOnly })}
/>
{!hideNotOwned && (
<IconToggle
icon={ICON_PACKAGE}
label="Not owned"
hint="Only cards you don't have a single copy of yet."
on={c.notOwned}
onToggle={() => onChange({ notOwned: !c.notOwned })}
/>
)}
Rework library sync around a JSON document; add card filters Sync The previous design put the merge inside SQL: timestamp-stamping triggers, a tombstone table, per-device SQLite snapshots, a device id in the database, and row merges written as correlated-subquery UPDATEs. All of that schema was inherited by desktop, and the merge was very hard to verify. Replace it with a single JSON document plus a three-way merge in plain Rust. Storing the last-read remote document as the merge base makes "who changed what" and "what was deleted" derivable, which removes the tombstone table, the updated_at columns and all eight triggers. It also makes a single shared file safe: if a publish is lost the remote is unchanged, so the next merge re-detects the local edits and republishes rather than reading their absence as a deletion. That removes the need for per-device files and for device identity entirely. Schema impact drops to one column, decks.uuid. Conflicts resolve to "theirs", which is required for convergence rather than chosen for taste — preferring ours has each device re-asserting its own value forever. Every such choice is reported to the user. SQLite becomes invisible: the working copy lives in the app data dir and the user only ever names a .json file. There is always a document, defaulting to private storage, so edits are never stranded with nowhere to be saved. Pointing at a file that doesn't exist yet moves the current library there; pointing at one that does adopts it. A converter brings a pre-document library.db across. Android reaches the folder through the Storage Access Framework rather than MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all. Filters Track card origin in reference.db: Universes Beyond, digital-only and Un-set cards, plus numeric power/toughness. Each origin flag is derived as "every printing", not "any printing" — ordinary cards get reprinted into those sets, and Lightning Bolt appearing in Marvel Commander must not hide it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range filters instead of being compared as zero. Also wire up filters over data already present: price range, format legality (restricted counts as legal) and "can be a commander". All of these degrade silently against an older reference.db rather than failing every query. The shared criteria live in one component used by both filter panels, as a compact row of icon toggles. Card Search gains the Library tab's bulk selection: checkboxes in both views, select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
</div>
</div>
</>
);
}
/**
* A single icon filter toggle, styled like [`ColorToggles`]' pips: dim when
* off, lit with a ring when on. `exclude` marks a filter that *removes* cards
* rather than selecting them, which is worth distinguishing visually since
* both live in the same row.
*/
function IconToggle({
icon,
label,
hint,
on,
exclude,
onToggle,
}: {
icon: ReactNode;
label: string;
hint: string;
on: boolean;
exclude?: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
className={`icon-toggle ${exclude ? "exclude" : ""} ${on ? "on" : ""}`}
// Title carries the full explanation the old inline hint text did — the
// whole point of this row is to stop that prose eating the panel.
title={`${label}\n\n${hint}`}
aria-label={label}
aria-pressed={on}
onClick={onToggle}
>
{icon}
{/* A slash over the glyph when an exclusion is active, so "hidden" is
legible without reading the tooltip. */}
{exclude && on && <span className="icon-toggle-slash" />}
</button>
);
}
Replace folder sync with an HTTP sync server, keeping the app offline-first Folder sync left too much to the user's sync tool: platform file access (the Android SAF plugin), choosing the document, merging conflict copies — and its merge base could fall out of step with the document (absorbing conflict copies, "forget", picking a new Android folder), reading every difference as a deletion. The new mtgc-server crate stores one versioned library per user and accepts a write only when it names the version it replaces. It never merges: devices still do that with merge3, through a new sync cycle over a `Remote` trait in mtgc-core. The base advances only on knowledge — to what was read, or to a write the server confirmed — and is stored with the server and account it belongs to, so a different account starts from an empty ancestor instead of deleting. The working copy is only rewritten under the app-state lock, and sync commands now run off the main thread. Sign-in happens in the browser on the server's pages, via the owner's OIDC provider and/or local username/password accounts (Argon2id, managed with `mtgc-server user ...`), and returns a per-device revocable token to the app by PKCE-protected code: a loopback port on desktop, the mtgc:// scheme on Android. The Info tab gains sign-in, the first-sync combine/replace choice, the account's devices, and JSON export/import; the top bar shows sync state. Includes a Dockerfile, Compose/Caddy example, and scripts to build and push ${DOCKER_REGISTRY}/mtgc/sync-server.
2026-10-08 22:32:08 -05:00
/** Compact relative time, e.g. "2h ago" — for "updated"/"synced" stamps. */
export 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`;
}