deckhearth/pages/admin/card-import.js
Randall Stillwell b7ffeef270 feat(design-system): finish Liquid Glass — close all remaining sub-convoys
Follow-up PR to #95 (Liquid Glass foundation + primitives + Layout shell)
that closes out the remaining sub-convoy briefs in a single sweep.
Operator-instructed scope: "finish off the design changes."

After this PR, **all 8 Liquid Glass sub-convoys are MERGED to main**;
the deferred-from-#5 `fix-card3d-state` convoy is dropped (its target,
`components/Card3D.js`, turned out to be dead code).

## #2 Brief 2 — Remaining 8 modals migrated to <Modal> primitive

- `CollectionsSuccessModal.js` — wrap in <Modal hideCloseButton>; 2 Buttons.
- `CollectionsEditModal.js` — full <Modal> + <Input> + <Button> rewrite (4 fields, tag chip section, public-toggle preserved, 2 footer Buttons).
- `CollectionEditModal.js` — same pattern as above (4 fields + public-toggle + 2 Buttons).
- `CardDetailDeckModal.js` — <Modal> + native select (Select primitive not in scope) + 2 Buttons; sweep `gradient-bg-purple` → `<Button variant="primary">`.
- `UploadImageModal.js` — <Modal> + token-driven URL/file tab switcher + drag-drop using `--accent-ember` rim + 2 Buttons (one with `loading` prop).
- `CollectionSelectionModal.js` — largest of the set (header summary + SearchBar + scrollable list w/ checkbox toggles + footer); migrated to <Modal size="lg"> while preserving the per-collection card preview thumbnails.
- `OCRSettings.js` — trivial <Modal> wrap + single primary <Button>.
- `pages/decks.js` — both inline modals (Create Deck + Edit Deck) and `components/ScannerPageView.js` (Create List) migrated; ScannerPageView dropped its `useFocusTrap` named-import (Modal's internal focus trap owns the panel ref now).
- **`.github/workflows/ci.yml` `forbidden-modal-shell-without-primitive`** — grandfather list emptied to zero entries; gate is now strict.

## #3 Brief 2 — Forms migrated to <Button> / <SearchBar>

- `pages/dashboard.js` — 3 CTAs → <Button> (Create List with leadingIcon, Create Your First List, View All Lists).
- `pages/my-cards.js` — empty-state CTA → <Button variant="primary" size="lg">. View-mode toggle buttons intentionally left native (icon-only, doesn't match Button variants).
- `pages/community/collections.js` — Go to My Lists CTA → <Button>.
- `components/CollectionsPageView.js` — Discover Community + Create List header CTAs → <Button>; search input → <SearchBar>.
- Card-grid per-row icon buttons (CollectionsPageView, my-cards, CardsPageView) intentionally left native — tiny per-card actions whose styling doesn't match Button variants and would invalidate visual-diff baselines.

## #5 — scope revised + landed

`components/Card3D.js` deletion: surveyed every importer with grep — **zero consumers** in `pages/**` or `components/**`. Only references were in convoy docs. The "pre-existing state-management bug" (state setters used without useState declarations) never affected the running app because the component was never rendered. -505 LOC. The `fix-card3d-state` convoy is dropped from the roadmap as a result.

The actual card-grid component (`components/CardItem.js`) is intentionally **not** modified in this sweep — it has per-rarity glow tuning that the existing visual-diff baseline locks in, and the architect's #5 deferral note specifically called out the dedicated baseline re-seed cost. A future implementer turn can apply rim-light tokens to CardItem with its own baseline re-seed when an operator wants that polish.

## #6 Brief 1 — Landing + invite pages glass-migrated

- `pages/index.js` — top nav: `var(--glass-surface-mid)` + `--glass-blur-mid` + rim-light. 3 feature cards: `<GlassSurface tint="mid" rim="subtle" elevation="ambient">`. Featured-list cards (the public collection grid): same `<GlassSurface>` recipe with motion-token transitions. All 6 CTA buttons → <Button variant="primary"|"secondary"|"ghost"> with proper sizes. Pulse-loading placeholders tagged `.motion-essential` so reduced-motion users still see them animate (state-meaningful).
- `pages/invite/accept.js` + `pages/invite/decline.js` — both outcome panels wrapped in `<GlassSurface tint="mid" rim="subtle" elevation="pronounced">`. Loading spinner border colors corrected from `--text-accent` (which didn't exist) to `--accent-ember`. All 8 buttons → <Button>. `gradient-bg-ember` consumers retained (the canonical warm-palette utility class is fine).

## #8 Brief 2 — Legacy alias sweep + CI gate graduation

- Swept `gradient-bg-purple` → `gradient-bg-ember` across **8 files** / **13 occurrences**: `CardDetailQuantityModal`, `CardEditorView`, `CardEditorForm`, `AdminProtected`, `pages/card/[id]`, `pages/invite/{accept,decline}`, `pages/admin/card-import`. `gradient-bg-purple` was a dangling class name with no CSS definition (it was rendering no styling), so the sweep is also a bug fix — those buttons now actually get the ember gradient.
- Deleted the 5 dead CSS classes from `styles/globals.css`: `.gradient-text-blue`, `.gradient-text-purple`, `[data-theme="dark"] .glow-blue`, `[data-theme="dark"] .glow-purple`, `[data-theme="dark"] .glow-pink`. Each was zero-consumer post-sweep.
- **Graduated the `forbidden-deprecated-color-aliases` CI job from WARN to FAIL.** All 9 patterns (`gradient-text-{purple,pink,blue}`, `glow-{purple,pink,blue}`, `gradient-bg-{purple,blue,pink}`) now block the build if any consumer is reintroduced.

## Verification (local + CI gates locally exercised)

- Lint: 0 errors, 2 pre-existing warnings (`CardEditorForm.js` + `CollectionsPageView.js` carry-overs from before #95; out of scope).
- Vitest: 104/104 passing — unchanged from #95.
- Build: clean (Turbopack default; passes both light + dark theme prerender).
- `forbidden-modal-shell-without-primitive` gate: locally clear (`grep -lE 'fixed inset-0 bg-black bg-opacity-' pages components -r --include='*.js'` returns no matches).
- `forbidden-deprecated-color-aliases` gate: locally clear (all 9 patterns return no matches in `pages/` or `components/`).

## What still needs human action

- **Linux visual-diff baselines** must re-seed via the Docker workflow in `AGENTS.md` § 6. This PR's landing-page + invite-page changes will produce baseline drift on the homepage screenshot (which is currently the only baseline committed) AND additional baselines will be generated for the landing's glass-card sections once the visual spec is expanded. Recommended: run the Docker re-seed against this PR's Vercel preview, commit the result to this branch, push, verify CI green, then merge.
- Vercel auto-promotes the merge to production.

## Closes / supersedes

- Closes `.convoys/liquid-glass-modal-and-surface-primitive.md` Brief 2 (status → merged).
- Closes `.convoys/liquid-glass-form-primitives.md` Brief 2 (status → merged with explicit per-row-icon-button deferral note).
- Closes `.convoys/liquid-glass-public-and-auth.md` Brief 1 (status → merged).
- Closes `.convoys/cleanup-legacy-design-css.md` Brief 2 (status → merged + CI gate FAIL).
- Drops `.convoys/liquid-glass-card-surfaces.md` Brief 1 prerequisite (`fix-card3d-state` no longer needed; Card3D deleted).
- Drops the queued `fix-card3d-state` follow-up from the roadmap (target deleted).
- Updates `.convoys/ship-readiness.md` § "Design-system redesign portfolio" with a "Finish-portfolio sweep" subsection documenting final status of all 8 sub-convoys.

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

427 lines
No EOL
16 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.

import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import dynamic from 'next/dynamic';
import Layout from '../../components/Layout';
import AdminProtected from '../../components/AdminProtected';
const CardImport = () => {
const router = useRouter();
const [importType, setImportType] = useState('mtg');
const [setCode, setSetCode] = useState('');
const [isImporting, setIsImporting] = useState(false);
const [importResult, setImportResult] = useState(null);
const [isSyncing, setIsSyncing] = useState(false);
const [syncResult, setSyncResult] = useState(null);
const handleCatalogSync = async () => {
setIsSyncing(true);
setSyncResult(null);
try {
const response = await fetch('/api/admin/sync-catalog', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
},
});
const result = await response.json();
if (response.ok) {
setSyncResult({ success: true, ...result });
} else {
setSyncResult({
success: false,
message: result.error || 'Catalog sync failed',
});
}
} catch (error) {
setSyncResult({
success: false,
message: `Network error: ${error.message}`,
});
} finally {
setIsSyncing(false);
}
};
const handleImport = async () => {
if (!setCode.trim()) {
alert('Please enter a set code');
return;
}
setIsImporting(true);
setImportResult(null);
try {
const endpoint = importType === 'mtg'
? '/api/cards/import-mtg'
: '/api/cards/import-pokemon';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
},
body: JSON.stringify({ setCode: setCode.trim() }),
});
const result = await response.json();
if (response.ok) {
setImportResult({
success: true,
message: result.message,
imported: result.imported,
skipped: result.skipped,
total: result.total
});
} else {
setImportResult({
success: false,
message: result.error || 'Import failed'
});
}
} catch (error) {
setImportResult({
success: false,
message: 'Network error: ' + error.message
});
} finally {
setIsImporting(false);
}
};
const popularSets = {
mtg: [
{ code: 'neo', name: 'Kamigawa: Neon Dynasty' },
{ code: 'vow', name: 'Innistrad: Crimson Vow' },
{ code: 'mid', name: 'Innistrad: Midnight Hunt' },
{ code: 'afr', name: 'Adventures in the Forgotten Realms' },
{ code: 'stx', name: 'Strixhaven: School of Mages' },
{ code: 'khm', name: 'Kaldheim' },
{ code: 'znr', name: 'Zendikar Rising' },
{ code: 'iko', name: 'Ikoria: Lair of Behemoths' },
{ code: 'thb', name: 'Theros Beyond Death' },
{ code: 'eld', name: 'Throne of Eldraine' }
],
pokemon: [
{ code: 'swsh1', name: 'Sword & Shield' },
{ code: 'swsh2', name: 'Rebel Clash' },
{ code: 'swsh3', name: 'Darkness Ablaze' },
{ code: 'swsh4', name: 'Vivid Voltage' },
{ code: 'swsh5', name: 'Battle Styles' },
{ code: 'swsh6', name: 'Chilling Reign' },
{ code: 'swsh7', name: 'Evolving Skies' },
{ code: 'swsh8', name: 'Fusion Strike' },
{ code: 'swsh9', name: 'Brilliant Stars' },
{ code: 'swsh10', name: 'Astral Radiance' }
]
};
return (
<AdminProtected>
{(user) => (
<Layout user={user}>
<div className="container mx-auto px-6 py-8">
{/* Admin Navigation */}
<div className="mb-8 p-4 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
Admin Tools
</h1>
<div className="flex gap-4">
<button
onClick={() => router.push('/admin/card-editor')}
className="px-4 py-2 rounded-lg font-medium transition-all duration-200 border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
>
🖊 Card Editor
</button>
<button
onClick={() => router.push('/admin/card-import')}
className="px-4 py-2 rounded-lg font-medium gradient-bg-ember text-white"
>
📥 Card Import
</button>
</div>
</div>
</div>
{/* Card Import Section */}
<div className="mb-6">
<h2 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
Card Import Manager
</h2>
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
Import cards from external APIs into your database
</p>
</div>
{/* Catalog sync */}
<div className="mb-8 p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
Catalog sync
</h2>
<p className="text-sm max-w-2xl" style={{ color: 'var(--text-secondary)' }}>
Import up to three newest missing MTG and Pokémon sets the same job the weekly Vercel cron runs.
Pending scan submissions for those sets are auto-linked to the catalog when a unique match exists.
</p>
</div>
<button
type="button"
onClick={handleCatalogSync}
disabled={isSyncing}
className={`shrink-0 py-3 px-6 rounded-xl font-medium transition-all duration-200 ${
isSyncing
? 'opacity-50 cursor-not-allowed'
: 'gradient-bg-ember text-white hover:shadow-lg'
}`}
>
{isSyncing ? 'Syncing catalog…' : 'Run catalog sync'}
</button>
</div>
{syncResult && (
<div
className={`mt-4 p-4 rounded-xl ${
syncResult.success
? 'bg-green-100 border border-green-300'
: 'bg-red-100 border border-red-300'
}`}
>
<h3
className={`font-semibold mb-2 ${
syncResult.success ? 'text-green-800' : 'text-red-800'
}`}
>
{syncResult.success ? 'Catalog sync complete' : 'Catalog sync failed'}
</h3>
{syncResult.success ? (
<div className="text-sm space-y-2 text-green-900">
<p>
Imported {syncResult.imported} cards ({syncResult.skipped} skipped).
</p>
{syncResult.submissionsReconciled > 0 && (
<p>
Linked {syncResult.submissionsReconciled} pending scan submission
{syncResult.submissionsReconciled === 1 ? '' : 's'} to catalog cards.
</p>
)}
{(syncResult.pendingMtgSets != null || syncResult.pendingPokemonSets != null) && (
<p>
Still pending: {syncResult.pendingMtgSets} MTG sets,{' '}
{syncResult.pendingPokemonSets} Pokémon sets.
</p>
)}
{syncResult.lorcana && <p>{syncResult.lorcana}</p>}
{Array.isArray(syncResult.setsProcessed) && syncResult.setsProcessed.length > 0 && (
<ul className="list-disc pl-5 space-y-1">
{syncResult.setsProcessed.map((set) => (
<li key={`${set.game}-${set.setCode}`}>
{set.name} ({set.game}/{set.setCode}) {set.imported} imported
</li>
))}
</ul>
)}
{Array.isArray(syncResult.reconciliation) && syncResult.reconciliation.length > 0 && (
<ul className="list-disc pl-5 space-y-1">
{syncResult.reconciliation.flatMap((entry) =>
entry.details.map((detail) => (
<li key={`submission-${detail.submissionId}`}>
Submission #{detail.submissionId}: {detail.name}
{detail.cardNumber ? ` (${detail.cardNumber})` : ''} card #{detail.cardId}
</li>
))
)}
</ul>
)}
{Array.isArray(syncResult.errors) && syncResult.errors.length > 0 && (
<ul className="list-disc pl-5 space-y-1 text-red-800">
{syncResult.errors.map((entry) => (
<li key={`${entry.game}-${entry.setCode}`}>
{entry.game}/{entry.setCode}: {entry.message}
</li>
))}
</ul>
)}
</div>
) : (
<p className="text-sm text-red-800">{syncResult.message}</p>
)}
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Import Form */}
<div className="space-y-6">
<div className="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<h2 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Import Settings
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
TCG Type
</label>
<select
value={importType}
onChange={(e) => setImportType(e.target.value)}
className="input-field w-full"
>
<option value="mtg">Magic: The Gathering</option>
<option value="pokemon">Pokemon TCG</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Set Code
</label>
<input
type="text"
value={setCode}
onChange={(e) => setSetCode(e.target.value)}
placeholder="e.g., neo, vow, swsh1"
className="input-field w-full"
/>
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
Enter the set code (usually 3-4 characters)
</p>
</div>
<button
onClick={handleImport}
disabled={isImporting || !setCode.trim()}
className={`w-full py-3 px-6 rounded-xl font-medium transition-all duration-200 ${
isImporting || !setCode.trim()
? 'opacity-50 cursor-not-allowed'
: 'gradient-bg-ember text-white hover:shadow-lg'
}`}
>
{isImporting ? 'Importing...' : 'Import Cards'}
</button>
</div>
</div>
{/* Import Result */}
{importResult && (
<div className={`p-6 rounded-xl ${
importResult.success
? 'bg-green-100 border border-green-300'
: 'bg-red-100 border border-red-300'
}`}>
<h3 className={`font-semibold mb-2 ${
importResult.success ? 'text-green-800' : 'text-red-800'
}`}>
{importResult.success ? 'Import Successful' : 'Import Failed'}
</h3>
<p className="text-sm mb-2">
{importResult.message}
</p>
{importResult.success && (
<div className="text-sm space-y-1">
<p>Imported: {importResult.imported}</p>
<p>Skipped: {importResult.skipped}</p>
<p>Total: {importResult.total}</p>
</div>
)}
</div>
)}
</div>
{/* Popular Sets */}
<div className="space-y-6">
<div className="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<h2 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Popular Sets
</h2>
<div className="space-y-4">
<div>
<h3 className="font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Magic: The Gathering
</h3>
<div className="grid grid-cols-2 gap-2">
{popularSets.mtg.map((set) => (
<button
key={set.code}
onClick={() => {
setImportType('mtg');
setSetCode(set.code);
}}
className="text-left p-2 rounded-lg text-sm hover:bg-opacity-20 transition-all duration-200"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-secondary)'
}}
>
<div className="font-medium">{set.code.toUpperCase()}</div>
<div className="text-xs opacity-75">{set.name}</div>
</button>
))}
</div>
</div>
<div>
<h3 className="font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Pokemon TCG
</h3>
<div className="grid grid-cols-2 gap-2">
{popularSets.pokemon.map((set) => (
<button
key={set.code}
onClick={() => {
setImportType('pokemon');
setSetCode(set.code);
}}
className="text-left p-2 rounded-lg text-sm hover:bg-opacity-20 transition-all duration-200"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-secondary)'
}}
>
<div className="font-medium">{set.code.toUpperCase()}</div>
<div className="text-xs opacity-75">{set.name}</div>
</button>
))}
</div>
</div>
</div>
</div>
{/* Import Tips */}
<div className="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<h2 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Import Tips
</h2>
<ul className="space-y-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<li> Set codes are case-insensitive</li>
<li> Duplicate cards will be skipped automatically</li>
<li> Import may take several minutes for large sets</li>
<li> Prices are fetched from TCGPlayer when available</li>
<li> Images are stored as URLs to external sources</li>
</ul>
</div>
</div>
</div>
</div>
</Layout>
)}
</AdminProtected>
);
};
// Export with dynamic import to disable SSR
export default dynamic(() => Promise.resolve(CardImport), { ssr: false });