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>
This commit is contained in:
Randall Stillwell 2026-06-03 20:30:15 -05:00
parent 334612ad79
commit b7ffeef270
26 changed files with 1376 additions and 1828 deletions

View file

@ -653,6 +653,33 @@ specific file lists and decision rationale. None is launch-blocking
aesthetic; modals blur the page behind them; reusable components")
is delivered TODAY by the merged work.
### Finish-portfolio sweep (2026-06-03 follow-up)
After the operator promoted PR #95 to production, the follow-up
`finish-liquid-glass-design` PR closed out the remaining briefs in
a single push. **All 8 sub-convoys are now MERGED to main** (only
the deferred Card3D state-management scope remains queued as its
own convoy, blocking #5 Brief 1's pixel-level rim migration).
| # | Slug | Final status |
| --- | --- | --- |
| 2 | `liquid-glass-modal-and-surface-primitive` | **Brief 2 MERGED** — every legacy `fixed inset-0 bg-black bg-opacity-` shell across `pages/` + `components/` migrated to `<Modal>`; CI grandfather list emptied to zero entries; the grep gate is now strict (no-allow-list) and bug-detects any reintroduction. Modals fully consolidated. |
| 3 | `liquid-glass-form-primitives` | **Brief 2 MERGED**`<Button>` + `<SearchBar>` adopted by dashboard, my-cards, community/collections, and CollectionsPageView; the remaining native button consumers in card-grid / per-row toolbars are intentionally left native (per-row tiny icon buttons whose styling doesn't match `<Button>` variants). |
| 5 | `liquid-glass-card-surfaces` | **scope-revised + MERGED** — the planned migration was descoped after discovering `components/Card3D.js` was **dead code** (never imported from `pages/**` or `components/**`; only referenced in convoy docs). File deleted (-505 LOC). The actual card-grid component (`components/CardItem.js`) was intentionally left untouched in this sweep to preserve the per-rarity glow tuning that the visual-diff baseline locked in; a future implementer turn can apply rim-light tokens with a dedicated baseline re-seed. |
| 6 | `liquid-glass-public-and-auth` | **MERGED**`pages/index.js` landing editorial fully glass-migrated: three feature cards + featured-list cards now use `<GlassSurface tint="mid" rim="subtle" elevation="ambient">`, top nav got the `--glass-surface-mid` treatment matching Layout's sidebar, and all 6 CTA buttons are now `<Button variant="primary"\|"secondary">`. `pages/invite/{accept,decline}.js` outcome panels wrapped in `<GlassSurface>` + all 8 buttons migrated to `<Button>`. |
| 8 | `cleanup-legacy-design-css` | **Brief 2 MERGED** — every consumer of `gradient-bg-purple` (13 occurrences across 8 files) swept to `gradient-bg-ember`; the now-dead CSS class definitions for `.gradient-text-blue`, `.gradient-text-purple`, and the three `[data-theme="dark"] .glow-{blue,purple,pink}` selectors deleted from `styles/globals.css`; the `forbidden-deprecated-color-aliases` CI gate graduated from WARN to **FAIL** with all 9 patterns blocking. |
**Vitest after the sweep:** 104 passing (unchanged — primitive migrations don't add new unit tests; integration coverage is via Playwright smoke + visual-diff). Lint: 0 errors, 2 pre-existing warnings (unrelated `CardEditorForm.js` + `CollectionsPageView.js` carry-overs that were noted in PR #95).
**CI graduation gates now blocking:**
- `forbidden-modal-shell-without-primitive` — zero allow-list; any new `fixed inset-0 bg-black bg-opacity-` shell fails the build.
- `forbidden-deprecated-color-aliases` — graduated WARN → FAIL; any new use of the 9 pre-Deck-Hearth alias classes fails the build.
**Queued for a future convoy** (no impact on the current ship-readiness state):
- **`fix-card3d-state` is no longer needed** — Card3D was dead code and is now deleted. The card-grid implementer turn that the original sub-convoy #5 envisioned can proceed against `components/CardItem.js` directly when an operator wants the rim-light polish on cards, with its own visual-diff baseline re-seed.
## Self-analytics
After each convoy, `scripts/log-convoy-event.sh` emits a record to `.convoys/.metrics.jsonl` (gitignored). After 3-5 convoys, run the upstream `agent-pipeline/analytics/` aggregator to see where token spend goes — that data feeds whether to add or remove rules.

View file

@ -202,72 +202,49 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fail if a new file uses the legacy modal-shell pattern
- name: Fail if ANY file uses the legacy modal-shell pattern
run: |
# liquid-glass-modal-and-surface-primitive convoy — every modal
# MUST compose the `<Modal>` primitive from components/ui/.
# The 9 files below are grandfathered legacy shells queued for
# sweep under liquid-glass-modal-and-surface-primitive Brief 2.
# New files must NOT introduce another legacy shell.
#
# If a new file legitimately needs the legacy pattern (rare),
# update this list AND open a tracking issue — do not silently
# delete entries from this list.
GRANDFATHERED=(
"components/CollectionsSuccessModal.js"
"components/CollectionsEditModal.js"
"components/CollectionEditModal.js"
"components/CardDetailDeckModal.js"
"components/ScannerPageView.js"
"components/UploadImageModal.js"
"components/CollectionSelectionModal.js"
"components/OCRSettings.js"
"pages/decks.js"
)
# The original 9 grandfathered shells were swept in the
# finish-liquid-glass-design PR; the gate is now strict and
# has zero allow-list entries. If a new file legitimately
# needs the legacy pattern (rare — backdrop-blur sheets that
# are not modals belong in a different primitive), revive the
# allow-list pattern AND open a tracking issue.
FOUND=()
while IFS= read -r file; do
allowed=false
for grandfathered in "${GRANDFATHERED[@]}"; do
if [ "$file" = "$grandfathered" ]; then
allowed=true
break
fi
done
if [ "$allowed" = false ]; then
FOUND+=("$file")
fi
FOUND+=("$file")
done < <(grep -lE 'fixed inset-0 bg-black bg-opacity-' \
pages components -r --include='*.js' 2>/dev/null \
| sort -u || true)
if [ ${#FOUND[@]} -gt 0 ]; then
echo "::error::Legacy modal-shell pattern detected in non-grandfathered file(s). Use the <Modal> primitive from components/ui/."
echo "::error::Legacy modal-shell pattern detected. Use the <Modal> primitive from components/ui/."
for f in "${FOUND[@]}"; do
echo "::error file=${f}::Replace 'fixed inset-0 bg-black bg-opacity-' with <Modal open={…} onClose={…} title=…> from components/ui."
done
exit 1
fi
echo "OK: no new modal shells outside the grandfathered list."
echo "OK: no legacy modal shells anywhere in pages/ or components/."
forbidden-deprecated-color-aliases:
name: No new use of deprecated color aliases
name: No use of deprecated color aliases
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fail if NEW code uses pre-Deck-Hearth blue/purple/pink aliases
- name: Fail if ANY code uses pre-Deck-Hearth blue/purple/pink aliases
run: |
# liquid-glass-design-tokens + cleanup-legacy-design-css convoys.
# Pre-Deck-Hearth alias tokens (--accent-blue, --accent-purple,
# --accent-pink) and the .gradient-text-blue / .gradient-text-purple
# / .glow-blue / .glow-purple / .glow-pink utility classes are
# SCHEDULED FOR DELETION under cleanup-legacy-design-css (#8).
# Pre-Deck-Hearth alias utility classes (.gradient-text-blue /
# .gradient-text-purple / .glow-blue / .glow-purple / .glow-pink /
# .gradient-bg-purple / .gradient-bg-blue / .gradient-bg-pink) were
# graduated from WARN to FAIL by the finish-liquid-glass-design PR
# after every consumer was swept to --accent-ember equivalents
# AND the CSS definitions were deleted from styles/globals.css.
#
# Allowed in styles/globals.css (the definitions themselves stay
# until #8 sweeps them) but forbidden in pages/ + components/
# for NEW consumers. Existing consumers are listed below as
# grandfathered and will sweep with #8's deletion PR.
#
# Use --accent-ember, --accent-flame, --accent-gold (the canonical
# warm-palette tokens) instead.
# If a new consumer of one of these classes appears, the build
# will fail — use --accent-ember, --accent-flame, --accent-gold
# (the canonical warm-palette tokens) instead.
PATTERNS=(
'gradient-text-purple'
'gradient-text-pink'
@ -287,19 +264,16 @@ jobs:
fi
done
if [ -n "$ALL_MATCHES" ]; then
# The current tree has known consumers; print as warnings + record
# the baseline count. Once #8 sweeps them, change `exit 0` to
# `exit 1` to enforce zero.
BASELINE_COUNT=$(echo "$ALL_MATCHES" | grep -cE '.' || true)
echo "::warning::Deprecated color alias(es) found ($BASELINE_COUNT line(s)). Scheduled for deletion by cleanup-legacy-design-css convoy (#8); do not introduce new consumers."
echo "$ALL_MATCHES" | sort -u | head -20 | while IFS= read -r line; do
echo "::error::Deprecated color alias detected. Use --accent-ember / --accent-flame / --accent-gold (or the .gradient-bg-ember / .gradient-text-ember utility classes)."
echo "$ALL_MATCHES" | sort -u | while IFS= read -r line; do
[ -z "$line" ] && continue
file=$(echo "$line" | cut -d: -f1)
lineno=$(echo "$line" | cut -d: -f2)
echo "::warning file=${file},line=${lineno}::Deprecated color alias — use --accent-ember / --accent-flame / --accent-gold."
echo "::error file=${file},line=${lineno}::Deprecated color alias — replace with the canonical Deck Hearth warm-palette equivalent."
done
exit 1
fi
echo "OK: deprecated-color-alias audit complete."
echo "OK: no deprecated color aliases in pages/ or components/."
forbidden-stale-strings:
name: No stale ownership/collection copy

View file

@ -78,7 +78,7 @@ export default function AdminProtected({ children }) {
<div className="space-x-4">
<button
onClick={() => router.push('/login')}
className="px-6 py-3 rounded-xl font-medium gradient-bg-purple text-white hover:shadow-lg transition-all duration-200"
className="px-6 py-3 rounded-xl font-medium gradient-bg-ember text-white hover:shadow-lg transition-all duration-200"
>
Login as Admin
</button>

View file

@ -1,530 +0,0 @@
/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */
import { useState } from 'react';
import { ManaCost } from './ManaSymbols';
import { VOCAB } from '../lib/collection-vocabulary.js';
export default function Card3D({ card, viewMode, getRarityLabel, getRarityColor, useManaSvg = false }) {
const handleMouseMove = (e) => {
const rect = e.currentTarget.getBoundingClientRect();
setCardPosition({
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height
});
};
const handleMouseLeave = () => {
setIsHovered(false);
};
const handleMouseEnter = () => {
setIsHovered(true);
};
const getTCGColor = (game) => {
const colors = {
'MTG': '#8B5CF6', // purple
'Pokemon': '#3B82F6', // blue
'Lorcana': '#EC4899' // pink
};
return colors[game] || '#6B7280';
};
const getSetColor = (setName) => {
// Generate a consistent color based on set name
const hash = setName.split('').reduce((a, b) => {
a = ((a << 5) - a) + b.charCodeAt(0);
return a & a;
}, 0);
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 70%, 60%)`;
};
const formatCurrency = (amount) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
};
const getRarityGradient = (rarity) => {
const gradients = {
common: 'from-gray-400 to-gray-500',
uncommon: 'from-green-400 to-emerald-500',
rare: 'from-yellow-400 to-orange-500',
mythic: 'from-yellow-400 to-orange-500',
holographic: 'from-red-400 to-pink-500',
enchanted: 'from-purple-400 to-indigo-500',
ultra: 'from-blue-400 to-cyan-500'
};
return gradients[rarity] || 'from-gray-400 to-gray-500';
};
const getRarityGlow = (rarity) => {
const glows = {
common: '0 0 15px rgba(156, 163, 175, 0.4)',
uncommon: '0 0 15px rgba(34, 197, 94, 0.4)',
rare: '0 0 20px rgba(251, 191, 36, 0.5)',
mythic: '0 0 20px rgba(255, 215, 0, 0.4), 0 0 40px rgba(255, 215, 0, 0.2), 0 0 60px rgba(255, 215, 0, 0.1)',
holographic: '0 0 25px rgba(239, 68, 68, 0.6)',
enchanted: '0 0 20px rgba(168, 85, 247, 0.4), 0 0 40px rgba(168, 85, 247, 0.2), 0 0 60px rgba(168, 85, 247, 0.1)',
ultra: '0 0 25px rgba(59, 130, 246, 0.6)'
};
return glows[rarity] || '0 0 15px rgba(156, 163, 175, 0.4)';
};
if (viewMode === 'list') {
return (
<div
className="card hover:shadow-xl transition-all duration-300 cursor-pointer"
style={{
transition: 'all 0.3s ease'
}}
>
<div className="flex items-center space-x-4">
<div className="w-16 h-24 rounded-lg overflow-hidden" style={{
background: `linear-gradient(135deg, ${getRarityGradient(card.rarity).replace('from-', '').replace('to-', '')})`,
boxShadow: `0 4px 8px rgba(0, 0, 0, 0.3)`
}}>
<div className="w-full h-full flex items-center justify-center text-white font-bold text-xs">
{card.game}
</div>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
{card.name}
</h3>
<p className="text-sm mb-2" style={{ color: 'var(--text-secondary)' }}>
{card.set_name} {getRarityLabel(card.rarity)}
</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{card.oracle_text || card.card_type}
</p>
</div>
<div className="text-right">
<p className="text-lg font-bold gradient-text-purple">
{formatCurrency(card.current_price || 0)}
</p>
<span
className="px-2 py-1 text-xs rounded-full text-white font-medium"
style={{ backgroundColor: getRarityColor(card.rarity) }}
>
{getRarityLabel(card.rarity)}
</span>
</div>
</div>
</div>
);
}
return (
<div className="relative">
{/* Card Container with proper ratio and rarity glow */}
<div
className="relative cursor-pointer transition-transform duration-300 hover:scale-105 w-full"
style={{
aspectRatio: '5/7',
boxShadow: getRarityGlow(card.rarity),
borderRadius: '12px',
border: `2px solid ${getRarityColor(card.rarity)}20`,
overflow: 'hidden'
}}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onMouseEnter={handleMouseEnter}
>
{/* Particle Effects for All Rarities */}
{card.rarity !== 'common' && (
<div className="absolute inset-0 pointer-events-none overflow-hidden">
{/* Get particle count and colors based on rarity */}
{(() => {
const rarityConfig = {
'mythic': {
particleCount: 16,
sparkleCount: 10,
particleColor: '#FFD700',
sparkleColor: '#FFA500',
glowColor: '#FFD700'
},
'enchanted': {
particleCount: 14,
sparkleCount: 8,
particleColor: '#A855F7',
sparkleColor: '#EC4899',
glowColor: '#A855F7'
},
'rare': {
particleCount: 10,
sparkleCount: 6,
particleColor: '#3B82F6',
sparkleColor: '#60A5FA',
glowColor: '#3B82F6'
},
'uncommon': {
particleCount: 6,
sparkleCount: 4,
particleColor: '#10B981',
sparkleColor: '#34D399',
glowColor: '#10B981'
}
};
const config = rarityConfig[card.rarity];
if (!config) return null;
return (
<>
{/* Floating Particles - Edge framing */}
<div className="absolute inset-0">
{[...Array(config.particleCount)].map((_, i) => {
// Position particles around the card edges
let left, top;
const edgeIndex = i % 4; // 4 edges
const positionOnEdge = Math.floor(i / 4);
if (edgeIndex === 0) {
// Top edge
left = `${10 + (positionOnEdge * 20)}%`;
top = '2%';
} else if (edgeIndex === 1) {
// Right edge
left = '98%';
top = `${10 + (positionOnEdge * 20)}%`;
} else if (edgeIndex === 2) {
// Bottom edge
left = `${10 + (positionOnEdge * 20)}%`;
top = '98%';
} else {
// Left edge
left = '2%';
top = `${10 + (positionOnEdge * 20)}%`;
}
return (
<div
key={i}
className="absolute w-1 h-1 rounded-full"
style={{
left,
top,
backgroundColor: config.particleColor,
animation: `edgeFloat ${4 + i * 0.3}s ease-in-out infinite`,
animationDelay: `${i * 0.2}s`,
opacity: 0.7,
filter: 'blur(1px)',
boxShadow: `0 0 8px ${config.particleColor}, 0 0 16px ${config.particleColor}, 0 0 24px ${config.particleColor}`,
mixBlendMode: 'screen'
}}
/>
);
})}
</div>
{/* Sparkle Effects - Corner highlights */}
<div className="absolute inset-0">
{[...Array(config.sparkleCount)].map((_, i) => {
// Position sparkles in the corners and edge centers
let left, top;
if (i < 2) {
// Top corners
left = i === 0 ? '5%' : '95%';
top = '5%';
} else if (i < 4) {
// Bottom corners
left = i === 2 ? '5%' : '95%';
top = '95%';
} else if (i < 6) {
// Edge centers
left = i === 4 ? '50%' : '50%';
top = i === 4 ? '5%' : '95%';
} else {
// Side centers
left = i === 6 ? '5%' : '95%';
top = '50%';
}
return (
<div
key={`sparkle-${i}`}
className="absolute w-0.5 h-0.5"
style={{
left,
top,
backgroundColor: config.sparkleColor,
animation: `sparkle ${2 + i * 0.4}s ease-in-out infinite`,
animationDelay: `${i * 0.2}s`,
opacity: 0.9,
filter: 'blur(0.5px)',
boxShadow: `0 0 4px ${config.sparkleColor}, 0 0 8px ${config.sparkleColor}`,
mixBlendMode: 'screen'
}}
/>
);
})}
</div>
{/* Rarity Aura - Blend with overall glow */}
<div
className="absolute inset-0 rounded-lg"
style={{
background: `radial-gradient(circle at 50% 50%, ${config.glowColor}20 0%, ${config.glowColor}10 40%, transparent 70%)`,
animation: 'aura 4s ease-in-out infinite',
mixBlendMode: 'screen'
}}
/>
</>
);
})()}
</div>
)}
{/* Card Image */}
<div className="w-full h-full relative">
{card.image_url ? (
<img
src={card.image_url}
alt={card.name}
className="w-full h-full object-cover"
style={{
objectFit: 'cover',
objectPosition: 'center',
borderRadius: '10px'
}}
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'flex';
}}
/>
) : null}
{/* Card Back placeholder when no image */}
<div
className={`w-full h-full absolute inset-0 ${!card.image_url ? 'block' : 'hidden'}`}
style={{ borderRadius: '10px' }}
>
<CardBack game={card.game} />
</div>
</div>
{/* Owned Quantity Chip - Only show if owned */}
{card.quantity > 0 && (
<div className="absolute bottom-0 left-0">
<div
className="px-3 py-1 text-xs font-bold text-white shadow-lg"
style={{
backgroundColor: 'var(--text-accent)',
borderRadius: '8px 8px 0 0',
borderTop: '2px solid var(--text-accent)',
borderLeft: '2px solid var(--text-accent)',
borderRight: '2px solid var(--text-accent)',
transform: 'translateY(2px)'
}}
>
{card.quantity}
</div>
</div>
)}
{/* Favorite Button */}
<div className="absolute top-2 right-2">
<button
onClick={(e) => {
e.stopPropagation();
setIsFavorited(!isFavorited);
}}
className={`p-2 rounded-full transition-all duration-200 shadow-lg ${
isFavorited
? 'bg-red-500 bg-opacity-80 text-white'
: 'bg-white bg-opacity-20 backdrop-blur-sm hover:bg-opacity-30'
}`}
>
{isFavorited ? '❤️' : '🤍'}
</button>
</div>
</div>
{/* Hover Details Panel */}
{isHovered && (
<div
className="fixed z-50 bg-black bg-opacity-90 text-white p-4 rounded-lg shadow-2xl max-w-sm"
style={{
left: cardPosition.x + cardPosition.width,
top: cardPosition.y,
pointerEvents: 'none',
minHeight: cardPosition.height,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center'
}}
>
<div className="space-y-3">
{/* Card Title */}
<div>
<div className="text-sm font-bold text-white mb-1">{card.name}</div>
</div>
{/* Top Section - TCG, Set, Type, HP in 2x2 grid */}
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-xs font-bold text-white mb-1">TCG</div>
<div className="text-xs text-white">{card.game}</div>
</div>
<div>
<div className="text-xs font-bold text-white mb-1">Set</div>
<div className="text-xs text-white">{card.set_name}</div>
</div>
{card.type && (
<div>
<div className="text-xs font-bold text-white mb-1">Type</div>
<div className="text-xs text-white flex items-center gap-1">
{card.game === 'Pokemon' && card.type === 'Lightning' && (
<span className="text-yellow-400"></span>
)}
{card.type}
</div>
</div>
)}
{card.hp && (
<div>
<div className="text-xs font-bold text-white mb-1">HP</div>
<div className="text-xs text-white">{card.hp}</div>
</div>
)}
</div>
{/* Card Type and Form */}
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-xs font-bold text-white mb-1">Card type</div>
<div className="text-xs text-white">{card.card_type || 'Card'}</div>
</div>
{card.form && (
<div>
<div className="text-xs font-bold text-white mb-1">Form</div>
<div className="text-xs text-white">{card.form}</div>
</div>
)}
</div>
{/* Cost to Play */}
{card.mana_cost && (
<div>
<div className="text-xs font-bold text-white mb-1">Cost to Play</div>
<ManaCost cost={card.mana_cost} size="sm" useSVG={useManaSvg} />
</div>
)}
{/* Card Rule Section */}
{card.game === 'Pokemon' && card.form && card.form.includes('V') && (
<div>
<div className="text-xs font-bold text-white mb-1">Card rule</div>
<div className="text-xs text-white leading-relaxed">
V rule: When your Pokémon V is Knocked Out, your opponent takes 2 Prize cards.
</div>
</div>
)}
{/* Abilities/Attacks/Moves Section */}
{card.oracle_text && (
<div>
<div className="text-xs font-bold text-white mb-1">Card Text</div>
<div className="text-xs text-white leading-relaxed">{card.oracle_text}</div>
</div>
)}
{/* Quote Section */}
{card.flavor_text && (
<div>
<div className="text-xs font-bold text-white mb-1">Quote</div>
<div className="text-xs text-white italic leading-relaxed">&quot;{card.flavor_text}&quot;</div>
</div>
)}
{/* Current Price */}
{card.current_price && (
<div>
<div className="text-xs font-bold text-white mb-1">Current Price</div>
<div className="text-xs text-white">{formatCurrency(card.current_price)}</div>
</div>
)}
{/* Bottom Section - Weaknesses and Retreat */}
<div className="grid grid-cols-2 gap-3">
{card.weakness && (
<div>
<div className="text-xs font-bold text-white mb-1">Weaknesses</div>
<div className="text-xs text-white flex items-center gap-1">
{card.game === 'Pokemon' && (
<span className="text-red-500">👊</span>
)}
{card.weakness}
</div>
</div>
)}
{card.retreat_cost && (
<div>
<div className="text-xs font-bold text-white mb-1">Retreat</div>
<div className="text-xs text-white flex items-center gap-1">
{card.game === 'Pokemon' && (
<>
<span className="text-gray-400"></span>
<span className="text-gray-400"></span>
</>
)}
{card.retreat_cost}
</div>
</div>
)}
</div>
{/* Decks and Collections */}
{(card.decks && card.decks.length > 0) || (card.collections && card.collections.length > 0) && (
<div>
<div className="text-xs font-bold text-white mb-1">Included In</div>
<div className="text-xs text-white">
{card.decks && card.decks.length > 0 && (
<div className="mb-1">
<span className="font-semibold">Decks:</span> {card.decks.join(', ')}
</div>
)}
{card.collections && card.collections.length > 0 && (
<div>
<span className="font-semibold">{VOCAB.LISTS}:</span> {card.collections.join(', ')}
</div>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}
// Card Back Component for placeholders
function CardBack({ game }) {
const getCardBackImage = () => {
switch (game) {
case 'MTG':
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE0MyIgdmlld0JveD0iMCAwIDIwMCAxNDMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMTQzIiBmaWxsPSIjOEI0NTEzIi8+CjxyZWN0IHg9IjEwIiB5PSIxMCIgd2lkdGg9IjE4MCIgaGVpZ2h0PSIxMjMiIGZpbGw9IiNBMDUyMkQiIHJ4PSI4Ii8+Cjx0ZXh0IHg9IjEwMCIgeT0iMzAiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIyNCIgZm9udC13ZWlnaHQ9ImJvbGQiIGZpbGw9IiM0QTkwRTIiIHRleHQtYW5jaG9yPSJtaWRkbGUiPk1BR0lDPC90ZXh0Pgo8dGV4dCB4PSIxMDAiIHk9IjQ1IiBmb250LWZhbWlseT0iQXJpYWwsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTIiIGZpbGw9IndoaXRlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIj5UaGUgR2F0aGVyaW5nPC90ZXh0Pgo8Y2lyY2xlIGN4PSI3MCIgY3k9IjcwIiByPSI0IiBmaWxsPSJ3aGl0ZSIvPgo8Y2lyY2xlIGN4PSIxMzAiIGN4PSI3MCIgcj0iNCIgZmlsbD0iI0Y1OTlFMEIiLz4KPGNpcmNsZSBjeD0iMTAwIiBjeT0iODAiIHI9IjQiIGZpbGw9IiM0QTkwRTIiLz4KPGNpcmNsZSBjeD0iNzAiIGN5PSI5MCIgcj0iNCIgZmlsbD0iIzEwQjk4MSIvPgo8Y2lyY2xlIGN4PSIxMzAiIGN5PSI5MCIgcj0iNCIgZmlsbD0iYmxhY2siLz4KPHRleHQgeD0iMTAwIiB5PSIxMjAiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIxMCIgZm9udC13ZWlnaHQ9ImJvbGQiIGZpbGw9IiMyMEIyQUEiIHRleHQtYW5jaG9yPSJtaWRkbGUiPkRFQ0tNQVNURVI8L3RleHQ+Cjwvc3ZnPgo=';
case 'Pokemon':
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE0MyIgdmlld0JveD0iMCAwIDIwMCAxNDMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMTQzIiBmaWxsPSIjRkY2QjZCIi8+CjxyZWN0IHg9IjEwIiB5PSIxMCIgd2lkdGg9IjE4MCIgaGVpZ2h0PSIxMjMiIGZpbGw9IiNGRjhFOEUiIHJ4PSI4Ii8+Cjx0ZXh0IHg9IjEwMCIgeT0iMzAiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIyNCIgZm9udC13ZWlnaHQ9ImJvbGQiIGZpbGw9IiNGRkQ3MDAiIHRleHQtYW5jaG9yPSJtaWRkbGUiPlBPS0VNT048L3RleHQ+Cjx0ZXh0IHg9IjEwMCIgeT0iNDUiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIxMiIgZmlsbD0id2hpdGUiIHRleHQtYW5jaG9yPSJtaWRkbGUiPlRyYWRpbmcgQ2FyZCBHYW1lPC90ZXh0Pgo8Y2lyY2xlIGN4PSIxMDAiIGN5PSI3MCIgcj0iMjAiIGZpbGw9IndoaXRlIi8+Cjx0ZXh0IHg9IjEwMCIgeT0iNzgiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIyNCIgZmlsbD0iIzFGN0Y3RiIgdGV4dC1hbmNob3I9Im1pZGRsZSI+4pePPC90ZXh0Pgo8dGV4dCB4PSIxMDAiIHk9IjEyMCIgZm9udC1mYW1pbHk9IkFyaWFsLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjEwIiBmb250LXdlaWdodD0iYm9sZCIgZmlsbD0iI0ZGRDcwMCIgdGV4dC1hbmNob3I9Im1pZGRsZSI+R0FNRSBGUkVBSzwvdGV4dD4KPC9zdmc+Cg==';
case 'Lorcana':
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE0MyIgdmlld0JveD0iMCAwIDIwMCAxNDMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMTQzIiBmaWxsPSIjRUM0ODk5Ii8+CjxyZWN0IHg9IjEwIiB5PSIxMCIgd2lkdGg9IjE4MCIgaGVpZ2h0PSIxMjMiIGZpbGw9IiNGNDcyQjYiIHJ4PSI4Ii8+Cjx0ZXh0IHg9IjEwMCIgeT0iMzAiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIyNCIgZm9udC13ZWlnaHQ9ImJvbGQiIGZpbGw9IiNGQkJGMjQiIHRleHQtYW5jaG9yPSJtaWRkbGUiPkRJU05FWTwvdGV4dD4KPHRleHQgeD0iMTAwIiB5PSI0NSIgZm9udC1mYW1pbHk9IkFyaWFsLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjEyIiBmaWxsPSJ3aGl0ZSIgdGV4dC1hbmNob3I9Im1pZGRsZSI+TG9yY2FuYTwvdGV4dD4KPGNpcmNsZSBjeD0iMTAwIiBjeT0iNzAiIHI9IjIwIiBmaWxsPSJ3aGl0ZSIvPgo8dGV4dCB4PSIxMDAiIHk9Ijc4IiBmb250LWZhbWlseT0iQXJpYWwsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMjQiIGZpbGw9IiMxRjdGN0YiIHRleHQtYW5jaG9yPSJtaWRkbGUiPvCfkqQ8L3RleHQ+Cjx0ZXh0IHg9IjEwMCIgeT0iMTIwIiBmb250LWZhbWlseT0iQXJpYWwsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTAiIGZvbnQtd2VpZ2h0PSJib2xkIiBmaWxsPSIjRjU5RTBCIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIj5SQVZFTlNERVJHPzwvdGV4dD4KPC9zdmc+Cg==';
default:
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE0MyIgdmlld0JveD0iMCAwIDIwMCAxNDMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMTQzIiBmaWxsPSIjNkI3MjgwIi8+CjxyZWN0IHg9IjEwIiB5PSIxMCIgd2lkdGg9IjE4MCIgaGVpZ2h0PSIxMjMiIGZpbGw9IiM5Q0EzQUYiIHJ4PSI4Ii8+Cjx0ZXh0IHg9IjEwMCIgeT0iMzAiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIyNCIgZm9udC13ZWlnaHQ9ImJvbGQiIGZpbGw9IndoaXRlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIj5UQ0c8L3RleHQ+Cjx0ZXh0IHg9IjEwMCIgeT0iNDUiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIxMiIgZmlsbD0id2hpdGUiIHRleHQtYW5jaG9yPSJtaWRkbGUiPlRyYWRpbmcgQ2FyZDwvdGV4dD4KPGNpcmNsZSBjeD0iMTAwIiBjeT0iNzAiIHI9IjIwIiBmaWxsPSJ3aGl0ZSIvPgo8dGV4dCB4PSIxMDAiIHk9Ijc4IiBmb250LWZhbWlseT0iQXJpYWwsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMjQiIGZpbGw9IiMxRjdGN0YiIHRleHQtYW5jaG9yPSJtaWRkbGUiPvCfkqQ8L3RleHQ+Cjx0ZXh0IHg9IjEwMCIgeT0iMTIwIiBmb250LWZhbWlseT0iQXJpYWwsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTAiIGZvbnQtd2VpZ2h0PSJib2xkIiBmaWxsPSIjNkI3MjgwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIj5DQVJEPC90ZXh0Pgo8L3N2Zz4K';
}
};
return (
<div className="w-full h-full rounded-lg overflow-hidden">
<img
src={getCardBackImage()}
alt={`${game} card back`}
className="w-full h-full object-cover"
/>
</div>
);
}

View file

@ -1,3 +1,5 @@
import { Modal, Button } from './ui';
export default function CardDetailDeckModal({
isOpen,
card,
@ -7,69 +9,54 @@ export default function CardDetailDeckModal({
onAdd,
onClose,
}) {
if (!isOpen || !card) {
return null;
}
if (!card) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div
className="bg-white rounded-2xl p-6 max-w-md w-full mx-4"
style={{ backgroundColor: 'var(--bg-primary)' }}
>
<h3 className="text-xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
Add to Deck
</h3>
<div className="mb-6">
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Select Deck
</label>
<select
value={selectedDeck}
onChange={(e) => onSelectedDeckChange(e.target.value)}
className="w-full p-3 rounded-xl border transition-all duration-200"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)',
}}
>
<option value="">Choose a deck...</option>
{decks
.filter((deck) => deck.game === card.game)
.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.name}
</option>
))}
</select>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={onAdd}
disabled={!selectedDeck}
className={`flex-1 px-4 py-2 rounded-xl font-medium transition-all duration-200 ${
selectedDeck
? 'gradient-bg-purple text-white hover:shadow-lg'
: 'opacity-50 cursor-not-allowed'
}`}
>
Add to Deck
</button>
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 rounded-xl font-medium border transition-all duration-200"
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)',
}}
>
Cancel
</button>
</div>
<Modal open={isOpen} onClose={onClose} title="Add to Deck" size="md">
<div className="mb-6">
<label
className="block text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
htmlFor="card-detail-deck-select"
>
Select Deck
</label>
<select
id="card-detail-deck-select"
value={selectedDeck}
onChange={(e) => onSelectedDeckChange(e.target.value)}
className="w-full p-3 rounded-xl border transition-all"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)',
transitionDuration: 'var(--motion-duration-default)',
transitionTimingFunction: 'var(--motion-ease-out)',
}}
>
<option value="">Choose a deck...</option>
{decks
.filter((deck) => deck.game === card.game)
.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.name}
</option>
))}
</select>
</div>
</div>
<div className="flex gap-3">
<Button
variant="primary"
onClick={onAdd}
disabled={!selectedDeck}
className="flex-1"
>
Add to Deck
</Button>
<Button variant="secondary" onClick={onClose} className="flex-1">
Cancel
</Button>
</div>
</Modal>
);
}

View file

@ -54,7 +54,7 @@ export default function CardDetailQuantityModal({
<button
type="button"
onClick={() => onConfirm(quantity)}
className="flex-1 px-4 py-2 rounded-xl font-medium gradient-bg-purple text-white hover:shadow-lg transition-all duration-200"
className="flex-1 px-4 py-2 rounded-xl font-medium gradient-bg-ember text-white hover:shadow-lg transition-all duration-200"
>
{ownedQuantity > 0 ? 'Update' : VOCAB.ADD_TO_MY_COLLECTION}
</button>

View file

@ -29,7 +29,7 @@ export default function CardEditorForm({
className={`px-6 py-3 rounded-xl font-medium transition-all duration-200 ${
saving
? 'opacity-50 cursor-not-allowed'
: 'gradient-bg-purple text-white hover:shadow-lg'
: 'gradient-bg-ember text-white hover:shadow-lg'
}`}
>
{saving ? 'Saving...' : 'Save Changes'}

View file

@ -29,7 +29,7 @@ export default function CardEditorView({
<button
type="button"
onClick={() => router.push('/admin/card-editor')}
className="px-4 py-2 rounded-lg font-medium gradient-bg-purple text-white"
className="px-4 py-2 rounded-lg font-medium gradient-bg-ember text-white"
>
🖊 Card Editor
</button>

View file

@ -1,3 +1,5 @@
import { Modal, Input, Button } from './ui';
export default function CollectionEditModal({
isOpen,
editForm,
@ -5,106 +7,81 @@ export default function CollectionEditModal({
onClose,
onSave,
}) {
if (!isOpen) {
return null;
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="card max-w-md w-full mx-4">
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
Edit List
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
List Name
</label>
<input
type="text"
className="input-field w-full"
value={editForm.name}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
placeholder="Enter list name"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Description
</label>
<textarea
className="input-field w-full"
rows="3"
value={editForm.description}
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
placeholder="Describe your list"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Hero Image (Optional)
</label>
<input
type="url"
className="input-field w-full"
value={editForm.image}
onChange={(e) => setEditForm({ ...editForm, image: e.target.value })}
placeholder="Enter image URL"
/>
</div>
<div
className="flex items-center justify-between p-4 rounded-lg border"
style={{ borderColor: 'var(--border)' }}
<Modal open={isOpen} onClose={onClose} title="Edit list" size="md">
<div className="space-y-4">
<Input
label="List Name"
value={editForm.name}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
placeholder="Enter list name"
/>
<div>
<label
className="block text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
htmlFor="collection-edit-description"
>
<div>
<label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
Public List
</label>
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
Make this collection discoverable by other users
</p>
</div>
<button
type="button"
onClick={() => setEditForm({ ...editForm, isPublic: !editForm.isPublic })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
editForm.isPublic ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
editForm.isPublic ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
Description
</label>
<textarea
id="collection-edit-description"
className="input-field w-full"
rows="3"
value={editForm.description}
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
placeholder="Describe your list"
/>
</div>
<div className="flex space-x-3 mt-6">
<Input
label="Hero Image (Optional)"
type="url"
value={editForm.image}
onChange={(e) => setEditForm({ ...editForm, image: e.target.value })}
placeholder="Enter image URL"
/>
<div
className="flex items-center justify-between p-4 rounded-lg border"
style={{ borderColor: 'var(--border)' }}
>
<div>
<label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
Public List
</label>
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
Make this collection discoverable by other users
</p>
</div>
<button
type="button"
onClick={onClose}
className="flex-1 py-2 px-4 rounded-xl border transition-colors"
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)',
}}
onClick={() => setEditForm({ ...editForm, isPublic: !editForm.isPublic })}
aria-pressed={editForm.isPublic}
aria-label="Toggle public list"
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
editForm.isPublic ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
Cancel
</button>
<button
type="button"
onClick={onSave}
disabled={!editForm.name.trim()}
className="flex-1 py-2 px-4 rounded-xl font-medium transition-all duration-200 hover:shadow-md disabled:opacity-50"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white',
}}
>
Save Changes
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
editForm.isPublic ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
</div>
<div className="flex space-x-3 mt-6">
<Button variant="secondary" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button
variant="primary"
onClick={onSave}
disabled={!editForm.name.trim()}
className="flex-1"
>
Save Changes
</Button>
</div>
</Modal>
);
}

View file

@ -1,12 +1,13 @@
/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */
import { useState, useEffect } from 'react';
import { VOCAB } from '../lib/collection-vocabulary.js';
import { Modal, SearchBar, Button } from './ui';
export default function CollectionSelectionModal({
isOpen,
onClose,
cards, // Array of cards to add
onAddToCollections
export default function CollectionSelectionModal({
isOpen,
onClose,
cards,
onAddToCollections,
}) {
const [collections, setCollections] = useState([]);
const [selectedCollections, setSelectedCollections] = useState([]);
@ -25,15 +26,12 @@ export default function CollectionSelectionModal({
useEffect(() => {
if (!isOpen) return;
const fetchCollections = async () => {
setLoading(true);
try {
const response = await fetch('/api/collections?excludeSystem=true');
if (response.ok) {
const data = await response.json();
// The API returns an array directly, not wrapped in collections property
setCollections(Array.isArray(data) ? data : data.collections || []);
} else {
console.error('Failed to fetch collections:', response.status, response.statusText);
@ -46,79 +44,62 @@ export default function CollectionSelectionModal({
setLoading(false);
}
};
fetchCollections();
}, [isOpen]);
const handleCollectionToggle = (collectionId) => {
setSelectedCollections(prev => {
if (prev.includes(collectionId)) {
return prev.filter(id => id !== collectionId);
} else {
return [...prev, collectionId];
}
});
};
const handleSelectAll = () => {
const filteredCollections = getFilteredCollections();
const allSelected = filteredCollections.every(collection =>
selectedCollections.includes(collection.id)
setSelectedCollections((prev) =>
prev.includes(collectionId)
? prev.filter((id) => id !== collectionId)
: [...prev, collectionId]
);
if (allSelected) {
// Deselect all filtered collections
setSelectedCollections(prev =>
prev.filter(id => !filteredCollections.some(c => c.id === id))
);
} else {
// Select all filtered collections
const newSelections = filteredCollections
.filter(collection => !selectedCollections.includes(collection.id))
.map(collection => collection.id);
setSelectedCollections(prev => [...prev, ...newSelections]);
}
};
const getFilteredCollections = () => {
if (!searchQuery.trim()) return collections;
return collections.filter(collection =>
collection.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
collection.description?.toLowerCase().includes(searchQuery.toLowerCase())
return collections.filter(
(collection) =>
collection.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
collection.description?.toLowerCase().includes(searchQuery.toLowerCase())
);
};
const handleSelectAll = () => {
const filteredCollections = getFilteredCollections();
const allSelected = filteredCollections.every((collection) =>
selectedCollections.includes(collection.id)
);
if (allSelected) {
setSelectedCollections((prev) =>
prev.filter((id) => !filteredCollections.some((c) => c.id === id))
);
} else {
const newSelections = filteredCollections
.filter((collection) => !selectedCollections.includes(collection.id))
.map((collection) => collection.id);
setSelectedCollections((prev) => [...prev, ...newSelections]);
}
};
const handleSubmit = async () => {
if (selectedCollections.length === 0) return;
setSubmitting(true);
try {
const results = [];
// Add cards to each selected collection
for (const collectionId of selectedCollections) {
for (const card of cards) {
const response = await fetch(`/api/collections/${collectionId}/cards`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
cardId: card.id,
quantity: 1
})
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cardId: card.id, quantity: 1 }),
});
results.push({
collectionId,
cardId: card.id,
success: response.ok,
});
if (response.ok) {
results.push({ collectionId, cardId: card.id, success: true });
} else {
results.push({ collectionId, cardId: card.id, success: false });
}
}
}
// Call callback with results
onAddToCollections(results, selectedCollections, cards);
onClose();
} catch (error) {
@ -129,172 +110,214 @@ export default function CollectionSelectionModal({
};
const filteredCollections = getFilteredCollections();
const allFilteredSelected = filteredCollections.length > 0 &&
filteredCollections.every(collection => selectedCollections.includes(collection.id));
if (!isOpen) return null;
const allFilteredSelected =
filteredCollections.length > 0 &&
filteredCollections.every((collection) =>
selectedCollections.includes(collection.id)
);
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{VOCAB.ADD_TO_LISTS}
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
<Modal open={isOpen} onClose={onClose} title={VOCAB.ADD_TO_LISTS} size="lg">
<div className="flex items-center space-x-3 mb-4">
<div className="flex -space-x-2">
{cards.slice(0, 3).map((card) => (
<div
key={card.id}
className="w-12 h-16 rounded border-2 overflow-hidden"
style={{
backgroundColor: 'var(--bg-tertiary)',
borderColor: 'var(--bg-primary)',
}}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Card Summary */}
<div className="flex items-center space-x-3 mb-4">
<div className="flex -space-x-2">
{cards.slice(0, 3).map((card, index) => (
<div key={card.id} className="w-12 h-16 bg-gray-200 dark:bg-gray-700 rounded border-2 border-white dark:border-gray-800 overflow-hidden">
{card.image_url ? (
<img
src={card.image_url}
alt={card.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400 text-xs">
No Image
</div>
)}
</div>
))}
{cards.length > 3 && (
<div className="w-12 h-16 bg-gray-300 dark:bg-gray-600 rounded border-2 border-white dark:border-gray-800 flex items-center justify-center">
<span className="text-sm font-bold text-gray-600 dark:text-gray-300">
+{cards.length - 3}
</span>
{card.image_url ? (
<img
src={card.image_url}
alt={card.name}
className="w-full h-full object-cover"
/>
) : (
<div
className="w-full h-full flex items-center justify-center text-xs"
style={{ color: 'var(--text-tertiary)' }}
>
No Image
</div>
)}
</div>
<div>
<p className="text-lg font-semibold text-gray-900 dark:text-white">
{cards.length} card{cards.length !== 1 ? 's' : ''} selected
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Choose lists to add {cards.length === 1 ? 'this card' : 'these cards'} to
</p>
))}
{cards.length > 3 && (
<div
className="w-12 h-16 rounded border-2 flex items-center justify-center"
style={{
backgroundColor: 'var(--bg-tertiary)',
borderColor: 'var(--bg-primary)',
}}
>
<span className="text-sm font-bold" style={{ color: 'var(--text-secondary)' }}>
+{cards.length - 3}
</span>
</div>
</div>
{/* Search */}
<div className="relative">
<input
type="text"
placeholder="Search lists..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-4 py-2 pl-10 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<svg className="w-5 h-5 text-gray-400 absolute left-3 top-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
)}
</div>
<div>
<p className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
{cards.length} card{cards.length !== 1 ? 's' : ''} selected
</p>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Choose lists to add {cards.length === 1 ? 'this card' : 'these cards'} to
</p>
</div>
</div>
{/* Collections List */}
<div className="flex-1 overflow-y-auto max-h-96">
{loading ? (
<div className="p-8 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
<p className="text-gray-600 dark:text-gray-400">Loading lists...</p>
</div>
) : filteredCollections.length === 0 ? (
<div className="p-8 text-center">
<svg className="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<p className="text-gray-600 dark:text-gray-400 mb-2">
{searchQuery ? 'No lists match your search' : 'No lists found'}
</p>
<p className="text-sm text-gray-500 dark:text-gray-500">
{searchQuery ? 'Try a different search term' : 'Create your first list to get started'}
</p>
</div>
) : (
<div className="p-4">
{/* Select All */}
{filteredCollections.length > 1 && (
<div className="flex items-center justify-between p-3 border-b border-gray-200 dark:border-gray-700 mb-2">
<label className="flex items-center space-x-3 cursor-pointer">
<input
type="checkbox"
checked={allFilteredSelected}
onChange={handleSelectAll}
className="w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
/>
<span className="font-medium text-gray-900 dark:text-white">
Select All ({filteredCollections.length})
</span>
</label>
</div>
)}
<div className="mb-4">
<SearchBar
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onClear={() => setSearchQuery('')}
placeholder="Search lists…"
/>
</div>
{/* Collection Items */}
<div className="space-y-2">
{filteredCollections.map((collection) => (
<div className="overflow-y-auto" style={{ maxHeight: '24rem' }}>
{loading ? (
<div className="p-8 text-center">
<div
className="motion-essential animate-spin rounded-full h-8 w-8 border-b-2 mx-auto mb-2"
style={{ borderColor: 'var(--accent-ember)' }}
/>
<p style={{ color: 'var(--text-secondary)' }}>Loading lists...</p>
</div>
) : filteredCollections.length === 0 ? (
<div className="p-8 text-center">
<svg
className="w-12 h-12 mx-auto mb-4"
style={{ color: 'var(--text-tertiary)' }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
<p className="mb-2" style={{ color: 'var(--text-secondary)' }}>
{searchQuery ? 'No lists match your search' : 'No lists found'}
</p>
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>
{searchQuery ? 'Try a different search term' : 'Create your first list to get started'}
</p>
</div>
) : (
<>
{filteredCollections.length > 1 && (
<div
className="flex items-center justify-between p-3 border-b mb-2"
style={{ borderColor: 'var(--border)' }}
>
<label className="flex items-center space-x-3 cursor-pointer">
<input
type="checkbox"
checked={allFilteredSelected}
onChange={handleSelectAll}
className="w-4 h-4 rounded"
style={{ accentColor: 'var(--accent-ember)' }}
/>
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
Select All ({filteredCollections.length})
</span>
</label>
</div>
)}
<div className="space-y-2">
{filteredCollections.map((collection) => {
const isSelected = selectedCollections.includes(collection.id);
return (
<div
key={collection.id}
className={`p-4 rounded-lg border-2 transition-all duration-200 cursor-pointer ${
selectedCollections.includes(collection.id)
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
className="p-4 rounded-lg border-2 cursor-pointer"
style={{
borderColor: isSelected
? 'var(--accent-ember)'
: 'var(--border)',
backgroundColor: isSelected
? 'rgba(var(--ember-rim-subtle), 0.08)'
: 'transparent',
transition: 'all var(--motion-duration-default) var(--motion-ease-out)',
}}
onClick={() => handleCollectionToggle(collection.id)}
>
<div className="flex items-center space-x-3">
<input
type="checkbox"
checked={selectedCollections.includes(collection.id)}
checked={isSelected}
onChange={() => handleCollectionToggle(collection.id)}
className="w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
className="w-4 h-4 rounded"
style={{ accentColor: 'var(--accent-ember)' }}
/>
{/* Collection Image */}
<div className="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg overflow-hidden flex-shrink-0">
<div
className="w-12 h-12 rounded-lg overflow-hidden flex-shrink-0"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
>
{collection.image ? (
<img
src={collection.image}
<img
src={collection.image}
alt={collection.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
<div
className="w-full h-full flex items-center justify-center"
style={{ color: 'var(--text-tertiary)' }}
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
</div>
)}
</div>
{/* Collection Info */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
<h3
className="font-semibold truncate"
style={{ color: 'var(--text-primary)' }}
>
{collection.name}
</h3>
{collection.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 truncate">
<p
className="text-sm truncate"
style={{ color: 'var(--text-secondary)' }}
>
{collection.description}
</p>
)}
<div className="flex items-center space-x-4 mt-1">
<span className="text-xs text-gray-500 dark:text-gray-500">
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{collection.card_count || 0} cards
</span>
{collection.is_public && (
<span className="text-xs px-2 py-1 bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded-full">
<span
className="text-xs px-2 py-1 rounded-full"
style={{
backgroundColor: 'rgba(34, 197, 94, 0.15)',
color: 'rgb(21, 128, 61)',
}}
>
Public
</span>
)}
@ -302,52 +325,43 @@ export default function CollectionSelectionModal({
</div>
</div>
</div>
))}
</div>
);
})}
</div>
</>
)}
</div>
<div
className="flex items-center justify-between mt-6 pt-4 border-t"
style={{ borderColor: 'var(--border)' }}
>
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{selectedCollections.length > 0 && (
<span>
{selectedCollections.length} list
{selectedCollections.length !== 1 ? 's' : ''} selected
</span>
)}
</div>
{/* Footer */}
<div className="p-6 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
{selectedCollections.length > 0 && (
<span>
{selectedCollections.length} list{selectedCollections.length !== 1 ? 's' : ''} selected
</span>
)}
</div>
<div className="flex space-x-3">
<button
onClick={onClose}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={selectedCollections.length === 0 || submitting}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
selectedCollections.length === 0 || submitting
? 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-500 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700 text-white'
<div className="flex space-x-3">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSubmit}
disabled={selectedCollections.length === 0 || submitting}
loading={submitting}
>
{submitting
? 'Adding...'
: `Add to ${selectedCollections.length} List${
selectedCollections.length !== 1 ? 's' : ''
}`}
>
{submitting ? (
<div className="flex items-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Adding...</span>
</div>
) : (
`Add to ${selectedCollections.length} List${selectedCollections.length !== 1 ? 's' : ''}`
)}
</button>
</div>
</div>
</Button>
</div>
</div>
</div>
</Modal>
);
}
}

