Files
mtgc/src/components.tsx

456 lines
12 KiB
TypeScript
Raw Normal View History

import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { api, CardRef, COLORS, maskToLetters } from "./api";
/**
* 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()}
/>
);
}
/** 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 the full-size preview. */
export function CardThumb({ card }: { card: CardRef }) {
const { handlers } = useHoverPreview();
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"
{...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).
*/
export function CardSearchBox({
placeholder,
value,
onChange,
results,
onPick,
autoFocus,
big,
}: {
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;
}) {
const [sel, setSel] = useState(0);
const inputRef = useRef<HTMLInputElement>(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={inputRef}
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(--panel-2)" } : 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>
)}
</>
);
}
// --- Hover preview --------------------------------------------------------
interface HoverApi {
enter: (card: CardRef, e: React.MouseEvent) => void;
move: (e: React.MouseEvent) => void;
leave: () => void;
}
const HoverCtx = createContext<HoverApi | null>(null);
/**
* Provides a single floating card-image preview that appears while hovering a
* card and tracks the cursor. Wrap the app once; attach `handlers(card)` from
* [`useHoverPreview`] to any element that should trigger it.
*/
export function HoverPreviewProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<{ card: CardRef; x: number; y: number } | null>(null);
const enter = useCallback((card: CardRef, e: React.MouseEvent) => {
if (card.image_uri) setState({ card, x: e.clientX, y: e.clientY });
}, []);
const move = useCallback((e: React.MouseEvent) => {
setState((s) => (s ? { ...s, x: e.clientX, y: e.clientY } : s));
}, []);
const leave = useCallback(() => setState(null), []);
return (
<HoverCtx.Provider value={{ enter, move, leave }}>
{children}
{state && <FloatingCard card={state.card} x={state.x} y={state.y} />}
</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(),
}
: {},
};
}
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 }}
/>
);
}
// --- 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<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"
? firstSubtype(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>
);
}
// --- 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. */
export function CardTile({
card,
corner,
footer,
}: {
card: CardRef;
corner?: ReactNode;
footer?: ReactNode;
}) {
return (
<div className="tile">
<div className="tile-img-wrap">
{card.image_uri ? (
<img className="tile-img" src={card.image_uri} alt={card.name} loading="lazy" />
) : (
<div className="tile-noimg">{card.name}</div>
)}
{corner && <div className="tile-corner">{corner}</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>
);
}