Add deck analysis panel with mana production/consumption bars and oracle text search
Threads a new produced_mana/produces_colorless field through the whole card read path (ingest, library, filters, decks, search) so the deck view can show what colors a deck's manabase actually produces, alongside its existing color identity. Also adds a mana consumption bar (colored casting costs of nonland spells) and stacks the mana curve histogram by color. Adds an oracle-text substring filter to the library search, mirroring the existing location filter. Rearranges the deck sidebar list into two rows (name, then format chip + actions) so long names and action buttons no longer collide, and removes the drop shadows from the commander hero panel/image. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
@@ -341,27 +342,27 @@ export function CardThumb({ card }: { card: CardRef }) {
|
||||
* A search input plus a results dropdown with keyboard navigation:
|
||||
* ↑/↓ move the highlight, Enter picks, mouse hover/click also work.
|
||||
* `value`/`onChange` are controlled by the caller (which runs the search).
|
||||
* Forwards a ref to the underlying `<input>` so callers can focus it
|
||||
* imperatively (e.g. redirecting a stray keystroke into the search box).
|
||||
*/
|
||||
export function CardSearchBox({
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
results,
|
||||
onPick,
|
||||
autoFocus,
|
||||
big,
|
||||
}: {
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
results: CardRef[];
|
||||
onPick: (card: CardRef) => void;
|
||||
autoFocus?: boolean;
|
||||
/** Larger, more prominent input (used for the primary Add Cards field). */
|
||||
big?: boolean;
|
||||
}) {
|
||||
export const CardSearchBox = forwardRef<
|
||||
HTMLInputElement,
|
||||
{
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
results: CardRef[];
|
||||
onPick: (card: CardRef) => void;
|
||||
autoFocus?: boolean;
|
||||
/** Larger, more prominent input (used for the primary Add Cards field). */
|
||||
big?: boolean;
|
||||
}
|
||||
>(function CardSearchBox(
|
||||
{ placeholder, value, onChange, results, onPick, autoFocus, big },
|
||||
forwardedRef,
|
||||
) {
|
||||
const [sel, setSel] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
useEffect(() => setSel(0), [results]);
|
||||
|
||||
// Keep focus in the box after a pick so cards can be added in a rapid streak.
|
||||
@@ -386,7 +387,11 @@ export function CardSearchBox({
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
ref={(el) => {
|
||||
inputRef.current = el;
|
||||
if (typeof forwardedRef === "function") forwardedRef(el);
|
||||
else if (forwardedRef) (forwardedRef as React.MutableRefObject<HTMLInputElement | null>).current = el;
|
||||
}}
|
||||
autoFocus={autoFocus}
|
||||
style={{
|
||||
width: "100%",
|
||||
@@ -416,6 +421,110 @@ export function CardSearchBox({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A chip-based multi-value text filter with autocomplete: selected values
|
||||
* render as removable chips, and typing filters `suggestions` into a
|
||||
* dropdown (↑/↓ to move, Enter to pick — or Enter with no match adds the
|
||||
* typed text verbatim, so it's not limited to only-suggested values).
|
||||
* Backspace on an empty input quick-removes the last chip.
|
||||
*/
|
||||
export function TagInput({
|
||||
values,
|
||||
onChange,
|
||||
suggestions,
|
||||
placeholder,
|
||||
}: {
|
||||
values: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
suggestions: string[];
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [text, setText] = useState("");
|
||||
const [sel, setSel] = useState(0);
|
||||
|
||||
const query = text.trim().toLowerCase();
|
||||
const filtered = query
|
||||
? suggestions
|
||||
.filter((s) => !values.some((v) => v.toLowerCase() === s.toLowerCase()))
|
||||
.filter((s) => s.toLowerCase().includes(query))
|
||||
.slice(0, 8)
|
||||
: [];
|
||||
|
||||
useEffect(() => setSel(0), [text]);
|
||||
|
||||
function addValue(v: string) {
|
||||
const trimmed = v.trim();
|
||||
if (!trimmed) return;
|
||||
if (!values.some((x) => x.toLowerCase() === trimmed.toLowerCase())) {
|
||||
onChange([...values, trimmed]);
|
||||
}
|
||||
setText("");
|
||||
}
|
||||
|
||||
function removeValue(v: string) {
|
||||
onChange(values.filter((x) => x !== v));
|
||||
}
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "ArrowDown" && filtered.length > 0) {
|
||||
e.preventDefault();
|
||||
setSel((s) => Math.min(s + 1, filtered.length - 1));
|
||||
} else if (e.key === "ArrowUp" && filtered.length > 0) {
|
||||
e.preventDefault();
|
||||
setSel((s) => Math.max(s - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (filtered[sel]) addValue(filtered[sel]);
|
||||
else if (text.trim()) addValue(text);
|
||||
} else if (e.key === "Backspace" && text === "" && values.length > 0) {
|
||||
onChange(values.slice(0, -1));
|
||||
} else if (e.key === "Escape") {
|
||||
setText("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tag-input">
|
||||
<div className="tag-chips">
|
||||
{values.map((v) => (
|
||||
<span key={v} className="tag-chip">
|
||||
{v}
|
||||
<button
|
||||
type="button"
|
||||
className="tag-chip-remove"
|
||||
onClick={() => removeValue(v)}
|
||||
title={`Remove ${v}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
className="tag-chip-input"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={values.length === 0 ? placeholder : ""}
|
||||
/>
|
||||
</div>
|
||||
{filtered.length > 0 && (
|
||||
<ul className="result-list tag-suggestions">
|
||||
{filtered.map((s, i) => (
|
||||
<li
|
||||
key={s}
|
||||
style={i === sel ? { background: "var(--accent-subtle)" } : undefined}
|
||||
onMouseEnter={() => setSel(i)}
|
||||
onClick={() => addValue(s)}
|
||||
>
|
||||
{s}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Hover + click preview -------------------------------------------------
|
||||
@@ -666,6 +775,98 @@ export function GroupBySelect({
|
||||
);
|
||||
}
|
||||
|
||||
// --- Stats widgets (sidebar "Your library" panel, per-deck analysis) ------
|
||||
|
||||
/** A single numeric stat in a small panel tile, e.g. "42 / unique". */
|
||||
export function StatTile({ value, label }: { value: string | number; label: string }) {
|
||||
return (
|
||||
<div className="stat-tile">
|
||||
<span className="stat-tile-value">{value}</span>
|
||||
<span className="stat-tile-label">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A segmented WUBRG(+colorless) bar sized proportionally to `colorCounts`. */
|
||||
export function ColorDistributionBar({
|
||||
colorCounts,
|
||||
colorlessCount,
|
||||
}: {
|
||||
colorCounts: [number, number, number, number, number]; // W, U, B, R, G
|
||||
colorlessCount: number;
|
||||
}) {
|
||||
const segs: [string, number][] = [
|
||||
["W", colorCounts[0]],
|
||||
["U", colorCounts[1]],
|
||||
["B", colorCounts[2]],
|
||||
["R", colorCounts[3]],
|
||||
["G", colorCounts[4]],
|
||||
["C", colorlessCount],
|
||||
];
|
||||
if (segs.every(([, count]) => count === 0)) return null;
|
||||
return (
|
||||
<div className="color-bar">
|
||||
{segs.map(([code, count]) =>
|
||||
count > 0 ? (
|
||||
<span
|
||||
key={code}
|
||||
className={`color-bar-seg ${code}`}
|
||||
style={{ flexGrow: count }}
|
||||
title={`${code === "C" ? "Colorless" : code}: ${count.toLocaleString()}`}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A mana-value histogram (bar height proportional to the largest bucket),
|
||||
* each bar segmented by color like {@link ColorDistributionBar}. */
|
||||
export function ManaCurve({
|
||||
buckets,
|
||||
}: {
|
||||
buckets: {
|
||||
label: string;
|
||||
count: number;
|
||||
colorCounts: [number, number, number, number, number]; // W, U, B, R, G
|
||||
colorlessCount: number;
|
||||
}[];
|
||||
}) {
|
||||
const max = Math.max(1, ...buckets.map((b) => b.count));
|
||||
return (
|
||||
<div className="mana-curve">
|
||||
{buckets.map((b) => {
|
||||
const segs: [string, number][] = [
|
||||
["W", b.colorCounts[0]],
|
||||
["U", b.colorCounts[1]],
|
||||
["B", b.colorCounts[2]],
|
||||
["R", b.colorCounts[3]],
|
||||
["G", b.colorCounts[4]],
|
||||
["C", b.colorlessCount],
|
||||
];
|
||||
return (
|
||||
<div key={b.label} className="mana-curve-col">
|
||||
<span className="mana-curve-count">{b.count || ""}</span>
|
||||
<div className="mana-curve-bar" style={{ height: `${(b.count / max) * 100}%` }}>
|
||||
{segs.map(([code, count]) =>
|
||||
count > 0 ? (
|
||||
<span
|
||||
key={code}
|
||||
className={`mana-curve-seg ${code}`}
|
||||
style={{ flexGrow: count }}
|
||||
title={`${code === "C" ? "Colorless" : code}: ${count.toLocaleString()}`}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
<span className="mana-curve-label">{b.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Persistence ----------------------------------------------------------
|
||||
|
||||
/** useState that mirrors its value to localStorage (JSON), so it survives
|
||||
|
||||
Reference in New Issue
Block a user