deckhearth/components/ScannedCardItem.js
Randall Stillwell d269bd29c1 feat(design-system): sweep authenticated body-content panels to glass
PR #95/#96 shipped the Liquid Glass foundation (tokens, primitives, gates)
plus Layout shell, modals, landing, auth pages, and form CTAs — but body-
content panels on authenticated pages (admin Card Editor, admin Card
Import, admin Submissions, dashboard, my-cards, settings, scanner panels,
card detail price cards, popovers) were still rendering as flat
var(--bg-secondary) cards. Result: the admin Tools screen and several
core pages looked unchanged after the redesign.

This sweep adds a `.glass-panel` / `.glass-panel-strong` utility
(<GlassSurface tint=mid/high rim=subtle elevation=ambient/pronounced
blur=mid/high> in class form) and applies it across 18 surfaces:

  * Admin Card Editor view, search panel, form (5 sections), preview
  * Admin Card Import navigation + 3 body cards + sync panel
  * Admin Card Submissions list items
  * Dashboard stat cards + empty-state + grid items (5 surfaces)
  * My-cards empty-state CTA card
  * Settings panels (3)
  * Scanner page settings + grid + queue + bulk toolbar + dialog
  * Scanner destination picker + camera status banner + disambiguation
  * Card detail price cards (Current / TCGPlayer / CardKingdom)
  * Permission indicator tooltips
  * Collections page header card
  * Card detail view price cards

Also migrates the lingering admin Card Editor "Card Editor / Card Import"
nav buttons and the "Save Changes" / "Import Cards" / "Run catalog sync"
CTAs to the <Button> primitive (consistent loading + disabled states).

Page header bands (full-bleed strips with border-bottom on dashboard,
my-cards, cards, collections, community/collections, collection/[id])
are intentionally left solid — they're not card-shaped surfaces and
stacking glass-on-glass directly below the already-glass topbar would
muddy the hierarchy.

Tests: lint clean, vitest 104/104, build green. The visual diff
baseline will need refresh because the homepage spec is unaffected
(it targets the unauthenticated landing page) but the dashboard/
admin/scanner surfaces will diff if/when we add baselines for them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 20:44:40 -05:00

