Deck actions UI, consistent icon buttons, native select fix, sidebar stats

- DeckView: rename/duplicate/delete deck via row-hover icon actions, with
  a ConfirmDialog for delete and PromptDialog (with friendly name-
  collision errors) for rename/duplicate.
- Add a shared IconButton (plus overlay/danger variants) and replace
  every ad hoc close/remove button in the app with it: deck actions,
  remove-card-from-deck (list and tile), clear-commander, the card-
  preview close, and the ingest-progress dismiss.
- Fix native <select> (Group by, Format, In deck): only color/border were
  overridden, leaving native OS chrome (height, arrow, popup) in place.
  Drop appearance and draw a custom chevron so they match text inputs.
- Click-to-preview: show both faces of a double-faced card side by side
  on wide viewports instead of requiring Flip, and size the preview
  images larger overall.
- Sidebar footer: replace the plain "N cards" line with a "Your library"
  panel (unique/copies/decks tiles + a WUBRG color-distribution bar),
  polling get_library_stats while card data is loaded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 21:21:47 -05:00
parent 3e41c9d055
commit e57c3dec51
7 changed files with 760 additions and 94 deletions

View File

@@ -9,6 +9,200 @@ import {
} from "react";
import { api, CardRef, COLORS, maskToLetters } from "./api";
// --- Icons ------------------------------------------------------------
const icon = (children: ReactNode) => (
<svg
viewBox="0 0 24 24"
width="15"
height="15"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
);
const ICON_TRASH = icon(
<>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</>,
);
const ICON_COPY = icon(
<>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</>,
);
const ICON_PENCIL = icon(<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />);
const ICON_CLOSE = icon(
<>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</>,
);
/**
* A small round icon-only button, used consistently for lightweight actions
* (rename, duplicate, delete, dismiss, close) throughout the app.
* - `overlay`: dark translucent variant for sitting on top of card art.
* - `danger`: tints red on hover, for destructive actions.
*/
export function IconButton({
icon,
label,
onClick,
overlay,
danger,
disabled,
}: {
icon: ReactNode;
label: string;
onClick: (e: React.MouseEvent) => void;
overlay?: boolean;
danger?: boolean;
disabled?: boolean;
}) {
return (
<button
type="button"
className={["icon-btn", overlay ? "icon-btn-overlay" : "", danger ? "icon-btn-danger" : ""]
.filter(Boolean)
.join(" ")}
onClick={onClick}
title={label}
aria-label={label}
disabled={disabled}
>
{icon}
</button>
);
}
export const Icons = {
trash: ICON_TRASH,
copy: ICON_COPY,
pencil: ICON_PENCIL,
close: ICON_CLOSE,
};
// --- Dialogs ------------------------------------------------------------
/**
* A centered modal dialog (backdrop + card) shared by [`ConfirmDialog`] and
* [`PromptDialog`]. Dismisses on Escape or backdrop click.
*/
function DialogFrame({
title,
children,
onCancel,
}: {
title: string;
children: ReactNode;
onCancel: () => void;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onCancel();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
return (
<div className="dialog-backdrop" onClick={onCancel}>
<div className="dialog" onClick={(e) => e.stopPropagation()}>
<h3 className="dialog-title">{title}</h3>
{children}
</div>
</div>
);
}
/** A confirm/cancel dialog for destructive or otherwise consequential actions. */
export function ConfirmDialog({
title,
message,
confirmLabel = "Confirm",
danger,
onConfirm,
onCancel,
}: {
title: string;
message: ReactNode;
confirmLabel?: string;
danger?: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<DialogFrame title={title} onCancel={onCancel}>
<p className="dialog-message">{message}</p>
<div className="dialog-actions">
<button onClick={onCancel}>Cancel</button>
<button className={danger ? "danger" : "primary"} onClick={onConfirm} autoFocus>
{confirmLabel}
</button>
</div>
</DialogFrame>
);
}
/** A single-text-field dialog (rename, duplicate-as, etc.), Enter to confirm. */
export function PromptDialog({
title,
label,
defaultValue,
confirmLabel = "Save",
error,
onConfirm,
onCancel,
}: {
title: string;
label: string;
defaultValue: string;
confirmLabel?: string;
error?: string | null;
onConfirm: (value: string) => void;
onCancel: () => void;
}) {
const [value, setValue] = useState(defaultValue);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => inputRef.current?.select(), []);
const confirm = () => {
const v = value.trim();
if (v) onConfirm(v);
};
return (
<DialogFrame title={title} onCancel={onCancel}>
<label className="dialog-field">
<span className="muted">{label}</span>
<input
ref={inputRef}
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && confirm()}
/>
</label>
{error && <p className="dialog-error">{error}</p>}
<div className="dialog-actions">
<button onClick={onCancel}>Cancel</button>
<button className="primary" onClick={confirm} disabled={!value.trim()}>
{confirmLabel}
</button>
</div>
</DialogFrame>
);
}
/**
* Inline editor for a card's free-text storage location (e.g. "Box 1").
* Saves on blur; used for owned cards that aren't in any deck.
@@ -332,15 +526,34 @@ function ClickPreviewModal({
onFlip: () => void;
}) {
const { card, showBack } = state;
const src = showBack && card.back_image_uri ? card.back_image_uri : card.image_uri;
const hasBack = !!card.back_image_uri;
// Both faces are always in the DOM when the card has one; a wide-viewport
// media query shows them side by side (no flip needed), while a narrow one
// shows only the "active" face and the flip button toggles between them.
return (
<div className="preview-modal-backdrop" onClick={onClose}>
<div className="preview-modal" onClick={(e) => e.stopPropagation()}>
<img className="preview-modal-img" src={src} alt={card.name} />
<button className="preview-modal-close" onClick={onClose} title="Close (Esc)">
✕
</button>
{card.back_image_uri && (
<div
className={`preview-modal ${hasBack ? "preview-modal-dfc" : ""}`}
onClick={(e) => e.stopPropagation()}
>
<div className="preview-modal-faces">
<img
className={`preview-modal-img ${showBack ? "" : "active"}`}
src={card.image_uri}
alt={card.name}
/>
{hasBack && (
<img
className={`preview-modal-img ${showBack ? "active" : ""}`}
src={card.back_image_uri}
alt={`${card.name} (back)`}
/>
)}
</div>
<span className="preview-modal-close">
<IconButton icon={Icons.close} label="Close (Esc)" onClick={onClose} overlay />
</span>
{hasBack && (
<button className="preview-modal-flip" onClick={onFlip} title="Show the other side">
⟲ Flip
</button>