Suggestion scoring is fully offline (tribal/mechanic/archetype/mana-base signals derived at ingest, no external synergy database), with an "unusual picks" mode that surfaces underappreciated cards instead of staples. The deck page's top panels now use a CSS grid so wide windows don't waste horizontal space. Library's bulk add-to-deck can clobber existing deck quantities, so it now offers an undo (snackbar + command palette, the latter with no timeout) that restores each card's prior state exactly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1162 lines
32 KiB
TypeScript
1162 lines
32 KiB
TypeScript
import {
|
||
createContext,
|
||
forwardRef,
|
||
ReactNode,
|
||
useCallback,
|
||
useContext,
|
||
useEffect,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { api, CardRef, COLORS, maskToLetters } from "./api";
|
||
|
||
// --- 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 or backdrop click.
|
||
*/
|
||
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]);
|
||
|
||
return (
|
||
<div className="dialog-backdrop" onClick={onCancel}>
|
||
<div className={`dialog ${wide ? "dialog-wide" : ""}`} onClick={(e) => e.stopPropagation()}>
|
||
<h3 className="dialog-title">{title}</h3>
|
||
{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: CardRef) => 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: 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 (
|
||
<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: 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 (
|
||
<img
|
||
className="hover-card"
|
||
src={card.image_uri}
|
||
alt={card.name}
|
||
style={{ left, top, width: PREVIEW_W }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="preview-modal-backdrop" onClick={onClose}>
|
||
<div
|
||
className={`preview-modal ${hasBack ? "preview-modal-dfc" : ""}`}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="preview-modal-faces">
|
||
<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)`}
|
||
/>
|
||
)}
|
||
</div>
|
||
<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>
|
||
</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";
|
||
}
|
||
|
||
/** 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") {
|
||
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 (
|
||
<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>
|
||
);
|
||
}
|
||
|
||
// --- 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. */
|
||
export function useViewMode(key: string): [ViewMode, (m: ViewMode) => void] {
|
||
const [mode, setMode] = useState<ViewMode>(
|
||
() => (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 (
|
||
<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,
|
||
}: {
|
||
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;
|
||
}) {
|
||
const { open } = useClickPreview();
|
||
return (
|
||
<div className={`tile ${selected ? "tile-selected" : ""}`}>
|
||
<div className="tile-img-wrap">
|
||
{card.image_uri ? (
|
||
<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,
|
||
}: {
|
||
value: number;
|
||
onChange: (mask: number) => void;
|
||
}) {
|
||
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>
|
||
))}
|
||
</span>
|
||
);
|
||
}
|