307 lines
11 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */
import { useEffect, useState } from 'react';
import { formatProcessedDestination, VOCAB } from '../lib/collection-vocabulary.js';
export const CONDITION_OPTIONS = ['NM', 'LP', 'MP', 'HP', 'DMG'];
export default function ScannedCardItem({
card,
collections,
decks,
selected,
onToggleSelect,
onIncrement,
onDecrement,
onUpdateMetadata,
onMarkOwned,
onAddToCollection,
onAddToDeck,
onRemove,
isAdding = false,
}) {
const [ownedQuantity, setOwnedQuantity] = useState(null);
const resolvedOwnedQuantity = card.databaseId ? ownedQuantity : null;
useEffect(() => {
if (!card.databaseId) {
return;
}
let cancelled = false;
(async () => {
try {
const response = await fetch(`/api/cards/${card.databaseId}/ownership`, {
headers: {
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
},
});
if (!response.ok) return;
const data = await response.json();
if (!cancelled) {
setOwnedQuantity(typeof data.quantity === 'number' ? data.quantity : 0);
}
} catch {
if (!cancelled) setOwnedQuantity(null);
}
})();
return () => {
cancelled = true;
};
}, [card.databaseId]);
return (
<div
className={`flex gap-4 p-4 rounded-lg border ${card.processed ? 'opacity-60' : ''}`}
style={{
backgroundColor: 'var(--bg-tertiary)',
borderColor: selected ? 'var(--accent-ember)' : 'var(--border)',
borderWidth: selected ? '2px' : '1px',
}}
>
<div className="relative flex-shrink-0">
<div className="glass-panel w-20 h-28 rounded-lg overflow-hidden flex items-center justify-center">
{card.image_url ? (
<img src={card.image_url} alt={card.name} className="w-full h-full object-cover" />
) : (
<div className="text-center text-xs p-2" style={{ color: 'var(--text-secondary)' }}>
<div className="text-2xl mb-1">🃏</div>
<div>No Image</div>
</div>
)}
</div>
{!card.processed && (
<div className="absolute top-1 left-1">
<input
type="checkbox"
checked={selected}
onChange={onToggleSelect}
className="w-5 h-5 rounded border-2 border-white shadow-lg"
style={{ accentColor: 'var(--accent-ember)' }}
aria-label={`Select ${card.name}`}
/>
</div>
)}
</div>
<div className="flex-1 min-w-0">
{card.confidence && (
<div
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium mb-2"
style={{
backgroundColor:
card.confidence >= 90
? 'var(--accent-gold)'
: card.confidence >= 70
? 'var(--accent-ember)'
: 'var(--text-secondary)',
color: 'white',
}}
>
{Math.round(card.confidence)}% confidence
</div>
)}
<div className="flex items-start justify-between mb-2 gap-4">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg truncate" style={{ color: 'var(--text-primary)' }}>
{card.name}
</h3>
{card.isExisting && (
<div className="text-xs font-medium" style={{ color: 'var(--accent-gold)' }}>
<span aria-hidden="true"> </span>
<span>Found in database</span>
</div>
)}
{resolvedOwnedQuantity !== null && resolvedOwnedQuantity > 0 && (
<div
role="status"
className="text-xs font-medium mt-1"
style={{ color: 'var(--text-secondary)' }}
aria-label={`You already own ${resolvedOwnedQuantity} copies of this card`}
>
You own {resolvedOwnedQuantity}
</div>
)}
</div>
{!card.processed && (
<div className="flex items-center gap-2 flex-shrink-0">
<button
type="button"
onClick={onDecrement}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold hover:opacity-80"
style={{
backgroundColor: 'var(--bg-secondary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
}}
aria-label="Decrease quantity"
>
</button>
<span className="min-w-[2rem] text-center font-medium" style={{ color: 'var(--text-primary)' }}>
{card.quantity || 1}
</span>
<button
type="button"
onClick={onIncrement}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold hover:opacity-80"
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
aria-label="Increase quantity"
>
+
</button>
</div>
)}
</div>
{!card.processed && (
<div className="flex flex-wrap items-center gap-3 mb-3">
<label
htmlFor={`scan-condition-${card.id}`}
className="flex items-center gap-2 text-sm"
style={{ color: 'var(--text-secondary)' }}
>
<span>Condition</span>
<select
id={`scan-condition-${card.id}`}
value={card.condition || 'NM'}
onChange={(e) => onUpdateMetadata({ condition: e.target.value })}
className="px-2 py-1 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)',
}}
aria-label={`Condition for ${card.name}`}
>
{CONDITION_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer" style={{ color: 'var(--text-secondary)' }}>
<input
type="checkbox"
checked={Boolean(card.isFoil)}
onChange={(e) => onUpdateMetadata({ isFoil: e.target.checked })}
className="rounded"
style={{ accentColor: 'var(--accent-ember)' }}
/>
Foil
</label>
</div>
)}
<div className="space-y-1 mb-3">
{card.set && (
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
<span className="font-semibold">Set:</span> {card.set}
{card.setCode && <span className="ml-2 text-xs">({card.setCode})</span>}
</div>
)}
{card.cardNumber && (
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<span className="font-semibold">Number:</span> {card.cardNumber}
</div>
)}
{!card.processed && (card.condition || card.isFoil) && (
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<span className="font-semibold">Adding as:</span>{' '}
{card.condition || 'NM'}
{card.isFoil ? ' · Foil' : ''}
</div>
)}
</div>
{!card.processed ? (
<div className="space-y-2">
<div className="flex gap-2">
<button
type="button"
onClick={onMarkOwned}
disabled={isAdding}
className="flex-1 px-3 py-2 rounded text-sm font-medium hover:opacity-80 flex items-center justify-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
>
<span aria-hidden="true">💎 </span>
{isAdding ? 'Adding…' : VOCAB.ADD_TO_MY_COLLECTION}
</button>
<button
type="button"
onClick={onRemove}
className="px-3 py-2 rounded text-sm hover:opacity-80"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--bg-secondary)' }}
aria-label={`Remove ${card.name} from scan queue`}
>
<span aria-hidden="true">🗑</span>
</button>
</div>
<div className="flex gap-2">
{collections.length > 0 && (
<select
onChange={(e) => {
const value = e.target.value;
e.target.value = '';
if (value) onAddToCollection(value);
}}
className="flex-1 px-3 py-2 rounded text-sm"
style={{
backgroundColor: 'var(--bg-secondary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
}}
defaultValue=""
aria-label={`Add ${card.name} to a list`}
>
<option value="">{`📚 ${VOCAB.ADD_TO_LIST}`}</option>
{collections.map((collection) => (
<option key={collection.id} value={collection.id}>
{collection.name}
</option>
))}
</select>
)}
{decks.length > 0 && (
<select
onChange={(e) => {
const value = e.target.value;
e.target.value = '';
if (value) onAddToDeck(value);
}}
className="flex-1 px-3 py-2 rounded text-sm"
style={{
backgroundColor: 'var(--bg-secondary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
}}
defaultValue=""
aria-label={`Add ${card.name} to a deck`}
>
<option value="">🃏 Add to Deck</option>
{decks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.name}
</option>
))}
</select>
)}
</div>
</div>
) : (
<div className="text-sm flex items-center gap-2" style={{ color: 'var(--accent-ember)' }}>
<span aria-hidden="true"></span>
<span>Added to {formatProcessedDestination(card.processedAction)}</span>
</div>
)}
</div>
</div>
);
}