feat(design-system): finish Liquid Glass — close all remaining sub-convoys (#96)

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:
varutasu 2026-06-03 20:34:06 -05:00 committed by GitHub
parent 334612ad79
commit 926ce7a45b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1376 additions and 1828 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,5 @@
import { Modal, Input, Button } from './ui';
export default function CollectionEditModal({
isOpen,
editForm,
@ -5,34 +7,25 @@ export default function CollectionEditModal({
onClose,
onSave,
}) {
if (!isOpen) {
return null;
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="card max-w-md w-full mx-4">
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
Edit List
</h2>
<Modal open={isOpen} onClose={onClose} title="Edit list" size="md">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
List Name
</label>
<input
type="text"
className="input-field w-full"
<Input
label="List Name"
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)' }}>
<label
className="block text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
htmlFor="collection-edit-description"
>
Description
</label>
<textarea
id="collection-edit-description"
className="input-field w-full"
rows="3"
value={editForm.description}
@ -40,18 +33,13 @@ export default function CollectionEditModal({
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
<Input
label="Hero Image (Optional)"
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)' }}
@ -67,6 +55,8 @@ export default function CollectionEditModal({
<button
type="button"
onClick={() => setEditForm({ ...editForm, isPublic: !editForm.isPublic })}
aria-pressed={editForm.isPublic}
aria-label="Toggle public list"
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
editForm.isPublic ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'
}`}
@ -80,31 +70,18 @@ export default function CollectionEditModal({
</div>
</div>
<div className="flex space-x-3 mt-6">
<button
type="button"
onClick={onClose}
className="flex-1 py-2 px-4 rounded-xl border transition-colors"
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)',
}}
>
<Button variant="secondary" onClick={onClose} className="flex-1">
Cancel
</button>
<button
type="button"
</Button>
<Button
variant="primary"
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',
}}
className="flex-1"
>
Save Changes
</button>
</div>
</div>
</Button>
</div>
</Modal>
);
}

View file

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

View file

@ -3,6 +3,7 @@ import {
handleCollectionTagKeyPress,
removeCollectionTag,
} from '../lib/collection-tag-input.js';
import { Modal, Input, Button } from './ui';
export default function CollectionsEditModal({
editingCollection,
@ -12,61 +13,61 @@ export default function CollectionsEditModal({
onClose,
onUpdate,
}) {
if (!editingCollection) {
return null;
}
if (!editingCollection) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="card max-w-md w-full mx-4">
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
Edit List
</h2>
<Modal open onClose={onClose} title="Edit list" size="md">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
List Name
</label>
<input
type="text"
className="input-field w-full"
<Input
label="List Name"
value={editingCollection.name}
onChange={(e) => setEditingCollection({ ...editingCollection, name: e.target.value })}
onChange={(e) =>
setEditingCollection({ ...editingCollection, name: e.target.value })
}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
<label
className="block text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
htmlFor="collections-edit-description"
>
Description
</label>
<textarea
id="collections-edit-description"
className="input-field w-full"
rows="3"
value={editingCollection.description}
onChange={(e) => setEditingCollection({ ...editingCollection, description: e.target.value })}
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
<Input
label="Hero Image"
type="url"
className="input-field w-full"
value={editingCollection.image || ''}
onChange={(e) => setEditingCollection({ ...editingCollection, image: e.target.value })}
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)' }}>
<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
type="text"
className="input-field flex-1"
<Input
id="collections-edit-tags"
value={editTagInput}
onChange={(e) => setEditTagInput(e.target.value)}
onKeyPress={(e) =>
@ -79,21 +80,24 @@ export default function CollectionsEditModal({
)
}
placeholder="Add tags (press Enter)"
className="flex-1"
/>
<button
<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',
}}
variant="primary"
size="md"
disabled={!editTagInput.trim()}
onClick={() =>
addCollectionTag(
editTagInput,
setEditTagInput,
editingCollection,
setEditingCollection
)
}
>
Add
</button>
</Button>
</div>
{editingCollection.tags && editingCollection.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
@ -110,7 +114,10 @@ export default function CollectionsEditModal({
{tag}
<button
type="button"
onClick={() => removeCollectionTag(tag, editingCollection, setEditingCollection)}
onClick={() =>
removeCollectionTag(tag, editingCollection, setEditingCollection)
}
aria-label={`Remove tag ${tag}`}
className="ml-1 hover:text-red-500 transition-colors"
>
×
@ -137,8 +144,13 @@ export default function CollectionsEditModal({
<button
type="button"
onClick={() =>
setEditingCollection({ ...editingCollection, isPublic: !editingCollection.isPublic })
setEditingCollection({
...editingCollection,
isPublic: !editingCollection.isPublic,
})
}
aria-pressed={editingCollection.isPublic}
aria-label="Toggle public list"
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
editingCollection.isPublic ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'
}`}
@ -152,31 +164,18 @@ export default function CollectionsEditModal({
</div>
</div>
<div className="flex space-x-3 mt-6">
<button
type="button"
onClick={onClose}
className="flex-1 py-2 px-4 rounded-xl border transition-colors"
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)',
}}
>
<Button variant="secondary" onClick={onClose} className="flex-1">
Cancel
</button>
<button
type="button"
</Button>
<Button
variant="primary"
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',
}}
className="flex-1"
>
Update List
</button>
</div>
</div>
</Button>
</div>
</Modal>
);
}

