From c15c5b09e5b56455c650acb7d361198030c3343c Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Mon, 8 Jun 2026 12:38:52 -0400 Subject: [PATCH 1/7] feat(builder): add card browser with filters and pagination New in-deck card browser for searching and bulk-adding cards without leaving the deck editor. Backed by an offset-paginated browse API and a Filters tab that round-trips through the existing syntax query string. Key changes: - card-browser/: browser UI (modes, density toggle, scry tray, side panel, filter builder, target picker, bulk bar, in-deck badges) plus deck-browser context and use-card-browser hook - api/cards/browse: paginated browse route over searchCardsBySyntax - search: add OFFSET pagination to searchCardsBySyntax; add serializeWhere as the inverse of parseSyntax so Filters tab and syntax box share one source-of-truth query - deck-search-context: browseTick/requestBrowse to open the browser - header-search + deck-builder: wire the browse entry point and styles - tests: serialize-where round-trip, browse route, filter-builder --- .../builder/card-browser/add-controls.tsx | 80 +++++ .../builder/card-browser/browser-card.tsx | 93 ++++++ .../builder/card-browser/browser-state.ts | 23 ++ .../builder/card-browser/bulk-bar.tsx | 36 +++ .../builder/card-browser/card-browser.tsx | 91 ++++++ .../builder/card-browser/color-pip.tsx | 48 +++ .../builder/card-browser/condensed-row.tsx | 57 ++++ .../card-browser/deck-browser-context.tsx | 230 ++++++++++++++ .../builder/card-browser/density-toggle.tsx | 44 +++ .../card-browser/filter-builder.test.tsx | 118 +++++++ .../builder/card-browser/filter-builder.tsx | 292 ++++++++++++++++++ .../builder/card-browser/in-deck-badge.tsx | 52 ++++ .../builder/card-browser/mode-tabs.tsx | 47 +++ .../builder/card-browser/scry-tray.tsx | 205 ++++++++++++ .../builder/card-browser/select-check.tsx | 41 +++ .../builder/card-browser/side-panel.tsx | 156 ++++++++++ .../builder/card-browser/syntax-input.tsx | 39 +++ .../builder/card-browser/target-picker.tsx | 44 +++ .../builder/card-browser/tray-card.tsx | 172 +++++++++++ .../builder/card-browser/use-card-browser.ts | 142 +++++++++ .../builder/card-browser/use-media-query.ts | 20 ++ app/_components/builder/deck-builder.tsx | 68 +++- .../builder/deck-search-context.tsx | 9 + app/_components/builder/decklist-toolbar.tsx | 8 +- app/_components/decks/deck-card-preview.tsx | 3 +- .../header-search/deck-mode-bar.tsx | 23 ++ .../header-search-bar-deferred.tsx | 52 +++- .../header-search/header-search-bar.tsx | 15 +- .../header-search/header-search-context.tsx | 12 +- app/api/cards/browse/route.test.ts | 97 ++++++ app/api/cards/browse/route.ts | 64 ++++ app/globals.css | 106 ++++++- lib/search/__tests__/serialize-where.test.ts | 88 ++++++ lib/search/card-search.ts | 2 + lib/search/syntax-parser.ts | 38 +++ 35 files changed, 2595 insertions(+), 20 deletions(-) create mode 100644 app/_components/builder/card-browser/add-controls.tsx create mode 100644 app/_components/builder/card-browser/browser-card.tsx create mode 100644 app/_components/builder/card-browser/browser-state.ts create mode 100644 app/_components/builder/card-browser/bulk-bar.tsx create mode 100644 app/_components/builder/card-browser/card-browser.tsx create mode 100644 app/_components/builder/card-browser/color-pip.tsx create mode 100644 app/_components/builder/card-browser/condensed-row.tsx create mode 100644 app/_components/builder/card-browser/deck-browser-context.tsx create mode 100644 app/_components/builder/card-browser/density-toggle.tsx create mode 100644 app/_components/builder/card-browser/filter-builder.test.tsx create mode 100644 app/_components/builder/card-browser/filter-builder.tsx create mode 100644 app/_components/builder/card-browser/in-deck-badge.tsx create mode 100644 app/_components/builder/card-browser/mode-tabs.tsx create mode 100644 app/_components/builder/card-browser/scry-tray.tsx create mode 100644 app/_components/builder/card-browser/select-check.tsx create mode 100644 app/_components/builder/card-browser/side-panel.tsx create mode 100644 app/_components/builder/card-browser/syntax-input.tsx create mode 100644 app/_components/builder/card-browser/target-picker.tsx create mode 100644 app/_components/builder/card-browser/tray-card.tsx create mode 100644 app/_components/builder/card-browser/use-card-browser.ts create mode 100644 app/_components/builder/card-browser/use-media-query.ts create mode 100644 app/api/cards/browse/route.test.ts create mode 100644 app/api/cards/browse/route.ts create mode 100644 lib/search/__tests__/serialize-where.test.ts diff --git a/app/_components/builder/card-browser/add-controls.tsx b/app/_components/builder/card-browser/add-controls.tsx new file mode 100644 index 0000000..b5f20a7 --- /dev/null +++ b/app/_components/builder/card-browser/add-controls.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Minus, Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { CardSearchResult } from "@/lib/search/card-search"; +import { useDeckBrowser } from "./deck-browser-context"; + +interface AddControlsProps { + card: CardSearchResult; + size?: "sm" | "md"; +} + +/** + * Add button that becomes a −/qty/+ stepper once the card is in the deck. + * Shared by the grid tile and condensed row. Stops propagation so clicks here + * don't trigger the surrounding tile's add/select handler. + */ +export function AddControls({ card, size = "md" }: AddControlsProps) { + const deck = useDeckBrowser(); + const qty = deck.countOf(card.id); + const h = size === "sm" ? 26 : 30; + + if (qty > 0) { + return ( +
e.stopPropagation()} + > + + + {qty} + + +
+ ); + } + + return ( + + ); +} diff --git a/app/_components/builder/card-browser/browser-card.tsx b/app/_components/builder/card-browser/browser-card.tsx new file mode 100644 index 0000000..b0512b2 --- /dev/null +++ b/app/_components/builder/card-browser/browser-card.tsx @@ -0,0 +1,93 @@ +"use client"; + +import Image from "next/image"; +import { ManaCost } from "@/app/_components/card/mana-cost"; +import { GameChangerChip } from "@/app/_components/builder/card-row"; +import type { CardSearchResult } from "@/lib/search/card-search"; +import { cn } from "@/lib/utils"; +import { useDeckBrowser } from "./deck-browser-context"; +import { AddControls } from "./add-controls"; +import { SelectCheck } from "./select-check"; +import { InDeckBadge, IllegalBadge } from "./in-deck-badge"; + +/** Grid tile: real card art with overlaid name/type/mana and hover add controls. */ +export function BrowserCard({ card }: { card: CardSearchResult }) { + const deck = useDeckBrowser(); + const qty = deck.countOf(card.id); + const selected = deck.selected.has(card.id); + const { legal, reasons } = deck.legalityOf(card); + + return ( +
+ deck.selectMode ? deck.toggleSelect(card) : deck.add(card, 1) + } + > + {card.name} + {/* top controls */} +
+ + {!deck.selectMode && ( + + )} + {!deck.selectMode && !legal && } +
+ {card.manaCost && ( +
+ +
+ )} + {/* bottom name plate */} +
+ {qty > 0 && ( +
+ +
+ )} +
+ {card.name} +
+ {card.typeLine && ( +
+ {card.typeLine} +
+ )} +
+ {/* hover add */} + {!deck.selectMode && ( +
e.stopPropagation()} + > + +
+ )} +
+ ); +} diff --git a/app/_components/builder/card-browser/browser-state.ts b/app/_components/builder/card-browser/browser-state.ts new file mode 100644 index 0000000..ee64091 --- /dev/null +++ b/app/_components/builder/card-browser/browser-state.ts @@ -0,0 +1,23 @@ +import type { ParsedWhere } from "@/lib/search/syntax-parser"; +import type { CardSearchResult } from "@/lib/search/card-search"; +import type { BrowserMode } from "./mode-tabs"; +import type { Density } from "./density-toggle"; + +/** Shared search state threaded from the parent into whichever surface renders. */ +export interface BrowserState { + raw: string; + setRaw: (raw: string) => void; + mode: BrowserMode; + setMode: (mode: BrowserMode) => void; + density: Density; + setDensity: (density: Density) => void; + parsed: ParsedWhere; + activeCount: number; + results: CardSearchResult[]; + loading: boolean; + loadingMore: boolean; + hasMore: boolean; + count: number; + error: string | null; + showMore: () => void; +} diff --git a/app/_components/builder/card-browser/bulk-bar.tsx b/app/_components/builder/card-browser/bulk-bar.tsx new file mode 100644 index 0000000..199d5be --- /dev/null +++ b/app/_components/builder/card-browser/bulk-bar.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Plus } from "lucide-react"; +import { toTitleCase } from "@/lib/utils"; +import { useDeckBrowser } from "./deck-browser-context"; + +/** Floating multi-select action bar; shown while items are selected. */ +export function BulkBar({ target }: { target: string | null }) { + const deck = useDeckBrowser(); + const n = deck.selected.size; + const label = target ? toTitleCase(target) : "Mainboard"; + return ( +
+ {n} selected + + + to + {label} + +
+ ); +} diff --git a/app/_components/builder/card-browser/card-browser.tsx b/app/_components/builder/card-browser/card-browser.tsx new file mode 100644 index 0000000..f0998ee --- /dev/null +++ b/app/_components/builder/card-browser/card-browser.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { parseSyntax } from "@/lib/search/syntax-parser"; +import type { Format } from "@/lib/generated/prisma/enums"; +import type { DeckCard, ZoneAction } from "@/lib/deck/zone-view"; +import { DeckBrowserProvider } from "./deck-browser-context"; +import { useCardBrowser } from "./use-card-browser"; +import { useMediaQuery } from "./use-media-query"; +import { activeFilterCount } from "./filter-builder"; +import type { BrowserState } from "./browser-state"; +import type { BrowserMode } from "./mode-tabs"; +import type { Density } from "./density-toggle"; +import { SidePanel } from "./side-panel"; +import { ScryTray } from "./scry-tray"; + +interface CardBrowserProps { + open: boolean; + onClose: () => void; + deckId: string; + format: Format; + categories: string[]; + cards: DeckCard[]; + dispatch: (action: ZoneAction) => void; + commanderIdentity: string[]; +} + +/** + * Card browser parent. Owns the shared search state (`raw` query is the single + * source of truth; the Filters and Syntax tabs both read/write it) and picks + * the surface by viewport: a docked side panel ≥lg, a bottom Scry Tray below. + * Both share one provider mount so deck state and selection persist across a + * breakpoint change. + */ +export function CardBrowser({ + open, + onClose, + deckId, + format, + categories, + cards, + dispatch, + commanderIdentity, +}: CardBrowserProps) { + const [raw, setRaw] = useState(""); + const [mode, setMode] = useState("filters"); + const [density, setDensity] = useState("grid"); + const isDesktop = useMediaQuery("(min-width: 1024px)"); + + const parsed = useMemo(() => parseSyntax(raw), [raw]); + const activeCount = useMemo(() => activeFilterCount(parsed), [parsed]); + const { results, loading, loadingMore, hasMore, count, error, showMore } = + useCardBrowser(open ? raw : ""); + + const browser: BrowserState = { + raw, + setRaw, + mode, + setMode, + density, + setDensity, + parsed, + activeCount, + results, + loading, + loadingMore, + hasMore, + count, + error, + showMore, + }; + + if (!open) return null; + + return ( + + {isDesktop ? ( + + ) : ( + + )} + + ); +} diff --git a/app/_components/builder/card-browser/color-pip.tsx b/app/_components/builder/card-browser/color-pip.tsx new file mode 100644 index 0000000..4eac338 --- /dev/null +++ b/app/_components/builder/card-browser/color-pip.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +/** WUBRG swatch colors, matching the mana-font palette used across the app. */ +const SWATCH: Record = { + W: { bg: "#f8f0d1", bd: "#d8c98a", fg: "#7a6c3a" }, + U: { bg: "#0e68ab", bd: "#0a4f81", fg: "#ffffff" }, + B: { bg: "#1a1512", bd: "#000000", fg: "#cdc5be" }, + R: { bg: "#d3202a", bd: "#9b1820", fg: "#ffffff" }, + G: { bg: "#00733e", bd: "#005529", fg: "#ffffff" }, +}; + +interface ColorPipProps { + color: string; + active: boolean; + onClick: () => void; + size?: number; +} + +export function ColorPip({ color, active, onClick, size = 28 }: ColorPipProps) { + const sw = SWATCH[color] ?? SWATCH["W"]!; + return ( + + ); +} diff --git a/app/_components/builder/card-browser/condensed-row.tsx b/app/_components/builder/card-browser/condensed-row.tsx new file mode 100644 index 0000000..004334d --- /dev/null +++ b/app/_components/builder/card-browser/condensed-row.tsx @@ -0,0 +1,57 @@ +"use client"; + +import Image from "next/image"; +import { ManaCost } from "@/app/_components/card/mana-cost"; +import { GameChangerChip } from "@/app/_components/builder/card-row"; +import type { CardSearchResult } from "@/lib/search/card-search"; +import { cn } from "@/lib/utils"; +import { useDeckBrowser } from "./deck-browser-context"; +import { AddControls } from "./add-controls"; +import { SelectCheck } from "./select-check"; +import { InDeckBadge, IllegalBadge } from "./in-deck-badge"; + +/** Dense list row used by the panel's "list" density. */ +export function CondensedRow({ card }: { card: CardSearchResult }) { + const deck = useDeckBrowser(); + const qty = deck.countOf(card.id); + const selected = deck.selected.has(card.id); + const { legal, reasons } = deck.legalityOf(card); + + return ( +
deck.selectMode && deck.toggleSelect(card)} + > + {deck.selectMode && } +
+ +
+
+
+ {card.name} + + {!legal && } +
+ {card.typeLine && ( +
+ {card.typeLine} +
+ )} +
+ {card.manaCost && } + {qty > 0 && ( +
+ +
+ )} + {!deck.selectMode && ( +
+ +
+ )} +
+ ); +} diff --git a/app/_components/builder/card-browser/deck-browser-context.tsx b/app/_components/builder/card-browser/deck-browser-context.tsx new file mode 100644 index 0000000..6ef90af --- /dev/null +++ b/app/_components/builder/card-browser/deck-browser-context.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + useTransition, + type ReactNode, +} from "react"; +import { + addCardToDeck, + removeCardFromDeck, + updateCardQuantity, +} from "@/lib/deck/editor-actions"; +import { evaluateAddIntent } from "@/lib/deck/add-intent"; +import { Zone, type Format } from "@/lib/generated/prisma/enums"; +import type { CardSearchResult } from "@/lib/search/card-search"; +import type { DeckCard, ZoneAction } from "@/lib/deck/zone-view"; + +interface CardLegality { + legal: boolean; + reasons: string[]; + currentCopies: number; +} + +interface DeckBrowserValue { + /** Total copies of a card across every zone of the deck. */ + countOf: (cardId: number) => number; + add: (card: CardSearchResult, qty: number) => void; + remove: (card: CardSearchResult) => void; + legalityOf: (card: CardSearchResult) => CardLegality; + /** Mainboard category adds land in, or null for uncategorized. */ + target: string | null; + setTarget: (target: string | null) => void; + categories: string[]; + format: Format; + commanderIdentity: string[]; + selectMode: boolean; + setSelectMode: (on: boolean) => void; + selected: Set; + toggleSelect: (card: CardSearchResult) => void; + clearSelect: () => void; + addSelected: (target: string | null) => void; + pending: boolean; +} + +const DeckBrowserContext = createContext(null); + +export function useDeckBrowser(): DeckBrowserValue { + const ctx = useContext(DeckBrowserContext); + if (!ctx) { + throw new Error("useDeckBrowser must be used within a DeckBrowserProvider"); + } + return ctx; +} + +interface DeckBrowserProviderProps { + deckId: string; + cards: DeckCard[]; + dispatch: (action: ZoneAction) => void; + categories: string[]; + format: Format; + commanderIdentity: string[]; + children: ReactNode; +} + +export function DeckBrowserProvider({ + deckId, + cards, + dispatch, + categories, + format, + commanderIdentity, + children, +}: DeckBrowserProviderProps) { + const [target, setTarget] = useState(null); + const [selectMode, setSelectModeState] = useState(false); + const [selected, setSelected] = useState>( + () => new Map(), + ); + const [pending, startTransition] = useTransition(); + + const countOf = useCallback( + (cardId: number) => { + let total = 0; + for (const dc of cards) { + if (dc.card.id === cardId) total += dc.quantity; + } + return total; + }, + [cards], + ); + + const legalityOf = useCallback( + (card: CardSearchResult): CardLegality => { + const { legal, reasons, currentCopies } = evaluateAddIntent({ + card, + format, + deckCards: cards, + quantity: 1, + commanderIdentity, + }); + return { legal, reasons, currentCopies }; + }, + [cards, format, commanderIdentity], + ); + + // Adds land in MAINBOARD under the active target category. No optimistic + // ZoneAction exists for inserts, so the live decklist updates once the server + // action revalidates the deck tag — the same path the header search uses. + const add = useCallback( + (card: CardSearchResult, qty: number) => { + startTransition(async () => { + await addCardToDeck(deckId, card.id, { + quantity: qty, + zone: Zone.MAINBOARD, + category: target, + }); + }); + }, + [deckId, target], + ); + + // Decrement one copy from the first matching deck card (preferring mainboard). + const remove = useCallback( + (card: CardSearchResult) => { + const matches = cards.filter((dc) => dc.card.id === card.id); + const dc = + matches.find((m) => m.zone === Zone.MAINBOARD) ?? matches[0]; + if (!dc) return; + startTransition(async () => { + if (dc.quantity > 1) { + dispatch({ + type: "update", + deckCardId: dc.id, + quantity: dc.quantity - 1, + }); + await updateCardQuantity(deckId, dc.id, dc.quantity - 1); + } else { + dispatch({ type: "remove", deckCardId: dc.id }); + await removeCardFromDeck(deckId, dc.id); + } + }); + }, + [cards, deckId, dispatch], + ); + + const setSelectMode = useCallback((on: boolean) => { + setSelectModeState(on); + if (!on) setSelected(new Map()); + }, []); + + const toggleSelect = useCallback((card: CardSearchResult) => { + setSelected((prev) => { + const next = new Map(prev); + if (next.has(card.id)) next.delete(card.id); + else next.set(card.id, card); + return next; + }); + }, []); + + const clearSelect = useCallback(() => setSelected(new Map()), []); + + const addSelected = useCallback( + (dest: string | null) => { + const picked = [...selected.values()]; + if (picked.length === 0) return; + startTransition(async () => { + for (const card of picked) { + await addCardToDeck(deckId, card.id, { + quantity: 1, + zone: Zone.MAINBOARD, + category: dest, + }); + } + setSelected(new Map()); + setSelectModeState(false); + }); + }, + [selected, deckId], + ); + + const selectedIds = useMemo(() => new Set(selected.keys()), [selected]); + + const value = useMemo( + () => ({ + countOf, + add, + remove, + legalityOf, + target, + setTarget, + categories, + format, + commanderIdentity, + selectMode, + setSelectMode, + selected: selectedIds, + toggleSelect, + clearSelect, + addSelected, + pending, + }), + [ + countOf, + add, + remove, + legalityOf, + target, + categories, + format, + commanderIdentity, + selectMode, + setSelectMode, + selectedIds, + toggleSelect, + clearSelect, + addSelected, + pending, + ], + ); + + return ( + + {children} + + ); +} diff --git a/app/_components/builder/card-browser/density-toggle.tsx b/app/_components/builder/card-browser/density-toggle.tsx new file mode 100644 index 0000000..0ebc2ea --- /dev/null +++ b/app/_components/builder/card-browser/density-toggle.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { LayoutGrid, List } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export type Density = "grid" | "list"; + +interface DensityToggleProps { + value: Density; + onChange: (density: Density) => void; +} + +const OPTS: ReadonlyArray<[Density, string, typeof LayoutGrid]> = [ + ["grid", "Grid", LayoutGrid], + ["list", "List", List], +]; + +export function DensityToggle({ value, onChange }: DensityToggleProps) { + return ( +
+ {OPTS.map(([v, label, Icon]) => { + const active = value === v; + return ( + + ); + })} +
+ ); +} diff --git a/app/_components/builder/card-browser/filter-builder.test.tsx b/app/_components/builder/card-browser/filter-builder.test.tsx new file mode 100644 index 0000000..9c28ded --- /dev/null +++ b/app/_components/builder/card-browser/filter-builder.test.tsx @@ -0,0 +1,118 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { parseSyntax } from "@/lib/search/syntax-parser"; +import { FilterBuilder, activeFilterCount } from "./filter-builder"; + +/** + * Models the real round-trip: the parent re-parses each emitted `raw` back + * into `parsed`, so the name input's render-time resync sees its own edits. + */ +function Harness({ + initial, + spy, +}: { + initial: string; + spy: (raw: string) => void; +}) { + const [raw, setRaw] = useState(initial); + return ( + { + setRaw(r); + spy(r); + }} + /> + ); +} + +function setup(raw: string) { + const onChange = vi.fn(); + render(); + return onChange; +} + +describe("FilterBuilder chip → syntax mapping", () => { + it("maps a color pip to c:", async () => { + const onChange = setup(""); + await userEvent.click(screen.getByLabelText("Color U")); + expect(onChange).toHaveBeenCalledWith("c:U"); + }); + + it("maps a card-type chip to t: (lowercased)", async () => { + const onChange = setup(""); + await userEvent.click(screen.getByRole("button", { name: "Creature" })); + expect(onChange).toHaveBeenCalledWith("t:creature"); + }); + + it("maps a keyword chip to o: (lowercased)", async () => { + const onChange = setup(""); + await userEvent.click(screen.getByRole("button", { name: "Flying" })); + expect(onChange).toHaveBeenCalledWith("o:flying"); + }); + + it("toggles an active color pip off, clearing the clause", async () => { + const onChange = setup("c:U"); + await userEvent.click(screen.getByLabelText("Color U")); + expect(onChange).toHaveBeenCalledWith(""); + }); + + it("emits cmc>= when the min slider moves off zero", () => { + const onChange = setup(""); + fireEvent.change(screen.getByLabelText("Mana value min"), { + target: { value: "3" }, + }); + expect(onChange).toHaveBeenCalledWith("cmc>=3"); + }); + + it("emits cmc<= when the max slider drops below the cap", () => { + const onChange = setup(""); + fireEvent.change(screen.getByLabelText("Mana value max"), { + target: { value: "5" }, + }); + expect(onChange).toHaveBeenCalledWith("cmc<=5"); + }); + + it("preserves an existing name fragment when toggling a color", async () => { + const onChange = setup("bolt"); + await userEvent.click(screen.getByLabelText("Color R")); + expect(onChange).toHaveBeenCalledWith("bolt c:R"); + }); +}); + +describe("FilterBuilder card-name input → syntax mapping", () => { + it("maps a single-word name to a bare fragment", async () => { + const onChange = setup(""); + await userEvent.type(screen.getByLabelText("Card name"), "bolt"); + expect(onChange).toHaveBeenLastCalledWith("bolt"); + }); + + it("quotes a multi-word name into one fragment", async () => { + const onChange = setup(""); + await userEvent.type(screen.getByLabelText("Card name"), "lightning bolt"); + expect(onChange).toHaveBeenLastCalledWith('"lightning bolt"'); + }); + + it("keeps the name first alongside other facets", async () => { + const onChange = setup("c:R"); + await userEvent.type(screen.getByLabelText("Card name"), "bolt"); + expect(onChange).toHaveBeenLastCalledWith("bolt c:R"); + }); + + it("drops the fragment when the box is cleared", async () => { + const onChange = setup("bolt"); + await userEvent.clear(screen.getByLabelText("Card name")); + expect(onChange).toHaveBeenLastCalledWith(""); + }); +}); + +describe("activeFilterCount", () => { + it("counts each active facet", () => { + expect(activeFilterCount(parseSyntax(""))).toBe(0); + expect(activeFilterCount(parseSyntax("c:U t:instant o:flying"))).toBe(3); + expect(activeFilterCount(parseSyntax("cmc>=2 cmc<=5"))).toBe(2); + expect(activeFilterCount(parseSyntax("bolt"))).toBe(1); + }); +}); diff --git a/app/_components/builder/card-browser/filter-builder.tsx b/app/_components/builder/card-browser/filter-builder.tsx new file mode 100644 index 0000000..698b3d1 --- /dev/null +++ b/app/_components/builder/card-browser/filter-builder.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { useState } from "react"; +import { Eyebrow } from "@/components/ui/eyebrow"; +import { cn } from "@/lib/utils"; +import { + serializeWhere, + type ParsedWhere, +} from "@/lib/search/syntax-parser"; +import { ColorPip } from "./color-pip"; + +const WUBRG = ["W", "U", "B", "R", "G"] as const; +const TYPES = [ + "Creature", + "Instant", + "Sorcery", + "Enchantment", + "Artifact", + "Planeswalker", + "Land", +] as const; +const KEYWORDS = [ + "Flying", + "Trample", + "Lifelink", + "Deathtouch", + "Haste", + "Draw", + "Token", + "Counter", +] as const; + +const MV_MAX = 8; + +function clone(p: ParsedWhere): ParsedWhere { + return { + nameFragments: [...p.nameFragments], + colors: [...p.colors], + typeFragments: [...p.typeFragments], + cmcFilters: p.cmcFilters.map((f) => ({ ...f })), + oracleFragments: [...p.oracleFragments], + }; +} + +function toggle(list: string[], value: string): string[] { + const i = list.indexOf(value); + if (i >= 0) return [...list.slice(0, i), ...list.slice(i + 1)]; + return [...list, value]; +} + +/** Lower (cmc>=) and upper (cmc<=) bounds derived from the parsed filters. */ +function manaRange(p: ParsedWhere): { min: number; max: number } { + let min = 0; + let max = MV_MAX; + for (const f of p.cmcFilters) { + if (f.op === ">=") min = f.value; + else if (f.op === "<=") max = f.value; + } + return { min, max }; +} + +/** Count of active filter facets — drives the Filters tab badge. */ +export function activeFilterCount(p: ParsedWhere): number { + const { min, max } = manaRange(p); + return ( + p.colors.length + + p.typeFragments.length + + p.oracleFragments.length + + p.nameFragments.length + + (min > 0 ? 1 : 0) + + (max < MV_MAX ? 1 : 0) + ); +} + +interface FilterBuilderProps { + parsed: ParsedWhere; + onChange: (raw: string) => void; + small?: boolean; +} + +export function FilterBuilder({ parsed, onChange, small }: FilterBuilderProps) { + function patch(mut: (next: ParsedWhere) => void) { + const next = clone(parsed); + mut(next); + onChange(serializeWhere(next)); + } + + const { min, max } = manaRange(parsed); + + // Local mirror of the name fragment with render-time resync — avoids the + // set-state-in-effect rule and the controlled-input trailing-space clobber + // (same pattern as use-card-browser.ts). + const nameFromParsed = parsed.nameFragments.join(" "); + const [nameText, setNameText] = useState(nameFromParsed); + const [prevName, setPrevName] = useState(nameFromParsed); + if (nameFromParsed !== prevName) { + // External edit (syntax tab / clear) — pull it back in. + setPrevName(nameFromParsed); + setNameText(nameFromParsed); + } + function onNameChange(v: string) { + setNameText(v); + const trimmed = v.trim(); + setPrevName(trimmed); // keep resync from clobbering keystrokes + patch((n) => { + n.nameFragments = trimmed ? [trimmed] : []; + }); + } + + function setBounds(nextMin: number, nextMax: number) { + patch((n) => { + // Drop existing range bounds, keep any other cmc comparisons. + n.cmcFilters = n.cmcFilters.filter((f) => f.op !== ">=" && f.op !== "<="); + if (nextMin > 0) n.cmcFilters.push({ op: ">=", value: nextMin }); + if (nextMax < MV_MAX) n.cmcFilters.push({ op: "<=", value: nextMax }); + }); + } + + return ( +
+ {/* Card name */} +
+ onNameChange(e.target.value)} + placeholder="Lightning Bolt" + aria-label="Card name" + spellCheck={false} + autoCapitalize="none" + autoCorrect="off" + className={cn( + "w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:ring-1 focus:ring-ring", + small ? "h-9" : "h-10", + )} + /> +
+ + {/* Colors */} +
+
+ {WUBRG.map((c) => ( + patch((n) => (n.colors = toggle(n.colors, c)))} + size={small ? 28 : 30} + /> + ))} + {parsed.colors.length > 0 && ( + + )} +
+
+ + {/* Mana value */} +
+ {min === 0 && max === MV_MAX + ? "any" + : `${min}–${max === MV_MAX ? "8+" : max}`} + + } + > +
+ {(["min", "max"] as const).map((which) => { + const val = which === "min" ? min : max; + return ( +
+ + {which} + + { + const v = parseInt(e.target.value, 10); + if (which === "min") setBounds(v, Math.max(v, max)); + else setBounds(Math.min(min, v), v); + }} + /> + + {which === "max" && val === MV_MAX ? "8+" : val} + +
+ ); + })} +
+
+ + {/* Card type */} +
+
+ {TYPES.map((t) => { + const v = t.toLowerCase(); + return ( + + patch((n) => (n.typeFragments = toggle(n.typeFragments, v))) + } + > + {t} + + ); + })} +
+
+ + {/* Keywords */} +
+
+ {KEYWORDS.map((k) => { + const v = k.toLowerCase(); + return ( + + patch((n) => (n.oracleFragments = toggle(n.oracleFragments, v))) + } + > + {k} + + ); + })} +
+
+
+ ); +} + +function Section({ + label, + right, + children, +}: { + label: string; + right?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+ {label} + {right} +
+ {children} +
+ ); +} + +function FilterChip({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/app/_components/builder/card-browser/in-deck-badge.tsx b/app/_components/builder/card-browser/in-deck-badge.tsx new file mode 100644 index 0000000..ff8b7e0 --- /dev/null +++ b/app/_components/builder/card-browser/in-deck-badge.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Check, X } from "lucide-react"; + +interface InDeckBadgeProps { + qty: number; + compact?: boolean; +} + +/** "N in deck" pill shown on results already present in the open deck. */ +export function InDeckBadge({ qty, compact }: InDeckBadgeProps) { + if (!qty) return null; + return ( + + + {qty} + {compact ? "" : " in deck"} + + ); +} + +/** Small square badge marking a result that is illegal in the deck's format. */ +export function IllegalBadge({ reasons }: { reasons?: string[] }) { + return ( + 0 + ? reasons.join("; ") + : "Not legal in this deck's format" + } + className="inline-flex items-center justify-center rounded-[4px] text-destructive" + style={{ + width: 18, + height: 18, + background: "color-mix(in oklab, var(--destructive) 14%, transparent)", + border: "1px solid color-mix(in oklab, var(--destructive) 30%, transparent)", + }} + > + + + ); +} diff --git a/app/_components/builder/card-browser/mode-tabs.tsx b/app/_components/builder/card-browser/mode-tabs.tsx new file mode 100644 index 0000000..2c6caf1 --- /dev/null +++ b/app/_components/builder/card-browser/mode-tabs.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +export type BrowserMode = "filters" | "syntax"; + +interface ModeTabsProps { + mode: BrowserMode; + onMode: (mode: BrowserMode) => void; + activeCount: number; +} + +const TABS: ReadonlyArray<[BrowserMode, string]> = [ + ["filters", "Filters"], + ["syntax", "Scryfall syntax"], +]; + +export function ModeTabs({ mode, onMode, activeCount }: ModeTabsProps) { + return ( +
+ {TABS.map(([value, label]) => { + const active = mode === value; + return ( + + ); + })} +
+ ); +} diff --git a/app/_components/builder/card-browser/scry-tray.tsx b/app/_components/builder/card-browser/scry-tray.tsx new file mode 100644 index 0000000..998b280 --- /dev/null +++ b/app/_components/builder/card-browser/scry-tray.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useState } from "react"; +import { CheckSquare, ChevronDown, ScanSearch, SlidersHorizontal } from "lucide-react"; +import { Eyebrow } from "@/components/ui/eyebrow"; +import { cn } from "@/lib/utils"; +import { serializeWhere } from "@/lib/search/syntax-parser"; +import type { BrowserState } from "./browser-state"; +import { useDeckBrowser } from "./deck-browser-context"; +import { TargetPicker } from "./target-picker"; +import { FilterBuilder } from "./filter-builder"; +import { ColorPip } from "./color-pip"; +import { TrayCard } from "./tray-card"; +import { BulkBar } from "./bulk-bar"; + +const WUBRG = ["W", "U", "B", "R", "G"] as const; +const TYPE_CHIPS: ReadonlyArray<[string, string]> = [ + ["creature", "Creature"], + ["instant", "Instant"], + ["sorcery", "Sorcery"], + ["enchantment", "Enchant"], + ["land", "Land"], +]; + +/** Mobile ( void; +}) { + const deck = useDeckBrowser(); + const [showFilters, setShowFilters] = useState(false); + const { parsed } = browser; + + function toggleColor(c: string) { + const colors = parsed.colors.includes(c) + ? parsed.colors.filter((x) => x !== c) + : [...parsed.colors, c]; + browser.setRaw(serializeWhere({ ...parsed, colors })); + } + function toggleType(t: string) { + const typeFragments = parsed.typeFragments.includes(t) + ? parsed.typeFragments.filter((x) => x !== t) + : [...parsed.typeFragments, t]; + browser.setRaw(serializeWhere({ ...parsed, typeFragments })); + } + + return ( +
+ {/* filters sheet */} + {showFilters && ( +
+
+ +
+
+ )} + + {/* command row */} +
+ Scry Tray +
+ + browser.setRaw(e.target.value)} + placeholder="c:U t:instant cmc<=2" + aria-label="Scryfall syntax query" + spellCheck={false} + autoCapitalize="none" + autoCorrect="off" + className="h-[34px] w-full rounded-lg border border-border bg-card pl-8 pr-2.5 font-mono text-[12.5px] outline-none focus:ring-1 focus:ring-ring" + /> +
+
+ {WUBRG.map((c) => ( + toggleColor(c)} + /> + ))} +
+ +
+ {TYPE_CHIPS.map(([v, label]) => { + const on = parsed.typeFragments.includes(v); + return ( + + ); + })} +
+ +
+ + + +
+ + {/* filmstrip */} +
+
+ {browser.results.length === 0 ? ( +
+ {browser.error + ? browser.error + : browser.raw.trim() === "" + ? "Pick filters or type a query to browse cards." + : browser.loading + ? "Searching…" + : "No cards match — adjust your filters."} +
+ ) : ( + <> + {browser.results.map((c) => ( + + ))} + {browser.hasMore && ( + + )} + + )} +
+
+ {browser.count} results + · deck stays visible above · tap or flick ↑ to add +
+
+ + {deck.selectMode && ( +
+ +
+ )} +
+ ); +} diff --git a/app/_components/builder/card-browser/select-check.tsx b/app/_components/builder/card-browser/select-check.tsx new file mode 100644 index 0000000..9254227 --- /dev/null +++ b/app/_components/builder/card-browser/select-check.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { Check } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { CardSearchResult } from "@/lib/search/card-search"; +import { useDeckBrowser } from "./deck-browser-context"; + +/** Multi-select checkbox overlay; renders only while select mode is active. */ +export function SelectCheck({ card }: { card: CardSearchResult }) { + const deck = useDeckBrowser(); + if (!deck.selectMode) return null; + const on = deck.selected.has(card.id); + return ( + + ); +} diff --git a/app/_components/builder/card-browser/side-panel.tsx b/app/_components/builder/card-browser/side-panel.tsx new file mode 100644 index 0000000..f2c2be4 --- /dev/null +++ b/app/_components/builder/card-browser/side-panel.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { CheckSquare, ChevronRight, Search } from "lucide-react"; +import { Eyebrow } from "@/components/ui/eyebrow"; +import { cn } from "@/lib/utils"; +import type { BrowserState } from "./browser-state"; +import { useDeckBrowser } from "./deck-browser-context"; +import { ModeTabs } from "./mode-tabs"; +import { DensityToggle } from "./density-toggle"; +import { TargetPicker } from "./target-picker"; +import { SyntaxInput } from "./syntax-input"; +import { FilterBuilder } from "./filter-builder"; +import { BrowserCard } from "./browser-card"; +import { CondensedRow } from "./condensed-row"; +import { BulkBar } from "./bulk-bar"; + +/** Desktop (≥lg) docked browser: fixed to the right edge beside the decklist. */ +export function SidePanel({ + browser, + onClose, +}: { + browser: BrowserState; + onClose: () => void; +}) { + const deck = useDeckBrowser(); + return ( +
+ {/* header */} +
+ + Card Browser +
+ + +
+ + {/* controls */} +
+
+ + +
+ {browser.mode === "syntax" && ( +
+ +
+ )} +
+ + + {browser.count} card{browser.count !== 1 ? "s" : ""} + +
+
+ + {/* scroll body */} +
+ {browser.mode === "filters" && ( +
+ +
+ )} + +
+ + {deck.selectMode && ( +
+ +
+ )} +
+ ); +} + +function ResultsBody({ browser }: { browser: BrowserState }) { + if (browser.error) { + return ( +

+ {browser.error} +

+ ); + } + if (browser.raw.trim() === "") { + return ( +

+ Pick filters or type a query to browse cards. +

+ ); + } + if (browser.results.length === 0) { + return ( +

+ {browser.loading ? "Searching…" : "No cards match these filters."} +

+ ); + } + return ( + <> + {browser.density === "grid" ? ( +
+ {browser.results.map((c) => ( + + ))} +
+ ) : ( +
+ {browser.results.map((c) => ( + + ))} +
+ )} + {browser.hasMore && ( + + )} + + ); +} diff --git a/app/_components/builder/card-browser/syntax-input.tsx b/app/_components/builder/card-browser/syntax-input.tsx new file mode 100644 index 0000000..4704906 --- /dev/null +++ b/app/_components/builder/card-browser/syntax-input.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { ScanSearch } from "lucide-react"; + +interface SyntaxInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + autoFocus?: boolean; +} + +/** Raw Scryfall-syntax text box (the syntax tab's single source of truth). */ +export function SyntaxInput({ + value, + onChange, + placeholder = 'c:U t:creature cmc<=3 o:"flying"', + autoFocus, +}: SyntaxInputProps) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + aria-label="Scryfall syntax query" + spellCheck={false} + autoCapitalize="none" + autoCorrect="off" + className="h-11 w-full rounded-md border border-input bg-card pl-9 pr-3 font-mono text-[13.5px] outline-none focus:ring-1 focus:ring-ring" + /> +
+ ); +} diff --git a/app/_components/builder/card-browser/target-picker.tsx b/app/_components/builder/card-browser/target-picker.tsx new file mode 100644 index 0000000..311e69d --- /dev/null +++ b/app/_components/builder/card-browser/target-picker.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Check, ChevronDown } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { toTitleCase } from "@/lib/utils"; + +interface TargetPickerProps { + value: string | null; + categories: string[]; + onChange: (target: string | null) => void; +} + +const MAINBOARD_LABEL = "Mainboard"; + +/** Chooses the mainboard category that browser adds land in. `null` = uncategorized. */ +export function TargetPicker({ value, categories, onChange }: TargetPickerProps) { + const label = value ? toTitleCase(value) : MAINBOARD_LABEL; + return ( + + + Add to + {label} + + + + onChange(null)}> + {MAINBOARD_LABEL} + {value === null && } + + {categories.map((c) => ( + onChange(c)}> + {toTitleCase(c)} + {value === c && } + + ))} + + + ); +} diff --git a/app/_components/builder/card-browser/tray-card.tsx b/app/_components/builder/card-browser/tray-card.tsx new file mode 100644 index 0000000..1c3dad3 --- /dev/null +++ b/app/_components/builder/card-browser/tray-card.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useRef } from "react"; +import Image from "next/image"; +import { ManaCost } from "@/app/_components/card/mana-cost"; +import { GameChangerChip } from "@/app/_components/builder/card-row"; +import type { CardSearchResult } from "@/lib/search/card-search"; +import { useDeckBrowser } from "./deck-browser-context"; +import { SelectCheck } from "./select-check"; +import { InDeckBadge } from "./in-deck-badge"; + +interface DragState { + active: boolean; + sx: number; + sy: number; + moved: boolean; + vert: boolean; +} + +/** + * Filmstrip card for the mobile Scry Tray. Tap adds (or toggles in select + * mode); a vertical flick upward adds with a little fly-away animation, while + * horizontal drags fall through to the strip's native scroll (`touchAction: + * pan-x`). + */ +export function TrayCard({ card }: { card: CardSearchResult }) { + const deck = useDeckBrowser(); + const qty = deck.countOf(card.id); + const selected = deck.selected.has(card.id); + const ref = useRef(null); + const drag = useRef({ + active: false, + sx: 0, + sy: 0, + moved: false, + vert: false, + }); + + function down(e: React.PointerEvent) { + drag.current = { + active: true, + sx: e.clientX, + sy: e.clientY, + moved: false, + vert: false, + }; + } + function move(e: React.PointerEvent) { + const d = drag.current; + if (!d.active) return; + const dx = e.clientX - d.sx; + const dy = e.clientY - d.sy; + // Horizontal intent → release so the strip can scroll. + if (!d.moved && Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 6) { + d.active = false; + return; + } + if (dy < 0) { + d.vert = true; + d.moved = true; + const el = ref.current; + if (el) { + el.style.transform = `translateY(${dy}px)`; + el.style.opacity = String(Math.max(0.3, 1 + dy / 240)); + } + } + } + function up(e: React.PointerEvent) { + const d = drag.current; + const el = ref.current; + const dy = e.clientY - d.sy; + if (d.active && d.vert && dy < -64 && !deck.selectMode) { + if (el) { + el.style.transition = "transform .25s ease, opacity .25s ease"; + el.style.transform = "translateY(-120px)"; + el.style.opacity = "0"; + } + deck.add(card, 1); + setTimeout(() => { + if (el) { + el.style.transition = ""; + el.style.transform = ""; + el.style.opacity = ""; + } + }, 260); + } else { + if (el) { + el.style.transition = "transform .18s ease, opacity .18s ease"; + el.style.transform = ""; + el.style.opacity = ""; + setTimeout(() => { + if (el) el.style.transition = ""; + }, 200); + } + if (!d.moved) { + if (deck.selectMode) deck.toggleSelect(card); + else deck.add(card, 1); + } + } + drag.current.active = false; + } + + return ( +
{ + if (drag.current.active) up(e); + }} + style={{ + width: 146, + aspectRatio: "5 / 7", + cursor: "pointer", + touchAction: "pan-x", + border: selected ? "1.5px solid var(--foreground)" : "1px solid var(--border)", + boxShadow: selected ? "0 0 0 2px var(--foreground)" : "none", + }} + > + {card.name} +
+ {card.manaCost && } +
+
+ + {!deck.selectMode && ( + + )} +
+
+ {qty > 0 && ( +
+ +
+ )} +
+ {card.name} +
+
+ {!deck.selectMode && ( +
+ + ↑ flick to add + +
+ )} +
+ ); +} diff --git a/app/_components/builder/card-browser/use-card-browser.ts b/app/_components/builder/card-browser/use-card-browser.ts new file mode 100644 index 0000000..dc18304 --- /dev/null +++ b/app/_components/builder/card-browser/use-card-browser.ts @@ -0,0 +1,142 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { resolveRetryAfterMs } from "@/app/_components/header-search/retry-after"; +import type { CardSearchResult } from "@/lib/search/card-search"; + +const DEBOUNCE_MS = 250; +const PAGE_SIZE = 60; + +interface UseCardBrowserState { + results: CardSearchResult[]; + loading: boolean; + loadingMore: boolean; + hasMore: boolean; + count: number; + error: string | null; + showMore: () => void; +} + +/** + * Fetches `/api/cards/browse?q=` for the browser surfaces. Debounces the raw + * syntax query, gates on a non-empty query (empty never hits the table), backs + * off on 429 via `Retry-After`, and paginates with `showMore`. Page-one fetches + * replace results; `showMore` appends. Mirrors the header-search hook's + * abort/back-off behaviour. + */ +export function useCardBrowser(raw: string): UseCardBrowserState { + const [debounced, setDebounced] = useState(raw); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [offset, setOffset] = useState(0); + const [error, setError] = useState(null); + const [retryNonce, setRetryNonce] = useState(0); + + const query = debounced.trim(); + const active = query.length > 0; + + useEffect(() => { + const t = setTimeout(() => setDebounced(raw), DEBOUNCE_MS); + return () => clearTimeout(t); + }, [raw]); + + // Sync loading/results during render when the debounced query changes — keeps + // the fetch effect free of synchronous setState. Mirrors the header-search hook. + const [prev, setPrev] = useState(debounced); + if (debounced !== prev) { + setPrev(debounced); + setOffset(0); + setHasMore(false); + setError(null); + if (active) { + setLoading(true); + } else { + setResults([]); + setLoading(false); + } + } + + useEffect(() => { + if (!active) return; + const controller = new AbortController(); + let cancelled = false; + let retryTimer: ReturnType | undefined; + void (async () => { + try { + const res = await fetch( + `/api/cards/browse?q=${encodeURIComponent(query)}&limit=${PAGE_SIZE}&offset=0`, + { signal: controller.signal }, + ); + if (cancelled) return; + if (!res.ok) { + if (res.status === 429) { + setError("Too many searches — retrying…"); + setLoading(false); + retryTimer = setTimeout( + () => setRetryNonce((n) => n + 1), + resolveRetryAfterMs(res.headers.get("Retry-After")), + ); + return; + } + setError("Search failed. Try again."); + setResults([]); + setLoading(false); + setHasMore(false); + return; + } + const data: unknown = await res.json(); + if (cancelled) return; + const items = Array.isArray(data) ? (data as CardSearchResult[]) : []; + setResults(items); + setOffset(items.length); + setHasMore(items.length === PAGE_SIZE); + setLoading(false); + } catch (e) { + if (cancelled) return; + if ((e as Error)?.name === "AbortError") return; + setError("Search failed. Try again."); + setResults([]); + setLoading(false); + setHasMore(false); + } + })(); + return () => { + cancelled = true; + controller.abort(); + if (retryTimer) clearTimeout(retryTimer); + }; + }, [query, active, retryNonce]); + + const showMore = useCallback(() => { + if (!active || !hasMore || loadingMore) return; + setLoadingMore(true); + void (async () => { + try { + const res = await fetch( + `/api/cards/browse?q=${encodeURIComponent(query)}&limit=${PAGE_SIZE}&offset=${offset}`, + ); + const data: unknown = await res.json(); + const items = Array.isArray(data) ? (data as CardSearchResult[]) : []; + setResults((prev) => [...prev, ...items]); + setOffset((o) => o + items.length); + setHasMore(items.length === PAGE_SIZE); + } catch { + setHasMore(false); + } finally { + setLoadingMore(false); + } + })(); + }, [active, hasMore, loadingMore, query, offset]); + + return { + results, + loading, + loadingMore, + hasMore, + count: results.length, + error, + showMore, + }; +} diff --git a/app/_components/builder/card-browser/use-media-query.ts b/app/_components/builder/card-browser/use-media-query.ts new file mode 100644 index 0000000..ef13ab1 --- /dev/null +++ b/app/_components/builder/card-browser/use-media-query.ts @@ -0,0 +1,20 @@ +"use client"; + +import { useCallback, useSyncExternalStore } from "react"; + +/** + * Subscribe to a CSS media query without setState-in-effect. SSR snapshot is + * `false`, so the first client paint reconciles to the real viewport on hydrate. + */ +export function useMediaQuery(query: string): boolean { + const subscribe = useCallback( + (onChange: () => void) => { + const mql = window.matchMedia(query); + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); + }, + [query], + ); + const getSnapshot = useCallback(() => window.matchMedia(query).matches, [query]); + return useSyncExternalStore(subscribe, getSnapshot, () => false); +} diff --git a/app/_components/builder/deck-builder.tsx b/app/_components/builder/deck-builder.tsx index 8b848dc..82f8e7c 100644 --- a/app/_components/builder/deck-builder.tsx +++ b/app/_components/builder/deck-builder.tsx @@ -1,12 +1,24 @@ "use client"; -import { useMemo, useOptimistic, type ReactNode } from "react"; +import { + useEffect, + useMemo, + useOptimistic, + useRef, + useState, + type ReactNode, +} from "react"; import dynamic from "next/dynamic"; +import { CardBrowser } from "@/app/_components/builder/card-browser/card-browser"; +import { cn } from "@/lib/utils"; import { DeckPreviewPane, DeckPreviewProvider, } from "@/app/_components/deck/deck-preview-pane"; -import { DeckSearchCardsBridge } from "@/app/_components/builder/deck-search-context"; +import { + DeckSearchCardsBridge, + useDeckSearch, +} from "@/app/_components/builder/deck-search-context"; import { Decklist } from "@/app/_components/builder/decklist"; import { DecklistToolbar } from "@/app/_components/builder/decklist-toolbar"; import { SideboardConsidering } from "@/app/_components/builder/sideboard-considering"; @@ -52,8 +64,21 @@ export function DeckBuilder({ toolbar, }: DeckBuilderProps) { const [cards, dispatch] = useOptimistic(deck.cards, applyZoneOptimistic); + const [browserOpen, setBrowserOpen] = useState(false); const bulkEditText = useMemo(() => toPlainText(deck), [deck]); + // Bridge the search bar's "Browse cards" button (rendered in the header, + // a separate subtree) through DeckSearchContext. A monotonic tick lets a + // re-click re-open the panel even after it was closed. + const browseTick = useDeckSearch()?.browseTick ?? 0; + const prevBrowseTick = useRef(browseTick); + useEffect(() => { + if (browseTick !== prevBrowseTick.current) { + prevBrowseTick.current = browseTick; + setBrowserOpen(true); + } + }, [browseTick]); + const categoryNames = useMemo( () => [...deck.categories] @@ -77,8 +102,22 @@ export function DeckBuilder({ .flatMap((c) => c.card.colorIdentity), ), ]; + const commanderIdentity = [ + ...new Set( + activeCards + .filter((c) => c.zone === "COMMANDER") + .flatMap((c) => c.card.colorIdentity), + ), + ]; + const browsing = isOwner && browserOpen; return ( -
+
-
+
{lists}
- + {!browsing && }
+ {isOwner && ( + setBrowserOpen(false)} + deckId={deck.id} + format={deck.format} + categories={categoryNames} + cards={activeCards} + dispatch={activeDispatch} + commanderIdentity={commanderIdentity} + /> + )}
); } diff --git a/app/_components/builder/deck-search-context.tsx b/app/_components/builder/deck-search-context.tsx index 48aafbc..2b2774f 100644 --- a/app/_components/builder/deck-search-context.tsx +++ b/app/_components/builder/deck-search-context.tsx @@ -38,6 +38,8 @@ interface DeckSearchContextValue { scrollToId: string | null; requestScrollTo: (id: string) => void; consumeScrollTo: () => void; + browseTick: number; + requestBrowse: () => void; reset: () => void; } @@ -51,6 +53,7 @@ export function DeckSearchProvider({ children }: { children: ReactNode }) { const [query, setQuery] = useState(""); const [meta, setMeta] = useState(EMPTY_META); const [scrollToId, setScrollToId] = useState(null); + const [browseTick, setBrowseTick] = useState(0); const registerMeta = useCallback((next: DeckSearchMeta) => { setMeta(next); @@ -64,6 +67,8 @@ export function DeckSearchProvider({ children }: { children: ReactNode }) { setScrollToId(null); }, []); + const requestBrowse = useCallback(() => setBrowseTick((t) => t + 1), []); + const reset = useCallback(() => { setQuery(""); setScrollToId(null); @@ -86,6 +91,8 @@ export function DeckSearchProvider({ children }: { children: ReactNode }) { scrollToId, requestScrollTo, consumeScrollTo, + browseTick, + requestBrowse, reset, }), [ @@ -97,6 +104,8 @@ export function DeckSearchProvider({ children }: { children: ReactNode }) { scrollToId, requestScrollTo, consumeScrollTo, + browseTick, + requestBrowse, reset, ], ); diff --git a/app/_components/builder/decklist-toolbar.tsx b/app/_components/builder/decklist-toolbar.tsx index 353fcd6..ab421aa 100644 --- a/app/_components/builder/decklist-toolbar.tsx +++ b/app/_components/builder/decklist-toolbar.tsx @@ -19,7 +19,13 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Kbd } from "@/components/ui/kbd"; -import { ChevronDown, Eye, ListRestart, Mountain, Wand2 } from "lucide-react"; +import { + ChevronDown, + Eye, + ListRestart, + Mountain, + Wand2, +} from "lucide-react"; import { useDeckViewOptions, type DeckViewOptionKey, diff --git a/app/_components/decks/deck-card-preview.tsx b/app/_components/decks/deck-card-preview.tsx index b9f4766..5149f0c 100644 --- a/app/_components/decks/deck-card-preview.tsx +++ b/app/_components/decks/deck-card-preview.tsx @@ -46,7 +46,8 @@ function timeAgo(date: Date | string): string { if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); if (days < 30) return `${days}d ago`; - return d.toLocaleDateString(); + // Locale-independent YYYY-MM-DD so SSR and client agree (no hydration drift). + return d.toISOString().slice(0, 10); } function releasedLabel(date: Date | string): string { diff --git a/app/_components/header-search/deck-mode-bar.tsx b/app/_components/header-search/deck-mode-bar.tsx index 57619a9..d8fd063 100644 --- a/app/_components/header-search/deck-mode-bar.tsx +++ b/app/_components/header-search/deck-mode-bar.tsx @@ -13,6 +13,7 @@ import { ArrowLeft, ChevronRight, Keyboard, + LayoutGrid, Search as SearchIcon, X as XIcon, } from "lucide-react"; @@ -21,6 +22,12 @@ import { type DeckRouteSignal, } from "@/app/_components/header-search/header-search-context"; import { useDeckSearch } from "@/app/_components/builder/deck-search-context"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { partitionShortcuts, type ShortcutEntry, @@ -770,6 +777,22 @@ export function DeckModeBar({ deckRoute }: { deckRoute: DeckRouteSignal }) { onKeyDown={onInputKeyDown} className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground disabled:opacity-50" /> + {isOwner && view === "list" && ( + + + e.preventDefault()} + onClick={() => search?.requestBrowse()} + className="shrink-0 text-muted-foreground hover:text-foreground" + > + + + Open card browser + + + )} {query || view === "destination" || view === "shortcuts" ? (
diff --git a/app/_components/header-search/header-search-bar.tsx b/app/_components/header-search/header-search-bar.tsx index 422659e..c1785c8 100644 --- a/app/_components/header-search/header-search-bar.tsx +++ b/app/_components/header-search/header-search-bar.tsx @@ -1,20 +1,17 @@ "use client"; -import { useSyncExternalStore } from "react"; import { useHeaderSearch } from "@/app/_components/header-search/header-search-context"; import { SimpleBar } from "./simple-bar"; import { DeckModeBar } from "./deck-mode-bar"; -const noopSubscribe = () => () => {}; - export function HeaderSearchBar() { const { deckRoute } = useHeaderSearch(); - const hydrated = useSyncExternalStore( - noopSubscribe, - () => true, - () => false, - ); - if (hydrated && deckRoute) { + // deckRoute is null on the server and on the first client render (it is + // seeded by DeckRouteBridge's layout effect), so SSR and hydration both + // render SimpleBar — no mismatch. The layout effect then sets deckRoute + // before paint, so DeckModeBar (and its owner controls) appears on the + // first visible frame rather than a frame later. + if (deckRoute) { return ; } return ; diff --git a/app/_components/header-search/header-search-context.tsx b/app/_components/header-search/header-search-context.tsx index 0e6a288..159bf41 100644 --- a/app/_components/header-search/header-search-context.tsx +++ b/app/_components/header-search/header-search-context.tsx @@ -5,6 +5,7 @@ import { useCallback, useContext, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -12,6 +13,11 @@ import { } from "react"; import { Zone } from "@/lib/generated/prisma/enums"; +// useLayoutEffect on the server warns; fall back to useEffect there. The +// component using it (DeckRouteBridge) renders null on the server anyway. +const useIsomorphicLayoutEffect = + typeof window !== "undefined" ? useLayoutEffect : useEffect; + export interface DeckRouteSignal { deckId: string; isOwner: boolean; @@ -165,7 +171,11 @@ export function HeaderSearchProvider({ children }: { children: ReactNode }) { */ export function DeckRouteBridge({ deckId, isOwner }: DeckRouteSignal) { const { registerDeckRoute } = useHeaderSearch(); - useEffect(() => { + // Layout effect (not useEffect) so the route registers before the browser + // paints — otherwise the header paints SimpleBar first, then swaps in + // DeckModeBar a frame later, making the owner controls (e.g. the Browse + // cards icon) appear to load in slowly. + useIsomorphicLayoutEffect(() => { registerDeckRoute({ deckId, isOwner }); return () => registerDeckRoute(null); }, [registerDeckRoute, deckId, isOwner]); diff --git a/app/api/cards/browse/route.test.ts b/app/api/cards/browse/route.test.ts new file mode 100644 index 0000000..e719462 --- /dev/null +++ b/app/api/cards/browse/route.test.ts @@ -0,0 +1,97 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/rate-limit/redis", () => ({ rateLimit: vi.fn() })); +vi.mock("@/lib/rate-limit/request", () => ({ getClientIp: vi.fn(() => "1.2.3.4") })); +vi.mock("@/lib/search/card-search", () => ({ searchCardsBySyntax: vi.fn() })); + +import { GET } from "./route"; +import { rateLimit } from "@/lib/rate-limit/redis"; +import { searchCardsBySyntax } from "@/lib/search/card-search"; + +const rateLimitMock = vi.mocked(rateLimit); +const searchMock = vi.mocked(searchCardsBySyntax); + +function req(path: string) { + return new NextRequest(`http://localhost${path}`); +} + +function allow() { + rateLimitMock.mockResolvedValue({ + success: true, + limit: 90, + remaining: 89, + resetSeconds: 60, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /api/cards/browse", () => { + it("returns 429 with rate-limit headers when denied", async () => { + rateLimitMock.mockResolvedValue({ + success: false, + limit: 90, + remaining: 0, + resetSeconds: 7, + }); + + const res = await GET(req("/api/cards/browse?q=c:U")); + + expect(res.status).toBe(429); + expect(res.headers.get("Retry-After")).toBe("7"); + expect(searchMock).not.toHaveBeenCalled(); + }); + + it("returns an empty array for a missing query without hitting the table", async () => { + allow(); + + const res = await GET(req("/api/cards/browse")); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual([]); + expect(searchMock).not.toHaveBeenCalled(); + expect(res.headers.get("X-RateLimit-Remaining")).toBe("89"); + }); + + it("returns an empty array for a whitespace query", async () => { + allow(); + + const res = await GET(req("/api/cards/browse?q=%20%20")); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual([]); + expect(searchMock).not.toHaveBeenCalled(); + }); + + it("parses the syntax and returns results with pagination", async () => { + allow(); + const results = [{ id: 1, name: "Counterspell" }]; + searchMock.mockResolvedValue( + results as unknown as Awaited>, + ); + + const res = await GET(req("/api/cards/browse?q=c%3AU+t%3Ainstant&limit=20&offset=40")); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual(results); + const [parsed, colors, types, limit, offset] = searchMock.mock.calls[0]!; + expect(parsed.colors).toEqual(["U"]); + expect(parsed.typeFragments).toEqual(["instant"]); + expect(colors).toEqual([]); + expect(types).toEqual([]); + expect(limit).toBe(20); + expect(offset).toBe(40); + }); + + it("returns 400 when the query exceeds the max length", async () => { + allow(); + + const res = await GET(req(`/api/cards/browse?q=${"a".repeat(65)}`)); + + expect(res.status).toBe(400); + expect(searchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/app/api/cards/browse/route.ts b/app/api/cards/browse/route.ts new file mode 100644 index 0000000..96538c9 --- /dev/null +++ b/app/api/cards/browse/route.ts @@ -0,0 +1,64 @@ +import { type NextRequest } from "next/server"; +import { searchCardsBySyntax } from "@/lib/search/card-search"; +import { parseSyntax } from "@/lib/search/syntax-parser"; +import { rateLimit } from "@/lib/rate-limit/redis"; +import { getClientIp } from "@/lib/rate-limit/request"; + +const MAX_Q_LENGTH = 64; +const RATE_LIMIT = 90; +const RATE_WINDOW_SECONDS = 60; +const DEFAULT_LIMIT = 60; +const MAX_LIMIT = 120; + +export async function GET(request: NextRequest) { + const ip = getClientIp(request); + const limit = await rateLimit(`cards-browse:${ip}`, RATE_LIMIT, RATE_WINDOW_SECONDS); + + if (!limit.success) { + return Response.json( + { error: "Too many requests" }, + { + status: 429, + headers: { + "Retry-After": String(limit.resetSeconds), + "X-RateLimit-Limit": String(limit.limit), + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": String(limit.resetSeconds), + }, + }, + ); + } + + const rateHeaders = { + "X-RateLimit-Limit": String(limit.limit), + "X-RateLimit-Remaining": String(limit.remaining), + "X-RateLimit-Reset": String(limit.resetSeconds), + }; + + const q = request.nextUrl.searchParams.get("q"); + + // Empty/whitespace query returns nothing — the browse grid must never stream + // the entire card table when no filter is active. + if (!q || !q.trim()) { + return Response.json([], { headers: rateHeaders }); + } + + if (q.length > MAX_Q_LENGTH) { + return Response.json( + { error: `Query parameter q must be ${MAX_Q_LENGTH} characters or fewer` }, + { status: 400 }, + ); + } + + const limitRaw = request.nextUrl.searchParams.get("limit"); + const pageLimit = Math.min( + MAX_LIMIT, + Math.max(1, Number(limitRaw ?? DEFAULT_LIMIT) | 0 || DEFAULT_LIMIT), + ); + const offsetRaw = request.nextUrl.searchParams.get("offset"); + const offset = Math.max(0, Number(offsetRaw ?? "0") | 0); + + const parsed = parseSyntax(q.trim()); + const results = await searchCardsBySyntax(parsed, [], [], pageLimit, offset); + return Response.json(results, { headers: rateHeaders }); +} diff --git a/app/globals.css b/app/globals.css index f78bcc2..8bf5a91 100644 --- a/app/globals.css +++ b/app/globals.css @@ -263,4 +263,108 @@ font-family: "Arial", sans-serif; text-align: center; z-index: 1; -} \ No newline at end of file +} +/* ── Card browser (issue #22) ───────────────────────────────────────────── + Range slider, scrollbars, and paint-safe entrance motion ported from the + design handoff. Every entrance keyframe rests at opacity:1 with only a small + transform offset, so a frozen timeline (reduced motion, throttled tab) still + paints the element fully visible at its resting position. */ + +/* Thin, on-brand scrollbars (panel body) */ +.scroll-thin { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} +.scroll-thin::-webkit-scrollbar { + width: 9px; + height: 9px; +} +.scroll-thin::-webkit-scrollbar-thumb { + background: color-mix(in oklab, var(--foreground) 18%, transparent); + border-radius: 99px; + border: 2px solid transparent; + background-clip: padding-box; +} +.scroll-thin::-webkit-scrollbar-thumb:hover { + background: color-mix(in oklab, var(--foreground) 30%, transparent); +} + +/* Hide scrollbar but keep scrolling (tray filmstrip) */ +.scroll-none { + scrollbar-width: none; +} +.scroll-none::-webkit-scrollbar { + display: none; +} + +/* Range slider (mana value) — minimal, monochrome */ +input[type="range"].md-range { + -webkit-appearance: none; + appearance: none; + height: 3px; + border-radius: 99px; + background: var(--border); + outline: none; +} +input[type="range"].md-range::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--foreground); + cursor: pointer; + border: 2px solid var(--background); +} +input[type="range"].md-range::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--foreground); + cursor: pointer; + border: 2px solid var(--background); +} + +/* Entrance motion — paint-safe (see note above) */ +.anim-fade, +.anim-slide-right, +.anim-slide-up { + opacity: 1; +} + +@keyframes md-slide-right { + from { + transform: translateX(16px); + } + to { + transform: none; + } +} +@keyframes md-slide-up { + from { + transform: translateY(16px); + } + to { + transform: none; + } +} +@keyframes md-fade-in { + from { + transform: translateY(4px); + } + to { + transform: none; + } +} + +@media (prefers-reduced-motion: no-preference) { + .anim-fade { + animation: md-fade-in 0.18s ease both; + } + .anim-slide-right { + animation: md-slide-right 0.26s cubic-bezier(0.2, 0.7, 0.2, 1) both; + } + .anim-slide-up { + animation: md-slide-up 0.28s cubic-bezier(0.2, 0.7, 0.2, 1) both; + } +} diff --git a/lib/search/__tests__/serialize-where.test.ts b/lib/search/__tests__/serialize-where.test.ts new file mode 100644 index 0000000..e40a4c2 --- /dev/null +++ b/lib/search/__tests__/serialize-where.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { + parseSyntax, + serializeWhere, + type ParsedWhere, +} from "../syntax-parser"; + +const empty: ParsedWhere = { + nameFragments: [], + colors: [], + typeFragments: [], + cmcFilters: [], + oracleFragments: [], +}; + +describe("serializeWhere — filter → syntax mapping", () => { + it("serializes nothing for an empty filter", () => { + expect(serializeWhere(empty)).toBe(""); + }); + + it("maps colors to a single c: token in WUBRG order", () => { + expect(serializeWhere({ ...empty, colors: ["G", "W", "U"] })).toBe("c:WUG"); + }); + + it("emits one t: token per type fragment", () => { + expect( + serializeWhere({ ...empty, typeFragments: ["creature", "artifact"] }), + ).toBe("t:creature t:artifact"); + }); + + it("maps cmc bounds to cmc>= / cmc<=", () => { + expect( + serializeWhere({ + ...empty, + cmcFilters: [ + { op: ">=", value: 2 }, + { op: "<=", value: 5 }, + ], + }), + ).toBe("cmc>=2 cmc<=5"); + }); + + it("maps oracle words to o: and quotes phrases", () => { + expect( + serializeWhere({ ...empty, oracleFragments: ["flying", "draw a card"] }), + ).toBe('o:flying o:"draw a card"'); + }); + + it("quotes multi-word name fragments", () => { + expect(serializeWhere({ ...empty, nameFragments: ["sol", "ring of"] })).toBe( + 'sol "ring of"', + ); + }); +}); + +describe("serializeWhere ↔ parseSyntax round-trip", () => { + const cases: ParsedWhere[] = [ + { ...empty, colors: ["W", "U", "B", "R", "G"] }, + { ...empty, typeFragments: ["creature"] }, + { + ...empty, + colors: ["U"], + typeFragments: ["instant"], + cmcFilters: [{ op: "<=", value: 3 }], + oracleFragments: ["flying"], + }, + { + ...empty, + nameFragments: ["bolt"], + cmcFilters: [ + { op: ">=", value: 1 }, + { op: "<=", value: 4 }, + ], + }, + { ...empty, oracleFragments: ["draw a card"] }, + ]; + + it.each(cases)("parseSyntax(serializeWhere(p)) === p for %o", (p) => { + expect(parseSyntax(serializeWhere(p))).toEqual(p); + }); + + it("is stable under a second round-trip", () => { + for (const p of cases) { + const once = serializeWhere(p); + expect(serializeWhere(parseSyntax(once))).toBe(once); + } + }); +}); diff --git a/lib/search/card-search.ts b/lib/search/card-search.ts index 2563554..4c97d61 100644 --- a/lib/search/card-search.ts +++ b/lib/search/card-search.ts @@ -114,6 +114,7 @@ export async function searchCardsBySyntax( colors: string[] = [], chipTypes: string[] = [], limit = 60, + offset = 0, ): Promise { "use cache"; cacheLife("minutes"); @@ -192,6 +193,7 @@ export async function searchCardsBySyntax( ${whereClause} ORDER BY c.name LIMIT ${limit} + OFFSET ${offset} `); return rows.map((row) => ({ diff --git a/lib/search/syntax-parser.ts b/lib/search/syntax-parser.ts index 4ddd234..4745681 100644 --- a/lib/search/syntax-parser.ts +++ b/lib/search/syntax-parser.ts @@ -106,3 +106,41 @@ export function parseSyntax(input: string): ParsedWhere { return result; } +const WUBRG_ORDER = ["W", "U", "B", "R", "G"] as const; + +/** + * Serialize a {@link ParsedWhere} back into the Scryfall-syntax dialect this + * module parses — the inverse of {@link parseSyntax}. Emits, in order: name + * fragments, `c:WUBRG`, `t:` per type, `cmcN`, then `o:` oracle + * fragments. Fragments containing whitespace are quoted. The output is stable + * under `parseSyntax(serializeWhere(p))`, so the Filters tab and the syntax box + * round-trip through a single source-of-truth query string. + */ +export function serializeWhere(p: ParsedWhere): string { + const parts: string[] = []; + + for (const frag of p.nameFragments) { + parts.push(/\s/.test(frag) ? `"${frag}"` : frag); + } + + if (p.colors.length > 0) { + const present = new Set(p.colors.map((c) => c.toUpperCase())); + const ordered = WUBRG_ORDER.filter((c) => present.has(c)); + if (ordered.length > 0) parts.push(`c:${ordered.join("")}`); + } + + for (const type of p.typeFragments) { + parts.push(`t:${type}`); + } + + for (const { op, value } of p.cmcFilters) { + parts.push(`cmc${op}${value}`); + } + + for (const frag of p.oracleFragments) { + parts.push(/\s/.test(frag) ? `o:"${frag}"` : `o:${frag}`); + } + + return parts.join(" "); +} + From 06957210fb700f9723746ecde6e1a5e4ec5401bd Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Mon, 8 Jun 2026 17:11:40 -0400 Subject: [PATCH 2/7] feat(builder): refine card browser tray and panel UX Polish the card browser added in the previous commit so it stays out of the way and dismisses naturally on mobile and desktop. Key changes: - Close the scry tray and side panel on outside pointer-down (ignoring Radix dropdown portals), replacing the explicit chevron close button - Render BulkBar inline as a full-width strip inside the tray instead of a floating pill, via a new `inline` prop - Hide the filmstrip until there's a query or active filter so an empty tray no longer covers the deck - Gate the filter-builder card-name field behind a `showName` prop; the tray's query bar already takes names, only the side panel shows it - Cap tray height and add safe-area padding; let toolbar and action row rows wrap on narrow widths --- .../builder/card-browser/bulk-bar.tsx | 18 +++-- .../card-browser/filter-builder.test.tsx | 1 + .../builder/card-browser/filter-builder.tsx | 36 +++++----- .../builder/card-browser/scry-tray.tsx | 70 ++++++++++--------- .../builder/card-browser/side-panel.tsx | 19 ++++- app/_components/builder/decklist-toolbar.tsx | 2 +- app/_components/deck/deck-action-row.tsx | 2 +- 7 files changed, 89 insertions(+), 59 deletions(-) diff --git a/app/_components/builder/card-browser/bulk-bar.tsx b/app/_components/builder/card-browser/bulk-bar.tsx index 199d5be..61b62d8 100644 --- a/app/_components/builder/card-browser/bulk-bar.tsx +++ b/app/_components/builder/card-browser/bulk-bar.tsx @@ -1,16 +1,26 @@ "use client"; import { Plus } from "lucide-react"; -import { toTitleCase } from "@/lib/utils"; +import { cn, toTitleCase } from "@/lib/utils"; import { useDeckBrowser } from "./deck-browser-context"; -/** Floating multi-select action bar; shown while items are selected. */ -export function BulkBar({ target }: { target: string | null }) { +/** + * Multi-select action bar; shown while items are selected. + * `inline` renders a full-width tray strip; default is a floating pill. + */ +export function BulkBar({ target, inline = false }: { target: string | null; inline?: boolean }) { const deck = useDeckBrowser(); const n = deck.selected.size; const label = target ? toTitleCase(target) : "Mainboard"; return ( -
+
{n} selected
- {/* filmstrip */} + {deck.selectMode && } + + {/* filters sheet */} + {showFilters && ( +
+
+ +
+
+ )} + + {/* filmstrip — hidden while picking filters, and until there's a query + or filter so an empty tray doesn't cover the deck */} + {!showFilters && browser.raw.trim() !== "" && (
{browser.results.length === 0 ? (
{browser.error ? browser.error - : browser.raw.trim() === "" - ? "Pick filters or type a query to browse cards." - : browser.loading - ? "Searching…" - : "No cards match — adjust your filters."} + : browser.loading + ? "Searching…" + : "No cards match — adjust your filters."}
) : ( <> @@ -194,11 +201,6 @@ export function ScryTray({ · deck stays visible above · tap or flick ↑ to add
- - {deck.selectMode && ( -
- -
)}
); diff --git a/app/_components/builder/card-browser/side-panel.tsx b/app/_components/builder/card-browser/side-panel.tsx index f2c2be4..625ebed 100644 --- a/app/_components/builder/card-browser/side-panel.tsx +++ b/app/_components/builder/card-browser/side-panel.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useRef } from "react"; import { CheckSquare, ChevronRight, Search } from "lucide-react"; import { Eyebrow } from "@/components/ui/eyebrow"; import { cn } from "@/lib/utils"; @@ -23,8 +24,24 @@ export function SidePanel({ onClose: () => void; }) { const deck = useDeckBrowser(); + const panelRef = useRef(null); + + // Close when the user points outside the panel (e.g. taps the decklist). + // Radix dropdowns (TargetPicker) portal to , so ignore those too. + useEffect(() => { + function onPointerDown(e: PointerEvent) { + const target = e.target as Element | null; + if (panelRef.current?.contains(target)) return; + if (target?.closest("[data-radix-popper-content-wrapper]")) return; + onClose(); + } + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, [onClose]); + return (
{browser.mode === "filters" && (
- +
)} diff --git a/app/_components/builder/decklist-toolbar.tsx b/app/_components/builder/decklist-toolbar.tsx index ab421aa..df670e1 100644 --- a/app/_components/builder/decklist-toolbar.tsx +++ b/app/_components/builder/decklist-toolbar.tsx @@ -132,7 +132,7 @@ export function DecklistToolbar({ sortDir={sortDir} onChange={handleChange} /> -
+
diff --git a/app/_components/deck/deck-action-row.tsx b/app/_components/deck/deck-action-row.tsx index 1f8c18a..c2b5b8c 100644 --- a/app/_components/deck/deck-action-row.tsx +++ b/app/_components/deck/deck-action-row.tsx @@ -105,7 +105,7 @@ export function DeckActionRow({ if (!isOwner) { if (isPrivate) return null; return ( -
+
{showSave && ( From c4655726c88eba1842ff402a77af235c53149d3f Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Mon, 8 Jun 2026 17:24:53 -0400 Subject: [PATCH 3/7] test(builder): cover card-browser hooks to satisfy coverage gate The card-browser surface shipped with use-card-browser, use-media-query, and browser-state untested, dropping global coverage below the 100/99 thresholds and failing CI. Key changes: - Add use-card-browser.test.tsx: fetch/debounce, empty-query gate, hasMore paging, 429 back-off + retry, error/abort/non-array paths, post-unmount guards, and showMore append/no-op/throw/non-array - Add use-media-query.test.tsx: match state, change toggle, unmount cleanup, and the server-snapshot fallback via SSR render - Fix use-card-browser: clear `error` on a successful page-one fetch so a 429 that retries to success drops the stale "Too many searches" warning (mirrors the header-search hook) - Exclude type-only browser-state.ts from coverage --- .../card-browser/use-card-browser.test.tsx | 276 ++++++++++++++++++ .../builder/card-browser/use-card-browser.ts | 1 + .../card-browser/use-media-query.test.tsx | 69 +++++ vitest.config.ts | 1 + 4 files changed, 347 insertions(+) create mode 100644 app/_components/builder/card-browser/use-card-browser.test.tsx create mode 100644 app/_components/builder/card-browser/use-media-query.test.tsx diff --git a/app/_components/builder/card-browser/use-card-browser.test.tsx b/app/_components/builder/card-browser/use-card-browser.test.tsx new file mode 100644 index 0000000..991cfb3 --- /dev/null +++ b/app/_components/builder/card-browser/use-card-browser.test.tsx @@ -0,0 +1,276 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; + +import { useCardBrowser } from "./use-card-browser"; +import type { CardSearchResult } from "@/lib/search/card-search"; + +const PAGE_SIZE = 60; + +function card(id: number): CardSearchResult { + return { id, name: `Card ${id}` } as unknown as CardSearchResult; +} + +const fullPage = Array.from({ length: PAGE_SIZE }, (_, i) => card(i)); + +function mockRes(opts: { + ok: boolean; + status: number; + headers?: Record; + json: unknown; +}): Response { + return { + ok: opts.ok, + status: opts.status, + headers: { get: (k: string) => opts.headers?.[k] ?? null }, + json: async () => opts.json, + } as unknown as Response; +} + +function abortError() { + return Object.assign(new Error("aborted"), { name: "AbortError" }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("useCardBrowser", () => { + it("debounces, fetches page one, and exposes results", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(mockRes({ ok: true, status: 200, json: [card(1)] })); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useCardBrowser("bolt")); + + await waitFor(() => expect(result.current.results).toHaveLength(1)); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain("q=bolt"); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain("offset=0"); + expect(result.current.loading).toBe(false); + expect(result.current.hasMore).toBe(false); + expect(result.current.count).toBe(1); + expect(result.current.error).toBeNull(); + }); + + it("never fetches for an empty / whitespace query", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useCardBrowser(" ")); + await new Promise((r) => setTimeout(r, 400)); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.current.results).toEqual([]); + expect(result.current.loading).toBe(false); + }); + + it("clears results when the query drops back to empty", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(mockRes({ ok: true, status: 200, json: [card(1)] })); + vi.stubGlobal("fetch", fetchMock); + + const { result, rerender } = renderHook( + ({ q }: { q: string }) => useCardBrowser(q), + { initialProps: { q: "bolt" } }, + ); + await waitFor(() => expect(result.current.results).toHaveLength(1)); + + rerender({ q: "" }); + await waitFor(() => expect(result.current.results).toEqual([])); + expect(result.current.loading).toBe(false); + }); + + it("spins while a newly-typed query debounces in", async () => { + // Never-resolving fetch keeps the request in flight so the transient + // loading=true from the render-sync block stays observable. + const fetchMock = vi.fn(() => new Promise(() => {})); + vi.stubGlobal("fetch", fetchMock); + + const { result, rerender } = renderHook( + ({ q }: { q: string }) => useCardBrowser(q), + { initialProps: { q: "" } }, + ); + expect(result.current.loading).toBe(false); + + rerender({ q: "bolt" }); + // Debounce fires -> render-sync block flips loading true before the fetch. + await waitFor(() => expect(result.current.loading).toBe(true)); + expect(fetchMock).toHaveBeenCalled(); + }); + + it("flags hasMore when page one fills PAGE_SIZE", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(mockRes({ ok: true, status: 200, json: fullPage })); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useCardBrowser("bolt")); + + await waitFor(() => expect(result.current.results).toHaveLength(PAGE_SIZE)); + expect(result.current.hasMore).toBe(true); + }); + + it("backs off on 429 and auto-retries", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + mockRes({ + ok: false, + status: 429, + headers: { "Retry-After": "1" }, + json: {}, + }), + ) + .mockResolvedValueOnce(mockRes({ ok: true, status: 200, json: [card(1)] })); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useCardBrowser("bolt")); + + await waitFor(() => + expect(result.current.error).toBe("Too many searches — retrying…"), + ); + expect(result.current.loading).toBe(false); + await waitFor(() => expect(result.current.results).toHaveLength(1), { + timeout: 3000, + }); + expect(result.current.error).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("surfaces a generic error for a non-429 failure", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(mockRes({ ok: false, status: 500, json: {} })); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useCardBrowser("bolt")); + + await waitFor(() => + expect(result.current.error).toBe("Search failed. Try again."), + ); + expect(result.current.results).toEqual([]); + expect(result.current.hasMore).toBe(false); + }); + + it("surfaces a generic error when the request throws", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("network down")); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useCardBrowser("bolt")); + + await waitFor(() => + expect(result.current.error).toBe("Search failed. Try again."), + ); + }); + + it("swallows abort errors without surfacing them", async () => { + const fetchMock = vi.fn().mockRejectedValue(abortError()); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useCardBrowser("bolt")); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 0)); + expect(result.current.error).toBeNull(); + }); + + it("treats a non-array payload as empty results", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(mockRes({ ok: true, status: 200, json: { oops: true } })); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useCardBrowser("bolt")); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.results).toEqual([]); + expect(result.current.error).toBeNull(); + }); + + it("ignores a fetch that resolves after unmount", async () => { + let resolveFetch!: (r: Response) => void; + const fetchMock = vi.fn( + () => new Promise((r) => { resolveFetch = r; }), + ); + vi.stubGlobal("fetch", fetchMock); + + const { unmount } = renderHook(() => useCardBrowser("bolt")); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + + unmount(); + resolveFetch(mockRes({ ok: true, status: 200, json: [card(1)] })); + await new Promise((r) => setTimeout(r, 0)); + }); + + it("appends a second page via showMore and updates hasMore", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(mockRes({ ok: true, status: 200, json: fullPage })) + .mockResolvedValueOnce(mockRes({ ok: true, status: 200, json: [card(99)] })); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useCardBrowser("bolt")); + await waitFor(() => expect(result.current.hasMore).toBe(true)); + + act(() => result.current.showMore()); + await waitFor(() => expect(result.current.loadingMore).toBe(false)); + + expect(result.current.results).toHaveLength(PAGE_SIZE + 1); + expect(result.current.hasMore).toBe(false); + expect(String(fetchMock.mock.calls[1]?.[0])).toContain(`offset=${PAGE_SIZE}`); + }); + + it("showMore no-ops when there is no next page", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(mockRes({ ok: true, status: 200, json: [card(1)] })); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useCardBrowser("bolt")); + await waitFor(() => expect(result.current.results).toHaveLength(1)); + + act(() => result.current.showMore()); + await new Promise((r) => setTimeout(r, 0)); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("showMore stops paginating when the next page throws", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(mockRes({ ok: true, status: 200, json: fullPage })) + .mockRejectedValueOnce(new Error("boom")); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useCardBrowser("bolt")); + await waitFor(() => expect(result.current.hasMore).toBe(true)); + + act(() => result.current.showMore()); + await waitFor(() => expect(result.current.loadingMore).toBe(false)); + + expect(result.current.hasMore).toBe(false); + expect(result.current.results).toHaveLength(PAGE_SIZE); + }); + + it("showMore treats a non-array second page as empty", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(mockRes({ ok: true, status: 200, json: fullPage })) + .mockResolvedValueOnce(mockRes({ ok: true, status: 200, json: null })); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useCardBrowser("bolt")); + await waitFor(() => expect(result.current.hasMore).toBe(true)); + + act(() => result.current.showMore()); + await waitFor(() => expect(result.current.loadingMore).toBe(false)); + + expect(result.current.results).toHaveLength(PAGE_SIZE); + expect(result.current.hasMore).toBe(false); + }); +}); diff --git a/app/_components/builder/card-browser/use-card-browser.ts b/app/_components/builder/card-browser/use-card-browser.ts index dc18304..107a36b 100644 --- a/app/_components/builder/card-browser/use-card-browser.ts +++ b/app/_components/builder/card-browser/use-card-browser.ts @@ -92,6 +92,7 @@ export function useCardBrowser(raw: string): UseCardBrowserState { setResults(items); setOffset(items.length); setHasMore(items.length === PAGE_SIZE); + setError(null); setLoading(false); } catch (e) { if (cancelled) return; diff --git a/app/_components/builder/card-browser/use-media-query.test.tsx b/app/_components/builder/card-browser/use-media-query.test.tsx new file mode 100644 index 0000000..4f07701 --- /dev/null +++ b/app/_components/builder/card-browser/use-media-query.test.tsx @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { useMediaQuery } from "./use-media-query"; + +interface FakeMql { + matches: boolean; + listeners: Set<() => void>; +} + +function stubMatchMedia(matches: boolean) { + const mql: FakeMql = { matches, listeners: new Set() }; + const matchMedia = vi.fn((query: string) => ({ + matches: mql.matches, + media: query, + addEventListener: (_: string, cb: () => void) => mql.listeners.add(cb), + removeEventListener: (_: string, cb: () => void) => mql.listeners.delete(cb), + })); + vi.stubGlobal("matchMedia", matchMedia); + return { + matchMedia, + set(next: boolean) { + mql.matches = next; + for (const cb of mql.listeners) cb(); + }, + get listenerCount() { + return mql.listeners.size; + }, + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("useMediaQuery", () => { + it("reports the current match state for the query", () => { + stubMatchMedia(true); + const { result } = renderHook(() => useMediaQuery("(min-width: 768px)")); + expect(result.current).toBe(true); + }); + + it("re-renders when the media query toggles", () => { + const mm = stubMatchMedia(false); + const { result } = renderHook(() => useMediaQuery("(min-width: 768px)")); + expect(result.current).toBe(false); + + act(() => mm.set(true)); + expect(result.current).toBe(true); + }); + + it("falls back to false on the server snapshot", () => { + function Probe() { + return <>{String(useMediaQuery("(min-width: 768px)"))}; + } + // Server render takes the getServerSnapshot path, never touching matchMedia. + expect(renderToStaticMarkup()).toBe("false"); + }); + + it("unsubscribes the listener on unmount", () => { + const mm = stubMatchMedia(false); + const { unmount } = renderHook(() => useMediaQuery("(min-width: 768px)")); + expect(mm.listenerCount).toBe(1); + + unmount(); + expect(mm.listenerCount).toBe(0); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 134f26f..a0c97fa 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -79,6 +79,7 @@ export default defineConfig({ "lib/card/types-meta.ts", "lib/deck/io/adapters/types.ts", "lib/deck/mutation/types.ts", + "app/_components/builder/card-browser/browser-state.ts", // Next.js route shells — pages/layouts/error boundaries are exercised // via integration/E2E, not unit coverage. The og-image route is a thin From f1c631b0969eb96be20f250326f37a37eb44ca47 Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Mon, 8 Jun 2026 17:40:16 -0400 Subject: [PATCH 4/7] fix(builder): address PR #64 card-browser review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Color search queried the printed colors column; for a Commander-first deckbuilder `c:` means color identity. Plus pagination/leak hardening, a batched bulk-add, and SQL-shape test coverage that made the bug visible. Key changes: - card-search: `c:` filters `color_identity` not `c.colors`; short-circuit to [] when zero conditions so a lone `-foo` can't leak a whole-table top-N - editor-actions: add batched `addCardsToDeck` (one tx + one revalidation via applyChanges); rewrite bulk `addSelected` off the per-card await loop - use-card-browser: monotonic reqId guard drops stale `showMore` appends when the query changes mid-flight - browse route: trim before MAX_Q_LENGTH check; attach rateHeaders to 400 - card-search tests: pin generated SQL (color_identity/cmc/tsv), replace the empty-branch test to assert the [] short-circuit - syntax-parser: correct `colors` JSDoc to "color identity" Follow-up: no GIN index on `color_identity` — `@>` containment seq-scans; add `@@index([colorIdentity], type: Gin)` in a separate migration. --- .../card-browser/deck-browser-context.tsx | 13 ++--- .../card-browser/use-card-browser.test.tsx | 35 ++++++++++++ .../builder/card-browser/use-card-browser.ts | 11 +++- app/api/cards/browse/route.ts | 4 +- lib/deck/editor-actions.ts | 26 +++++++++ lib/search/__tests__/card-search.test.ts | 55 ++++++++++++++++--- lib/search/card-search.ts | 9 ++- lib/search/syntax-parser.ts | 2 +- 8 files changed, 131 insertions(+), 24 deletions(-) diff --git a/app/_components/builder/card-browser/deck-browser-context.tsx b/app/_components/builder/card-browser/deck-browser-context.tsx index 6ef90af..3c726c3 100644 --- a/app/_components/builder/card-browser/deck-browser-context.tsx +++ b/app/_components/builder/card-browser/deck-browser-context.tsx @@ -11,6 +11,7 @@ import { } from "react"; import { addCardToDeck, + addCardsToDeck, removeCardFromDeck, updateCardQuantity, } from "@/lib/deck/editor-actions"; @@ -168,13 +169,11 @@ export function DeckBrowserProvider({ const picked = [...selected.values()]; if (picked.length === 0) return; startTransition(async () => { - for (const card of picked) { - await addCardToDeck(deckId, card.id, { - quantity: 1, - zone: Zone.MAINBOARD, - category: dest, - }); - } + await addCardsToDeck( + deckId, + picked.map((c) => ({ cardId: c.id })), + { zone: Zone.MAINBOARD, category: dest }, + ); setSelected(new Map()); setSelectModeState(false); }); diff --git a/app/_components/builder/card-browser/use-card-browser.test.tsx b/app/_components/builder/card-browser/use-card-browser.test.tsx index 991cfb3..a352b47 100644 --- a/app/_components/builder/card-browser/use-card-browser.test.tsx +++ b/app/_components/builder/card-browser/use-card-browser.test.tsx @@ -257,6 +257,41 @@ describe("useCardBrowser", () => { expect(result.current.results).toHaveLength(PAGE_SIZE); }); + it("ignores a stale showMore append after the query changes mid-flight", async () => { + let resolveAppend!: (r: Response) => void; + const fetchMock = vi + .fn() + // page one for "bolt" + .mockResolvedValueOnce(mockRes({ ok: true, status: 200, json: fullPage })) + // showMore append — held open until we swap the query + .mockImplementationOnce( + () => new Promise((r) => { resolveAppend = r; }), + ) + // page one for the new query "foo" + .mockResolvedValueOnce(mockRes({ ok: true, status: 200, json: [card(500)] })); + vi.stubGlobal("fetch", fetchMock); + + const { result, rerender } = renderHook( + ({ q }: { q: string }) => useCardBrowser(q), + { initialProps: { q: "bolt" } }, + ); + await waitFor(() => expect(result.current.hasMore).toBe(true)); + + // Kick off an append, then change the query before it resolves. + act(() => result.current.showMore()); + await waitFor(() => expect(result.current.loadingMore).toBe(true)); + + rerender({ q: "foo" }); + await waitFor(() => expect(result.current.results).toEqual([card(500)])); + + // The stale append resolves last — its rows must NOT be merged in. + act(() => resolveAppend(mockRes({ ok: true, status: 200, json: [card(99)] }))); + await new Promise((r) => setTimeout(r, 0)); + + expect(result.current.results).toEqual([card(500)]); + expect(result.current.results.some((c) => c.id === 99)).toBe(false); + }); + it("showMore treats a non-array second page as empty", async () => { const fetchMock = vi .fn() diff --git a/app/_components/builder/card-browser/use-card-browser.ts b/app/_components/builder/card-browser/use-card-browser.ts index 107a36b..cbc9177 100644 --- a/app/_components/builder/card-browser/use-card-browser.ts +++ b/app/_components/builder/card-browser/use-card-browser.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { resolveRetryAfterMs } from "@/app/_components/header-search/retry-after"; import type { CardSearchResult } from "@/lib/search/card-search"; @@ -33,6 +33,9 @@ export function useCardBrowser(raw: string): UseCardBrowserState { const [offset, setOffset] = useState(0); const [error, setError] = useState(null); const [retryNonce, setRetryNonce] = useState(0); + // Monotonic id guarding the append path: a query change or a newer showMore + // bumps it, making any in-flight append stale (its writes bail). + const reqId = useRef(0); const query = debounced.trim(); const active = query.length > 0; @@ -59,6 +62,9 @@ export function useCardBrowser(raw: string): UseCardBrowserState { } useEffect(() => { + // Bump on every query change (effect re-runs on [query, ...]) so any + // in-flight showMore append from the prior query bails when it resolves. + reqId.current++; if (!active) return; const controller = new AbortController(); let cancelled = false; @@ -112,6 +118,7 @@ export function useCardBrowser(raw: string): UseCardBrowserState { const showMore = useCallback(() => { if (!active || !hasMore || loadingMore) return; + const id = ++reqId.current; setLoadingMore(true); void (async () => { try { @@ -120,10 +127,12 @@ export function useCardBrowser(raw: string): UseCardBrowserState { ); const data: unknown = await res.json(); const items = Array.isArray(data) ? (data as CardSearchResult[]) : []; + if (id !== reqId.current) return; // stale append — query changed mid-flight setResults((prev) => [...prev, ...items]); setOffset((o) => o + items.length); setHasMore(items.length === PAGE_SIZE); } catch { + if (id !== reqId.current) return; setHasMore(false); } finally { setLoadingMore(false); diff --git a/app/api/cards/browse/route.ts b/app/api/cards/browse/route.ts index 96538c9..760ec07 100644 --- a/app/api/cards/browse/route.ts +++ b/app/api/cards/browse/route.ts @@ -43,10 +43,10 @@ export async function GET(request: NextRequest) { return Response.json([], { headers: rateHeaders }); } - if (q.length > MAX_Q_LENGTH) { + if (q.trim().length > MAX_Q_LENGTH) { return Response.json( { error: `Query parameter q must be ${MAX_Q_LENGTH} characters or fewer` }, - { status: 400 }, + { status: 400, headers: rateHeaders }, ); } diff --git a/lib/deck/editor-actions.ts b/lib/deck/editor-actions.ts index 0958a72..e6be8d4 100644 --- a/lib/deck/editor-actions.ts +++ b/lib/deck/editor-actions.ts @@ -37,6 +37,32 @@ export const addCardToDeck = runOwnerDeckMutation( }, ); +export const addCardsToDeck = runOwnerDeckMutation( + "deck.addCards", + "none", + async ( + { deckId, userId }, + cards: { cardId: number; quantity?: number; zone?: Zone; category?: string | null }[], + opts?: { zone?: Zone; category?: string | null }, + ): Promise => { + const changes = cards.map((c) => { + const zone = c.zone ?? opts?.zone ?? Zone.MAINBOARD; + const category = c.category ?? opts?.category ?? null; + if (category !== null && zone !== Zone.MAINBOARD) { + throw new Error("Subcategories only apply to MAINBOARD cards"); + } + return { op: "add" as const, cardId: c.cardId, quantity: c.quantity ?? 1, zone, category }; + }); + + try { + await applyChanges(deckId, userId, changes); // one tx + one revalidation + } catch (err) { + if (err instanceof InvariantViolation) return; + throw err; + } + }, +); + export const removeCardFromDeck = runOwnerDeckMutation( "deck.removeCard", "none", diff --git a/lib/search/__tests__/card-search.test.ts b/lib/search/__tests__/card-search.test.ts index c8353cd..1687b2f 100644 --- a/lib/search/__tests__/card-search.test.ts +++ b/lib/search/__tests__/card-search.test.ts @@ -45,6 +45,15 @@ function emptyParsed(overrides: Partial = {}): ParsedWhere { }; } +type SqlCall = { sql?: string; strings?: string[]; values: unknown[] }; + +/** Flatten the composed Prisma.Sql of a $queryRaw call into a searchable haystack + values. */ +function inspect(): { text: string; values: unknown[] } { + const call = mockQueryRaw.mock.calls[0]![0] as SqlCall; + const text = call.sql ?? (call.strings ?? []).join("?"); + return { text, values: call.values }; +} + describe("searchCards", () => { it("returns [] for whitespace-only query without hitting the database", async () => { const result = await searchCards(" "); @@ -115,15 +124,45 @@ describe("searchCards", () => { }); describe("searchCardsBySyntax", () => { - it("returns mapped rows when no conditions are present (Prisma.empty branch)", async () => { - mockQueryRaw.mockResolvedValue([RAW_ROW] as never); - + it("short-circuits to [] when no conditions are present, never hitting the database", async () => { const result = await searchCardsBySyntax(emptyParsed()); - expect(mockQueryRaw).toHaveBeenCalledTimes(1); - expect(mockCacheTag).toHaveBeenCalledWith("card-search"); - expect(result).toHaveLength(1); - expect(result[0]?.name).toBe("Lightning Bolt"); + expect(result).toEqual([]); + expect(mockQueryRaw).not.toHaveBeenCalled(); + }); + + it("filters colors on color_identity (not the printed colors column)", async () => { + mockQueryRaw.mockResolvedValue([] as never); + + await searchCardsBySyntax(emptyParsed({ colors: ["U"] })); + + const { text, values } = inspect(); + expect(text).toContain("color_identity @>"); + expect(text).not.toContain("c.colors @>"); + expect(values).toContain("U"); + }); + + it("builds a cmc comparison condition with the operator and value", async () => { + mockQueryRaw.mockResolvedValue([] as never); + + await searchCardsBySyntax(emptyParsed({ cmcFilters: [{ op: ">=", value: 3 }] })); + + const { text, values } = inspect(); + expect(text).toContain("c.cmc >="); + expect(values).toContain(3); + }); + + it("routes type and oracle fragments through websearch_to_tsquery", async () => { + mockQueryRaw.mockResolvedValue([] as never); + + await searchCardsBySyntax( + emptyParsed({ typeFragments: ["creature"], oracleFragments: ["draw"] }), + ); + + const { text, values } = inspect(); + expect(text).toContain("websearch_to_tsquery"); + expect(values).toContain("creature"); + expect(values).toContain("draw"); }); it("merges parsed colors/types with chip-level colors/types and dedupes", async () => { @@ -208,7 +247,7 @@ describe("searchCardsBySyntax", () => { { ...RAW_ROW, legalities: null, game_changer: null, color_identity: null }, ] as never); - const [row] = await searchCardsBySyntax(emptyParsed()); + const [row] = await searchCardsBySyntax(emptyParsed({ nameFragments: ["bolt"] })); expect(row?.legalities).toEqual({}); expect(row?.gameChanger).toBe(false); diff --git a/lib/search/card-search.ts b/lib/search/card-search.ts index 4c97d61..65eaaaa 100644 --- a/lib/search/card-search.ts +++ b/lib/search/card-search.ts @@ -138,7 +138,7 @@ export async function searchCardsBySyntax( } for (const color of allColors) { - conditions.push(Prisma.sql`c.colors @> ARRAY[${color}]::text[]`); + conditions.push(Prisma.sql`c.color_identity @> ARRAY[${color}]::text[]`); } // Type fragments: use the card_search_tsv GIN index (tsvector over name + @@ -166,10 +166,9 @@ export async function searchCardsBySyntax( ); } - const whereClause = - conditions.length > 0 - ? Prisma.sql`WHERE ${Prisma.join(conditions, " AND ")}` - : Prisma.empty; + if (conditions.length === 0) return []; + + const whereClause = Prisma.sql`WHERE ${Prisma.join(conditions, " AND ")}`; const rows = await prisma.$queryRaw(Prisma.sql` SELECT diff --git a/lib/search/syntax-parser.ts b/lib/search/syntax-parser.ts index 4745681..a231aa2 100644 --- a/lib/search/syntax-parser.ts +++ b/lib/search/syntax-parser.ts @@ -21,7 +21,7 @@ export type ParsedWhere = { /** ILIKE name fragments — ANDed together */ nameFragments: string[]; - /** Colors that must ALL appear in the card's colors array */ + /** Colors that must ALL appear in the card's color identity */ colors: string[]; /** Type line must contain all of these strings */ typeFragments: string[]; From 4a0fb4adaabcbe02492dada933f3b87efbf63b40 Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Mon, 8 Jun 2026 17:48:53 -0400 Subject: [PATCH 5/7] perf(card): add GIN index on color_identity for `c:` search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `c:` color filter (searchCardsBySyntax) issues `color_identity @> ARRAY[...]::text[]`, which seq-scans without an index. Add a GIN index over the text[] column — default array_ops supports @>. Mirrors the card_search_tsv_idx pattern, including the CONCURRENTLY prod-rollout note for a zero-downtime apply. --- .../migration.sql | 18 ++++++++++++++++++ prisma/schema.prisma | 1 + 2 files changed, 19 insertions(+) create mode 100644 prisma/migrations/20260608000000_card_color_identity_gin/migration.sql diff --git a/prisma/migrations/20260608000000_card_color_identity_gin/migration.sql b/prisma/migrations/20260608000000_card_color_identity_gin/migration.sql new file mode 100644 index 0000000..0a1490e --- /dev/null +++ b/prisma/migrations/20260608000000_card_color_identity_gin/migration.sql @@ -0,0 +1,18 @@ +-- Add a GIN index on card.color_identity (text[]) to back the `@>` array +-- containment used by the `c:` color filter in card search (searchCardsBySyntax). +-- Without it, `color_identity @> ARRAY[...]::text[]` falls back to a seq scan. +-- The default GIN array_ops operator class supports @>, <@, &&, and =. + +-- NOTE ON PRODUCTION ROLLOUT: +-- A plain CREATE INDEX takes an ACCESS EXCLUSIVE lock on "card" for the build. +-- For zero-downtime prod apply, run the CONCURRENTLY form manually BEFORE +-- `prisma migrate deploy`, then mark this migration already applied: +-- +-- psql "$DATABASE_URL" <<'SQL' +-- CREATE INDEX CONCURRENTLY IF NOT EXISTS "card_color_identity_idx" +-- ON "card" USING GIN (color_identity); +-- SQL +-- pnpm prisma migrate resolve --applied 20260608000000_card_color_identity_gin + +CREATE INDEX IF NOT EXISTS "card_color_identity_idx" + ON "card" USING GIN (color_identity); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 976b889..4790c64 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -112,6 +112,7 @@ model Card { @@index([name(ops: raw("gin_trgm_ops"))], type: Gin, map: "card_name_trgm_idx") @@index([searchTsv], type: Gin, map: "card_search_tsv_idx") + @@index([colorIdentity], type: Gin, map: "card_color_identity_idx") @@map("card") } From aa479e0082f58c5653bece6f325ef30cc62e382d Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Mon, 8 Jun 2026 17:52:20 -0400 Subject: [PATCH 6/7] fix(builder): address card-browser review findings Resolve still-valid PR review findings on the card browser surfaces. Key changes: - browser-card: make hover overlay non-interactive off-hover so the parent click adds a card in non-select mode (pointer-events-none + group-hover:pointer-events-auto) - browse route: replace `| 0` parsing with parseInt + finite/trunc clamping and cap offset via MAX_OFFSET to bound deep scans - browse route test: cover limit/offset normalization edge cases - header search: reserve fallback height with explicit px (h-[36px]) - globals.css: rename md-fade-in -> md-slide-subtle (transform-only) - target-picker: fix "adds land in" -> "adds cards in" doc typo --- .../builder/card-browser/browser-card.tsx | 2 +- .../builder/card-browser/target-picker.tsx | 2 +- .../header-search-bar-deferred.tsx | 2 +- app/api/cards/browse/route.test.ts | 44 +++++++++++++++++++ app/api/cards/browse/route.ts | 23 +++++++--- app/globals.css | 4 +- 6 files changed, 66 insertions(+), 11 deletions(-) diff --git a/app/_components/builder/card-browser/browser-card.tsx b/app/_components/builder/card-browser/browser-card.tsx index b0512b2..e34843c 100644 --- a/app/_components/builder/card-browser/browser-card.tsx +++ b/app/_components/builder/card-browser/browser-card.tsx @@ -81,7 +81,7 @@ export function BrowserCard({ card }: { card: CardSearchResult }) { {/* hover add */} {!deck.selectMode && (
e.stopPropagation()} > diff --git a/app/_components/builder/card-browser/target-picker.tsx b/app/_components/builder/card-browser/target-picker.tsx index 311e69d..91bfb5e 100644 --- a/app/_components/builder/card-browser/target-picker.tsx +++ b/app/_components/builder/card-browser/target-picker.tsx @@ -17,7 +17,7 @@ interface TargetPickerProps { const MAINBOARD_LABEL = "Mainboard"; -/** Chooses the mainboard category that browser adds land in. `null` = uncategorized. */ +/** Chooses the mainboard category that browser adds cards in. `null` = uncategorized. */ export function TargetPicker({ value, categories, onChange }: TargetPickerProps) { const label = value ? toTitleCase(value) : MAINBOARD_LABEL; return ( diff --git a/app/_components/header-search/header-search-bar-deferred.tsx b/app/_components/header-search/header-search-bar-deferred.tsx index 8557f6e..fc0ad67 100644 --- a/app/_components/header-search/header-search-bar-deferred.tsx +++ b/app/_components/header-search/header-search-bar-deferred.tsx @@ -77,7 +77,7 @@ function RestingInput({ onActivate }: { onActivate: () => void }) { return (
-
+