2026-07-22 20:55:17 -05:00
|
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
|
|
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter } from "../api";
|
|
|
|
|
|
import {
|
|
|
|
|
|
CardThumb,
|
|
|
|
|
|
CardTile,
|
|
|
|
|
|
ColorPips,
|
|
|
|
|
|
ColorToggles,
|
|
|
|
|
|
GroupBySelect,
|
|
|
|
|
|
GroupMode,
|
|
|
|
|
|
IconButton,
|
|
|
|
|
|
Icons,
|
|
|
|
|
|
QtyStepper,
|
|
|
|
|
|
SortBySelect,
|
|
|
|
|
|
SortDir,
|
|
|
|
|
|
SortMode,
|
|
|
|
|
|
TagInput,
|
|
|
|
|
|
ViewToggle,
|
|
|
|
|
|
groupCards,
|
|
|
|
|
|
sortCards,
|
|
|
|
|
|
usePersistedState,
|
|
|
|
|
|
useViewMode,
|
|
|
|
|
|
} from "../components";
|
|
|
|
|
|
|
|
|
|
|
|
// Server-side cap on results — this browses the whole ~30k-card reference
|
|
|
|
|
|
// db, not just what's owned, so an unbounded/loose filter needs a limit.
|
|
|
|
|
|
const CAP = 300;
|
|
|
|
|
|
|
|
|
|
|
|
const NO_FACETS: LibraryFacets = { types: [], subtypes: [], keywords: [] };
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Browse the whole downloaded card universe (every card in reference.db,
|
|
|
|
|
|
* owned or not) — the Library tab's sibling, reusing its filter/group/sort/
|
|
|
|
|
|
* list/tiles machinery. Results are annotated with ownership: an "Owned ×N"
|
|
|
|
|
|
* stepper and any decks the card is already in.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `onOfferUndo` is threaded in from `App`, same as `LibraryBrowser`, so the
|
|
|
|
|
|
* "Add to deck" undo is also reachable from the command palette.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function CardSearch({
|
|
|
|
|
|
onOfferUndo,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
onOfferUndo: (message: string, run: () => void | Promise<void>) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [text, setText] = useState("");
|
|
|
|
|
|
const [commander, setCommander] = useState(0);
|
|
|
|
|
|
const [colorsInclude, setColorsInclude] = useState(0);
|
|
|
|
|
|
const [cmcMin, setCmcMin] = useState("");
|
|
|
|
|
|
const [cmcMax, setCmcMax] = useState("");
|
|
|
|
|
|
const [types, setTypes] = useState<string[]>([]);
|
|
|
|
|
|
const [subtypes, setSubtypes] = useState<string[]>([]);
|
|
|
|
|
|
const [keywords, setKeywords] = useState<string[]>([]);
|
|
|
|
|
|
const [oracleText, setOracleText] = useState("");
|
2026-08-02 16:10:25 -05:00
|
|
|
|
// Collapsed by default (relevant on narrow/mobile screens only — see
|
|
|
|
|
|
// .filters-more's CSS, which is always expanded above that breakpoint
|
|
|
|
|
|
// regardless of this).
|
|
|
|
|
|
const [filtersExpanded, setFiltersExpanded] = useState(false);
|
2026-07-22 20:55:17 -05:00
|
|
|
|
const [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
|
|
|
|
|
|
|
|
|
|
|
|
const [decks, setDecks] = useState<Deck[]>([]);
|
|
|
|
|
|
const [deckId, setDeckId] = useState<number | "">("");
|
|
|
|
|
|
const [rows, setRows] = useState<LibraryCard[]>([]);
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
const [busy, setBusy] = useState<Set<string>>(new Set());
|
|
|
|
|
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
|
|
|
|
|
|
|
|
|
|
const [view, setView] = useViewMode("cardsearch-view");
|
|
|
|
|
|
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("cardsearch-group", "none");
|
|
|
|
|
|
const [sortBy, setSortBy] = usePersistedState<SortMode>("cardsearch-sort", "name");
|
|
|
|
|
|
const [sortDir, setSortDir] = usePersistedState<SortDir>("cardsearch-sort-dir", "asc");
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
api.listDecks().then(setDecks);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
api.getReferenceFacets().then(setFacets).catch(() => setFacets(NO_FACETS));
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const hasActiveFilter =
|
|
|
|
|
|
text.trim() !== "" ||
|
|
|
|
|
|
commander !== 0 ||
|
|
|
|
|
|
colorsInclude !== 0 ||
|
|
|
|
|
|
cmcMin !== "" ||
|
|
|
|
|
|
cmcMax !== "" ||
|
|
|
|
|
|
types.length > 0 ||
|
|
|
|
|
|
subtypes.length > 0 ||
|
|
|
|
|
|
keywords.length > 0 ||
|
|
|
|
|
|
oracleText.trim() !== "";
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!hasActiveFilter) {
|
|
|
|
|
|
setRows([]);
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const filter: LibraryFilter = {
|
|
|
|
|
|
text: text.trim() || null,
|
|
|
|
|
|
commander_identity: commander || null,
|
|
|
|
|
|
colors_include: colorsInclude || null,
|
|
|
|
|
|
cmc_min: cmcMin === "" ? null : Number(cmcMin),
|
|
|
|
|
|
cmc_max: cmcMax === "" ? null : Number(cmcMax),
|
|
|
|
|
|
types,
|
|
|
|
|
|
subtypes,
|
|
|
|
|
|
keywords,
|
|
|
|
|
|
oracle_text: oracleText.trim() || null,
|
|
|
|
|
|
};
|
|
|
|
|
|
const t = setTimeout(() => {
|
|
|
|
|
|
api
|
|
|
|
|
|
.searchReference(filter, CAP)
|
|
|
|
|
|
.then((r) => {
|
|
|
|
|
|
setRows(r);
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch((e) => {
|
|
|
|
|
|
setRows([]);
|
|
|
|
|
|
setError(String(e));
|
|
|
|
|
|
});
|
|
|
|
|
|
}, 120);
|
|
|
|
|
|
return () => clearTimeout(t);
|
|
|
|
|
|
}, [
|
|
|
|
|
|
hasActiveFilter,
|
|
|
|
|
|
text,
|
|
|
|
|
|
commander,
|
|
|
|
|
|
colorsInclude,
|
|
|
|
|
|
cmcMin,
|
|
|
|
|
|
cmcMax,
|
|
|
|
|
|
types,
|
|
|
|
|
|
subtypes,
|
|
|
|
|
|
keywords,
|
|
|
|
|
|
oracleText,
|
|
|
|
|
|
refreshKey,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
async function adjust(oracleId: string, delta: number) {
|
|
|
|
|
|
const qty = await api.addOwned(oracleId, delta);
|
|
|
|
|
|
setRows((rs) => rs.map((c) => (c.oracle_id === oracleId ? { ...c, quantity_owned: qty } : c)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Add one copy of `c` to the deck picked in the toolbar. Snapshots the
|
|
|
|
|
|
* card's prior deck_cards row (or absence of one) first and offers an
|
|
|
|
|
|
* "Undo" that restores exactly that prior state — same pattern as
|
|
|
|
|
|
* `LibraryBrowser.bulkAddToDeck`.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function addToDeck(c: LibraryCard) {
|
|
|
|
|
|
if (deckId === "") return;
|
|
|
|
|
|
const deck = decks.find((d) => d.id === deckId);
|
|
|
|
|
|
if (!deck) return;
|
|
|
|
|
|
setBusy((b) => new Set(b).add(c.oracle_id));
|
|
|
|
|
|
try {
|
|
|
|
|
|
const before = await api.deckContents(deck.id);
|
|
|
|
|
|
const prev = before.find((e) => e.oracle_id === c.oracle_id);
|
|
|
|
|
|
await api.setDeckCard(deck.id, c.oracle_id, 1, false, false);
|
|
|
|
|
|
setRefreshKey((k) => k + 1);
|
|
|
|
|
|
onOfferUndo(`Added ${c.name} to ${deck.name}.`, async () => {
|
|
|
|
|
|
await api.setDeckCard(
|
|
|
|
|
|
deck.id,
|
|
|
|
|
|
c.oracle_id,
|
|
|
|
|
|
prev?.quantity ?? 0,
|
|
|
|
|
|
prev?.is_commander ?? false,
|
|
|
|
|
|
prev?.is_maybeboard ?? false,
|
|
|
|
|
|
);
|
|
|
|
|
|
setRefreshKey((k) => k + 1);
|
|
|
|
|
|
});
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusy((b) => {
|
|
|
|
|
|
const next = new Set(b);
|
|
|
|
|
|
next.delete(c.oracle_id);
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function refTable(cards: LibraryCard[]) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th></th>
|
|
|
|
|
|
<th>Colors</th>
|
|
|
|
|
|
<th>Name</th>
|
|
|
|
|
|
<th>Type</th>
|
|
|
|
|
|
<th>MV</th>
|
|
|
|
|
|
<th>Owned</th>
|
|
|
|
|
|
<th>In decks</th>
|
|
|
|
|
|
<th></th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{cards.map((c) => (
|
|
|
|
|
|
<tr key={c.oracle_id}>
|
2026-08-02 16:10:25 -05:00
|
|
|
|
<td className="cell-thumb">
|
2026-07-22 20:55:17 -05:00
|
|
|
|
<CardThumb card={c} />
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<ColorPips mask={c.color_identity} />
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="card-name">{c.name}</td>
|
|
|
|
|
|
<td className="card-type">{c.type_line}</td>
|
2026-08-02 16:10:25 -05:00
|
|
|
|
<td data-label="MV">{c.mana_value}</td>
|
|
|
|
|
|
<td data-label="Owned">
|
2026-07-22 20:55:17 -05:00
|
|
|
|
<QtyStepper
|
|
|
|
|
|
value={c.quantity_owned}
|
|
|
|
|
|
onDecrement={() => adjust(c.oracle_id, -1)}
|
|
|
|
|
|
onIncrement={() => adjust(c.oracle_id, 1)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</td>
|
2026-08-02 16:10:25 -05:00
|
|
|
|
<td data-label="In decks">
|
2026-07-22 20:55:17 -05:00
|
|
|
|
{c.decks.map((d) => (
|
|
|
|
|
|
<span key={d} className="deck-chip">
|
|
|
|
|
|
{d}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => addToDeck(c)}
|
|
|
|
|
|
disabled={deckId === "" || busy.has(c.oracle_id)}
|
|
|
|
|
|
title={deckId === "" ? "Choose a deck above first" : "Add 1 copy to the selected deck"}
|
|
|
|
|
|
>
|
|
|
|
|
|
Add to deck
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function refTiles(cards: LibraryCard[]) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="tile-grid">
|
|
|
|
|
|
{cards.map((c) => (
|
|
|
|
|
|
<CardTile
|
|
|
|
|
|
key={c.oracle_id}
|
|
|
|
|
|
card={c}
|
|
|
|
|
|
corner={
|
|
|
|
|
|
<QtyStepper
|
|
|
|
|
|
overlay
|
|
|
|
|
|
value={c.quantity_owned}
|
|
|
|
|
|
onDecrement={() => adjust(c.oracle_id, -1)}
|
|
|
|
|
|
onIncrement={() => adjust(c.oracle_id, 1)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
|
|
|
|
|
cornerBottomLeft={
|
|
|
|
|
|
<IconButton
|
|
|
|
|
|
icon={Icons.cards}
|
|
|
|
|
|
label="Add to deck"
|
|
|
|
|
|
onClick={() => addToDeck(c)}
|
|
|
|
|
|
disabled={deckId === "" || busy.has(c.oracle_id)}
|
|
|
|
|
|
overlay
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
|
|
|
|
|
footer={
|
|
|
|
|
|
c.decks.length > 0
|
|
|
|
|
|
? c.decks.map((d) => (
|
|
|
|
|
|
<span key={d} className="deck-chip">
|
|
|
|
|
|
{d}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
))
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const groups = groupCards(sortCards(rows, sortBy, sortDir), groupBy);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
Cohesive styling pass: design tokens, shared primitives, responsive groundwork
The UI had a good terracotta-on-dark system that wasn't applied
consistently — magic-number spacing, ad-hoc page headers, ~6 copied
panel surfaces, ~8 drifting label variants, and scattered accent use.
- Tokens: warm the neutral ramp, add spacing/radius/type/motion scales,
--accent-quiet + panel highlight. Terracotta hue unchanged.
- Primitives: .panel, .section-label, .page-header/.toolbar — collapse
the copied surfaces and label variants onto shared classes.
- Unify each filter-heavy page's display controls (group/sort/view,
select-all, count) into a workspace toolbar inside the filter panel,
so they read as one surface instead of floating above it.
- Labels: sentence-case, higher-contrast form labels; uppercase section
markers bumped faint -> muted.
- Accent hierarchy: reserve full terracotta for primary actions/active
state; demote decorative labels and the group-header underline.
- Responsive groundwork: reflow classes for the deck two-pane and nav
rail (stacks / icon-collapse at breakpoints), touch-target sizing.
Desktop unchanged in kind.
2026-07-22 23:44:21 -05:00
|
|
|
|
<div className="page-header">
|
|
|
|
|
|
<h2 className="page-header-title">
|
2026-07-22 20:55:17 -05:00
|
|
|
|
Card Search {hasActiveFilter && <span className="badge">{rows.length}</span>}
|
|
|
|
|
|
</h2>
|
Cohesive styling pass: design tokens, shared primitives, responsive groundwork
The UI had a good terracotta-on-dark system that wasn't applied
consistently — magic-number spacing, ad-hoc page headers, ~6 copied
panel surfaces, ~8 drifting label variants, and scattered accent use.
- Tokens: warm the neutral ramp, add spacing/radius/type/motion scales,
--accent-quiet + panel highlight. Terracotta hue unchanged.
- Primitives: .panel, .section-label, .page-header/.toolbar — collapse
the copied surfaces and label variants onto shared classes.
- Unify each filter-heavy page's display controls (group/sort/view,
select-all, count) into a workspace toolbar inside the filter panel,
so they read as one surface instead of floating above it.
- Labels: sentence-case, higher-contrast form labels; uppercase section
markers bumped faint -> muted.
- Accent hierarchy: reserve full terracotta for primary actions/active
state; demote decorative labels and the group-header underline.
- Responsive groundwork: reflow classes for the deck two-pane and nav
rail (stacks / icon-collapse at breakpoints), touch-target sizing.
Desktop unchanged in kind.
2026-07-22 23:44:21 -05:00
|
|
|
|
<div className="page-header-actions">
|
2026-07-22 20:55:17 -05:00
|
|
|
|
<select
|
|
|
|
|
|
value={deckId}
|
|
|
|
|
|
onChange={(e) => setDeckId(e.target.value === "" ? "" : Number(e.target.value))}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">Add to deck…</option>
|
|
|
|
|
|
{decks.map((d) => (
|
|
|
|
|
|
<option key={d.id} value={d.id}>
|
|
|
|
|
|
{d.name}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
Cohesive styling pass: design tokens, shared primitives, responsive groundwork
The UI had a good terracotta-on-dark system that wasn't applied
consistently — magic-number spacing, ad-hoc page headers, ~6 copied
panel surfaces, ~8 drifting label variants, and scattered accent use.
- Tokens: warm the neutral ramp, add spacing/radius/type/motion scales,
--accent-quiet + panel highlight. Terracotta hue unchanged.
- Primitives: .panel, .section-label, .page-header/.toolbar — collapse
the copied surfaces and label variants onto shared classes.
- Unify each filter-heavy page's display controls (group/sort/view,
select-all, count) into a workspace toolbar inside the filter panel,
so they read as one surface instead of floating above it.
- Labels: sentence-case, higher-contrast form labels; uppercase section
markers bumped faint -> muted.
- Accent hierarchy: reserve full terracotta for primary actions/active
state; demote decorative labels and the group-header underline.
- Responsive groundwork: reflow classes for the deck two-pane and nav
rail (stacks / icon-collapse at breakpoints), touch-target sizing.
Desktop unchanged in kind.
2026-07-22 23:44:21 -05:00
|
|
|
|
<div className="panel workspace">
|
2026-07-22 20:55:17 -05:00
|
|
|
|
<div className="filters">
|
|
|
|
|
|
<div className="field">
|
|
|
|
|
|
<label>Name contains</label>
|
|
|
|
|
|
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="e.g. bolt" />
|
|
|
|
|
|
</div>
|
2026-08-02 16:10:25 -05:00
|
|
|
|
<div className={`filters-more ${filtersExpanded ? "expanded" : ""}`}>
|
2026-07-22 20:55:17 -05:00
|
|
|
|
<div className="field">
|
|
|
|
|
|
<label>Playable in commander (color identity ⊆)</label>
|
|
|
|
|
|
<ColorToggles value={commander} onChange={setCommander} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="field">
|
|
|
|
|
|
<label>Includes colors</label>
|
|
|
|
|
|
<ColorToggles value={colorsInclude} onChange={setColorsInclude} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="field">
|
|
|
|
|
|
<label>Mana value</label>
|
|
|
|
|
|
<div className="row">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
style={{ width: 70 }}
|
|
|
|
|
|
placeholder="min"
|
|
|
|
|
|
value={cmcMin}
|
|
|
|
|
|
onChange={(e) => setCmcMin(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="muted">–</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
style={{ width: 70 }}
|
|
|
|
|
|
placeholder="max"
|
|
|
|
|
|
value={cmcMax}
|
|
|
|
|
|
onChange={(e) => setCmcMax(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="field">
|
|
|
|
|
|
<label>Type(s)</label>
|
|
|
|
|
|
<TagInput values={types} onChange={setTypes} suggestions={facets.types} placeholder="Creature…" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="field">
|
|
|
|
|
|
<label>Subtype(s)</label>
|
|
|
|
|
|
<TagInput
|
|
|
|
|
|
values={subtypes}
|
|
|
|
|
|
onChange={setSubtypes}
|
|
|
|
|
|
suggestions={facets.subtypes}
|
|
|
|
|
|
placeholder="Elf…"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="field">
|
|
|
|
|
|
<label>Keyword(s)</label>
|
|
|
|
|
|
<TagInput
|
|
|
|
|
|
values={keywords}
|
|
|
|
|
|
onChange={setKeywords}
|
|
|
|
|
|
suggestions={facets.keywords}
|
|
|
|
|
|
placeholder="Flying…"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="field">
|
|
|
|
|
|
<label>Oracle text</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={oracleText}
|
|
|
|
|
|
onChange={(e) => setOracleText(e.target.value)}
|
|
|
|
|
|
placeholder="e.g. landfall"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
Cohesive styling pass: design tokens, shared primitives, responsive groundwork
The UI had a good terracotta-on-dark system that wasn't applied
consistently — magic-number spacing, ad-hoc page headers, ~6 copied
panel surfaces, ~8 drifting label variants, and scattered accent use.
- Tokens: warm the neutral ramp, add spacing/radius/type/motion scales,
--accent-quiet + panel highlight. Terracotta hue unchanged.
- Primitives: .panel, .section-label, .page-header/.toolbar — collapse
the copied surfaces and label variants onto shared classes.
- Unify each filter-heavy page's display controls (group/sort/view,
select-all, count) into a workspace toolbar inside the filter panel,
so they read as one surface instead of floating above it.
- Labels: sentence-case, higher-contrast form labels; uppercase section
markers bumped faint -> muted.
- Accent hierarchy: reserve full terracotta for primary actions/active
state; demote decorative labels and the group-header underline.
- Responsive groundwork: reflow classes for the deck two-pane and nav
rail (stacks / icon-collapse at breakpoints), touch-target sizing.
Desktop unchanged in kind.
2026-07-22 23:44:21 -05:00
|
|
|
|
</div>
|
2026-08-02 16:10:25 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="filters-toggle"
|
|
|
|
|
|
onClick={() => setFiltersExpanded((v) => !v)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{filtersExpanded ? "▴ Fewer filters" : "▾ More filters"}
|
|
|
|
|
|
</button>
|
Cohesive styling pass: design tokens, shared primitives, responsive groundwork
The UI had a good terracotta-on-dark system that wasn't applied
consistently — magic-number spacing, ad-hoc page headers, ~6 copied
panel surfaces, ~8 drifting label variants, and scattered accent use.
- Tokens: warm the neutral ramp, add spacing/radius/type/motion scales,
--accent-quiet + panel highlight. Terracotta hue unchanged.
- Primitives: .panel, .section-label, .page-header/.toolbar — collapse
the copied surfaces and label variants onto shared classes.
- Unify each filter-heavy page's display controls (group/sort/view,
select-all, count) into a workspace toolbar inside the filter panel,
so they read as one surface instead of floating above it.
- Labels: sentence-case, higher-contrast form labels; uppercase section
markers bumped faint -> muted.
- Accent hierarchy: reserve full terracotta for primary actions/active
state; demote decorative labels and the group-header underline.
- Responsive groundwork: reflow classes for the deck two-pane and nav
rail (stacks / icon-collapse at breakpoints), touch-target sizing.
Desktop unchanged in kind.
2026-07-22 23:44:21 -05:00
|
|
|
|
|
|
|
|
|
|
<div className="workspace-bar">
|
|
|
|
|
|
{hasActiveFilter && (
|
|
|
|
|
|
<span className="workspace-bar-count">
|
|
|
|
|
|
{rows.length} result{rows.length === 1 ? "" : "s"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span className="spacer" />
|
|
|
|
|
|
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
|
|
|
|
|
<SortBySelect
|
|
|
|
|
|
value={sortBy}
|
|
|
|
|
|
dir={sortDir}
|
|
|
|
|
|
onChangeValue={setSortBy}
|
|
|
|
|
|
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<ViewToggle mode={view} onChange={setView} />
|
|
|
|
|
|
</div>
|
2026-07-22 20:55:17 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{!hasActiveFilter ? (
|
|
|
|
|
|
<p className="hint">
|
|
|
|
|
|
Search the full card database — type a name or choose filters below to get started.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : error ? (
|
|
|
|
|
|
<p className="hint">{error}</p>
|
|
|
|
|
|
) : rows.length === 0 ? (
|
|
|
|
|
|
<p className="hint">No cards match your filters.</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{rows.length === CAP && (
|
|
|
|
|
|
<p className="hint">
|
|
|
|
|
|
Showing the first {CAP} matches — refine your search to narrow it down.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{groups.map((g) => (
|
|
|
|
|
|
<div key={g.label || "all"} style={{ marginBottom: g.label ? 20 : 0 }}>
|
|
|
|
|
|
{g.label && (
|
|
|
|
|
|
<h3 className="group-header">
|
|
|
|
|
|
{g.label} <span className="badge">{g.cards.length}</span>
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{view === "list" ? refTable(g.cards) : refTiles(g.cards)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|