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..e34843c --- /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..61b62d8 --- /dev/null +++ b/app/_components/builder/card-browser/bulk-bar.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { Plus } from "lucide-react"; +import { cn, toTitleCase } from "@/lib/utils"; +import { useDeckBrowser } from "./deck-browser-context"; + +/** + * 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 + + + 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..3c726c3 --- /dev/null +++ b/app/_components/builder/card-browser/deck-browser-context.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + useTransition, + type ReactNode, +} from "react"; +import { + addCardToDeck, + addCardsToDeck, + 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 () => { + await addCardsToDeck( + deckId, + picked.map((c) => ({ cardId: c.id })), + { 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..3fd622f --- /dev/null +++ b/app/_components/builder/card-browser/filter-builder.test.tsx @@ -0,0 +1,119 @@ +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..0ec8c34 --- /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; + showName?: boolean; +} + +export function FilterBuilder({ parsed, onChange, small, showName }: 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 — hidden in the tray; the query bar above already takes names */} + {showName && ( +
+ onNameChange(e.target.value)} + placeholder="Lightning Bolt" + aria-label="Card name" + spellCheck={false} + autoCapitalize="none" + autoCorrect="off" + className="h-10 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:ring-1 focus:ring-ring" + /> +
+ )} + + {/* 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..8a372d0 --- /dev/null +++ b/app/_components/builder/card-browser/scry-tray.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { CheckSquare, ScanSearch, SlidersHorizontal } from "lucide-react"; +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; + const trayRef = useRef(null); + + // Close when the user points outside the tray (e.g. taps the deck above). + // Radix dropdowns (TargetPicker) portal to , so ignore those too. + useEffect(() => { + function onPointerDown(e: PointerEvent) { + const target = e.target as Element | null; + if (trayRef.current?.contains(target)) return; + if (target?.closest("[data-radix-popper-content-wrapper]")) return; + onClose(); + } + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, [onClose]); + + 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 ( +
+ {/* command row */} +
+
+ + 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 ( + + ); + })} +
+ +
+ + +
+ + {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.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 +
+
+ )} +
+ ); +} 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..625ebed --- /dev/null +++ b/app/_components/builder/card-browser/side-panel.tsx @@ -0,0 +1,173 @@ +"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"; +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(); + 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 ( +
+ {/* 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..91bfb5e --- /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 cards 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.test.tsx b/app/_components/builder/card-browser/use-card-browser.test.tsx new file mode 100644 index 0000000..8de673d --- /dev/null +++ b/app/_components/builder/card-browser/use-card-browser.test.tsx @@ -0,0 +1,381 @@ +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("ignores page-one json that resolves after unmount", async () => { + let resolveJson!: (v: unknown) => void; + const res = { + ok: true, + status: 200, + headers: { get: () => null }, + json: () => new Promise((r) => { resolveJson = r; }), + } as unknown as Response; + const fetchMock = vi.fn().mockResolvedValue(res); + vi.stubGlobal("fetch", fetchMock); + + const { result, unmount } = renderHook(() => useCardBrowser("bolt")); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + // Let the fetch resolve and clear the !res.ok guard, parking on json(). + await new Promise((r) => setTimeout(r, 0)); + + unmount(); + resolveJson([card(1)]); + await new Promise((r) => setTimeout(r, 0)); + + expect(result.current.results).toEqual([]); + }); + + it("ignores a page-one rejection after unmount", async () => { + let rejectFetch!: (e: unknown) => void; + const fetchMock = vi.fn( + () => new Promise((_, rej) => { rejectFetch = rej; }), + ); + vi.stubGlobal("fetch", fetchMock); + + const { result, unmount } = renderHook(() => useCardBrowser("bolt")); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + + unmount(); + rejectFetch(new Error("late failure")); + await new Promise((r) => setTimeout(r, 0)); + + expect(result.current.error).toBeNull(); + }); + + it("ignores a stale showMore rejection after the query changes mid-flight", async () => { + let rejectAppend!: (e: unknown) => void; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(mockRes({ ok: true, status: 200, json: fullPage })) + .mockImplementationOnce( + () => new Promise((_, rej) => { rejectAppend = rej; }), + ) + .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)); + + act(() => result.current.showMore()); + await waitFor(() => expect(result.current.loadingMore).toBe(true)); + + rerender({ q: "foo" }); + await waitFor(() => expect(result.current.results).toEqual([card(500)])); + + // Stale append rejects last — its catch must bail without touching state. + act(() => rejectAppend(new Error("boom"))); + await new Promise((r) => setTimeout(r, 0)); + + expect(result.current.results).toEqual([card(500)]); + }); + + 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("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() + .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 new file mode 100644 index 0000000..cbc9177 --- /dev/null +++ b/app/_components/builder/card-browser/use-card-browser.ts @@ -0,0 +1,152 @@ +"use client"; + +import { useCallback, useEffect, useRef, 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); + // 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; + + 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(() => { + // 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; + 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); + setError(null); + 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; + const id = ++reqId.current; + 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[]) : []; + 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); + } + })(); + }, [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.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/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..df670e1 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, @@ -126,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 && ( 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" ? (