Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions app/_components/builder/card-browser/add-controls.tsx
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>
);
}
93 changes: 93 additions & 0 deletions app/_components/builder/card-browser/browser-card.tsx
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()}
>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<AddControls card={card} />
</div>
)}
</div>
);
}
23 changes: 23 additions & 0 deletions app/_components/builder/card-browser/browser-state.ts
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;
}
46 changes: 46 additions & 0 deletions app/_components/builder/card-browser/bulk-bar.tsx
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>
);
}
91 changes: 91 additions & 0 deletions app/_components/builder/card-browser/card-browser.tsx
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>
);
}
48 changes: 48 additions & 0 deletions app/_components/builder/card-browser/color-pip.tsx
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>
);
}
Loading