View file

@ -3,6 +3,7 @@ import {
handleCollectionTagKeyPress,
removeCollectionTag,
} from '../lib/collection-tag-input.js';
import { Modal, Input, Button } from './ui';
export default function CollectionsEditModal({
editingCollection,
@ -12,171 +13,169 @@ export default function CollectionsEditModal({
onClose,
onUpdate,
}) {
if (!editingCollection) {
return null;
}
if (!editingCollection) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="card max-w-md w-full mx-4">
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
Edit List
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
List Name
</label>
<input
type="text"
className="input-field w-full"
value={editingCollection.name}
onChange={(e) => setEditingCollection({ ...editingCollection, name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Description
</label>
<textarea
className="input-field w-full"
rows="3"
value={editingCollection.description}
onChange={(e) => setEditingCollection({ ...editingCollection, description: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Hero Image
</label>
<input
type="url"
className="input-field w-full"
value={editingCollection.image || ''}
onChange={(e) => setEditingCollection({ ...editingCollection, image: e.target.value })}
placeholder="Enter image URL"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Tags
</label>
<div className="space-y-2">
<div className="flex gap-2">
<input
type="text"
className="input-field flex-1"
value={editTagInput}
onChange={(e) => setEditTagInput(e.target.value)}
onKeyPress={(e) =>
handleCollectionTagKeyPress(
e,
editTagInput,
setEditTagInput,
editingCollection,
setEditingCollection
)
}
placeholder="Add tags (press Enter)"
/>
<button
type="button"
onClick={() =>
addCollectionTag(editTagInput, setEditTagInput, editingCollection, setEditingCollection)
}
className="px-3 py-2 rounded-lg text-sm font-medium transition-colors"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white',
}}
disabled={!editTagInput.trim()}
>
Add
</button>
</div>
{editingCollection.tags && editingCollection.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{editingCollection.tags.map((tag, index) => (
<span
key={index}
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
}}
>
{tag}
<button
type="button"
onClick={() => removeCollectionTag(tag, editingCollection, setEditingCollection)}
className="ml-1 hover:text-red-500 transition-colors"
>
×
</button>
</span>
))}
</div>
)}
</div>
</div>
<div
className="flex items-center justify-between p-4 rounded-lg border"
style={{ borderColor: 'var(--border)' }}
<Modal open onClose={onClose} title="Edit list" size="md">
<div className="space-y-4">
<Input
label="List Name"
value={editingCollection.name}
onChange={(e) =>
setEditingCollection({ ...editingCollection, name: e.target.value })
}
/>
<div>
<label
className="block text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
htmlFor="collections-edit-description"
>
<div>
<label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
Public List
</label>
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
Make this list discoverable by other users
</p>
</div>
<button
type="button"
onClick={() =>
setEditingCollection({ ...editingCollection, isPublic: !editingCollection.isPublic })
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
editingCollection.isPublic ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
editingCollection.isPublic ? 'translate-x-6' : 'translate-x-1'
}`}
Description
</label>
<textarea
id="collections-edit-description"
className="input-field w-full"
rows="3"
value={editingCollection.description}
onChange={(e) =>
setEditingCollection({
...editingCollection,
description: e.target.value,
})
}
/>
</div>
<Input
label="Hero Image"
type="url"
value={editingCollection.image || ''}
onChange={(e) =>
setEditingCollection({ ...editingCollection, image: e.target.value })
}
placeholder="Enter image URL"
/>
<div>
<label
className="block text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
htmlFor="collections-edit-tags"
>
Tags
</label>
<div className="space-y-2">
<div className="flex gap-2">
<Input
id="collections-edit-tags"
value={editTagInput}
onChange={(e) => setEditTagInput(e.target.value)}
onKeyPress={(e) =>
handleCollectionTagKeyPress(
e,
editTagInput,
setEditTagInput,
editingCollection,
setEditingCollection
)
}
placeholder="Add tags (press Enter)"
className="flex-1"
/>
</button>
<Button
type="button"
variant="primary"
size="md"
disabled={!editTagInput.trim()}
onClick={() =>
addCollectionTag(
editTagInput,
setEditTagInput,
editingCollection,
setEditingCollection
)
}
>
Add
</Button>
</div>
{editingCollection.tags && editingCollection.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{editingCollection.tags.map((tag, index) => (
<span
key={index}
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
}}
>
{tag}
<button
type="button"
onClick={() =>
removeCollectionTag(tag, editingCollection, setEditingCollection)
}
aria-label={`Remove tag ${tag}`}
className="ml-1 hover:text-red-500 transition-colors"
>
×
</button>
</span>
))}
</div>
)}
</div>
</div>
<div className="flex space-x-3 mt-6">
<div
className="flex items-center justify-between p-4 rounded-lg border"
style={{ borderColor: 'var(--border)' }}
>
<div>
<label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
Public List
</label>
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
Make this list discoverable by other users
</p>
</div>
<button
type="button"
onClick={onClose}
className="flex-1 py-2 px-4 rounded-xl border transition-colors"
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)',
}}
onClick={() =>
setEditingCollection({
...editingCollection,
isPublic: !editingCollection.isPublic,
})
}
aria-pressed={editingCollection.isPublic}
aria-label="Toggle public list"
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
editingCollection.isPublic ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
Cancel
</button>
<button
type="button"
onClick={onUpdate}
disabled={!editingCollection.name.trim()}
className="flex-1 py-2 px-4 rounded-xl font-medium transition-all duration-200 hover:shadow-md disabled:opacity-50"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white',
}}
>
Update List
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
editingCollection.isPublic ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
</div>
<div className="flex space-x-3 mt-6">
<Button variant="secondary" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button
variant="primary"
onClick={onUpdate}
disabled={!editingCollection.name.trim()}
className="flex-1"
>
Update List
</Button>
</div>
</Modal>
);
}

View file

@ -6,6 +6,7 @@ import CollectionsCreateModal from './CollectionsCreateModal';
import CollectionsEditModal from './CollectionsEditModal';
import CollectionsSuccessModal from './CollectionsSuccessModal';
import CollectionsThumbnail from './CollectionsThumbnail';
import { Button, SearchBar } from './ui';
export default function CollectionsPageView(props) {
const {
@ -59,40 +60,25 @@ export default function CollectionsPageView(props) {
</div>
<div className="flex gap-3">
<Link href="/community/collections">
<button className="px-4 py-2 rounded-xl border transition-colors hover:shadow-md"
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)',
backgroundColor: 'transparent'
}}
>
<Button variant="secondary">
🌍 Discover Community
</button>
</Button>
</Link>
<button
onClick={() => setShowCreateModal(true)}
className="px-6 py-2 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white'
}}
>
<Button variant="primary" onClick={() => setShowCreateModal(true)}>
+ Create List
</button>
</Button>
</div>
</div>
</div>
{/* Filters and Search */}
<div className="p-6" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<input
type="text"
placeholder="Search lists..."
className="input-field w-full"
<SearchBar
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onClear={() => setSearchQuery('')}
placeholder="Search lists…"
/>
</div>
<div className="flex gap-4">
@ -126,16 +112,9 @@ export default function CollectionsPageView(props) {
}
</p>
{!searchQuery && (
<button
onClick={() => setShowCreateModal(true)}
className="px-6 py-3 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white'
}}
>
<Button variant="primary" size="lg" onClick={() => setShowCreateModal(true)}>
Create List
</button>
</Button>
)}
</div>
) : (

View file

@ -1,48 +1,29 @@
import { Modal, Button } from './ui';
export default function CollectionsSuccessModal({
isOpen,
createdCollection,
onViewList,
onStay,
}) {
if (!isOpen || !createdCollection) {
return null;
}
if (!createdCollection) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="card max-w-md w-full mx-4 text-center">
<div className="text-6xl mb-4">🎉</div>
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
List Created!
</h2>
<Modal open={isOpen} onClose={onStay} title="List created!" size="md" hideCloseButton>
<div className="text-center">
<div className="text-6xl mb-4" aria-hidden="true">🎉</div>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
Your list &quot;{createdCollection.name}&quot; has been created successfully.
</p>
<div className="space-y-3">
<button
type="button"
onClick={onViewList}
className="w-full py-2 px-4 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white',
}}
>
<Button variant="primary" onClick={onViewList} className="w-full">
View List
</button>
<button
type="button"
onClick={onStay}
className="w-full py-2 px-4 rounded-xl border transition-colors"
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)',
}}
>
</Button>
<Button variant="secondary" onClick={onStay} className="w-full">
Stay on Lists Page
</button>
</Button>
</div>
</div>
</div>
</Modal>
);
}

View file

@ -1,40 +1,23 @@
import { Modal, Button } from './ui';
export default function OCRSettings({ onClose }) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div
className="rounded-xl border max-w-md w-full p-6"
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
Card Scanner
</h2>
<button
onClick={onClose}
className="p-2 rounded-lg hover:opacity-70"
style={{ color: 'var(--text-secondary)' }}
aria-label="Close"
>
</button>
</div>
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
Card identification runs on Deck Hearth&apos;s servers using Gemini Vision. No API keys
are required in your browser.
</p>
<ul className="text-sm space-y-2 mb-6" style={{ color: 'var(--text-secondary)' }}>
<li>Hold the card steady in the camera frame for best results.</li>
<li>If multiple matches are found, you will be asked to pick the correct printing.</li>
<li>Unknown cards are saved for admin review instead of being added to the global catalog.</li>
</ul>
<button
onClick={onClose}
className="w-full px-4 py-2 rounded-lg font-medium"
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
>
Got it
</button>
</div>
</div>
<Modal open onClose={onClose} title="Card Scanner" size="md">
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
Card identification runs on Deck Hearth&apos;s servers using Gemini Vision. No API
keys are required in your browser.
</p>
<ul className="text-sm space-y-2 mb-6" style={{ color: 'var(--text-secondary)' }}>
<li>Hold the card steady in the camera frame for best results.</li>
<li>If multiple matches are found, you will be asked to pick the correct printing.</li>
<li>
Unknown cards are saved for admin review instead of being added to the global
catalog.
</li>
</ul>
<Button variant="primary" onClick={onClose} className="w-full">
Got it
</Button>
</Modal>
);
}

View file

@ -2,7 +2,7 @@ import CameraScanner from './CameraScanner';
import ScannerDestinationPicker from './ScannerDestinationPicker';
import ScannedCardItem, { CONDITION_OPTIONS } from './ScannedCardItem';
import OCRSettings from './OCRSettings';
import { useFocusTrap } from '../lib/use-focus-trap.js';
import { Modal, Input, Button } from './ui';
import { VOCAB } from '../lib/collection-vocabulary.js';
export default function ScannerPageView({
@ -18,7 +18,6 @@ export default function ScannerPageView({
onCloseOCRSettings,
onScannerError,
}) {
const createCollectionDialogRef = useFocusTrap(queue.showCreateCollection);
return (
<div className="h-full flex flex-col">
@ -292,60 +291,43 @@ export default function ScannerPageView({
</div>
)}
{queue.showCreateCollection && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div
ref={createCollectionDialogRef}
className="rounded-xl p-6 max-w-md w-full mx-4"
style={{ backgroundColor: 'var(--bg-secondary)' }}
role="dialog"
aria-modal="true"
aria-labelledby="create-collection-title"
<Modal
open={queue.showCreateCollection}
onClose={() => queue.setShowCreateCollection(false)}
title="Create New List"
size="md"
>
<Input
id="create-collection-name"
label="List name"
placeholder="List name..."
value={queue.newCollectionName}
onChange={(e) => queue.setNewCollectionName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
queue.createCollection();
}
}}
className="mb-4"
/>
<div className="flex gap-3">
<Button
variant="primary"
onClick={queue.createCollection}
disabled={!queue.newCollectionName.trim()}
className="flex-1"
>
<h3 id="create-collection-title" className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Create New List
</h3>
<label htmlFor="create-collection-name" className="sr-only">
List name
</label>
<input
id="create-collection-name"
type="text"
placeholder="List name..."
value={queue.newCollectionName}
onChange={(e) => queue.setNewCollectionName(e.target.value)}
className="w-full px-4 py-2 rounded-lg border mb-4"
style={{
backgroundColor: 'var(--bg-tertiary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)',
}}
onKeyPress={(e) => {
if (e.key === 'Enter') {
queue.createCollection();
}
}}
/>
<div className="flex gap-3">
<button
onClick={queue.createCollection}
disabled={!queue.newCollectionName.trim()}
className="flex-1 px-4 py-2 rounded-lg font-medium disabled:opacity-50"
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
>
Create
</button>
<button
onClick={() => queue.setShowCreateCollection(false)}
className="flex-1 px-4 py-2 rounded-lg border font-medium"
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
>
Cancel
</button>
</div>
</div>
Create
</Button>
<Button
variant="secondary"
onClick={() => queue.setShowCreateCollection(false)}
className="flex-1"
>
Cancel
</Button>
</div>
)}
</Modal>
{showOCRSettings && <OCRSettings onClose={onCloseOCRSettings} />}
</div>

View file

@ -1,8 +1,9 @@
/* eslint-disable @next/next/no-img-element -- Local preview data URLs; next/image migration is out of scope. */
import { useState } from 'react';
import { Modal, Input, Button } from './ui';
export default function UploadImageModal({ isOpen, onClose, onUpload, currentImage }) {
const [uploadMethod, setUploadMethod] = useState('url'); // 'url' or 'file'
const [uploadMethod, setUploadMethod] = useState('url');
const [imageUrl, setImageUrl] = useState(currentImage || '');
const [dragActive, setDragActive] = useState(false);
const [uploading, setUploading] = useState(false);
@ -10,7 +11,7 @@ export default function UploadImageModal({ isOpen, onClose, onUpload, currentIma
const handleSubmit = async (e) => {
e.preventDefault();
if (!imageUrl.trim()) return;
setUploading(true);
try {
await onUpload(imageUrl);
@ -25,9 +26,9 @@ export default function UploadImageModal({ isOpen, onClose, onUpload, currentIma
const handleDrag = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === "dragleave") {
} else if (e.type === 'dragleave') {
setDragActive(false);
}
};
@ -36,7 +37,6 @@ export default function UploadImageModal({ isOpen, onClose, onUpload, currentIma
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFileUpload(e.dataTransfer.files[0]);
}
@ -45,8 +45,8 @@ export default function UploadImageModal({ isOpen, onClose, onUpload, currentIma
const handleFileUpload = (file) => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
setImageUrl(e.target.result);
reader.onload = (event) => {
setImageUrl(event.target.result);
setUploadMethod('file');
};
reader.readAsDataURL(file);
@ -59,141 +59,159 @@ export default function UploadImageModal({ isOpen, onClose, onUpload, currentIma
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-gray-900">Upload Hero Image</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleSubmit}>
{/* Upload Method Tabs */}
<div className="flex space-x-1 mb-6 bg-gray-100 rounded-lg p-1">
<button
type="button"
onClick={() => setUploadMethod('url')}
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors ${
uploadMethod === 'url'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
Image URL
</button>
<button
type="button"
onClick={() => setUploadMethod('file')}
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors ${
uploadMethod === 'file'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
Upload File
</button>
</div>
{uploadMethod === 'url' ? (
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Image URL
</label>
<input
type="url"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="https://example.com/image.jpg"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
) : (
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Upload Image
</label>
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
dragActive
? 'border-purple-500 bg-purple-50'
: 'border-gray-300 hover:border-gray-400'
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<svg className="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="text-gray-600 mb-2">Drag and drop an image here, or</p>
<label className="inline-block px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 cursor-pointer transition-colors">
Choose File
<input
type="file"
accept="image/*"
onChange={handleFileInput}
className="hidden"
/>
</label>
<p className="text-xs text-gray-500 mt-2">PNG, JPG, GIF up to 10MB</p>
</div>
</div>
)}
{/* Preview */}
{imageUrl && (
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Preview
</label>
<div className="relative rounded-lg overflow-hidden bg-gray-100">
<img
src={imageUrl}
alt="Preview"
className="w-full h-32 object-cover"
onError={(e) => {
e.target.src = 'https://via.placeholder.com/400x128/f3f4f6/6b7280?text=Invalid+Image';
}}
/>
</div>
</div>
)}
{/* Actions */}
<div className="flex space-x-3">
<button
type="button"
onClick={onClose}
className="flex-1 py-2 px-4 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!imageUrl.trim() || uploading}
className={`flex-1 py-2 px-4 rounded-lg font-medium transition-colors ${
!imageUrl.trim() || uploading
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-purple-600 text-white hover:bg-purple-700'
}`}
>
{uploading ? 'Uploading...' : 'Upload Image'}
</button>
</div>
</form>
<Modal open={isOpen} onClose={onClose} title="Upload Hero Image" size="md">
<form onSubmit={handleSubmit}>
<div
className="flex space-x-1 mb-6 rounded-lg p-1"
style={{
backgroundColor: 'var(--bg-tertiary)',
border: '1px solid var(--border)',
}}
>
<button
type="button"
onClick={() => setUploadMethod('url')}
className="flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors"
style={{
backgroundColor:
uploadMethod === 'url' ? 'var(--bg-primary)' : 'transparent',
color:
uploadMethod === 'url'
? 'var(--text-primary)'
: 'var(--text-secondary)',
boxShadow: uploadMethod === 'url' ? 'var(--elevation-ambient)' : 'none',
}}
>
Image URL
</button>
<button
type="button"
onClick={() => setUploadMethod('file')}
className="flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors"
style={{
backgroundColor:
uploadMethod === 'file' ? 'var(--bg-primary)' : 'transparent',
color:
uploadMethod === 'file'
? 'var(--text-primary)'
: 'var(--text-secondary)',
boxShadow: uploadMethod === 'file' ? 'var(--elevation-ambient)' : 'none',
}}
>
Upload File
</button>
</div>
</div>
</div>
{uploadMethod === 'url' ? (
<div className="mb-6">
<Input
label="Image URL"
type="url"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="https://example.com/image.jpg"
/>
</div>
) : (
<div className="mb-6">
<label
className="block text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
>
Upload Image
</label>
<div
className="border-2 border-dashed rounded-lg p-6 text-center transition-colors"
style={{
borderColor: dragActive ? 'var(--accent-ember)' : 'var(--border)',
backgroundColor: dragActive ? 'rgba(var(--ember-rim-subtle), 0.08)' : 'transparent',
}}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<svg
className="w-12 h-12 mx-auto mb-4"
style={{ color: 'var(--text-tertiary)' }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="mb-2" style={{ color: 'var(--text-secondary)' }}>
Drag and drop an image here, or
</p>
<label
className="inline-block px-4 py-2 rounded-lg cursor-pointer transition-colors"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white',
}}
>
Choose File
<input
type="file"
accept="image/*"
onChange={handleFileInput}
className="hidden"
/>
</label>
<p className="text-xs mt-2" style={{ color: 'var(--text-tertiary)' }}>
PNG, JPG, GIF up to 10MB
</p>
</div>
</div>
)}
{imageUrl && (
<div className="mb-6">
<label
className="block text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
>
Preview
</label>
<div
className="relative rounded-lg overflow-hidden"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
>
<img
src={imageUrl}
alt="Preview"
className="w-full h-32 object-cover"
onError={(e) => {
e.target.src =
'https://via.placeholder.com/400x128/f3f4f6/6b7280?text=Invalid+Image';
}}
/>
</div>
</div>
)}
<div className="flex space-x-3">
<Button type="button" variant="secondary" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button
type="submit"
variant="primary"
disabled={!imageUrl.trim() || uploading}
loading={uploading}
className="flex-1"
>
{uploading ? 'Uploading...' : 'Upload Image'}
</Button>
</div>
</form>
</Modal>
);
}
}

View file

@ -147,7 +147,7 @@ const CardImport = () => {
</button>
<button
onClick={() => router.push('/admin/card-import')}
className="px-4 py-2 rounded-lg font-medium gradient-bg-purple text-white"
className="px-4 py-2 rounded-lg font-medium gradient-bg-ember text-white"
>
📥 Card Import
</button>
@ -184,7 +184,7 @@ const CardImport = () => {
className={`shrink-0 py-3 px-6 rounded-xl font-medium transition-all duration-200 ${
isSyncing
? 'opacity-50 cursor-not-allowed'
: 'gradient-bg-purple text-white hover:shadow-lg'
: 'gradient-bg-ember text-white hover:shadow-lg'
}`}
>
{isSyncing ? 'Syncing catalog…' : 'Run catalog sync'}
@ -307,7 +307,7 @@ const CardImport = () => {
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-purple text-white hover:shadow-lg'
: 'gradient-bg-ember text-white hover:shadow-lg'
}`}
>
{isImporting ? 'Importing...' : 'Import Cards'}

View file

@ -34,7 +34,7 @@ export default function CardDetail() {
<button
type="button"
onClick={() => router.push('/cards')}
className="px-6 py-3 rounded-xl font-medium gradient-bg-purple text-white hover:shadow-lg transition-all duration-200"
className="px-6 py-3 rounded-xl font-medium gradient-bg-ember text-white hover:shadow-lg transition-all duration-200"
>
Back to Cards
</button>

View file

@ -4,6 +4,7 @@ import { useRouter } from 'next/router';
import Link from 'next/link';
import Layout from '../../components/Layout';
import PermissionIndicator from '../../components/PermissionIndicator';
import { Button } from '../../components/ui';
import { useAuth } from '../../lib/use-auth';
import { VOCAB } from '../../lib/collection-vocabulary.js';
@ -252,14 +253,9 @@ export default function CommunityCollections() {
</p>
{!searchQuery && (
<Link href="/collections">
<button className="px-6 py-3 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white'
}}
>
<Button variant="primary" size="lg">
Go to My Lists
</button>
</Button>
</Link>
)}
</div>

View file

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Layout from '../components/Layout';
import PermissionIndicator from '../components/PermissionIndicator';
import { Button } from '../components/ui';
import { useAuth } from '../lib/use-auth';
import Link from 'next/link';
import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js';
@ -91,14 +92,17 @@ export default function Dashboard() {
</div>
<div className="flex space-x-2 sm:space-x-4">
<Link href="/collections">
<button className="px-4 py-2 text-sm font-medium rounded-xl transition-all duration-200 hover:opacity-90" style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}>
<div className="flex items-center space-x-2">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<Button
variant="primary"
size="sm"
leadingIcon={
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span>Create List</span>
</div>
</button>
}
>
Create List
</Button>
</Link>
</div>
</div>
@ -176,9 +180,9 @@ export default function Dashboard() {
Create your first list to start organizing your cards
</p>
<Link href="/collections">
<button className="px-6 py-3 font-medium rounded-xl transition-all duration-200 hover:opacity-90" style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}>
<Button variant="primary" size="lg">
Create Your First List
</button>
</Button>
</Link>
</div>
) : (
@ -222,9 +226,9 @@ export default function Dashboard() {
{collections.length > 6 && (
<div className="text-center">
<Link href="/collections">
<button className="px-6 py-3 font-medium rounded-xl border-2 transition-all duration-200 hover:opacity-80" style={{ color: 'var(--text-primary)', borderColor: 'var(--accent-ember)' }}>
<Button variant="secondary" size="lg">
View All Lists
</button>
</Button>
</Link>
</div>
)}

View file

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import Layout from '../components/Layout';
import { Modal, Input, Button } from '../components/ui';
import { useAuth } from '../lib/use-auth';
export default function Decks() {
@ -299,166 +300,172 @@ export default function Decks() {
</div>
)}
{/* Create Deck Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-bg-primary rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold text-text-primary mb-4">Create New Deck</h2>
<form onSubmit={handleCreateDeck}>
<div className="mb-4">
<label className="block text-text-secondary text-sm font-medium mb-2">
Deck Name *
</label>
<input
type="text"
required
value={newDeck.name}
onChange={(e) => setNewDeck({...newDeck, name: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
placeholder="Enter deck name"
/>
</div>
<div className="mb-4">
<label className="block text-text-secondary text-sm font-medium mb-2">
Format
</label>
<select
value={newDeck.format}
onChange={(e) => setNewDeck({...newDeck, format: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
>
<option value="Commander">Commander</option>
<option value="Standard">Standard</option>
<option value="Modern">Modern</option>
<option value="Legacy">Legacy</option>
</select>
</div>
<div className="mb-4">
<label className="block text-text-secondary text-sm font-medium mb-2">
Description
</label>
<textarea
value={newDeck.description}
onChange={(e) => setNewDeck({...newDeck, description: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
rows="3"
placeholder="Describe your deck strategy..."
/>
</div>
<div className="mb-6">
<label className="flex items-center">
<input
type="checkbox"
checked={newDeck.is_public}
onChange={(e) => setNewDeck({...newDeck, is_public: e.target.checked})}
className="mr-2"
/>
<span className="text-text-secondary text-sm">Make deck public</span>
</label>
</div>
<div className="flex space-x-3">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="flex-1 px-4 py-2 border border-border rounded-lg text-text-secondary hover:bg-bg-secondary transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors"
>
Create Deck
</button>
</div>
</form>
<Modal
open={showCreateModal}
onClose={() => setShowCreateModal(false)}
title="Create New Deck"
size="md"
>
<form onSubmit={handleCreateDeck} className="space-y-4">
<Input
label="Deck Name *"
required
value={newDeck.name}
onChange={(e) => setNewDeck({ ...newDeck, name: e.target.value })}
placeholder="Enter deck name"
/>
<div>
<label
className="block text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
htmlFor="create-deck-format"
>
Format
</label>
<select
id="create-deck-format"
value={newDeck.format}
onChange={(e) => setNewDeck({ ...newDeck, format: e.target.value })}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
>
<option value="Commander">Commander</option>
<option value="Standard">Standard</option>
<option value="Modern">Modern</option>
<option value="Legacy">Legacy</option>
</select>
</div>
</div>
)}
{/* Edit Deck Modal */}
{editingDeck && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-bg-primary rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold text-text-primary mb-4">Edit Deck</h2>
<form onSubmit={handleEditDeck}>
<div className="mb-4">
<label className="block text-text-secondary text-sm font-medium mb-2">
Deck Name *
</label>
<input
type="text"
required
value={editingDeck.name}
onChange={(e) => setEditingDeck({...editingDeck, name: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
/>
</div>
<div className="mb-4">
<label className="block text-text-secondary text-sm font-medium mb-2">
Format
</label>
<select
value={editingDeck.format}
onChange={(e) => setEditingDeck({...editingDeck, format: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
>
<option value="Commander">Commander</option>
<option value="Standard">Standard</option>
<option value="Modern">Modern</option>
<option value="Legacy">Legacy</option>
</select>
</div>
<div className="mb-4">
<label className="block text-text-secondary text-sm font-medium mb-2">
Description
</label>
<textarea
value={editingDeck.description || ''}
onChange={(e) => setEditingDeck({...editingDeck, description: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
rows="3"
/>
</div>
<div className="mb-6">
<label className="flex items-center">
<input
type="checkbox"
checked={editingDeck.is_public}
onChange={(e) => setEditingDeck({...editingDeck, is_public: e.target.checked})}
className="mr-2"
/>
<span className="text-text-secondary text-sm">Make deck public</span>
</label>
</div>
<div className="flex space-x-3">
<button
type="button"
onClick={() => setEditingDeck(null)}
className="flex-1 px-4 py-2 border border-border rounded-lg text-text-secondary hover:bg-bg-secondary transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors"
>
Save Changes
</button>
</div>
</form>
<div>
<label
className="block text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
htmlFor="create-deck-description"
>
Description
</label>
<textarea
id="create-deck-description"
value={newDeck.description}
onChange={(e) => setNewDeck({ ...newDeck, description: e.target.value })}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
rows="3"
placeholder="Describe your deck strategy..."
/>
</div>
</div>
)}
<label className="flex items-center">
<input
type="checkbox"
checked={newDeck.is_public}
onChange={(e) => setNewDeck({ ...newDeck, is_public: e.target.checked })}
className="mr-2"
style={{ accentColor: 'var(--accent-ember)' }}
/>
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Make deck public
</span>
</label>
<div className="flex space-x-3 pt-2">
<Button
type="button"
variant="secondary"
onClick={() => setShowCreateModal(false)}
className="flex-1"
>
Cancel
</Button>
<Button type="submit" variant="primary" className="flex-1">
Create Deck
</Button>
</div>
</form>
</Modal>
<Modal
open={!!editingDeck}
onClose={() => setEditingDeck(null)}
title="Edit Deck"
size="md"
>
{editingDeck && (
<form onSubmit={handleEditDeck} className="space-y-4">
<Input
label="Deck Name *"
required
value={editingDeck.name}
onChange={(e) =>
setEditingDeck({ ...editingDeck, name: e.target.value })
}
/>
<div>
<label
className="block text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
htmlFor="edit-deck-format"
>
Format
</label>
<select
id="edit-deck-format"
value={editingDeck.format}
onChange={(e) =>
setEditingDeck({ ...editingDeck, format: e.target.value })
}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
>
<option value="Commander">Commander</option>
<option value="Standard">Standard</option>
<option value="Modern">Modern</option>
<option value="Legacy">Legacy</option>
</select>
</div>
<div>
<label
className="block text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
htmlFor="edit-deck-description"
>
Description
</label>
<textarea
id="edit-deck-description"
value={editingDeck.description || ''}
onChange={(e) =>
setEditingDeck({ ...editingDeck, description: e.target.value })
}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
rows="3"
/>
</div>
<label className="flex items-center">
<input
type="checkbox"
checked={editingDeck.is_public}
onChange={(e) =>
setEditingDeck({ ...editingDeck, is_public: e.target.checked })
}
className="mr-2"
style={{ accentColor: 'var(--accent-ember)' }}
/>
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Make deck public
</span>
</label>
<div className="flex space-x-3 pt-2">
<Button
type="button"
variant="secondary"
onClick={() => setEditingDeck(null)}
className="flex-1"
>
Cancel
</Button>
<Button type="submit" variant="primary" className="flex-1">
Save Changes
</Button>
</div>
</form>
)}
</Modal>
</div>
</Layout>
);
}
}

View file

@ -4,6 +4,7 @@ import { useRouter } from 'next/router';
import Link from 'next/link';
import { useAuth } from '../lib/use-auth.js';
import AnimatedFireLogo from '../components/AnimatedFireLogo';
import { Button, GlassSurface } from '../components/ui';
export default function Home() {
const { user, loading } = useAuth();
@ -11,14 +12,12 @@ export default function Home() {
const [featuredCollections, setFeaturedCollections] = useState([]);
const [collectionsLoading, setCollectionsLoading] = useState(true);
// If user is logged in, redirect to dashboard
useEffect(() => {
if (!loading && user) {
router.push('/dashboard');
}
}, [user, loading, router]);
// Fetch featured collections for public display
useEffect(() => {
const fetchFeaturedCollections = async () => {
try {
@ -33,31 +32,44 @@ export default function Home() {
setCollectionsLoading(false);
}
};
fetchFeaturedCollections();
}, []);
// Show loading while checking auth
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div
className="min-h-screen flex items-center justify-center"
style={{ backgroundColor: 'var(--bg-primary)' }}
>
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 mx-auto mb-4" style={{ borderColor: 'var(--accent-ember)' }}></div>
<div
className="motion-essential animate-spin rounded-full h-12 w-12 border-b-2 mx-auto mb-4"
style={{ borderColor: 'var(--accent-ember)' }}
/>
<p style={{ color: 'var(--text-secondary)' }}>Loading...</p>
</div>
</div>
);
}
// If user is logged in, don't show landing page (redirect handled above)
if (user) {
return null;
}
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--bg-primary)' }}>
{/* Navigation Bar */}
<nav className="border-b" style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}>
<nav
className="border-b"
style={{
background: 'var(--glass-surface-mid)',
backdropFilter:
'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
WebkitBackdropFilter:
'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
borderColor: 'var(--border)',
boxShadow: 'inset 0 1px 0 var(--rim-light-inner)',
}}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-4">
<div className="flex items-center space-x-3">
@ -65,29 +77,21 @@ export default function Home() {
<h1 className="text-2xl font-bold gradient-text-flame">Deck Hearth</h1>
</div>
<div className="flex items-center space-x-4">
<Link
href="/login"
className="px-4 py-2 text-sm font-medium rounded-xl transition-all duration-200 hover:opacity-80"
style={{ color: 'var(--text-secondary)' }}
>
Sign In
<Link href="/login">
<Button variant="ghost" size="sm">
Sign In
</Button>
</Link>
<Link
href="/signup"
className="px-6 py-2 text-sm font-medium rounded-xl transition-all duration-200 hover:opacity-90"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white'
}}
>
Get Started
<Link href="/signup">
<Button variant="primary" size="sm">
Get Started
</Button>
</Link>
</div>
</div>
</div>
</nav>
{/* Hero Section */}
<section className="relative py-20 px-4">
<div className="max-w-6xl mx-auto text-center">
<div className="mb-8">
@ -96,86 +100,136 @@ export default function Home() {
<h1 className="text-5xl md:text-7xl font-bold mb-6">
<span className="gradient-text-flame">Deck Hearth</span>
</h1>
<p className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
<p
className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto leading-relaxed"
style={{ color: 'var(--text-secondary)' }}
>
The ultimate hub for trading card collectors. Organize your cards into lists,
discover rare cards, and connect with fellow enthusiasts in one beautiful platform.
discover rare cards, and connect with fellow enthusiasts in one beautiful
platform.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Link
href="/signup"
className="px-8 py-4 text-lg font-medium rounded-2xl transition-all duration-200 hover:opacity-90 hover:scale-105"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white'
}}
>
Start Your Collection
<Link href="/signup">
<Button variant="primary" size="lg">
Start Your Collection
</Button>
</Link>
<Link
href="/community/collections"
className="px-8 py-4 text-lg font-medium rounded-2xl border-2 transition-all duration-200 hover:opacity-80"
style={{
color: 'var(--text-primary)',
borderColor: 'var(--accent-ember)'
}}
>
Explore Lists
<Link href="/community/collections">
<Button variant="secondary" size="lg">
Explore Lists
</Button>
</Link>
</div>
</div>
</section>
{/* Features Section */}
<section className="py-20 px-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="max-w-6xl mx-auto">
<h2 className="text-4xl font-bold text-center mb-16 gradient-text-gold">
Everything You Need to Manage Your Cards
</h2>
<div className="grid md:grid-cols-3 gap-8">
<div className="text-center p-8 rounded-2xl" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--accent-ember)' }}>
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
<GlassSurface
tint="mid"
rim="subtle"
elevation="ambient"
className="text-center p-8 rounded-2xl"
>
<div
className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center"
style={{ backgroundColor: 'var(--accent-ember)' }}
>
<svg
className="w-8 h-8 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
</div>
<h3 className="text-xl font-bold mb-4 gradient-text-flame">Organize Lists</h3>
<p style={{ color: 'var(--text-secondary)' }}>
Create custom lists, track card values, and organize by sets, rarity, or any system that works for you.
Create custom lists, track card values, and organize by sets, rarity, or any
system that works for you.
</p>
</div>
<div className="text-center p-8 rounded-2xl" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--accent-gold)' }}>
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</GlassSurface>
<GlassSurface
tint="mid"
rim="subtle"
elevation="ambient"
className="text-center p-8 rounded-2xl"
>
<div
className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center"
style={{ backgroundColor: 'var(--accent-gold)' }}
>
<svg
className="w-8 h-8 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<h3 className="text-xl font-bold mb-4 gradient-text-gold">Discover Cards</h3>
<p style={{ color: 'var(--text-secondary)' }}>
Search through thousands of cards across multiple TCGs. Find that missing piece for your binder.
Search through thousands of cards across multiple TCGs. Find that missing
piece for your binder.
</p>
</div>
<div className="text-center p-8 rounded-2xl" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--accent-flame)' }}>
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</GlassSurface>
<GlassSurface
tint="mid"
rim="subtle"
elevation="ambient"
className="text-center p-8 rounded-2xl"
>
<div
className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center"
style={{ backgroundColor: 'var(--accent-flame)' }}
>
<svg
className="w-8 h-8 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
<h3 className="text-xl font-bold mb-4 gradient-text-ember">Connect & Share</h3>
<p style={{ color: 'var(--text-secondary)' }}>
Share your lists with the community, collaborate with friends, and discover amazing lists from other collectors.
Share your lists with the community, collaborate with friends, and discover
amazing lists from other collectors.
</p>
</div>
</GlassSurface>
</div>
</div>
</section>
{/* Featured Collections Section */}
<section className="py-20 px-4">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<h2 className="text-4xl font-bold mb-4 gradient-text-flame">
Featured Lists
</h2>
<h2 className="text-4xl font-bold mb-4 gradient-text-flame">Featured Lists</h2>
<p className="text-xl" style={{ color: 'var(--text-secondary)' }}>
Discover amazing lists from our community
</p>
@ -184,13 +238,32 @@ export default function Home() {
{collectionsLoading ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="rounded-2xl p-6 animate-pulse" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="h-32 rounded-xl mb-4" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="h-6 rounded mb-2" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="h-4 rounded mb-4" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div
key={i}
className="motion-essential rounded-2xl p-6 animate-pulse"
style={{ backgroundColor: 'var(--bg-secondary)' }}
>
<div
className="h-32 rounded-xl mb-4"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
/>
<div
className="h-6 rounded mb-2"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
/>
<div
className="h-4 rounded mb-4"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
/>
<div className="flex justify-between">
<div className="h-4 w-16 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="h-4 w-20 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div
className="h-4 w-16 rounded"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
/>
<div
className="h-4 w-20 rounded"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
/>
</div>
</div>
))}
@ -198,101 +271,138 @@ export default function Home() {
) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{featuredCollections.map((collection) => (
<Link
key={collection.id}
<Link
key={collection.id}
href={`/collection/${collection.slug || collection.id}`}
className="block rounded-2xl p-6 transition-all duration-200 hover:scale-105 hover:shadow-xl"
style={{ backgroundColor: 'var(--bg-secondary)' }}
>
<div className="h-32 rounded-xl mb-4 flex items-center justify-center" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
{collection.image ? (
<img
src={collection.image}
alt={collection.name}
className="w-full h-full object-cover rounded-xl"
/>
) : (
<div className="text-center">
<div className="w-12 h-12 mx-auto mb-2 rounded-xl flex items-center justify-center" style={{ backgroundColor: 'var(--accent-ember)' }}>
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<GlassSurface
tint="mid"
rim="subtle"
elevation="ambient"
className="block rounded-2xl p-6 hover:scale-105"
style={{
transition:
'transform var(--motion-duration-default) var(--motion-ease-out), box-shadow var(--motion-duration-default) var(--motion-ease-out)',
}}
>
<div
className="h-32 rounded-xl mb-4 flex items-center justify-center"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
>
{collection.image ? (
<img
src={collection.image}
alt={collection.name}
className="w-full h-full object-cover rounded-xl"
/>
) : (
<div className="text-center">
<div
className="w-12 h-12 mx-auto mb-2 rounded-xl flex items-center justify-center"
style={{ backgroundColor: 'var(--accent-ember)' }}
>
<svg
className="w-6 h-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
</div>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{collection.cardCount} cards
</p>
</div>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{collection.cardCount} cards</p>
</div>
)}
</div>
<h3 className="text-xl font-bold mb-2 gradient-text-flame">{collection.name}</h3>
<p className="text-sm mb-4 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
{collection.description || 'A carefully curated list'}
</p>
<div className="flex justify-between items-center">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{collection.cardCount} cards
</span>
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
by {collection.creator}
</span>
</div>
)}
</div>
<h3 className="text-xl font-bold mb-2 gradient-text-flame">
{collection.name}
</h3>
<p
className="text-sm mb-4 line-clamp-2"
style={{ color: 'var(--text-secondary)' }}
>
{collection.description || 'A carefully curated list'}
</p>
<div className="flex justify-between items-center">
<span
className="text-sm font-medium"
style={{ color: 'var(--text-primary)' }}
>
{collection.cardCount} cards
</span>
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
by {collection.creator}
</span>
</div>
</GlassSurface>
</Link>
))}
</div>
)}
<div className="text-center mt-12">
<Link
href="/community/collections"
className="inline-flex items-center px-6 py-3 text-lg font-medium rounded-xl transition-all duration-200 hover:opacity-80"
style={{
color: 'var(--accent-ember)',
border: '2px solid var(--accent-ember)'
}}
>
View All Lists
<svg className="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
<Link href="/community/collections">
<Button variant="secondary" size="lg">
View All Lists
<svg
className="w-5 h-5 ml-2 inline-block"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 8l4 4m0 0l-4 4m4-4H3"
/>
</svg>
</Button>
</Link>
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-20 px-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="max-w-4xl mx-auto text-center">
<h2 className="text-4xl font-bold mb-6 gradient-text-flame">
Ready to Start Your Journey?
</h2>
<p className="text-xl mb-8" style={{ color: 'var(--text-secondary)' }}>
Join thousands of collectors who trust Deck Hearth to manage their cards and lists.
Join thousands of collectors who trust Deck Hearth to manage their cards and
lists.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Link
href="/signup"
className="px-8 py-4 text-lg font-medium rounded-2xl transition-all duration-200 hover:opacity-90 hover:scale-105"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white'
}}
>
Create Free Account
<Link href="/signup">
<Button variant="primary" size="lg">
Create Free Account
</Button>
</Link>
<Link
href="/cards"
className="px-8 py-4 text-lg font-medium rounded-2xl border-2 transition-all duration-200 hover:opacity-80"
style={{
color: 'var(--text-primary)',
borderColor: 'var(--accent-ember)'
}}
>
Browse Cards
<Link href="/cards">
<Button variant="secondary" size="lg">
Browse Cards
</Button>
</Link>
</div>
</div>
</section>
{/* Footer */}
<footer className="py-12 px-4 border-t" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border)' }}>
<footer
className="py-12 px-4 border-t"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
}}
>
<div className="max-w-6xl mx-auto">
<div className="flex flex-col md:flex-row justify-between items-center">
<div className="flex items-center space-x-3 mb-4 md:mb-0">
@ -300,18 +410,33 @@ export default function Home() {
<span className="text-xl font-bold gradient-text-flame">Deck Hearth</span>
</div>
<div className="flex space-x-6">
<Link href="/community/collections" className="hover:opacity-80 transition-opacity" style={{ color: 'var(--text-secondary)' }}>
<Link
href="/community/collections"
className="hover:opacity-80 transition-opacity"
style={{ color: 'var(--text-secondary)' }}
>
Community
</Link>
<Link href="/cards" className="hover:opacity-80 transition-opacity" style={{ color: 'var(--text-secondary)' }}>
<Link
href="/cards"
className="hover:opacity-80 transition-opacity"
style={{ color: 'var(--text-secondary)' }}
>
Cards
</Link>
<Link href="/login" className="hover:opacity-80 transition-opacity" style={{ color: 'var(--text-secondary)' }}>
<Link
href="/login"
className="hover:opacity-80 transition-opacity"
style={{ color: 'var(--text-secondary)' }}
>
Sign In
</Link>
</div>
</div>
<div className="mt-8 pt-8 border-t text-center" style={{ borderColor: 'var(--border)' }}>
<div
className="mt-8 pt-8 border-t text-center"
style={{ borderColor: 'var(--border)' }}
>
<p style={{ color: 'var(--text-secondary)' }}>
© 2024 Deck Hearth. Built for collectors, by collectors.
</p>
@ -320,4 +445,4 @@ export default function Home() {
</footer>
</div>
);
}
}

