import {
createContext,
forwardRef,
ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { api, CardRef, COLORS, maskToLetters } from "./api";
// --- Icons ------------------------------------------------------------
const icon = (children: ReactNode) => (
{children}
);
const ICON_TRASH = icon(
<>
>,
);
const ICON_COPY = icon(
<>
>,
);
const ICON_PENCIL = icon( );
const ICON_CLOSE = icon(
<>
>,
);
/**
* A small round icon-only button, used consistently for lightweight actions
* (rename, duplicate, delete, dismiss, close) throughout the app.
* - `overlay`: dark translucent variant for sitting on top of card art.
* - `danger`: tints red on hover, for destructive actions.
*/
export function IconButton({
icon,
label,
onClick,
overlay,
danger,
disabled,
}: {
icon: ReactNode;
label: string;
onClick: (e: React.MouseEvent) => void;
overlay?: boolean;
danger?: boolean;
disabled?: boolean;
}) {
return (
{icon}
);
}
export const Icons = {
trash: ICON_TRASH,
copy: ICON_COPY,
pencil: ICON_PENCIL,
close: ICON_CLOSE,
};
// --- Dialogs ------------------------------------------------------------
/**
* A centered modal dialog (backdrop + card) shared by [`ConfirmDialog`] and
* [`PromptDialog`]. Dismisses on Escape or backdrop click.
*/
function DialogFrame({
title,
children,
onCancel,
}: {
title: string;
children: ReactNode;
onCancel: () => void;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onCancel();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
return (
e.stopPropagation()}>
{title}
{children}
);
}
/** A confirm/cancel dialog for destructive or otherwise consequential actions. */
export function ConfirmDialog({
title,
message,
confirmLabel = "Confirm",
danger,
onConfirm,
onCancel,
}: {
title: string;
message: ReactNode;
confirmLabel?: string;
danger?: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
{message}
Cancel
{confirmLabel}
);
}
/** A single-text-field dialog (rename, duplicate-as, etc.), Enter to confirm. */
export function PromptDialog({
title,
label,
defaultValue,
confirmLabel = "Save",
error,
onConfirm,
onCancel,
}: {
title: string;
label: string;
defaultValue: string;
confirmLabel?: string;
error?: string | null;
onConfirm: (value: string) => void;
onCancel: () => void;
}) {
const [value, setValue] = useState(defaultValue);
const inputRef = useRef(null);
useEffect(() => inputRef.current?.select(), []);
const confirm = () => {
const v = value.trim();
if (v) onConfirm(v);
};
return (
{label}
setValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && confirm()}
/>
{error && {error}
}
Cancel
{confirmLabel}
);
}
/**
* 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 (
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 (
β
{value}
+
);
}
/** 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 (
C
);
}
return (
{letters.split("").map((c) => (
{c}
))}
);
}
/**
* 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 (
π
);
}
return (
{
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 ` ` 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(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 (
<>
{
inputRef.current = el;
if (typeof forwardedRef === "function") forwardedRef(el);
else if (forwardedRef) (forwardedRef as React.MutableRefObject).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 && (
{results.map((c, i) => (
setSel(i)}
onClick={() => pick(c)}
>
{c.name}
{c.type_line}
))}
)}
>
);
});
/**
* 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 (
{values.map((v) => (
{v}
removeValue(v)}
title={`Remove ${v}`}
>
Γ
))}
setText(e.target.value)}
onKeyDown={onKeyDown}
placeholder={values.length === 0 ? placeholder : ""}
/>
{filtered.length > 0 && (
{filtered.map((s, i) => (
setSel(i)}
onClick={() => addValue(s)}
>
{s}
))}
)}
);
}
// --- Hover + click preview -------------------------------------------------
interface HoverApi {
enter: (card: CardRef, e: React.MouseEvent) => void;
move: (e: React.MouseEvent) => void;
leave: () => void;
}
interface ClickApi {
open: (card: CardRef) => void;
}
const HoverCtx = createContext(null);
const ClickCtx = createContext(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: CardRef; 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: CardRef) => {
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]);
return (
{children}
{!clicked && hover && }
{clicked && }
);
}
/** 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: CardRef) => 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 }) {
// Prefer the right of the cursor; flip left if it would overflow.
let left = x + GAP;
if (left + PREVIEW_W + PAD > window.innerWidth) left = x - PREVIEW_W - GAP;
const top = Math.max(PAD, Math.min(y - PREVIEW_H / 2, window.innerHeight - PREVIEW_H - PAD));
return (
);
}
function ClickPreviewModal({
state,
onClose,
onFlip,
}: {
state: { card: CardRef; showBack: boolean };
onClose: () => void;
onFlip: () => void;
}) {
const { card, showBack } = state;
const hasBack = !!card.back_image_uri;
// Both faces are always in the DOM when the card has one; a wide-viewport
// media query shows them side by side (no flip needed), while a narrow one
// shows only the "active" face and the flip button toggles between them.
return (
e.stopPropagation()}
>
{hasBack && (
)}
{hasBack && (
β² Flip
)}
);
}
// --- 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";
}
function firstSubtype(typeLine: string): string {
const parts = typeLine.split("β");
if (parts.length < 2) return "No subtype";
return parts[1].trim().split(/\s+/)[0] || "No 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(
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"
? firstSubtype(c.type_line)
: mode === "cmc"
? `Mana value ${c.mana_value}`
: colorLabel(c.color_identity);
const map = new Map();
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") {
const rank = (l: string) => {
const i = TYPE_PRIORITY.indexOf(l);
return i === -1 ? TYPE_PRIORITY.length : i;
};
groups.sort((a, b) => rank(a.label) - rank(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 (
Group by
onChange(e.target.value as GroupMode)}>
{GROUP_OPTIONS.map((o) => (
{o.label}
))}
);
}
// --- 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 (
{value}
{label}
);
}
/** 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 (
{segs.map(([code, count]) =>
count > 0 ? (
) : null,
)}
);
}
/** 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 (
{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 (
{b.count || ""}
{segs.map(([code, count]) =>
count > 0 ? (
) : null,
)}
{b.label}
);
})}
);
}
// --- Persistence ----------------------------------------------------------
/** useState that mirrors its value to localStorage (JSON), so it survives
* remounts and app restarts. */
export function usePersistedState(key: string, initial: T): [T, (v: T) => void] {
const [val, setVal] = useState(() => {
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. */
export function useViewMode(key: string): [ViewMode, (m: ViewMode) => void] {
const [mode, setMode] = useState(
() => (localStorage.getItem(key) as ViewMode) || "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 (
onChange("list")}
title="List view"
>
β° List
onChange("tiles")}
title="Image tiles"
>
β¦ Tiles
);
}
/**
* 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,
footer,
selected,
}: {
card: CardRef;
corner?: ReactNode;
/** Rendered top-left, e.g. a selection checkbox (see `selected`). */
cornerLeft?: ReactNode;
footer?: ReactNode;
/** Highlights the tile and keeps `cornerLeft` visible without hovering. */
selected?: boolean;
}) {
const { open } = useClickPreview();
return (
{card.image_uri ? (
open(card)}
/>
) : (
{card.name}
)}
{cornerLeft &&
{cornerLeft}
}
{corner &&
{corner}
}
{footer &&
{footer}
}
);
}
/** Clickable WUBRG toggles that edit a bitmask value. */
export function ColorToggles({
value,
onChange,
}: {
value: number;
onChange: (mask: number) => void;
}) {
return (
{COLORS.map((c) => (
onChange(value ^ c.bit)}
>
{c.code}
))}
);
}