Skip to content

Commit 88721e4

Browse files
committed
feat: add selectable deck cover cards
Amp-Thread-ID: https://ampcode.com/threads/T-019fed38-c8b0-738e-9cd6-c1edbc6df43e
1 parent 004fe4f commit 88721e4

19 files changed

Lines changed: 315 additions & 71 deletions

File tree

‎assets/react/src/components/ui/dialog.tsx‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,16 @@ export function DialogTitle({ children, className, ...props }: HTMLAttributes<HT
179179
)
180180
}
181181

182-
export function DialogClose({ onClose }: { onClose: () => void }) {
182+
export function DialogClose({ className, onClose }: { className?: string; onClose: () => void }) {
183183
return (
184-
<Button type="button" variant="ghost" size="icon" aria-label="Close dialog" onClick={onClose}>
184+
<Button
185+
type="button"
186+
variant="ghost"
187+
size="icon"
188+
className={className}
189+
aria-label="Close dialog"
190+
onClick={onClose}
191+
>
185192
<X className="h-4 w-4" />
186193
</Button>
187194
)

‎assets/react/src/gql/gql.ts‎

Lines changed: 12 additions & 12 deletions
Large diffs are not rendered by default.

‎assets/react/src/gql/graphql.ts‎

Lines changed: 9 additions & 8 deletions
Large diffs are not rendered by default.

‎assets/react/src/pages/decks/deck-card-model.ts‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,3 @@ export function colorOrder(color: string) {
7777
const index = COLOR_ORDER.indexOf(color)
7878
return index === -1 ? 99 : index
7979
}
80-
81-
export function deckDetailCoverUrl(deckCards: DeckCardEntry[]) {
82-
const cover = deckCards.find(
83-
(deckCard) => cardImageUrl(deckCard, "artCropUrl") || cardImageUrl(deckCard, "imageUrl"),
84-
)
85-
return cover ? cardImageUrl(cover, "artCropUrl") || cardImageUrl(cover, "imageUrl") : null
86-
}

‎assets/react/src/pages/decks/deck-detail-header.tsx‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import { Button } from "../../components/ui/button"
1818
import type { DeckGroupBy } from "../../lib/deck-grouping"
1919
import { compactNumber, cn, titleize } from "../../lib/utils"
2020
import { ShareModeHidden, SummaryActionMenu } from "./deck-actions"
21-
import { deckDetailCoverUrl } from "./deck-card-model"
2221
import type { DeckLegalityIssue, DeckPrice, DetailZoneCounts } from "./deck-detail-types"
2322
import { DeckGroupMenu } from "./deck-group-menu"
2423
import { deckLegalityIssueCountLabel, deckLegalityLabel, deckLegalityTone } from "./deck-legality"
@@ -197,7 +196,7 @@ export function DeckDetailHeader({
197196
</ShareModeHidden>
198197

199198
<ImageSummaryCard
200-
imageUrl={deckDetailCoverUrl(deckCards)}
199+
imageUrl={deck.coverImageUrl}
201200
fallback={<Layers className="h-12 w-12" />}
202201
interactive={false}
203202
typeLine={<Badge>{titleize(deck.format)}</Badge>}
@@ -218,10 +217,7 @@ export function DeckDetailHeader({
218217
</div>
219218
}
220219
nameLine={
221-
<DeckNameWithCommanderIdentity
222-
colors={deck.commanderColorIdentity}
223-
name={deck.name}
224-
/>
220+
<DeckNameWithCommanderIdentity colors={deck.commanderColorIdentity} name={deck.name} />
225221
}
226222
actionSlot={
227223
<ShareModeHidden shareMode={shareMode}>

‎assets/react/src/pages/decks/deck-editor-dialogs.tsx‎

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
Select,
1616
SelectContent,
1717
SelectItem,
18+
SELECT_NONE_VALUE,
1819
SelectTrigger,
1920
SelectValue,
2021
} from "../../components/ui/select"
@@ -40,13 +41,16 @@ export function EditDeckDialog({
4041
const [name, setName] = useState("")
4142
const [format, setFormat] = useState<(typeof DECK_FORMATS)[number]>("commander")
4243
const [status, setStatus] = useState<(typeof DECK_STATUSES)[number]>("brewing")
44+
const [coverDeckCardId, setCoverDeckCardId] = useState<string | null>(null)
4345
const [error, setError] = useState<string | null>(null)
46+
const deckCards = deck && "deckCards" in deck ? deck.deckCards : null
4447

4548
useEffect(() => {
4649
if (!deck || !isOpen) return
4750
setName(deck.name)
4851
setFormat(deckFormatValue(deck.format))
4952
setStatus(deckStatusValue(deck.status))
53+
setCoverDeckCardId(deck.coverDeckCardId)
5054
setError(null)
5155
}, [deck, isOpen])
5256

@@ -63,7 +67,12 @@ export function EditDeckDialog({
6367
void updateDeckMutation({
6468
variables: {
6569
id: deck.id,
66-
input: { name: name.trim(), format, status },
70+
input: {
71+
name: name.trim(),
72+
format,
73+
status,
74+
...(deckCards ? { coverDeckCardId } : {}),
75+
},
6776
},
6877
onCompleted: () => {
6978
void refetchActiveQueries(client)
@@ -101,15 +110,18 @@ export function EditDeckDialog({
101110
<DialogHeader>
102111
<div>
103112
<DialogTitle id="edit-deck-title">Edit deck</DialogTitle>
104-
<p className="mt-1 text-sm text-base-content/60">Update deck metadata.</p>
113+
<p className="mt-1 text-sm text-base-content/75">Update deck metadata.</p>
105114
</div>
106-
<DialogClose onClose={close} />
115+
<DialogClose className="h-11 w-11" onClose={close} />
107116
</DialogHeader>
108117

109118
<form className="space-y-5 p-5" onSubmit={submit}>
110119
<label className="block space-y-2">
111-
<span className="text-xs font-black uppercase tracking-[0.18em] text-accent">Name</span>
120+
<span className="text-xs font-black uppercase tracking-[0.18em] text-base-content/80">
121+
Name
122+
</span>
112123
<Input
124+
className="min-h-11"
113125
value={name}
114126
onChange={(event) => setName(event.target.value)}
115127
placeholder="Deck name"
@@ -119,11 +131,11 @@ export function EditDeckDialog({
119131

120132
<div className="grid gap-4 sm:grid-cols-2">
121133
<label className="block space-y-2">
122-
<span className="text-xs font-black uppercase tracking-[0.18em] text-accent">
134+
<span className="text-xs font-black uppercase tracking-[0.18em] text-base-content/80">
123135
Format
124136
</span>
125137
<Select value={format} onValueChange={(value) => setFormat(deckFormatValue(value))}>
126-
<SelectTrigger className="bg-base-100 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20">
138+
<SelectTrigger className="min-h-11 bg-base-100 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20">
127139
<SelectValue />
128140
</SelectTrigger>
129141
<SelectContent>
@@ -137,11 +149,11 @@ export function EditDeckDialog({
137149
</label>
138150

139151
<label className="block space-y-2">
140-
<span className="text-xs font-black uppercase tracking-[0.18em] text-accent">
152+
<span className="text-xs font-black uppercase tracking-[0.18em] text-base-content/80">
141153
Status
142154
</span>
143155
<Select value={status} onValueChange={(value) => setStatus(deckStatusValue(value))}>
144-
<SelectTrigger className="bg-base-100 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20">
156+
<SelectTrigger className="min-h-11 bg-base-100 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20">
145157
<SelectValue />
146158
</SelectTrigger>
147159
<SelectContent>
@@ -155,17 +167,56 @@ export function EditDeckDialog({
155167
</label>
156168
</div>
157169

170+
{deckCards ? (
171+
<label className="block space-y-2">
172+
<span className="text-xs font-black uppercase tracking-[0.18em] text-base-content/80">
173+
Cover card
174+
</span>
175+
<Select
176+
value={coverDeckCardId || SELECT_NONE_VALUE}
177+
onValueChange={(value) =>
178+
setCoverDeckCardId(value === SELECT_NONE_VALUE ? null : value)
179+
}
180+
>
181+
<SelectTrigger
182+
aria-label="Cover card"
183+
className="min-h-11 bg-base-100 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
184+
>
185+
<SelectValue />
186+
</SelectTrigger>
187+
<SelectContent>
188+
<SelectItem value={SELECT_NONE_VALUE}>Automatic (commander first)</SelectItem>
189+
{deckCards.map((deckCard) => (
190+
<SelectItem key={deckCard.id} value={deckCard.id}>
191+
{deckCard.card?.name || "Unknown card"} ·{" "}
192+
{titleize(deckCard.zone || "mainboard")}
193+
</SelectItem>
194+
))}
195+
</SelectContent>
196+
</Select>
197+
<span className="block text-sm text-base-content/75">
198+
Uses the commander by default. Choose any card in this deck to override it.
199+
</span>
200+
</label>
201+
) : null}
202+
158203
{error ? (
159204
<p className="rounded-box border border-error/30 bg-error/10 px-3 py-2 text-sm text-error">
160205
{error}
161206
</p>
162207
) : null}
163208

164209
<div className="flex flex-wrap justify-end gap-2 border-t border-base-300 pt-4">
165-
<Button type="button" variant="ghost" onClick={close} disabled={updateDeck.isPending}>
210+
<Button
211+
type="button"
212+
variant="ghost"
213+
className="min-h-11"
214+
onClick={close}
215+
disabled={updateDeck.isPending}
216+
>
166217
Cancel
167218
</Button>
168-
<Button type="submit" disabled={updateDeck.isPending}>
219+
<Button type="submit" className="min-h-11" disabled={updateDeck.isPending}>
169220
<Edit3 className="h-4 w-4" />
170221
{updateDeck.isPending ? "Saving..." : "Save deck"}
171222
</Button>

‎assets/react/src/pages/decks/deck-list-model.tsx‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,3 @@ export function DeckNameWithCommanderIdentity({
4141
</span>
4242
)
4343
}
44-
45-

‎assets/react/src/pages/decks/queries.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const DecksDocument = graphql(`
1414
format
1515
status
1616
shareToken
17+
coverDeckCardId
1718
coverImageUrl
1819
commanderColorIdentity
1920
cardCount
@@ -41,6 +42,7 @@ export const CreateDeckDocument = graphql(`
4142
format
4243
status
4344
shareToken
45+
coverDeckCardId
4446
coverImageUrl
4547
commanderColorIdentity
4648
cardCount
@@ -67,6 +69,7 @@ export const UpdateDeckDocument = graphql(`
6769
format
6870
status
6971
shareToken
72+
coverDeckCardId
7073
coverImageUrl
7174
commanderColorIdentity
7275
cardCount
@@ -153,6 +156,8 @@ export const DeckDocument = graphql(`
153156
format
154157
status
155158
shareToken
159+
coverDeckCardId
160+
coverImageUrl
156161
cardCount
157162
commanderColorIdentity
158163
legality {

‎assets/react/test/deck-detail-overlays.test.tsx‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,32 @@ import { cleanup, render, screen } from "@testing-library/react"
22
import userEvent from "@testing-library/user-event"
33
import { afterEach, expect, test, vi } from "vitest"
44

5+
const apolloMocks = vi.hoisted(() => ({ mutationVariables: undefined as unknown }))
6+
7+
vi.mock("@apollo/client/react", () => ({
8+
useApolloClient: () => ({ refetchQueries: () => Promise.resolve([]) }),
9+
useMutation: () => [
10+
(options: { onCompleted?: () => void; variables?: unknown }) => {
11+
apolloMocks.mutationVariables = options.variables
12+
options.onCompleted?.()
13+
return Promise.resolve({ data: {} })
14+
},
15+
{ loading: false },
16+
],
17+
}))
18+
19+
vi.mock("../src/components/ui/toast", () => ({
20+
useToast: () => ({ showToast: vi.fn() }),
21+
}))
22+
523
import { DeckDetailDisassemblyOverlay } from "../src/pages/decks/deck-detail-disassembly-overlay"
624
import { DeckDetailShortcutsOverlay } from "../src/pages/decks/deck-detail-shortcuts-overlay"
25+
import { EditDeckDialog } from "../src/pages/decks/deck-editor-dialogs"
726

8-
afterEach(cleanup)
27+
afterEach(() => {
28+
cleanup()
29+
apolloMocks.mutationVariables = undefined
30+
})
931

1032
const deck = { id: "deck-1", name: "Archive Test" }
1133

@@ -53,3 +75,44 @@ test("shortcut overlay has one close transition", async () => {
5375
await user.click(screen.getByRole("button", { name: "Close dialog" }))
5476
expect(onClose).toHaveBeenCalledTimes(1)
5577
})
78+
79+
test("deck editor chooses any deck card as the cover", async () => {
80+
const user = userEvent.setup()
81+
82+
render(
83+
<EditDeckDialog
84+
deck={
85+
{
86+
id: "deck-1",
87+
name: "Partner Deck",
88+
format: "commander",
89+
status: "active",
90+
coverDeckCardId: null,
91+
deckCards: [
92+
{ id: "partner", zone: "commander", card: { name: "Partner Commander" } },
93+
{ id: "favorite", zone: "mainboard", card: { name: "Favorite Card" } },
94+
],
95+
} as never
96+
}
97+
open
98+
onOpenChange={vi.fn()}
99+
/>,
100+
)
101+
102+
const coverSelect = screen.getByRole("combobox", { name: "Cover card" })
103+
expect(coverSelect.textContent).toContain("Automatic (commander first)")
104+
105+
await user.click(coverSelect)
106+
await user.click(screen.getByRole("option", { name: "Favorite Card · Mainboard" }))
107+
await user.click(screen.getByRole("button", { name: "Save deck" }))
108+
109+
expect(apolloMocks.mutationVariables).toEqual({
110+
id: "deck-1",
111+
input: {
112+
name: "Partner Deck",
113+
format: "commander",
114+
status: "active",
115+
coverDeckCardId: "favorite",
116+
},
117+
})
118+
})

‎lib/manavault/catalog/deck.ex‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ defmodule Manavault.Catalog.Deck do
1111
field :format, :string, default: "commander"
1212
field :status, :string, default: "brewing"
1313
field :share_token, :string
14+
field :cover_deck_card_id, :id
1415
field :card_count, :integer, virtual: true
1516
field :unique_card_count, :integer, virtual: true
1617
field :cover_image_url, :string, virtual: true
@@ -28,11 +29,12 @@ defmodule Manavault.Catalog.Deck do
2829

2930
def changeset(deck, attrs) do
3031
deck
31-
|> cast(attrs, [:name, :format, :status])
32+
|> cast(attrs, [:name, :format, :status, :cover_deck_card_id])
3233
|> validate_required([:name, :format, :status])
3334
|> validate_length(:name, min: 1, max: 120)
3435
|> validate_inclusion(:format, @formats)
3536
|> validate_inclusion(:status, @statuses)
37+
|> foreign_key_constraint(:cover_deck_card_id)
3638
end
3739

3840
def share_changeset(deck, share_token) do

0 commit comments

Comments
 (0)