Deck chips open their deck

Deck-name chips in the Library, Card Search and the card preview modal are
now buttons (DeckChip) that open that deck in the Decks tab — on mobile,
straight to its building screen. App provides the open-deck callback via
context and hands the deck id to DeckView.

Closes #19
This commit is contained in:
2026-10-23 23:10:40 -05:00
parent 945e5ed766
commit b96931c7b8
6 changed files with 95 additions and 21 deletions

View File

@@ -16,6 +16,7 @@ import {
formatRelativeTime,
HoverPreviewProvider,
IconButton,
OpenDeckProvider,
Icons,
StatTile,
UndoBar,
@@ -241,6 +242,18 @@ export default function App() {
const [status, setStatus] = useState<Status | null>(null);
const [libStats, setLibStats] = useState<LibraryStats | null>(null);
const [tab, setTab] = usePersistedState<Tab>("active-tab", "add");
// A deck a chip elsewhere asked to open; DeckView picks it up and clears it.
const [openDeckId, setOpenDeckId] = useState<number | null>(null);
// Deck chips only carry the deck's name, which is unique per library.
const openDeck = useCallback(
async (name: string) => {
const deck = (await api.listDecks()).find((d) => d.name === name);
if (!deck) return;
setOpenDeckId(deck.id);
setTab("decks");
},
[setTab],
);
const [progress, setProgress] = useState<IngestProgress | null>(null);
const [busy, setBusy] = useState(false);
// Library sync state: signed in or not, and how the last sync went. Fetched
@@ -598,6 +611,7 @@ export default function App() {
if (status) setOpenLibrary(status.library.id);
return (
<OpenDeckProvider value={openDeck}>
<HoverPreviewProvider>
<div className="app">
<nav className="nav-rail">
@@ -763,7 +777,12 @@ export default function App() {
) : tab === "search" ? (
<CardSearch key={status?.library_label} onOfferUndo={offerUndo} />
) : tab === "decks" ? (
<DeckView key={status?.library_label} refreshSignal={deckRefreshSignal} />
<DeckView
key={status?.library_label}
refreshSignal={deckRefreshSignal}
openDeckId={openDeckId}
onOpenedDeck={() => setOpenDeckId(null)}
/>
) : tab === "game" ? (
// Like News, independent of the library: the game in progress
// is this device's scratch state, not synced data.
@@ -853,5 +872,6 @@ export default function App() {
onRunUndo={runUndo}
/>
</HoverPreviewProvider>
</OpenDeckProvider>
);
}

View File

@@ -756,6 +756,40 @@ type ClickedState = { card: PreviewCard; ctx?: PreviewContext; showBack: boolean
const HoverCtx = createContext<HoverApi | null>(null);
const ClickCtx = createContext<ClickApi | null>(null);
/** Opens a deck by name in the Decks tab. Provided by `App`; without it,
* deck chips render as plain labels. */
const OpenDeckCtx = createContext<((name: string) => void) | null>(null);
export const OpenDeckProvider = OpenDeckCtx.Provider;
/** A deck's name as a chip that opens the deck when clicked. `onOpen` runs
* first, e.g. to close the preview the chip sits in. */
export function DeckChip({
name,
className = "deck-chip",
onOpen,
}: {
name: string;
className?: string;
onOpen?: () => void;
}) {
const openDeck = useContext(OpenDeckCtx);
if (!openDeck) return <span className={className}>{name}</span>;
return (
<button
type="button"
className={`${className} deck-chip-link`}
title={`Open ${name}`}
onClick={(e) => {
e.stopPropagation();
onOpen?.();
openDeck(name);
}}
>
{name}
</button>
);
}
/**
* Provides card-image previews app-wide: a small floating preview that
* tracks the cursor while hovering (see [`useHoverPreview`]), and a larger
@@ -923,8 +957,8 @@ function LegalityPills({ legalities }: { legalities: number }) {
* legality. */
const CardInfoTile = forwardRef<
HTMLDivElement,
{ card: PreviewCard; ctx?: PreviewContext; onArtChange: (art: CardArt) => void }
>(function CardInfoTile({ card, ctx, onArtChange }, ref) {
{ card: PreviewCard; ctx?: PreviewContext; onArtChange: (art: CardArt) => void; onClose: () => void }
>(function CardInfoTile({ card, ctx, onArtChange, onClose }, ref) {
const printings = !!ctx && card.quantity_owned !== undefined;
return (
<div className="preview-info" ref={ref}>
@@ -954,9 +988,7 @@ const CardInfoTile = forwardRef<
<span className="preview-info-label">In decks</span>
<div className="preview-info-deck-chips">
{card.decks.map((d) => (
<span key={d} className="badge">
{d}
</span>
<DeckChip key={d} name={d} className="badge" onOpen={onClose} />
))}
</div>
</div>
@@ -1397,7 +1429,7 @@ function ClickPreviewModal({
</button>
)}
</div>
<CardInfoTile ref={infoRef} card={card} ctx={ctx} onArtChange={setArt} />
<CardInfoTile ref={infoRef} card={card} ctx={ctx} onArtChange={setArt} onClose={onClose} />
{compact && (
<div className="preview-modal-bar">
<PreviewPaneToggle hasBack={hasBack} showBack={showBack} pane={pane} onSelect={selectPane} />

View File

@@ -1671,6 +1671,19 @@ tbody tr:hover {
color: var(--text);
margin-right: 4px;
}
/* A deck chip that opens its deck (see `DeckChip`): keeps the chip's own
look rather than the global button's, plus a hover cue. */
.deck-chip-link {
line-height: inherit;
cursor: pointer;
}
.deck-chip.deck-chip-link {
font-weight: inherit;
}
.deck-chip-link:hover {
border-color: var(--accent);
color: var(--text-strong);
}
/* ============================================================
Bulk-selection action bar (Library tab)

View File

@@ -3,6 +3,7 @@ import { api, Deck, LibraryCard, LibraryFacets, PreviewContext } from "../api";
import {
CardThumb,
CardTile,
DeckChip,
ColorPips,
GroupBySelect,
GroupMode,
@@ -253,9 +254,7 @@ export function CardSearch({
</td>
<td data-label="In decks">
{c.decks.map((d) => (
<span key={d} className="deck-chip">
{d}
</span>
<DeckChip key={d} name={d} />
))}
</td>
<td>
@@ -310,11 +309,7 @@ export function CardSearch({
}
footer={
c.decks.length > 0
? c.decks.map((d) => (
<span key={d} className="deck-chip">
{d}
</span>
))
? c.decks.map((d) => <DeckChip key={d} name={d} />)
: undefined
}
/>

View File

@@ -427,7 +427,16 @@ function computeDeckLegality(deck: Deck | null | undefined, contents: DeckEntry[
* card list, mana curve, and legality tile recompute immediately instead of
* only on the next mount.
*/
export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
export function DeckView({
refreshSignal,
openDeckId,
onOpenedDeck,
}: {
refreshSignal?: number;
/** A deck to open, e.g. from a deck chip elsewhere; `onOpenedDeck` clears it. */
openDeckId?: number | null;
onOpenedDeck?: () => void;
}) {
const [decks, setDecks] = useState<Deck[]>([]);
const [selected, setSelected] = usePersistedState<number | null>(libraryKey("deck-selected"), null);
const [newName, setNewName] = useState("");
@@ -444,6 +453,14 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
// this only matters below the .deck-layout stacking breakpoint; CSS keeps
// both panes visible side by side above it regardless of this flag.
const [mobileShowList, setMobileShowList] = useState(true);
// Open a deck asked for from outside — straight to its building screen.
useEffect(() => {
if (openDeckId == null) return;
setSelected(openDeckId);
setMobileShowList(false);
onOpenedDeck?.();
}, [openDeckId]);
// Android back from the deck-building screen returns to the deck list, like
// its ← button. Only while they're separate screens (the ≤720px
// `.deck-layout` breakpoint): side by side, flipping the flag changes

View File

@@ -12,6 +12,7 @@ import {
import {
CardThumb,
CardTile,
DeckChip,
DialogFrame,
ColorPips,
GroupBySelect,
@@ -299,11 +300,7 @@ export function LibraryBrowser({
onSaved={() => setRefreshKey((k) => k + 1)}
/>
) : (
c.decks.map((d) => (
<span key={d} className="deck-chip">
{d}
</span>
))
c.decks.map((d) => <DeckChip key={d} name={d} />)
);
// Under the spares filter, how many copies are free — only worth saying for