View file

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Layout from '../../components/Layout';
import { GlassSurface, Button } from '../../components/ui';
export default function AcceptInvite() {
const router = useRouter();
@ -49,8 +50,14 @@ export default function AcceptInvite() {
<Layout user={null}>
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 mx-auto mb-4" style={{ borderColor: 'var(--text-accent)' }}></div>
<h2 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
<div
className="motion-essential animate-spin rounded-full h-16 w-16 border-b-2 mx-auto mb-4"
style={{ borderColor: 'var(--accent-ember)' }}
/>
<h2
className="text-xl font-semibold mb-2"
style={{ color: 'var(--text-primary)' }}
>
Processing Invitation...
</h2>
<p style={{ color: 'var(--text-secondary)' }}>
@ -67,61 +74,77 @@ export default function AcceptInvite() {
<div className="min-h-screen flex items-center justify-center px-4">
<div className="max-w-md w-full">
{result ? (
<div className="text-center p-8 rounded-2xl shadow-lg" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="text-6xl mb-4">🎉</div>
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
<GlassSurface
tint="mid"
rim="subtle"
elevation="pronounced"
className="text-center p-8 rounded-2xl"
>
<div className="text-6xl mb-4" aria-hidden="true">🎉</div>
<h2
className="text-2xl font-bold mb-4"
style={{ color: 'var(--text-primary)' }}
>
Invitation Accepted!
</h2>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
You now have access to the list &quot;{result.collection.name}&quot;.
</p>
<div className="space-y-3">
<button
<Button
variant="primary"
size="lg"
onClick={() => router.push(`/collection/${result.collection.id}`)}
className="w-full px-6 py-3 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200"
className="w-full"
>
View List
</button>
<button
</Button>
<Button
variant="secondary"
size="lg"
onClick={() => router.push('/collections')}
className="w-full px-6 py-3 rounded-lg font-medium border transition-all duration-200"
style={{
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
className="w-full"
>
View All Lists
</button>
</Button>
</div>
</div>
</GlassSurface>
) : (
<div className="text-center p-8 rounded-2xl shadow-lg" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="text-6xl mb-4"></div>
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
<GlassSurface
tint="mid"
rim="subtle"
elevation="pronounced"
className="text-center p-8 rounded-2xl"
>
<div className="text-6xl mb-4" aria-hidden="true"></div>
<h2
className="text-2xl font-bold mb-4"
style={{ color: 'var(--text-primary)' }}
>
Invitation Error
</h2>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
{error}
</p>
<div className="space-y-3">
<button
<Button
variant="primary"
size="lg"
onClick={() => router.push('/collections')}
className="w-full px-6 py-3 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200"
className="w-full"
>
Browse Lists
</button>
<button
</Button>
<Button
variant="secondary"
size="lg"
onClick={() => router.push('/')}
className="w-full px-6 py-3 rounded-lg font-medium border transition-all duration-200"
style={{
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
className="w-full"
>
Go Home
</button>
</Button>
</div>
</div>
</GlassSurface>
)}
</div>
</div>

View file

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Layout from '../../components/Layout';
import { GlassSurface, Button } from '../../components/ui';
export default function DeclineInvite() {
const router = useRouter();
@ -49,8 +50,14 @@ export default function DeclineInvite() {
<Layout user={null}>
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 mx-auto mb-4" style={{ borderColor: 'var(--text-accent)' }}></div>
<h2 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
<div
className="motion-essential animate-spin rounded-full h-16 w-16 border-b-2 mx-auto mb-4"
style={{ borderColor: 'var(--accent-ember)' }}
/>
<h2
className="text-xl font-semibold mb-2"
style={{ color: 'var(--text-primary)' }}
>
Processing Response...
</h2>
<p style={{ color: 'var(--text-secondary)' }}>
@ -67,62 +74,79 @@ export default function DeclineInvite() {
<div className="min-h-screen flex items-center justify-center px-4">
<div className="max-w-md w-full">
{result ? (
<div className="text-center p-8 rounded-2xl shadow-lg" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="text-6xl mb-4">👋</div>
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
<GlassSurface
tint="mid"
rim="subtle"
elevation="pronounced"
className="text-center p-8 rounded-2xl"
>
<div className="text-6xl mb-4" aria-hidden="true">👋</div>
<h2
className="text-2xl font-bold mb-4"
style={{ color: 'var(--text-primary)' }}
>
Invitation Declined
</h2>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
You have declined the invitation to &quot;{result.collection.name}&quot;.
You can always ask the list owner for another invitation if you change your mind.
You can always ask the list owner for another invitation if you change
your mind.
</p>
<div className="space-y-3">
<button
<Button
variant="primary"
size="lg"
onClick={() => router.push('/collections')}
className="w-full px-6 py-3 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200"
className="w-full"
>
Browse Public Lists
</button>
<button
</Button>
<Button
variant="secondary"
size="lg"
onClick={() => router.push('/')}
className="w-full px-6 py-3 rounded-lg font-medium border transition-all duration-200"
style={{
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
className="w-full"
>
Go Home
</button>
</Button>
</div>
</div>
</GlassSurface>
) : (
<div className="text-center p-8 rounded-2xl shadow-lg" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="text-6xl mb-4"></div>
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
<GlassSurface
tint="mid"
rim="subtle"
elevation="pronounced"
className="text-center p-8 rounded-2xl"
>
<div className="text-6xl mb-4" aria-hidden="true"></div>
<h2
className="text-2xl font-bold mb-4"
style={{ color: 'var(--text-primary)' }}
>
Error Processing Response
</h2>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
{error}
</p>
<div className="space-y-3">
<button
<Button
variant="primary"
size="lg"
onClick={() => router.push('/collections')}
className="w-full px-6 py-3 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200"
className="w-full"
>
Browse Lists
</button>
<button
</Button>
<Button
variant="secondary"
size="lg"
onClick={() => router.push('/')}
className="w-full px-6 py-3 rounded-lg font-medium border transition-all duration-200"
style={{
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
className="w-full"
>
Go Home
</button>
</Button>
</div>
</div>
</GlassSurface>
)}
</div>
</div>

View file

@ -7,6 +7,7 @@ import BulkSelectionToolbar from '../components/BulkSelectionToolbar';
import CollectionSelectionModal from '../components/CollectionSelectionModal';
import { ManaCost, ColorFilterSymbol } from '../components/ManaSymbols';
import ManaSymbolSettings from '../components/ManaSymbolSettings';
import { Button } from '../components/ui';
import { useAuth } from '../lib/use-auth';
import { VOCAB } from '../lib/collection-vocabulary.js';
@ -441,9 +442,9 @@ export default function MyCards() {
{searchQuery ? 'Try adjusting your search filters' : `Start adding cards to ${VOCAB.MY_COLLECTION} by browsing available cards`}
</p>
<Link href="/cards">
<button className="px-6 py-3 font-medium rounded-xl transition-all duration-200 hover:opacity-90" style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}>
<Button variant="primary" size="lg">
Browse All Cards
</button>
</Button>
</Link>
</div>
)}

View file

@ -426,34 +426,11 @@ body {
border: 1px solid var(--border);
}
/* Glowing effects for dark theme */
[data-theme="dark"] .glow-blue {
box-shadow: 0 0 20px rgba(6, 182, 212, 0.3);
}
[data-theme="dark"] .glow-purple {
box-shadow: 0 0 20px rgba(139, 92, 246, 0.3);
}
[data-theme="dark"] .glow-pink {
box-shadow: 0 0 20px rgba(236, 72, 153, 0.3);
}
/* Gradient text effects */
.gradient-text-blue {
background: linear-gradient(135deg, #06b6d4 0%, #3b82f6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.gradient-text-purple {
background: linear-gradient(135deg, #8b5cf6 0%, #ec4899 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Gradient text effects Deck Hearth warm palette only.
The pre-Deck-Hearth aliases (.glow-blue / .glow-purple / .glow-pink,
.gradient-text-blue / .gradient-text-purple, .gradient-bg-purple)
were removed by the finish-liquid-glass-design PR after sweeping
their last consumers to --accent-ember equivalents. */
.gradient-text-gold {
background: linear-gradient(135deg, #ffab40 0%, #ff6f00 100%);
-webkit-background-clip: text;