Add a Card Search tab and fix horizontal-card (Battle/Plane) preview orientation
Card Search browses the whole downloaded card universe (not just owned cards), reusing the Library tab's filter/group/sort/list/tiles UI; results show ownership and offer quick "own" and "add to deck" actions. Battles, Planes, and Phenomena store their Scryfall image portrait-shaped with the content itself rotated, so hover/click previews now detect this (a new `horizontal` flag threaded through ingest and every card query, with graceful fallback for a reference.db built before the column existed) and rotate the image back into place. Single-faced and non-DFC cases are verified fixed; a double-faced Battle's front face still needs another pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -756,20 +756,62 @@ 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 + 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));
|
||||
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 (
|
||||
<img
|
||||
className="hover-card"
|
||||
src={card.image_uri}
|
||||
alt={card.name}
|
||||
style={{ left, top, width: PREVIEW_W }}
|
||||
/>
|
||||
<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. */
|
||||
function useRotatedPreviewBox() {
|
||||
const compute = () => {
|
||||
const boundW = Math.min(720, window.innerWidth * 0.9);
|
||||
const boundH = window.innerHeight * 0.9;
|
||||
const w = Math.min(boundH, boundW * ROTATED_ASPECT);
|
||||
return { w, h: w / ROTATED_ASPECT };
|
||||
};
|
||||
const [box, setBox] = useState(compute);
|
||||
useEffect(() => {
|
||||
const onResize = () => setBox(compute());
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
return box;
|
||||
}
|
||||
|
||||
function ClickPreviewModal({
|
||||
state,
|
||||
onClose,
|
||||
@@ -780,6 +822,7 @@ function ClickPreviewModal({
|
||||
onFlip: () => void;
|
||||
}) {
|
||||
const { card, showBack } = state;
|
||||
const rotatedBox = useRotatedPreviewBox();
|
||||
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
|
||||
@@ -791,11 +834,28 @@ function ClickPreviewModal({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="preview-modal-faces">
|
||||
<img
|
||||
className={`preview-modal-img ${showBack ? "" : "active"}`}
|
||||
src={card.image_uri}
|
||||
alt={card.name}
|
||||
/>
|
||||
{/* 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" : ""}`}
|
||||
@@ -848,6 +908,13 @@ function primaryType(typeLine: string): string {
|
||||
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 {
|
||||
@@ -887,11 +954,7 @@ export function groupCards<T extends CardRef>(
|
||||
|
||||
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));
|
||||
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 {
|
||||
@@ -922,6 +985,86 @@ export function GroupBySelect({
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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". */
|
||||
|
||||
Reference in New Issue
Block a user