-
Notifications
You must be signed in to change notification settings - Fork 0
feat(builder): add card browser with filters, syntax search, and responsive surfaces #64
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
c15c5b0
feat(builder): add card browser with filters and pagination
jcserv 0695721
feat(builder): refine card browser tray and panel UX
jcserv c465572
test(builder): cover card-browser hooks to satisfy coverage gate
jcserv f1c631b
fix(builder): address PR #64 card-browser review findings
jcserv 4a0fb4a
perf(card): add GIN index on color_identity for `c:` search
jcserv aa479e0
fix(builder): address card-browser review findings
jcserv 6750700
test(builder): close card-browser coverage gaps to 100%
jcserv File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <div | ||
| className="inline-flex items-stretch overflow-hidden rounded-lg border border-border bg-card" | ||
| style={{ height: h }} | ||
| onClick={(e) => e.stopPropagation()} | ||
| > | ||
| <button | ||
| type="button" | ||
| onClick={(e) => { | ||
| e.stopPropagation(); | ||
| deck.remove(card); | ||
| }} | ||
| className="flex items-center justify-center text-foreground hover:bg-muted" | ||
| style={{ width: h - 2 }} | ||
| aria-label="Remove one" | ||
| > | ||
| <Minus className="size-3.5" strokeWidth={2.4} aria-hidden /> | ||
| </button> | ||
| <span | ||
| className="flex items-center justify-center font-mono tabular-nums font-semibold border-x border-border" | ||
| style={{ minWidth: h - 4, fontSize: size === "sm" ? 12 : 13 }} | ||
| > | ||
| {qty} | ||
| </span> | ||
| <button | ||
| type="button" | ||
| onClick={(e) => { | ||
| e.stopPropagation(); | ||
| deck.add(card, 1); | ||
| }} | ||
| className="flex items-center justify-center text-foreground hover:bg-muted" | ||
| style={{ width: h - 2 }} | ||
| aria-label="Add one" | ||
| > | ||
| <Plus className="size-3.5" strokeWidth={2.4} aria-hidden /> | ||
| </button> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <button | ||
| type="button" | ||
| onClick={(e) => { | ||
| e.stopPropagation(); | ||
| deck.add(card, 1); | ||
| }} | ||
| className={cn( | ||
| "inline-flex items-center justify-center gap-1.5 rounded-lg bg-primary px-3 font-medium text-primary-foreground transition-colors hover:bg-primary/90", | ||
| )} | ||
| style={{ height: h, fontSize: size === "sm" ? 12 : 13 }} | ||
| > | ||
| <Plus className="size-3.5" strokeWidth={2.6} aria-hidden /> | ||
| Add | ||
| </button> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <div | ||
| className={cn( | ||
| "group anim-fade relative cursor-pointer overflow-hidden rounded-xl bg-card transition-transform", | ||
| selected | ||
| ? "ring-2 ring-foreground" | ||
| : "border border-border hover:-translate-y-0.5", | ||
| )} | ||
| style={{ aspectRatio: "5 / 7" }} | ||
| onClick={() => | ||
| deck.selectMode ? deck.toggleSelect(card) : deck.add(card, 1) | ||
| } | ||
| > | ||
| <Image | ||
| src={card.imageUri} | ||
| alt={card.name} | ||
| fill | ||
| sizes="200px" | ||
| className="object-cover" | ||
| /> | ||
| {/* top controls */} | ||
| <div className="absolute left-2 top-2 flex items-center gap-1"> | ||
| <SelectCheck card={card} /> | ||
| {!deck.selectMode && ( | ||
| <GameChangerChip format={deck.format} gameChanger={card.gameChanger} /> | ||
| )} | ||
| {!deck.selectMode && !legal && <IllegalBadge reasons={reasons} />} | ||
| </div> | ||
| {card.manaCost && ( | ||
| <div className="absolute right-2 top-2"> | ||
| <ManaCost cost={card.manaCost} /> | ||
| </div> | ||
| )} | ||
| {/* bottom name plate */} | ||
| <div | ||
| className="absolute inset-x-0 bottom-0 px-2 pb-2 pt-5" | ||
| style={{ | ||
| background: | ||
| "linear-gradient(to top, color-mix(in oklab, #000 80%, transparent), transparent)", | ||
| }} | ||
| > | ||
| {qty > 0 && ( | ||
| <div className="mb-1"> | ||
| <InDeckBadge qty={qty} compact /> | ||
| </div> | ||
| )} | ||
| <div | ||
| className="truncate font-semibold text-white" | ||
| style={{ fontSize: 12.5, textShadow: "0 1px 3px rgba(0,0,0,.6)" }} | ||
| > | ||
| {card.name} | ||
| </div> | ||
| {card.typeLine && ( | ||
| <div | ||
| className="truncate font-mono text-white/60" | ||
| style={{ fontSize: 9.5, marginTop: 2 }} | ||
| > | ||
| {card.typeLine} | ||
| </div> | ||
| )} | ||
| </div> | ||
| {/* hover add */} | ||
| {!deck.selectMode && ( | ||
| <div | ||
| className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100" | ||
| style={{ background: "color-mix(in oklab, #000 28%, transparent)" }} | ||
| onClick={(e) => e.stopPropagation()} | ||
| > | ||
| <AddControls card={card} /> | ||
| </div> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <div | ||
| className={cn( | ||
| "anim-slide-up flex items-center gap-3", | ||
| inline | ||
| ? "w-full justify-between border-t border-border bg-card px-4 py-2.5" | ||
| : "rounded-xl border border-border bg-popover px-3.5 py-2.5 shadow-2xl", | ||
| )} | ||
| > | ||
| <span className="text-[13px] font-medium">{n} selected</span> | ||
| <button | ||
| type="button" | ||
| onClick={() => deck.clearSelect()} | ||
| className="inline-flex h-8 items-center rounded-lg border border-border px-3.5 text-[12.5px] text-muted-foreground hover:text-foreground" | ||
| > | ||
| Clear | ||
| </button> | ||
| <span className="h-5 w-px bg-border" /> | ||
| <span className="text-xs text-muted-foreground">to</span> | ||
| <span className="text-[12.5px] font-semibold">{label}</span> | ||
| <button | ||
| type="button" | ||
| disabled={n === 0 || deck.pending} | ||
| onClick={() => deck.addSelected(target)} | ||
| className="inline-flex h-8 items-center gap-1.5 whitespace-nowrap rounded-lg bg-primary px-3.5 text-[12.5px] font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground" | ||
| > | ||
| <Plus className="size-3.5" strokeWidth={2.6} aria-hidden /> | ||
| Add | ||
| </button> | ||
| </div> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<BrowserMode>("filters"); | ||
| const [density, setDensity] = useState<Density>("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 ( | ||
| <DeckBrowserProvider | ||
| deckId={deckId} | ||
| cards={cards} | ||
| dispatch={dispatch} | ||
| categories={categories} | ||
| format={format} | ||
| commanderIdentity={commanderIdentity} | ||
| > | ||
| {isDesktop ? ( | ||
| <SidePanel browser={browser} onClose={onClose} /> | ||
| ) : ( | ||
| <ScryTray browser={browser} onClose={onClose} /> | ||
| )} | ||
| </DeckBrowserProvider> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, { bg: string; bd: string; fg: string }> = { | ||
| 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 ( | ||
| <button | ||
| type="button" | ||
| onClick={onClick} | ||
| aria-pressed={active} | ||
| aria-label={`Color ${color}`} | ||
| className={cn( | ||
| "font-mono rounded-full transition-all", | ||
| active | ||
| ? "opacity-100 ring-2 ring-foreground ring-offset-1 ring-offset-background" | ||
| : "opacity-40 hover:opacity-70", | ||
| )} | ||
| style={{ | ||
| width: size, | ||
| height: size, | ||
| fontSize: size * 0.42, | ||
| fontWeight: 600, | ||
| background: sw.bg, | ||
| color: sw.fg, | ||
| border: `1.5px solid ${sw.bd}`, | ||
| }} | ||
| > | ||
| {color} | ||
| </button> | ||
| ); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.