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

View file

@ -78,7 +78,7 @@ export default function AdminProtected({ children }) {
<div className="space-x-4"> <div className="space-x-4">
<button <button
onClick={() => router.push('/login')} 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 Login as Admin
</button> </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({ export default function CardDetailDeckModal({
isOpen, isOpen,
card, card,
@ -7,69 +9,54 @@ export default function CardDetailDeckModal({
onAdd, onAdd,
onClose, onClose,
}) { }) {
if (!isOpen || !card) { if (!card) return null;
return null;
}
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <Modal open={isOpen} onClose={onClose} title="Add to Deck" size="md">
<div <div className="mb-6">
className="bg-white rounded-2xl p-6 max-w-md w-full mx-4" <label
style={{ backgroundColor: 'var(--bg-primary)' }} className="block text-sm font-medium mb-2"
> style={{ color: 'var(--text-primary)' }}
<h3 className="text-xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}> htmlFor="card-detail-deck-select"
Add to Deck >
</h3> Select Deck
<div className="mb-6"> </label>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}> <select
Select Deck id="card-detail-deck-select"
</label> value={selectedDeck}
<select onChange={(e) => onSelectedDeckChange(e.target.value)}
value={selectedDeck} className="w-full p-3 rounded-xl border transition-all"
onChange={(e) => onSelectedDeckChange(e.target.value)} style={{
className="w-full p-3 rounded-xl border transition-all duration-200" backgroundColor: 'var(--bg-secondary)',
style={{ borderColor: 'var(--border)',
backgroundColor: 'var(--bg-secondary)', color: 'var(--text-primary)',
borderColor: 'var(--border)', transitionDuration: 'var(--motion-duration-default)',
color: 'var(--text-primary)', transitionTimingFunction: 'var(--motion-ease-out)',
}} }}
> >
<option value="">Choose a deck...</option> <option value="">Choose a deck...</option>
{decks {decks
.filter((deck) => deck.game === card.game) .filter((deck) => deck.game === card.game)
.map((deck) => ( .map((deck) => (
<option key={deck.id} value={deck.id}> <option key={deck.id} value={deck.id}>
{deck.name} {deck.name}
</option> </option>
))} ))}
</select> </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>
</div> </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 <button
type="button" type="button"
onClick={() => onConfirm(quantity)} 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} {ownedQuantity > 0 ? 'Update' : VOCAB.ADD_TO_MY_COLLECTION}
</button> </button>

View file

@ -29,7 +29,7 @@ export default function CardEditorForm({
className={`px-6 py-3 rounded-xl font-medium transition-all duration-200 ${ className={`px-6 py-3 rounded-xl font-medium transition-all duration-200 ${
saving saving
? 'opacity-50 cursor-not-allowed' ? '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'} {saving ? 'Saving...' : 'Save Changes'}

View file

@ -29,7 +29,7 @@ export default function CardEditorView({
<button <button
type="button" type="button"
onClick={() => router.push('/admin/card-editor')} 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 🖊 Card Editor
</button> </button>

View file

@ -1,3 +1,5 @@
import { Modal, Input, Button } from './ui';
export default function CollectionEditModal({ export default function CollectionEditModal({
isOpen, isOpen,
editForm, editForm,
@ -5,106 +7,81 @@ export default function CollectionEditModal({
onClose, onClose,
onSave, onSave,
}) { }) {
if (!isOpen) {
return null;
}
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <Modal open={isOpen} onClose={onClose} title="Edit list" size="md">
<div className="card max-w-md w-full mx-4"> <div className="space-y-4">
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}> <Input
Edit List label="List Name"
</h2> value={editForm.name}
<div className="space-y-4"> onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
<div> placeholder="Enter list name"
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}> />
List Name <div>
</label> <label
<input className="block text-sm font-medium mb-2"
type="text" style={{ color: 'var(--text-primary)' }}
className="input-field w-full" htmlFor="collection-edit-description"
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)' }}
> >
<div> Description
<label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}> </label>
Public List <textarea
</label> id="collection-edit-description"
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}> className="input-field w-full"
Make this collection discoverable by other users rows="3"
</p> value={editForm.description}
</div> onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
<button placeholder="Describe your list"
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>
</div> </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 <button
type="button" type="button"
onClick={onClose} onClick={() => setEditForm({ ...editForm, isPublic: !editForm.isPublic })}
className="flex-1 py-2 px-4 rounded-xl border transition-colors" aria-pressed={editForm.isPublic}
style={{ aria-label="Toggle public list"
borderColor: 'var(--border)', className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
color: 'var(--text-secondary)', editForm.isPublic ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'
}} }`}
> >
Cancel <span
</button> className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
<button editForm.isPublic ? 'translate-x-6' : 'translate-x-1'
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
</button> </button>
</div> </div>
</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. */ /* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { VOCAB } from '../lib/collection-vocabulary.js'; import { VOCAB } from '../lib/collection-vocabulary.js';
import { Modal, SearchBar, Button } from './ui';
export default function CollectionSelectionModal({ export default function CollectionSelectionModal({
isOpen, isOpen,
onClose, onClose,
cards, // Array of cards to add cards,
onAddToCollections onAddToCollections,
}) { }) {
const [collections, setCollections] = useState([]); const [collections, setCollections] = useState([]);
const [selectedCollections, setSelectedCollections] = useState([]); const [selectedCollections, setSelectedCollections] = useState([]);
@ -25,15 +26,12 @@ export default function CollectionSelectionModal({
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
const fetchCollections = async () => { const fetchCollections = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await fetch('/api/collections?excludeSystem=true'); const response = await fetch('/api/collections?excludeSystem=true');
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
// The API returns an array directly, not wrapped in collections property
setCollections(Array.isArray(data) ? data : data.collections || []); setCollections(Array.isArray(data) ? data : data.collections || []);
} else { } else {
console.error('Failed to fetch collections:', response.status, response.statusText); console.error('Failed to fetch collections:', response.status, response.statusText);
@ -46,79 +44,62 @@ export default function CollectionSelectionModal({
setLoading(false); setLoading(false);
} }
}; };
fetchCollections(); fetchCollections();
}, [isOpen]); }, [isOpen]);
const handleCollectionToggle = (collectionId) => { const handleCollectionToggle = (collectionId) => {
setSelectedCollections(prev => { setSelectedCollections((prev) =>
if (prev.includes(collectionId)) { prev.includes(collectionId)
return prev.filter(id => id !== collectionId); ? prev.filter((id) => id !== collectionId)
} else { : [...prev, collectionId]
return [...prev, collectionId];
}
});
};
const handleSelectAll = () => {
const filteredCollections = getFilteredCollections();
const allSelected = filteredCollections.every(collection =>
selectedCollections.includes(collection.id)
); );
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 = () => { const getFilteredCollections = () => {
if (!searchQuery.trim()) return collections; if (!searchQuery.trim()) return collections;
return collections.filter(
return collections.filter(collection => (collection) =>
collection.name.toLowerCase().includes(searchQuery.toLowerCase()) || collection.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
collection.description?.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 () => { const handleSubmit = async () => {
if (selectedCollections.length === 0) return; if (selectedCollections.length === 0) return;
setSubmitting(true); setSubmitting(true);
try { try {
const results = []; const results = [];
// Add cards to each selected collection
for (const collectionId of selectedCollections) { for (const collectionId of selectedCollections) {
for (const card of cards) { for (const card of cards) {
const response = await fetch(`/api/collections/${collectionId}/cards`, { const response = await fetch(`/api/collections/${collectionId}/cards`, {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json' body: JSON.stringify({ cardId: card.id, quantity: 1 }),
}, });
body: JSON.stringify({ results.push({
cardId: card.id, collectionId,
quantity: 1 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); onAddToCollections(results, selectedCollections, cards);
onClose(); onClose();
} catch (error) { } catch (error) {
@ -129,141 +110,159 @@ export default function CollectionSelectionModal({
}; };
const filteredCollections = getFilteredCollections(); const filteredCollections = getFilteredCollections();
const allFilteredSelected = filteredCollections.length > 0 && const allFilteredSelected =
filteredCollections.every(collection => selectedCollections.includes(collection.id)); filteredCollections.length > 0 &&
filteredCollections.every((collection) =>
if (!isOpen) return null; selectedCollections.includes(collection.id)
);
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <Modal open={isOpen} onClose={onClose} title={VOCAB.ADD_TO_LISTS} size="lg">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden"> <div className="flex items-center space-x-3 mb-4">
{/* Header */} <div className="flex -space-x-2">
<div className="p-6 border-b border-gray-200 dark:border-gray-700"> {cards.slice(0, 3).map((card) => (
<div className="flex items-center justify-between mb-4"> <div
<h2 className="text-2xl font-bold text-gray-900 dark:text-white"> key={card.id}
{VOCAB.ADD_TO_LISTS} className="w-12 h-16 rounded border-2 overflow-hidden"
</h2> style={{
<button backgroundColor: 'var(--bg-tertiary)',
onClick={onClose} borderColor: 'var(--bg-primary)',
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors" }}
> >
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {card.image_url ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <img
</svg> src={card.image_url}
</button> alt={card.name}
</div> className="w-full h-full object-cover"
/>
{/* Card Summary */} ) : (
<div className="flex items-center space-x-3 mb-4"> <div
<div className="flex -space-x-2"> className="w-full h-full flex items-center justify-center text-xs"
{cards.slice(0, 3).map((card, index) => ( style={{ color: 'var(--text-tertiary)' }}
<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 ? ( No Image
<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>
</div> </div>
)} )}
</div> </div>
<div> ))}
<p className="text-lg font-semibold text-gray-900 dark:text-white"> {cards.length > 3 && (
{cards.length} card{cards.length !== 1 ? 's' : ''} selected <div
</p> className="w-12 h-16 rounded border-2 flex items-center justify-center"
<p className="text-sm text-gray-600 dark:text-gray-400"> style={{
Choose lists to add {cards.length === 1 ? 'this card' : 'these cards'} to backgroundColor: 'var(--bg-tertiary)',
</p> borderColor: 'var(--bg-primary)',
}}
>
<span className="text-sm font-bold" style={{ color: 'var(--text-secondary)' }}>
+{cards.length - 3}
</span>
</div> </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>
<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="mb-4">
<div className="flex-1 overflow-y-auto max-h-96"> <SearchBar
{loading ? ( value={searchQuery}
<div className="p-8 text-center"> onChange={(e) => setSearchQuery(e.target.value)}
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div> onClear={() => setSearchQuery('')}
<p className="text-gray-600 dark:text-gray-400">Loading lists...</p> placeholder="Search lists…"
</div> />
) : filteredCollections.length === 0 ? ( </div>
<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>
)}
{/* Collection Items */} <div className="overflow-y-auto" style={{ maxHeight: '24rem' }}>
<div className="space-y-2"> {loading ? (
{filteredCollections.map((collection) => ( <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 <div
key={collection.id} key={collection.id}
className={`p-4 rounded-lg border-2 transition-all duration-200 cursor-pointer ${ className="p-4 rounded-lg border-2 cursor-pointer"
selectedCollections.includes(collection.id) style={{
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' borderColor: isSelected
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600' ? '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)} onClick={() => handleCollectionToggle(collection.id)}
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<input <input
type="checkbox" type="checkbox"
checked={selectedCollections.includes(collection.id)} checked={isSelected}
onChange={() => handleCollectionToggle(collection.id)} 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)' }}
/> />
<div
{/* Collection Image */} className="w-12 h-12 rounded-lg overflow-hidden flex-shrink-0"
<div className="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg overflow-hidden flex-shrink-0"> style={{ backgroundColor: 'var(--bg-tertiary)' }}
>
{collection.image ? ( {collection.image ? (
<img <img
src={collection.image} src={collection.image}
@ -271,30 +270,54 @@ export default function CollectionSelectionModal({
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : ( ) : (
<div className="w-full h-full flex items-center justify-center text-gray-400"> <div
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> className="w-full h-full flex items-center justify-center"
<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" /> 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> </svg>
</div> </div>
)} )}
</div> </div>
{/* Collection Info */}
<div className="flex-1 min-w-0"> <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} {collection.name}
</h3> </h3>
{collection.description && ( {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} {collection.description}
</p> </p>
)} )}
<div className="flex items-center space-x-4 mt-1"> <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 {collection.card_count || 0} cards
</span> </span>
{collection.is_public && ( {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 Public
</span> </span>
)} )}
@ -302,52 +325,43 @@ export default function CollectionSelectionModal({
</div> </div>
</div> </div>
</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> </div>
<div className="flex space-x-3">
{/* Footer */} <Button variant="secondary" onClick={onClose}>
<div className="p-6 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900"> Cancel
<div className="flex items-center justify-between"> </Button>
<div className="text-sm text-gray-600 dark:text-gray-400"> <Button
{selectedCollections.length > 0 && ( variant="primary"
<span> onClick={handleSubmit}
{selectedCollections.length} list{selectedCollections.length !== 1 ? 's' : ''} selected disabled={selectedCollections.length === 0 || submitting}
</span> loading={submitting}
)} >
</div> {submitting
? 'Adding...'
<div className="flex space-x-3"> : `Add to ${selectedCollections.length} List${
<button selectedCollections.length !== 1 ? 's' : ''
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'
}`} }`}
> </Button>
{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>
</div> </div>
</div> </div>
</div> </Modal>
); );
} }

View file

@ -3,6 +3,7 @@ import {
handleCollectionTagKeyPress, handleCollectionTagKeyPress,
removeCollectionTag, removeCollectionTag,
} from '../lib/collection-tag-input.js'; } from '../lib/collection-tag-input.js';
import { Modal, Input, Button } from './ui';
export default function CollectionsEditModal({ export default function CollectionsEditModal({
editingCollection, editingCollection,
@ -12,171 +13,169 @@ export default function CollectionsEditModal({
onClose, onClose,
onUpdate, onUpdate,
}) { }) {
if (!editingCollection) { if (!editingCollection) return null;
return null;
}
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <Modal open onClose={onClose} title="Edit list" size="md">
<div className="card max-w-md w-full mx-4"> <div className="space-y-4">
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}> <Input
Edit List label="List Name"
</h2> value={editingCollection.name}
<div className="space-y-4"> onChange={(e) =>
<div> setEditingCollection({ ...editingCollection, name: e.target.value })
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}> }
List Name />
</label> <div>
<input <label
type="text" className="block text-sm font-medium mb-2"
className="input-field w-full" style={{ color: 'var(--text-primary)' }}
value={editingCollection.name} htmlFor="collections-edit-description"
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)' }}
> >
<div> Description
<label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}> </label>
Public List <textarea
</label> id="collections-edit-description"
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}> className="input-field w-full"
Make this list discoverable by other users rows="3"
</p> value={editingCollection.description}
</div> onChange={(e) =>
<button setEditingCollection({
type="button" ...editingCollection,
onClick={() => description: e.target.value,
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' </div>
}`} <Input
> label="Hero Image"
<span type="url"
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${ value={editingCollection.image || ''}
editingCollection.isPublic ? 'translate-x-6' : 'translate-x-1' 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> </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 <button
type="button" type="button"
onClick={onClose} onClick={() =>
className="flex-1 py-2 px-4 rounded-xl border transition-colors" setEditingCollection({
style={{ ...editingCollection,
borderColor: 'var(--border)', isPublic: !editingCollection.isPublic,
color: 'var(--text-secondary)', })
}} }
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 <span
</button> className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
<button editingCollection.isPublic ? 'translate-x-6' : 'translate-x-1'
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
</button> </button>
</div> </div>
</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 CollectionsEditModal from './CollectionsEditModal';
import CollectionsSuccessModal from './CollectionsSuccessModal'; import CollectionsSuccessModal from './CollectionsSuccessModal';
import CollectionsThumbnail from './CollectionsThumbnail'; import CollectionsThumbnail from './CollectionsThumbnail';
import { Button, SearchBar } from './ui';
export default function CollectionsPageView(props) { export default function CollectionsPageView(props) {
const { const {
@ -59,40 +60,25 @@ export default function CollectionsPageView(props) {
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<Link href="/community/collections"> <Link href="/community/collections">
<button className="px-4 py-2 rounded-xl border transition-colors hover:shadow-md" <Button variant="secondary">
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)',
backgroundColor: 'transparent'
}}
>
🌍 Discover Community 🌍 Discover Community
</button> </Button>
</Link> </Link>
<button <Button variant="primary" onClick={() => setShowCreateModal(true)}>
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'
}}
>
+ Create List + Create List
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
{/* Filters and Search */}
<div className="p-6" style={{ backgroundColor: 'var(--bg-secondary)' }}> <div className="p-6" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="flex flex-col md:flex-row gap-4"> <div className="flex flex-col md:flex-row gap-4">
<div className="flex-1"> <div className="flex-1">
<input <SearchBar
type="text"
placeholder="Search lists..."
className="input-field w-full"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
onClear={() => setSearchQuery('')}
placeholder="Search lists…"
/> />
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
@ -126,16 +112,9 @@ export default function CollectionsPageView(props) {
} }
</p> </p>
{!searchQuery && ( {!searchQuery && (
<button <Button variant="primary" size="lg" onClick={() => setShowCreateModal(true)}>
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'
}}
>
Create List Create List
</button> </Button>
)} )}
</div> </div>
) : ( ) : (

View file

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

View file

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

View file

@ -2,7 +2,7 @@ import CameraScanner from './CameraScanner';
import ScannerDestinationPicker from './ScannerDestinationPicker'; import ScannerDestinationPicker from './ScannerDestinationPicker';
import ScannedCardItem, { CONDITION_OPTIONS } from './ScannedCardItem'; import ScannedCardItem, { CONDITION_OPTIONS } from './ScannedCardItem';
import OCRSettings from './OCRSettings'; 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'; import { VOCAB } from '../lib/collection-vocabulary.js';
export default function ScannerPageView({ export default function ScannerPageView({
@ -18,7 +18,6 @@ export default function ScannerPageView({
onCloseOCRSettings, onCloseOCRSettings,
onScannerError, onScannerError,
}) { }) {
const createCollectionDialogRef = useFocusTrap(queue.showCreateCollection);
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
@ -292,60 +291,43 @@ export default function ScannerPageView({
</div> </div>
)} )}
{queue.showCreateCollection && ( <Modal
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> open={queue.showCreateCollection}
<div onClose={() => queue.setShowCreateCollection(false)}
ref={createCollectionDialogRef} title="Create New List"
className="rounded-xl p-6 max-w-md w-full mx-4" size="md"
style={{ backgroundColor: 'var(--bg-secondary)' }} >
role="dialog" <Input
aria-modal="true" id="create-collection-name"
aria-labelledby="create-collection-title" 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
Create New List </Button>
</h3> <Button
<label htmlFor="create-collection-name" className="sr-only"> variant="secondary"
List name onClick={() => queue.setShowCreateCollection(false)}
</label> className="flex-1"
<input >
id="create-collection-name" Cancel
type="text" </Button>
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>
</div> </div>
)} </Modal>
{showOCRSettings && <OCRSettings onClose={onCloseOCRSettings} />} {showOCRSettings && <OCRSettings onClose={onCloseOCRSettings} />}
</div> </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. */ /* eslint-disable @next/next/no-img-element -- Local preview data URLs; next/image migration is out of scope. */
import { useState } from 'react'; import { useState } from 'react';
import { Modal, Input, Button } from './ui';
export default function UploadImageModal({ isOpen, onClose, onUpload, currentImage }) { 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 [imageUrl, setImageUrl] = useState(currentImage || '');
const [dragActive, setDragActive] = useState(false); const [dragActive, setDragActive] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@ -25,9 +26,9 @@ export default function UploadImageModal({ isOpen, onClose, onUpload, currentIma
const handleDrag = (e) => { const handleDrag = (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") { if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true); setDragActive(true);
} else if (e.type === "dragleave") { } else if (e.type === 'dragleave') {
setDragActive(false); setDragActive(false);
} }
}; };
@ -36,7 +37,6 @@ export default function UploadImageModal({ isOpen, onClose, onUpload, currentIma
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setDragActive(false); setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) { if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFileUpload(e.dataTransfer.files[0]); handleFileUpload(e.dataTransfer.files[0]);
} }
@ -45,8 +45,8 @@ export default function UploadImageModal({ isOpen, onClose, onUpload, currentIma
const handleFileUpload = (file) => { const handleFileUpload = (file) => {
if (file.type.startsWith('image/')) { if (file.type.startsWith('image/')) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (event) => {
setImageUrl(e.target.result); setImageUrl(event.target.result);
setUploadMethod('file'); setUploadMethod('file');
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
@ -59,141 +59,159 @@ export default function UploadImageModal({ isOpen, onClose, onUpload, currentIma
} }
}; };
if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <Modal open={isOpen} onClose={onClose} title="Upload Hero Image" size="md">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto"> <form onSubmit={handleSubmit}>
<div className="p-6"> <div
<div className="flex items-center justify-between mb-6"> className="flex space-x-1 mb-6 rounded-lg p-1"
<h2 className="text-xl font-bold text-gray-900">Upload Hero Image</h2> style={{
<button backgroundColor: 'var(--bg-tertiary)',
onClick={onClose} border: '1px solid var(--border)',
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"> <button
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> type="button"
</svg> onClick={() => setUploadMethod('url')}
</button> className="flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors"
</div> style={{
backgroundColor:
<form onSubmit={handleSubmit}> uploadMethod === 'url' ? 'var(--bg-primary)' : 'transparent',
{/* Upload Method Tabs */} color:
<div className="flex space-x-1 mb-6 bg-gray-100 rounded-lg p-1"> uploadMethod === 'url'
<button ? 'var(--text-primary)'
type="button" : 'var(--text-secondary)',
onClick={() => setUploadMethod('url')} boxShadow: uploadMethod === 'url' ? 'var(--elevation-ambient)' : 'none',
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors ${ }}
uploadMethod === 'url' >
? 'bg-white text-gray-900 shadow-sm' Image URL
: 'text-gray-600 hover:text-gray-900' </button>
}`} <button
> type="button"
Image URL onClick={() => setUploadMethod('file')}
</button> className="flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors"
<button style={{
type="button" backgroundColor:
onClick={() => setUploadMethod('file')} uploadMethod === 'file' ? 'var(--bg-primary)' : 'transparent',
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors ${ color:
uploadMethod === 'file' uploadMethod === 'file'
? 'bg-white text-gray-900 shadow-sm' ? 'var(--text-primary)'
: 'text-gray-600 hover:text-gray-900' : 'var(--text-secondary)',
}`} boxShadow: uploadMethod === 'file' ? 'var(--elevation-ambient)' : 'none',
> }}
Upload File >
</button> Upload File
</div> </button>
{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>
</div> </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>
<button <button
onClick={() => router.push('/admin/card-import')} 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 📥 Card Import
</button> </button>
@ -184,7 +184,7 @@ const CardImport = () => {
className={`shrink-0 py-3 px-6 rounded-xl font-medium transition-all duration-200 ${ className={`shrink-0 py-3 px-6 rounded-xl font-medium transition-all duration-200 ${
isSyncing isSyncing
? 'opacity-50 cursor-not-allowed' ? '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'} {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 ${ className={`w-full py-3 px-6 rounded-xl font-medium transition-all duration-200 ${
isImporting || !setCode.trim() isImporting || !setCode.trim()
? 'opacity-50 cursor-not-allowed' ? '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'} {isImporting ? 'Importing...' : 'Import Cards'}

View file

@ -34,7 +34,7 @@ export default function CardDetail() {
<button <button
type="button" type="button"
onClick={() => router.push('/cards')} 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 Back to Cards
</button> </button>

View file

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

View file

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import PermissionIndicator from '../components/PermissionIndicator'; import PermissionIndicator from '../components/PermissionIndicator';
import { Button } from '../components/ui';
import { useAuth } from '../lib/use-auth'; import { useAuth } from '../lib/use-auth';
import Link from 'next/link'; import Link from 'next/link';
import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js'; import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js';
@ -91,14 +92,17 @@ export default function Dashboard() {
</div> </div>
<div className="flex space-x-2 sm:space-x-4"> <div className="flex space-x-2 sm:space-x-4">
<Link href="/collections"> <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' }}> <Button
<div className="flex items-center space-x-2"> variant="primary"
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg> </svg>
<span>Create List</span> }
</div> >
</button> Create List
</Button>
</Link> </Link>
</div> </div>
</div> </div>
@ -176,9 +180,9 @@ export default function Dashboard() {
Create your first list to start organizing your cards Create your first list to start organizing your cards
</p> </p>
<Link href="/collections"> <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 Create Your First List
</button> </Button>
</Link> </Link>
</div> </div>
) : ( ) : (
@ -222,9 +226,9 @@ export default function Dashboard() {
{collections.length > 6 && ( {collections.length > 6 && (
<div className="text-center"> <div className="text-center">
<Link href="/collections"> <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 View All Lists
</button> </Button>
</Link> </Link>
</div> </div>
)} )}

View file

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Link from 'next/link'; import Link from 'next/link';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import { Modal, Input, Button } from '../components/ui';
import { useAuth } from '../lib/use-auth'; import { useAuth } from '../lib/use-auth';
export default function Decks() { export default function Decks() {
@ -299,165 +300,171 @@ export default function Decks() {
</div> </div>
)} )}
{/* Create Deck Modal */} <Modal
{showCreateModal && ( open={showCreateModal}
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> onClose={() => setShowCreateModal(false)}
<div className="bg-bg-primary rounded-lg p-6 w-full max-w-md"> title="Create New Deck"
<h2 className="text-xl font-bold text-text-primary mb-4">Create New Deck</h2> size="md"
<form onSubmit={handleCreateDeck}> >
<div className="mb-4"> <form onSubmit={handleCreateDeck} className="space-y-4">
<label className="block text-text-secondary text-sm font-medium mb-2"> <Input
Deck Name * label="Deck Name *"
</label> required
<input value={newDeck.name}
type="text" onChange={(e) => setNewDeck({ ...newDeck, name: e.target.value })}
required placeholder="Enter deck name"
value={newDeck.name} />
onChange={(e) => setNewDeck({...newDeck, name: e.target.value})} <div>
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" <label
placeholder="Enter deck name" className="block text-sm font-medium mb-2"
/> style={{ color: 'var(--text-primary)' }}
</div> htmlFor="create-deck-format"
>
<div className="mb-4"> Format
<label className="block text-text-secondary text-sm font-medium mb-2"> </label>
Format <select
</label> id="create-deck-format"
<select value={newDeck.format}
value={newDeck.format} onChange={(e) => setNewDeck({ ...newDeck, format: e.target.value })}
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"
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="Commander">Commander</option> <option value="Standard">Standard</option>
<option value="Standard">Standard</option> <option value="Modern">Modern</option>
<option value="Modern">Modern</option> <option value="Legacy">Legacy</option>
<option value="Legacy">Legacy</option> </select>
</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>
</div> </div>
</div> <div>
)} <label
className="block text-sm font-medium mb-2"
{/* Edit Deck Modal */} style={{ color: 'var(--text-primary)' }}
{editingDeck && ( htmlFor="create-deck-description"
<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"> Description
<h2 className="text-xl font-bold text-text-primary mb-4">Edit Deck</h2> </label>
<form onSubmit={handleEditDeck}> <textarea
<div className="mb-4"> id="create-deck-description"
<label className="block text-text-secondary text-sm font-medium mb-2"> value={newDeck.description}
Deck Name * onChange={(e) => setNewDeck({ ...newDeck, description: e.target.value })}
</label> 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"
<input rows="3"
type="text" placeholder="Describe your deck strategy..."
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> </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> </div>
</Layout> </Layout>
); );

View file

@ -4,6 +4,7 @@ import { useRouter } from 'next/router';
import Link from 'next/link'; import Link from 'next/link';
import { useAuth } from '../lib/use-auth.js'; import { useAuth } from '../lib/use-auth.js';
import AnimatedFireLogo from '../components/AnimatedFireLogo'; import AnimatedFireLogo from '../components/AnimatedFireLogo';
import { Button, GlassSurface } from '../components/ui';
export default function Home() { export default function Home() {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
@ -11,14 +12,12 @@ export default function Home() {
const [featuredCollections, setFeaturedCollections] = useState([]); const [featuredCollections, setFeaturedCollections] = useState([]);
const [collectionsLoading, setCollectionsLoading] = useState(true); const [collectionsLoading, setCollectionsLoading] = useState(true);
// If user is logged in, redirect to dashboard
useEffect(() => { useEffect(() => {
if (!loading && user) { if (!loading && user) {
router.push('/dashboard'); router.push('/dashboard');
} }
}, [user, loading, router]); }, [user, loading, router]);
// Fetch featured collections for public display
useEffect(() => { useEffect(() => {
const fetchFeaturedCollections = async () => { const fetchFeaturedCollections = async () => {
try { try {
@ -33,31 +32,44 @@ export default function Home() {
setCollectionsLoading(false); setCollectionsLoading(false);
} }
}; };
fetchFeaturedCollections(); fetchFeaturedCollections();
}, []); }, []);
// Show loading while checking auth
if (loading) { if (loading) {
return ( 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="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> <p style={{ color: 'var(--text-secondary)' }}>Loading...</p>
</div> </div>
</div> </div>
); );
} }
// If user is logged in, don't show landing page (redirect handled above)
if (user) { if (user) {
return null; return null;
} }
return ( return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--bg-primary)' }}> <div className="min-h-screen" style={{ backgroundColor: 'var(--bg-primary)' }}>
{/* Navigation Bar */} <nav
<nav className="border-b" style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}> 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="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 justify-between items-center py-4">
<div className="flex items-center space-x-3"> <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> <h1 className="text-2xl font-bold gradient-text-flame">Deck Hearth</h1>
</div> </div>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<Link <Link href="/login">
href="/login" <Button variant="ghost" size="sm">
className="px-4 py-2 text-sm font-medium rounded-xl transition-all duration-200 hover:opacity-80" Sign In
style={{ color: 'var(--text-secondary)' }} </Button>
>
Sign In
</Link> </Link>
<Link <Link href="/signup">
href="/signup" <Button variant="primary" size="sm">
className="px-6 py-2 text-sm font-medium rounded-xl transition-all duration-200 hover:opacity-90" Get Started
style={{ </Button>
backgroundColor: 'var(--accent-ember)',
color: 'white'
}}
>
Get Started
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
</nav> </nav>
{/* Hero Section */}
<section className="relative py-20 px-4"> <section className="relative py-20 px-4">
<div className="max-w-6xl mx-auto text-center"> <div className="max-w-6xl mx-auto text-center">
<div className="mb-8"> <div className="mb-8">
@ -96,86 +100,136 @@ export default function Home() {
<h1 className="text-5xl md:text-7xl font-bold mb-6"> <h1 className="text-5xl md:text-7xl font-bold mb-6">
<span className="gradient-text-flame">Deck Hearth</span> <span className="gradient-text-flame">Deck Hearth</span>
</h1> </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, 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> </p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center"> <div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Link <Link href="/signup">
href="/signup" <Button variant="primary" size="lg">
className="px-8 py-4 text-lg font-medium rounded-2xl transition-all duration-200 hover:opacity-90 hover:scale-105" Start Your Collection
style={{ </Button>
backgroundColor: 'var(--accent-ember)',
color: 'white'
}}
>
Start Your Collection
</Link> </Link>
<Link <Link href="/community/collections">
href="/community/collections" <Button variant="secondary" size="lg">
className="px-8 py-4 text-lg font-medium rounded-2xl border-2 transition-all duration-200 hover:opacity-80" Explore Lists
style={{ </Button>
color: 'var(--text-primary)',
borderColor: 'var(--accent-ember)'
}}
>
Explore Lists
</Link> </Link>
</div> </div>
</div> </div>
</section> </section>
{/* Features Section */}
<section className="py-20 px-4" style={{ backgroundColor: 'var(--bg-secondary)' }}> <section className="py-20 px-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<h2 className="text-4xl font-bold text-center mb-16 gradient-text-gold"> <h2 className="text-4xl font-bold text-center mb-16 gradient-text-gold">
Everything You Need to Manage Your Cards Everything You Need to Manage Your Cards
</h2> </h2>
<div className="grid md:grid-cols-3 gap-8"> <div className="grid md:grid-cols-3 gap-8">
<div className="text-center p-8 rounded-2xl" style={{ backgroundColor: 'var(--bg-primary)' }}> <GlassSurface
<div className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--accent-ember)' }}> tint="mid"
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> rim="subtle"
<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" /> 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> </svg>
</div> </div>
<h3 className="text-xl font-bold mb-4 gradient-text-flame">Organize Lists</h3> <h3 className="text-xl font-bold mb-4 gradient-text-flame">Organize Lists</h3>
<p style={{ color: 'var(--text-secondary)' }}> <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> </p>
</div> </GlassSurface>
<div className="text-center p-8 rounded-2xl" style={{ backgroundColor: 'var(--bg-primary)' }}> <GlassSurface
<div className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--accent-gold)' }}> tint="mid"
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> rim="subtle"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> 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> </svg>
</div> </div>
<h3 className="text-xl font-bold mb-4 gradient-text-gold">Discover Cards</h3> <h3 className="text-xl font-bold mb-4 gradient-text-gold">Discover Cards</h3>
<p style={{ color: 'var(--text-secondary)' }}> <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> </p>
</div> </GlassSurface>
<div className="text-center p-8 rounded-2xl" style={{ backgroundColor: 'var(--bg-primary)' }}> <GlassSurface
<div className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--accent-flame)' }}> tint="mid"
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> rim="subtle"
<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" /> 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> </svg>
</div> </div>
<h3 className="text-xl font-bold mb-4 gradient-text-ember">Connect & Share</h3> <h3 className="text-xl font-bold mb-4 gradient-text-ember">Connect & Share</h3>
<p style={{ color: 'var(--text-secondary)' }}> <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> </p>
</div> </GlassSurface>
</div> </div>
</div> </div>
</section> </section>
{/* Featured Collections Section */}
<section className="py-20 px-4"> <section className="py-20 px-4">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<div className="text-center mb-16"> <div className="text-center mb-16">
<h2 className="text-4xl font-bold mb-4 gradient-text-flame"> <h2 className="text-4xl font-bold mb-4 gradient-text-flame">Featured Lists</h2>
Featured Lists
</h2>
<p className="text-xl" style={{ color: 'var(--text-secondary)' }}> <p className="text-xl" style={{ color: 'var(--text-secondary)' }}>
Discover amazing lists from our community Discover amazing lists from our community
</p> </p>
@ -184,13 +238,32 @@ export default function Home() {
{collectionsLoading ? ( {collectionsLoading ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => ( {[...Array(6)].map((_, i) => (
<div key={i} className="rounded-2xl p-6 animate-pulse" style={{ backgroundColor: 'var(--bg-secondary)' }}> <div
<div className="h-32 rounded-xl mb-4" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div> key={i}
<div className="h-6 rounded mb-2" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div> className="motion-essential rounded-2xl p-6 animate-pulse"
<div className="h-4 rounded mb-4" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div> 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="flex justify-between">
<div className="h-4 w-16 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div> <div
<div className="h-4 w-20 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></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>
</div> </div>
))} ))}
@ -201,98 +274,135 @@ export default function Home() {
<Link <Link
key={collection.id} key={collection.id}
href={`/collection/${collection.slug || 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)' }}> <GlassSurface
{collection.image ? ( tint="mid"
<img rim="subtle"
src={collection.image} elevation="ambient"
alt={collection.name} className="block rounded-2xl p-6 hover:scale-105"
className="w-full h-full object-cover rounded-xl" style={{
/> transition:
) : ( 'transform var(--motion-duration-default) var(--motion-ease-out), box-shadow var(--motion-duration-default) var(--motion-ease-out)',
<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"> <div
<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" /> className="h-32 rounded-xl mb-4 flex items-center justify-center"
</svg> 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> </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">
</div> {collection.name}
<h3 className="text-xl font-bold mb-2 gradient-text-flame">{collection.name}</h3> </h3>
<p className="text-sm mb-4 line-clamp-2" style={{ color: 'var(--text-secondary)' }}> <p
{collection.description || 'A carefully curated list'} className="text-sm mb-4 line-clamp-2"
</p> style={{ color: 'var(--text-secondary)' }}
<div className="flex justify-between items-center"> >
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}> {collection.description || 'A carefully curated list'}
{collection.cardCount} cards </p>
</span> <div className="flex justify-between items-center">
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}> <span
by {collection.creator} className="text-sm font-medium"
</span> style={{ color: 'var(--text-primary)' }}
</div> >
{collection.cardCount} cards
</span>
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
by {collection.creator}
</span>
</div>
</GlassSurface>
</Link> </Link>
))} ))}
</div> </div>
)} )}
<div className="text-center mt-12"> <div className="text-center mt-12">
<Link <Link href="/community/collections">
href="/community/collections" <Button variant="secondary" size="lg">
className="inline-flex items-center px-6 py-3 text-lg font-medium rounded-xl transition-all duration-200 hover:opacity-80" View All Lists
style={{ <svg
color: 'var(--accent-ember)', className="w-5 h-5 ml-2 inline-block"
border: '2px solid var(--accent-ember)' fill="none"
}} stroke="currentColor"
> viewBox="0 0 24 24"
View All Lists aria-hidden="true"
<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" /> <path
</svg> strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 8l4 4m0 0l-4 4m4-4H3"
/>
</svg>
</Button>
</Link> </Link>
</div> </div>
</div> </div>
</section> </section>
{/* CTA Section */}
<section className="py-20 px-4" style={{ backgroundColor: 'var(--bg-secondary)' }}> <section className="py-20 px-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="max-w-4xl mx-auto text-center"> <div className="max-w-4xl mx-auto text-center">
<h2 className="text-4xl font-bold mb-6 gradient-text-flame"> <h2 className="text-4xl font-bold mb-6 gradient-text-flame">
Ready to Start Your Journey? Ready to Start Your Journey?
</h2> </h2>
<p className="text-xl mb-8" style={{ color: 'var(--text-secondary)' }}> <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> </p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center"> <div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Link <Link href="/signup">
href="/signup" <Button variant="primary" size="lg">
className="px-8 py-4 text-lg font-medium rounded-2xl transition-all duration-200 hover:opacity-90 hover:scale-105" Create Free Account
style={{ </Button>
backgroundColor: 'var(--accent-ember)',
color: 'white'
}}
>
Create Free Account
</Link> </Link>
<Link <Link href="/cards">
href="/cards" <Button variant="secondary" size="lg">
className="px-8 py-4 text-lg font-medium rounded-2xl border-2 transition-all duration-200 hover:opacity-80" Browse Cards
style={{ </Button>
color: 'var(--text-primary)',
borderColor: 'var(--accent-ember)'
}}
>
Browse Cards
</Link> </Link>
</div> </div>
</div> </div>
</section> </section>
{/* Footer */} <footer
<footer className="py-12 px-4 border-t" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border)' }}> className="py-12 px-4 border-t"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
}}
>
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<div className="flex flex-col md:flex-row justify-between items-center"> <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"> <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> <span className="text-xl font-bold gradient-text-flame">Deck Hearth</span>
</div> </div>
<div className="flex space-x-6"> <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 Community
</Link> </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 Cards
</Link> </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 Sign In
</Link> </Link>
</div> </div>
</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)' }}> <p style={{ color: 'var(--text-secondary)' }}>
© 2024 Deck Hearth. Built for collectors, by collectors. © 2024 Deck Hearth. Built for collectors, by collectors.
</p> </p>

View file

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import { GlassSurface, Button } from '../../components/ui';
export default function AcceptInvite() { export default function AcceptInvite() {
const router = useRouter(); const router = useRouter();
@ -49,8 +50,14 @@ export default function AcceptInvite() {
<Layout user={null}> <Layout user={null}>
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center">
<div className="text-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> <div
<h2 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}> 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... Processing Invitation...
</h2> </h2>
<p style={{ color: 'var(--text-secondary)' }}> <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="min-h-screen flex items-center justify-center px-4">
<div className="max-w-md w-full"> <div className="max-w-md w-full">
{result ? ( {result ? (
<div className="text-center p-8 rounded-2xl shadow-lg" style={{ backgroundColor: 'var(--bg-secondary)' }}> <GlassSurface
<div className="text-6xl mb-4">🎉</div> tint="mid"
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}> 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! Invitation Accepted!
</h2> </h2>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}> <p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
You now have access to the list &quot;{result.collection.name}&quot;. You now have access to the list &quot;{result.collection.name}&quot;.
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
<button <Button
variant="primary"
size="lg"
onClick={() => router.push(`/collection/${result.collection.id}`)} 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 View List
</button> </Button>
<button <Button
variant="secondary"
size="lg"
onClick={() => router.push('/collections')} onClick={() => router.push('/collections')}
className="w-full px-6 py-3 rounded-lg font-medium border transition-all duration-200" className="w-full"
style={{
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
> >
View All Lists View All Lists
</button> </Button>
</div> </div>
</div> </GlassSurface>
) : ( ) : (
<div className="text-center p-8 rounded-2xl shadow-lg" style={{ backgroundColor: 'var(--bg-secondary)' }}> <GlassSurface
<div className="text-6xl mb-4"></div> tint="mid"
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}> 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 Invitation Error
</h2> </h2>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}> <p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
{error} {error}
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
<button <Button
variant="primary"
size="lg"
onClick={() => router.push('/collections')} 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 Browse Lists
</button> </Button>
<button <Button
variant="secondary"
size="lg"
onClick={() => router.push('/')} onClick={() => router.push('/')}
className="w-full px-6 py-3 rounded-lg font-medium border transition-all duration-200" className="w-full"
style={{
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
> >
Go Home Go Home
</button> </Button>
</div> </div>
</div> </GlassSurface>
)} )}
</div> </div>
</div> </div>

View file

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import { GlassSurface, Button } from '../../components/ui';
export default function DeclineInvite() { export default function DeclineInvite() {
const router = useRouter(); const router = useRouter();
@ -49,8 +50,14 @@ export default function DeclineInvite() {
<Layout user={null}> <Layout user={null}>
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center">
<div className="text-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> <div
<h2 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}> 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... Processing Response...
</h2> </h2>
<p style={{ color: 'var(--text-secondary)' }}> <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="min-h-screen flex items-center justify-center px-4">
<div className="max-w-md w-full"> <div className="max-w-md w-full">
{result ? ( {result ? (
<div className="text-center p-8 rounded-2xl shadow-lg" style={{ backgroundColor: 'var(--bg-secondary)' }}> <GlassSurface
<div className="text-6xl mb-4">👋</div> tint="mid"
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}> 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 Invitation Declined
</h2> </h2>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}> <p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
You have declined the invitation to &quot;{result.collection.name}&quot;. 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> </p>
<div className="space-y-3"> <div className="space-y-3">
<button <Button
variant="primary"
size="lg"
onClick={() => router.push('/collections')} 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 Browse Public Lists
</button> </Button>
<button <Button
variant="secondary"
size="lg"
onClick={() => router.push('/')} onClick={() => router.push('/')}
className="w-full px-6 py-3 rounded-lg font-medium border transition-all duration-200" className="w-full"
style={{
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
> >
Go Home Go Home
</button> </Button>
</div> </div>
</div> </GlassSurface>
) : ( ) : (
<div className="text-center p-8 rounded-2xl shadow-lg" style={{ backgroundColor: 'var(--bg-secondary)' }}> <GlassSurface
<div className="text-6xl mb-4"></div> tint="mid"
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}> 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 Error Processing Response
</h2> </h2>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}> <p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
{error} {error}
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
<button <Button
variant="primary"
size="lg"
onClick={() => router.push('/collections')} 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 Browse Lists
</button> </Button>
<button <Button
variant="secondary"
size="lg"
onClick={() => router.push('/')} onClick={() => router.push('/')}
className="w-full px-6 py-3 rounded-lg font-medium border transition-all duration-200" className="w-full"
style={{
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
> >
Go Home Go Home
</button> </Button>
</div> </div>
</div> </GlassSurface>
)} )}
</div> </div>
</div> </div>

View file

@ -7,6 +7,7 @@ import BulkSelectionToolbar from '../components/BulkSelectionToolbar';
import CollectionSelectionModal from '../components/CollectionSelectionModal'; import CollectionSelectionModal from '../components/CollectionSelectionModal';
import { ManaCost, ColorFilterSymbol } from '../components/ManaSymbols'; import { ManaCost, ColorFilterSymbol } from '../components/ManaSymbols';
import ManaSymbolSettings from '../components/ManaSymbolSettings'; import ManaSymbolSettings from '../components/ManaSymbolSettings';
import { Button } from '../components/ui';
import { useAuth } from '../lib/use-auth'; import { useAuth } from '../lib/use-auth';
import { VOCAB } from '../lib/collection-vocabulary.js'; 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`} {searchQuery ? 'Try adjusting your search filters' : `Start adding cards to ${VOCAB.MY_COLLECTION} by browsing available cards`}
</p> </p>
<Link href="/cards"> <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 Browse All Cards
</button> </Button>
</Link> </Link>
</div> </div>
)} )}

View file

@ -426,34 +426,11 @@ body {
border: 1px solid var(--border); border: 1px solid var(--border);
} }
/* Glowing effects for dark theme */ /* Gradient text effects Deck Hearth warm palette only.
[data-theme="dark"] .glow-blue { The pre-Deck-Hearth aliases (.glow-blue / .glow-purple / .glow-pink,
box-shadow: 0 0 20px rgba(6, 182, 212, 0.3); .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. */
[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-gold { .gradient-text-gold {
background: linear-gradient(135deg, #ffab40 0%, #ff6f00 100%); background: linear-gradient(135deg, #ffab40 0%, #ff6f00 100%);
-webkit-background-clip: text; -webkit-background-clip: text;