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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user