View file

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

View file

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

View file

@ -1,40 +1,23 @@
import { Modal, Button } from './ui';
export default function OCRSettings({ onClose }) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div
className="rounded-xl border max-w-md w-full p-6"
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
Card Scanner
</h2>
<button
onClick={onClose}
className="p-2 rounded-lg hover:opacity-70"
style={{ color: 'var(--text-secondary)' }}
aria-label="Close"
>
</button>
</div>
<Modal open onClose={onClose} title="Card Scanner" size="md">
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
Card identification runs on Deck Hearth&apos;s servers using Gemini Vision. No API keys
are required in your browser.
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>
<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' }}
>
<Button variant="primary" onClick={onClose} className="w-full">
Got it
</button>
</div>
</div>
</Button>
</Modal>
);
}

View file

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

View file

@ -1,8 +1,9 @@
/* eslint-disable @next/next/no-img-element -- Local preview data URLs; next/image migration is out of scope. */
import { useState } from 'react';
import { Modal, Input, Button } from './ui';
export default function UploadImageModal({ isOpen, onClose, onUpload, currentImage }) {
const [uploadMethod, setUploadMethod] = useState('url'); // 'url' or 'file'
const [uploadMethod, setUploadMethod] = useState('url');
const [imageUrl, setImageUrl] = useState(currentImage || '');
const [dragActive, setDragActive] = useState(false);
const [uploading, setUploading] = useState(false);
@ -25,9 +26,9 @@ export default function UploadImageModal({ isOpen, onClose, onUpload, currentIma
const handleDrag = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === "dragleave") {
} else if (e.type === 'dragleave') {
setDragActive(false);
}
};
@ -36,7 +37,6 @@ export default function UploadImageModal({ isOpen, onClose, onUpload, currentIma
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFileUpload(e.dataTransfer.files[0]);
}
@ -45,8 +45,8 @@ export default function UploadImageModal({ isOpen, onClose, onUpload, currentIma
const handleFileUpload = (file) => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
setImageUrl(e.target.result);
reader.onload = (event) => {
setImageUrl(event.target.result);
setUploadMethod('file');
};
reader.readAsDataURL(file);
@ -59,46 +59,45 @@ export default function UploadImageModal({ isOpen, onClose, onUpload, currentIma
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-gray-900">Upload Hero Image</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<Modal open={isOpen} onClose={onClose} title="Upload Hero Image" size="md">
<form onSubmit={handleSubmit}>
{/* Upload Method Tabs */}
<div className="flex space-x-1 mb-6 bg-gray-100 rounded-lg p-1">
<div
className="flex space-x-1 mb-6 rounded-lg p-1"
style={{
backgroundColor: 'var(--bg-tertiary)',
border: '1px solid var(--border)',
}}
>
<button
type="button"
onClick={() => setUploadMethod('url')}
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors ${
className="flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors"
style={{
backgroundColor:
uploadMethod === 'url' ? 'var(--bg-primary)' : 'transparent',
color:
uploadMethod === 'url'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
? 'var(--text-primary)'
: 'var(--text-secondary)',
boxShadow: uploadMethod === 'url' ? 'var(--elevation-ambient)' : 'none',
}}
>
Image URL
</button>
<button
type="button"
onClick={() => setUploadMethod('file')}
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors ${
className="flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors"
style={{
backgroundColor:
uploadMethod === 'file' ? 'var(--bg-primary)' : 'transparent',
color:
uploadMethod === 'file'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
? 'var(--text-primary)'
: 'var(--text-secondary)',
boxShadow: uploadMethod === 'file' ? 'var(--elevation-ambient)' : 'none',
}}
>
Upload File
</button>
@ -106,38 +105,58 @@ export default function UploadImageModal({ isOpen, onClose, onUpload, currentIma
{uploadMethod === 'url' ? (
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Image URL
</label>
<input
<Input
label="Image URL"
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">
<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 ${
dragActive
? 'border-purple-500 bg-purple-50'
: 'border-gray-300 hover:border-gray-400'
}`}
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 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
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="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">
<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"
@ -146,54 +165,53 @@ export default function UploadImageModal({ isOpen, onClose, onUpload, currentIma
className="hidden"
/>
</label>
<p className="text-xs text-gray-500 mt-2">PNG, JPG, GIF up to 10MB</p>
<p className="text-xs mt-2" style={{ color: 'var(--text-tertiary)' }}>
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">
<label
className="block text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
>
Preview
</label>
<div className="relative rounded-lg overflow-hidden bg-gray-100">
<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';
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"
>
<Button type="button" variant="secondary" onClick={onClose} className="flex-1">
Cancel
</button>
<button
</Button>
<Button
type="submit"
variant="primary"
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'
}`}
loading={uploading}
className="flex-1"
>
{uploading ? 'Uploading...' : 'Upload Image'}
</button>
</Button>
</div>
</form>
</div>
</div>
</div>
</Modal>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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