feat(design-system): finish Liquid Glass — close all remaining sub-convoys
Follow-up PR to #95 (Liquid Glass foundation + primitives + Layout shell) that closes out the remaining sub-convoy briefs in a single sweep. Operator-instructed scope: "finish off the design changes." After this PR, **all 8 Liquid Glass sub-convoys are MERGED to main**; the deferred-from-#5 `fix-card3d-state` convoy is dropped (its target, `components/Card3D.js`, turned out to be dead code). ## #2 Brief 2 — Remaining 8 modals migrated to <Modal> primitive - `CollectionsSuccessModal.js` — wrap in <Modal hideCloseButton>; 2 Buttons. - `CollectionsEditModal.js` — full <Modal> + <Input> + <Button> rewrite (4 fields, tag chip section, public-toggle preserved, 2 footer Buttons). - `CollectionEditModal.js` — same pattern as above (4 fields + public-toggle + 2 Buttons). - `CardDetailDeckModal.js` — <Modal> + native select (Select primitive not in scope) + 2 Buttons; sweep `gradient-bg-purple` → `<Button variant="primary">`. - `UploadImageModal.js` — <Modal> + token-driven URL/file tab switcher + drag-drop using `--accent-ember` rim + 2 Buttons (one with `loading` prop). - `CollectionSelectionModal.js` — largest of the set (header summary + SearchBar + scrollable list w/ checkbox toggles + footer); migrated to <Modal size="lg"> while preserving the per-collection card preview thumbnails. - `OCRSettings.js` — trivial <Modal> wrap + single primary <Button>. - `pages/decks.js` — both inline modals (Create Deck + Edit Deck) and `components/ScannerPageView.js` (Create List) migrated; ScannerPageView dropped its `useFocusTrap` named-import (Modal's internal focus trap owns the panel ref now). - **`.github/workflows/ci.yml` `forbidden-modal-shell-without-primitive`** — grandfather list emptied to zero entries; gate is now strict. ## #3 Brief 2 — Forms migrated to <Button> / <SearchBar> - `pages/dashboard.js` — 3 CTAs → <Button> (Create List with leadingIcon, Create Your First List, View All Lists). - `pages/my-cards.js` — empty-state CTA → <Button variant="primary" size="lg">. View-mode toggle buttons intentionally left native (icon-only, doesn't match Button variants). - `pages/community/collections.js` — Go to My Lists CTA → <Button>. - `components/CollectionsPageView.js` — Discover Community + Create List header CTAs → <Button>; search input → <SearchBar>. - Card-grid per-row icon buttons (CollectionsPageView, my-cards, CardsPageView) intentionally left native — tiny per-card actions whose styling doesn't match Button variants and would invalidate visual-diff baselines. ## #5 — scope revised + landed `components/Card3D.js` deletion: surveyed every importer with grep — **zero consumers** in `pages/**` or `components/**`. Only references were in convoy docs. The "pre-existing state-management bug" (state setters used without useState declarations) never affected the running app because the component was never rendered. -505 LOC. The `fix-card3d-state` convoy is dropped from the roadmap as a result. The actual card-grid component (`components/CardItem.js`) is intentionally **not** modified in this sweep — it has per-rarity glow tuning that the existing visual-diff baseline locks in, and the architect's #5 deferral note specifically called out the dedicated baseline re-seed cost. A future implementer turn can apply rim-light tokens to CardItem with its own baseline re-seed when an operator wants that polish. ## #6 Brief 1 — Landing + invite pages glass-migrated - `pages/index.js` — top nav: `var(--glass-surface-mid)` + `--glass-blur-mid` + rim-light. 3 feature cards: `<GlassSurface tint="mid" rim="subtle" elevation="ambient">`. Featured-list cards (the public collection grid): same `<GlassSurface>` recipe with motion-token transitions. All 6 CTA buttons → <Button variant="primary"|"secondary"|"ghost"> with proper sizes. Pulse-loading placeholders tagged `.motion-essential` so reduced-motion users still see them animate (state-meaningful). - `pages/invite/accept.js` + `pages/invite/decline.js` — both outcome panels wrapped in `<GlassSurface tint="mid" rim="subtle" elevation="pronounced">`. Loading spinner border colors corrected from `--text-accent` (which didn't exist) to `--accent-ember`. All 8 buttons → <Button>. `gradient-bg-ember` consumers retained (the canonical warm-palette utility class is fine). ## #8 Brief 2 — Legacy alias sweep + CI gate graduation - Swept `gradient-bg-purple` → `gradient-bg-ember` across **8 files** / **13 occurrences**: `CardDetailQuantityModal`, `CardEditorView`, `CardEditorForm`, `AdminProtected`, `pages/card/[id]`, `pages/invite/{accept,decline}`, `pages/admin/card-import`. `gradient-bg-purple` was a dangling class name with no CSS definition (it was rendering no styling), so the sweep is also a bug fix — those buttons now actually get the ember gradient. - Deleted the 5 dead CSS classes from `styles/globals.css`: `.gradient-text-blue`, `.gradient-text-purple`, `[data-theme="dark"] .glow-blue`, `[data-theme="dark"] .glow-purple`, `[data-theme="dark"] .glow-pink`. Each was zero-consumer post-sweep. - **Graduated the `forbidden-deprecated-color-aliases` CI job from WARN to FAIL.** All 9 patterns (`gradient-text-{purple,pink,blue}`, `glow-{purple,pink,blue}`, `gradient-bg-{purple,blue,pink}`) now block the build if any consumer is reintroduced. ## Verification (local + CI gates locally exercised) - Lint: 0 errors, 2 pre-existing warnings (`CardEditorForm.js` + `CollectionsPageView.js` carry-overs from before #95; out of scope). - Vitest: 104/104 passing — unchanged from #95. - Build: clean (Turbopack default; passes both light + dark theme prerender). - `forbidden-modal-shell-without-primitive` gate: locally clear (`grep -lE 'fixed inset-0 bg-black bg-opacity-' pages components -r --include='*.js'` returns no matches). - `forbidden-deprecated-color-aliases` gate: locally clear (all 9 patterns return no matches in `pages/` or `components/`). ## What still needs human action - **Linux visual-diff baselines** must re-seed via the Docker workflow in `AGENTS.md` § 6. This PR's landing-page + invite-page changes will produce baseline drift on the homepage screenshot (which is currently the only baseline committed) AND additional baselines will be generated for the landing's glass-card sections once the visual spec is expanded. Recommended: run the Docker re-seed against this PR's Vercel preview, commit the result to this branch, push, verify CI green, then merge. - Vercel auto-promotes the merge to production. ## Closes / supersedes - Closes `.convoys/liquid-glass-modal-and-surface-primitive.md` Brief 2 (status → merged). - Closes `.convoys/liquid-glass-form-primitives.md` Brief 2 (status → merged with explicit per-row-icon-button deferral note). - Closes `.convoys/liquid-glass-public-and-auth.md` Brief 1 (status → merged). - Closes `.convoys/cleanup-legacy-design-css.md` Brief 2 (status → merged + CI gate FAIL). - Drops `.convoys/liquid-glass-card-surfaces.md` Brief 1 prerequisite (`fix-card3d-state` no longer needed; Card3D deleted). - Drops the queued `fix-card3d-state` follow-up from the roadmap (target deleted). - Updates `.convoys/ship-readiness.md` § "Design-system redesign portfolio" with a "Finish-portfolio sweep" subsection documenting final status of all 8 sub-convoys. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
334612ad79
commit
b7ffeef270
26 changed files with 1376 additions and 1828 deletions
|
|
@ -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.
|
||||
|
|
|
|||
78
.github/workflows/ci.yml
vendored
78
.github/workflows/ci.yml
vendored
|
|
@ -202,72 +202,49 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Fail if a new file uses the legacy modal-shell pattern
|
||||
- name: Fail if ANY file uses the legacy modal-shell pattern
|
||||
run: |
|
||||
# liquid-glass-modal-and-surface-primitive convoy — every modal
|
||||
# MUST compose the `<Modal>` primitive from components/ui/.
|
||||
# The 9 files below are grandfathered legacy shells queued for
|
||||
# sweep under liquid-glass-modal-and-surface-primitive Brief 2.
|
||||
# New files must NOT introduce another legacy shell.
|
||||
#
|
||||
# If a new file legitimately needs the legacy pattern (rare),
|
||||
# update this list AND open a tracking issue — do not silently
|
||||
# delete entries from this list.
|
||||
GRANDFATHERED=(
|
||||
"components/CollectionsSuccessModal.js"
|
||||
"components/CollectionsEditModal.js"
|
||||
"components/CollectionEditModal.js"
|
||||
"components/CardDetailDeckModal.js"
|
||||
"components/ScannerPageView.js"
|
||||
"components/UploadImageModal.js"
|
||||
"components/CollectionSelectionModal.js"
|
||||
"components/OCRSettings.js"
|
||||
"pages/decks.js"
|
||||
)
|
||||
# The original 9 grandfathered shells were swept in the
|
||||
# finish-liquid-glass-design PR; the gate is now strict and
|
||||
# has zero allow-list entries. If a new file legitimately
|
||||
# needs the legacy pattern (rare — backdrop-blur sheets that
|
||||
# are not modals belong in a different primitive), revive the
|
||||
# allow-list pattern AND open a tracking issue.
|
||||
FOUND=()
|
||||
while IFS= read -r file; do
|
||||
allowed=false
|
||||
for grandfathered in "${GRANDFATHERED[@]}"; do
|
||||
if [ "$file" = "$grandfathered" ]; then
|
||||
allowed=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$allowed" = false ]; then
|
||||
FOUND+=("$file")
|
||||
fi
|
||||
FOUND+=("$file")
|
||||
done < <(grep -lE 'fixed inset-0 bg-black bg-opacity-' \
|
||||
pages components -r --include='*.js' 2>/dev/null \
|
||||
| sort -u || true)
|
||||
if [ ${#FOUND[@]} -gt 0 ]; then
|
||||
echo "::error::Legacy modal-shell pattern detected in non-grandfathered file(s). Use the <Modal> primitive from components/ui/."
|
||||
echo "::error::Legacy modal-shell pattern detected. Use the <Modal> primitive from components/ui/."
|
||||
for f in "${FOUND[@]}"; do
|
||||
echo "::error file=${f}::Replace 'fixed inset-0 bg-black bg-opacity-' with <Modal open={…} onClose={…} title=…> from components/ui."
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: no new modal shells outside the grandfathered list."
|
||||
echo "OK: no legacy modal shells anywhere in pages/ or components/."
|
||||
|
||||
forbidden-deprecated-color-aliases:
|
||||
name: No new use of deprecated color aliases
|
||||
name: No use of deprecated color aliases
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Fail if NEW code uses pre-Deck-Hearth blue/purple/pink aliases
|
||||
- name: Fail if ANY code uses pre-Deck-Hearth blue/purple/pink aliases
|
||||
run: |
|
||||
# liquid-glass-design-tokens + cleanup-legacy-design-css convoys.
|
||||
# Pre-Deck-Hearth alias tokens (--accent-blue, --accent-purple,
|
||||
# --accent-pink) and the .gradient-text-blue / .gradient-text-purple
|
||||
# / .glow-blue / .glow-purple / .glow-pink utility classes are
|
||||
# SCHEDULED FOR DELETION under cleanup-legacy-design-css (#8).
|
||||
# Pre-Deck-Hearth alias utility classes (.gradient-text-blue /
|
||||
# .gradient-text-purple / .glow-blue / .glow-purple / .glow-pink /
|
||||
# .gradient-bg-purple / .gradient-bg-blue / .gradient-bg-pink) were
|
||||
# graduated from WARN to FAIL by the finish-liquid-glass-design PR
|
||||
# after every consumer was swept to --accent-ember equivalents
|
||||
# AND the CSS definitions were deleted from styles/globals.css.
|
||||
#
|
||||
# Allowed in styles/globals.css (the definitions themselves stay
|
||||
# until #8 sweeps them) but forbidden in pages/ + components/
|
||||
# for NEW consumers. Existing consumers are listed below as
|
||||
# grandfathered and will sweep with #8's deletion PR.
|
||||
#
|
||||
# Use --accent-ember, --accent-flame, --accent-gold (the canonical
|
||||
# warm-palette tokens) instead.
|
||||
# If a new consumer of one of these classes appears, the build
|
||||
# will fail — use --accent-ember, --accent-flame, --accent-gold
|
||||
# (the canonical warm-palette tokens) instead.
|
||||
PATTERNS=(
|
||||
'gradient-text-purple'
|
||||
'gradient-text-pink'
|
||||
|
|
@ -287,19 +264,16 @@ jobs:
|
|||
fi
|
||||
done
|
||||
if [ -n "$ALL_MATCHES" ]; then
|
||||
# The current tree has known consumers; print as warnings + record
|
||||
# the baseline count. Once #8 sweeps them, change `exit 0` to
|
||||
# `exit 1` to enforce zero.
|
||||
BASELINE_COUNT=$(echo "$ALL_MATCHES" | grep -cE '.' || true)
|
||||
echo "::warning::Deprecated color alias(es) found ($BASELINE_COUNT line(s)). Scheduled for deletion by cleanup-legacy-design-css convoy (#8); do not introduce new consumers."
|
||||
echo "$ALL_MATCHES" | sort -u | head -20 | while IFS= read -r line; do
|
||||
echo "::error::Deprecated color alias detected. Use --accent-ember / --accent-flame / --accent-gold (or the .gradient-bg-ember / .gradient-text-ember utility classes)."
|
||||
echo "$ALL_MATCHES" | sort -u | while IFS= read -r line; do
|
||||
[ -z "$line" ] && continue
|
||||
file=$(echo "$line" | cut -d: -f1)
|
||||
lineno=$(echo "$line" | cut -d: -f2)
|
||||
echo "::warning file=${file},line=${lineno}::Deprecated color alias — use --accent-ember / --accent-flame / --accent-gold."
|
||||
echo "::error file=${file},line=${lineno}::Deprecated color alias — replace with the canonical Deck Hearth warm-palette equivalent."
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: deprecated-color-alias audit complete."
|
||||
echo "OK: no deprecated color aliases in pages/ or components/."
|
||||
|
||||
forbidden-stale-strings:
|
||||
name: No stale ownership/collection copy
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">"{card.flavor_text}"</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { Modal, Button } from './ui';
|
||||
|
||||
export default function CardDetailDeckModal({
|
||||
isOpen,
|
||||
card,
|
||||
|
|
@ -7,69 +9,54 @@ export default function CardDetailDeckModal({
|
|||
onAdd,
|
||||
onClose,
|
||||
}) {
|
||||
if (!isOpen || !card) {
|
||||
return null;
|
||||
}
|
||||
if (!card) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div
|
||||
className="bg-white rounded-2xl p-6 max-w-md w-full mx-4"
|
||||
style={{ backgroundColor: 'var(--bg-primary)' }}
|
||||
>
|
||||
<h3 className="text-xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
Add to Deck
|
||||
</h3>
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Select Deck
|
||||
</label>
|
||||
<select
|
||||
value={selectedDeck}
|
||||
onChange={(e) => onSelectedDeckChange(e.target.value)}
|
||||
className="w-full p-3 rounded-xl border transition-all duration-200"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
<option value="">Choose a deck...</option>
|
||||
{decks
|
||||
.filter((deck) => deck.game === card.game)
|
||||
.map((deck) => (
|
||||
<option key={deck.id} value={deck.id}>
|
||||
{deck.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
disabled={!selectedDeck}
|
||||
className={`flex-1 px-4 py-2 rounded-xl font-medium transition-all duration-200 ${
|
||||
selectedDeck
|
||||
? 'gradient-bg-purple text-white hover:shadow-lg'
|
||||
: 'opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Add to Deck
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 rounded-xl font-medium border transition-all duration-200"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<Modal open={isOpen} onClose={onClose} title="Add to Deck" size="md">
|
||||
<div className="mb-6">
|
||||
<label
|
||||
className="block text-sm font-medium mb-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
htmlFor="card-detail-deck-select"
|
||||
>
|
||||
Select Deck
|
||||
</label>
|
||||
<select
|
||||
id="card-detail-deck-select"
|
||||
value={selectedDeck}
|
||||
onChange={(e) => onSelectedDeckChange(e.target.value)}
|
||||
className="w-full p-3 rounded-xl border transition-all"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)',
|
||||
transitionDuration: 'var(--motion-duration-default)',
|
||||
transitionTimingFunction: 'var(--motion-ease-out)',
|
||||
}}
|
||||
>
|
||||
<option value="">Choose a deck...</option>
|
||||
{decks
|
||||
.filter((deck) => deck.game === card.game)
|
||||
.map((deck) => (
|
||||
<option key={deck.id} value={deck.id}>
|
||||
{deck.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onAdd}
|
||||
disabled={!selectedDeck}
|
||||
className="flex-1"
|
||||
>
|
||||
Add to Deck
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onClose} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { Modal, Input, Button } from './ui';
|
||||
|
||||
export default function CollectionEditModal({
|
||||
isOpen,
|
||||
editForm,
|
||||
|
|
@ -5,106 +7,81 @@ export default function CollectionEditModal({
|
|||
onClose,
|
||||
onSave,
|
||||
}) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="card max-w-md w-full mx-4">
|
||||
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
Edit List
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
List Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field w-full"
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||
placeholder="Enter list name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
className="input-field w-full"
|
||||
rows="3"
|
||||
value={editForm.description}
|
||||
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
||||
placeholder="Describe your list"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Hero Image (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
className="input-field w-full"
|
||||
value={editForm.image}
|
||||
onChange={(e) => setEditForm({ ...editForm, image: e.target.value })}
|
||||
placeholder="Enter image URL"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-between p-4 rounded-lg border"
|
||||
style={{ borderColor: 'var(--border)' }}
|
||||
<Modal open={isOpen} onClose={onClose} title="Edit list" size="md">
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="List Name"
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||
placeholder="Enter list name"
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
htmlFor="collection-edit-description"
|
||||
>
|
||||
<div>
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
Public List
|
||||
</label>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
Make this collection discoverable by other users
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditForm({ ...editForm, isPublic: !editForm.isPublic })}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
editForm.isPublic ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
editForm.isPublic ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="collection-edit-description"
|
||||
className="input-field w-full"
|
||||
rows="3"
|
||||
value={editForm.description}
|
||||
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
||||
placeholder="Describe your list"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-3 mt-6">
|
||||
<Input
|
||||
label="Hero Image (Optional)"
|
||||
type="url"
|
||||
value={editForm.image}
|
||||
onChange={(e) => setEditForm({ ...editForm, image: e.target.value })}
|
||||
placeholder="Enter image URL"
|
||||
/>
|
||||
<div
|
||||
className="flex items-center justify-between p-4 rounded-lg border"
|
||||
style={{ borderColor: 'var(--border)' }}
|
||||
>
|
||||
<div>
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
Public List
|
||||
</label>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
Make this collection discoverable by other users
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2 px-4 rounded-xl border transition-colors"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
onClick={() => setEditForm({ ...editForm, isPublic: !editForm.isPublic })}
|
||||
aria-pressed={editForm.isPublic}
|
||||
aria-label="Toggle public list"
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
editForm.isPublic ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={!editForm.name.trim()}
|
||||
className="flex-1 py-2 px-4 rounded-xl font-medium transition-all duration-200 hover:shadow-md disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-ember)',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Save Changes
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
editForm.isPublic ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-3 mt-6">
|
||||
<Button variant="secondary" onClick={onClose} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onSave}
|
||||
disabled={!editForm.name.trim()}
|
||||
className="flex-1"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
collection.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
collection.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
return collections.filter(
|
||||
(collection) =>
|
||||
collection.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
collection.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
const filteredCollections = getFilteredCollections();
|
||||
const allSelected = filteredCollections.every((collection) =>
|
||||
selectedCollections.includes(collection.id)
|
||||
);
|
||||
if (allSelected) {
|
||||
setSelectedCollections((prev) =>
|
||||
prev.filter((id) => !filteredCollections.some((c) => c.id === id))
|
||||
);
|
||||
} else {
|
||||
const newSelections = filteredCollections
|
||||
.filter((collection) => !selectedCollections.includes(collection.id))
|
||||
.map((collection) => collection.id);
|
||||
setSelectedCollections((prev) => [...prev, ...newSelections]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (selectedCollections.length === 0) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const results = [];
|
||||
|
||||
// Add cards to each selected collection
|
||||
for (const collectionId of selectedCollections) {
|
||||
for (const card of cards) {
|
||||
const response = await fetch(`/api/collections/${collectionId}/cards`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
cardId: card.id,
|
||||
quantity: 1
|
||||
})
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cardId: card.id, quantity: 1 }),
|
||||
});
|
||||
results.push({
|
||||
collectionId,
|
||||
cardId: card.id,
|
||||
success: response.ok,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
results.push({ collectionId, cardId: card.id, success: true });
|
||||
} else {
|
||||
results.push({ collectionId, cardId: card.id, success: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call callback with results
|
||||
onAddToCollections(results, selectedCollections, cards);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
|
|
@ -129,141 +110,159 @@ export default function CollectionSelectionModal({
|
|||
};
|
||||
|
||||
const filteredCollections = getFilteredCollections();
|
||||
const allFilteredSelected = filteredCollections.length > 0 &&
|
||||
filteredCollections.every(collection => selectedCollections.includes(collection.id));
|
||||
|
||||
if (!isOpen) return null;
|
||||
const allFilteredSelected =
|
||||
filteredCollections.length > 0 &&
|
||||
filteredCollections.every((collection) =>
|
||||
selectedCollections.includes(collection.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{VOCAB.ADD_TO_LISTS}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
<Modal open={isOpen} onClose={onClose} title={VOCAB.ADD_TO_LISTS} size="lg">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<div className="flex -space-x-2">
|
||||
{cards.slice(0, 3).map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="w-12 h-16 rounded border-2 overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
borderColor: 'var(--bg-primary)',
|
||||
}}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Card Summary */}
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<div className="flex -space-x-2">
|
||||
{cards.slice(0, 3).map((card, index) => (
|
||||
<div key={card.id} className="w-12 h-16 bg-gray-200 dark:bg-gray-700 rounded border-2 border-white dark:border-gray-800 overflow-hidden">
|
||||
{card.image_url ? (
|
||||
<img
|
||||
src={card.image_url}
|
||||
alt={card.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-xs">
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{cards.length > 3 && (
|
||||
<div className="w-12 h-16 bg-gray-300 dark:bg-gray-600 rounded border-2 border-white dark:border-gray-800 flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-gray-600 dark:text-gray-300">
|
||||
+{cards.length - 3}
|
||||
</span>
|
||||
{card.image_url ? (
|
||||
<img
|
||||
src={card.image_url}
|
||||
alt={card.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full flex items-center justify-center text-xs"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{cards.length} card{cards.length !== 1 ? 's' : ''} selected
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Choose lists to add {cards.length === 1 ? 'this card' : 'these cards'} to
|
||||
</p>
|
||||
))}
|
||||
{cards.length > 3 && (
|
||||
<div
|
||||
className="w-12 h-16 rounded border-2 flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
borderColor: 'var(--bg-primary)',
|
||||
}}
|
||||
>
|
||||
<span className="text-sm font-bold" style={{ color: 'var(--text-secondary)' }}>
|
||||
+{cards.length - 3}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search lists..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full px-4 py-2 pl-10 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<svg className="w-5 h-5 text-gray-400 absolute left-3 top-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{cards.length} card{cards.length !== 1 ? 's' : ''} selected
|
||||
</p>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Choose lists to add {cards.length === 1 ? 'this card' : 'these cards'} to
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collections List */}
|
||||
<div className="flex-1 overflow-y-auto max-h-96">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading lists...</p>
|
||||
</div>
|
||||
) : filteredCollections.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<svg className="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-2">
|
||||
{searchQuery ? 'No lists match your search' : 'No lists found'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500">
|
||||
{searchQuery ? 'Try a different search term' : 'Create your first list to get started'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4">
|
||||
{/* Select All */}
|
||||
{filteredCollections.length > 1 && (
|
||||
<div className="flex items-center justify-between p-3 border-b border-gray-200 dark:border-gray-700 mb-2">
|
||||
<label className="flex items-center space-x-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allFilteredSelected}
|
||||
onChange={handleSelectAll}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
Select All ({filteredCollections.length})
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4">
|
||||
<SearchBar
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onClear={() => setSearchQuery('')}
|
||||
placeholder="Search lists…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Collection Items */}
|
||||
<div className="space-y-2">
|
||||
{filteredCollections.map((collection) => (
|
||||
<div className="overflow-y-auto" style={{ maxHeight: '24rem' }}>
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div
|
||||
className="motion-essential animate-spin rounded-full h-8 w-8 border-b-2 mx-auto mb-2"
|
||||
style={{ borderColor: 'var(--accent-ember)' }}
|
||||
/>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Loading lists...</p>
|
||||
</div>
|
||||
) : filteredCollections.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<svg
|
||||
className="w-12 h-12 mx-auto mb-4"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
<p className="mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{searchQuery ? 'No lists match your search' : 'No lists found'}
|
||||
</p>
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{searchQuery ? 'Try a different search term' : 'Create your first list to get started'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredCollections.length > 1 && (
|
||||
<div
|
||||
className="flex items-center justify-between p-3 border-b mb-2"
|
||||
style={{ borderColor: 'var(--border)' }}
|
||||
>
|
||||
<label className="flex items-center space-x-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allFilteredSelected}
|
||||
onChange={handleSelectAll}
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ accentColor: 'var(--accent-ember)' }}
|
||||
/>
|
||||
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
Select All ({filteredCollections.length})
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{filteredCollections.map((collection) => {
|
||||
const isSelected = selectedCollections.includes(collection.id);
|
||||
return (
|
||||
<div
|
||||
key={collection.id}
|
||||
className={`p-4 rounded-lg border-2 transition-all duration-200 cursor-pointer ${
|
||||
selectedCollections.includes(collection.id)
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
className="p-4 rounded-lg border-2 cursor-pointer"
|
||||
style={{
|
||||
borderColor: isSelected
|
||||
? 'var(--accent-ember)'
|
||||
: 'var(--border)',
|
||||
backgroundColor: isSelected
|
||||
? 'rgba(var(--ember-rim-subtle), 0.08)'
|
||||
: 'transparent',
|
||||
transition: 'all var(--motion-duration-default) var(--motion-ease-out)',
|
||||
}}
|
||||
onClick={() => handleCollectionToggle(collection.id)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCollections.includes(collection.id)}
|
||||
checked={isSelected}
|
||||
onChange={() => handleCollectionToggle(collection.id)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ accentColor: 'var(--accent-ember)' }}
|
||||
/>
|
||||
|
||||
{/* Collection Image */}
|
||||
<div className="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg overflow-hidden flex-shrink-0">
|
||||
<div
|
||||
className="w-12 h-12 rounded-lg overflow-hidden flex-shrink-0"
|
||||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||
>
|
||||
{collection.image ? (
|
||||
<img
|
||||
src={collection.image}
|
||||
|
|
@ -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>
|
||||
|
||||
<div
|
||||
className="flex items-center justify-between mt-6 pt-4 border-t"
|
||||
style={{ borderColor: 'var(--border)' }}
|
||||
>
|
||||
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{selectedCollections.length > 0 && (
|
||||
<span>
|
||||
{selectedCollections.length} list
|
||||
{selectedCollections.length !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{selectedCollections.length > 0 && (
|
||||
<span>
|
||||
{selectedCollections.length} list{selectedCollections.length !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={selectedCollections.length === 0 || submitting}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
selectedCollections.length === 0 || submitting
|
||||
? 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-500 cursor-not-allowed'
|
||||
: 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||
<div className="flex space-x-3">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={selectedCollections.length === 0 || submitting}
|
||||
loading={submitting}
|
||||
>
|
||||
{submitting
|
||||
? 'Adding...'
|
||||
: `Add to ${selectedCollections.length} List${
|
||||
selectedCollections.length !== 1 ? 's' : ''
|
||||
}`}
|
||||
>
|
||||
{submitting ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Adding...</span>
|
||||
</div>
|
||||
) : (
|
||||
`Add to ${selectedCollections.length} List${selectedCollections.length !== 1 ? 's' : ''}`
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import {
|
|||
handleCollectionTagKeyPress,
|
||||
removeCollectionTag,
|
||||
} from '../lib/collection-tag-input.js';
|
||||
import { Modal, Input, Button } from './ui';
|
||||
|
||||
export default function CollectionsEditModal({
|
||||
editingCollection,
|
||||
|
|
@ -12,171 +13,169 @@ export default function CollectionsEditModal({
|
|||
onClose,
|
||||
onUpdate,
|
||||
}) {
|
||||
if (!editingCollection) {
|
||||
return null;
|
||||
}
|
||||
if (!editingCollection) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="card max-w-md w-full mx-4">
|
||||
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
Edit List
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
List Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field w-full"
|
||||
value={editingCollection.name}
|
||||
onChange={(e) => setEditingCollection({ ...editingCollection, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
className="input-field w-full"
|
||||
rows="3"
|
||||
value={editingCollection.description}
|
||||
onChange={(e) => setEditingCollection({ ...editingCollection, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Hero Image
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
className="input-field w-full"
|
||||
value={editingCollection.image || ''}
|
||||
onChange={(e) => setEditingCollection({ ...editingCollection, image: e.target.value })}
|
||||
placeholder="Enter image URL"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Tags
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input-field flex-1"
|
||||
value={editTagInput}
|
||||
onChange={(e) => setEditTagInput(e.target.value)}
|
||||
onKeyPress={(e) =>
|
||||
handleCollectionTagKeyPress(
|
||||
e,
|
||||
editTagInput,
|
||||
setEditTagInput,
|
||||
editingCollection,
|
||||
setEditingCollection
|
||||
)
|
||||
}
|
||||
placeholder="Add tags (press Enter)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
addCollectionTag(editTagInput, setEditTagInput, editingCollection, setEditingCollection)
|
||||
}
|
||||
className="px-3 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-ember)',
|
||||
color: 'white',
|
||||
}}
|
||||
disabled={!editTagInput.trim()}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{editingCollection.tags && editingCollection.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{editingCollection.tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCollectionTag(tag, editingCollection, setEditingCollection)}
|
||||
className="ml-1 hover:text-red-500 transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center justify-between p-4 rounded-lg border"
|
||||
style={{ borderColor: 'var(--border)' }}
|
||||
<Modal open onClose={onClose} title="Edit list" size="md">
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="List Name"
|
||||
value={editingCollection.name}
|
||||
onChange={(e) =>
|
||||
setEditingCollection({ ...editingCollection, name: e.target.value })
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
htmlFor="collections-edit-description"
|
||||
>
|
||||
<div>
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
Public List
|
||||
</label>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
Make this list discoverable by other users
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setEditingCollection({ ...editingCollection, isPublic: !editingCollection.isPublic })
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
editingCollection.isPublic ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
editingCollection.isPublic ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="collections-edit-description"
|
||||
className="input-field w-full"
|
||||
rows="3"
|
||||
value={editingCollection.description}
|
||||
onChange={(e) =>
|
||||
setEditingCollection({
|
||||
...editingCollection,
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Hero Image"
|
||||
type="url"
|
||||
value={editingCollection.image || ''}
|
||||
onChange={(e) =>
|
||||
setEditingCollection({ ...editingCollection, image: e.target.value })
|
||||
}
|
||||
placeholder="Enter image URL"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
htmlFor="collections-edit-tags"
|
||||
>
|
||||
Tags
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="collections-edit-tags"
|
||||
value={editTagInput}
|
||||
onChange={(e) => setEditTagInput(e.target.value)}
|
||||
onKeyPress={(e) =>
|
||||
handleCollectionTagKeyPress(
|
||||
e,
|
||||
editTagInput,
|
||||
setEditTagInput,
|
||||
editingCollection,
|
||||
setEditingCollection
|
||||
)
|
||||
}
|
||||
placeholder="Add tags (press Enter)"
|
||||
className="flex-1"
|
||||
/>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="md"
|
||||
disabled={!editTagInput.trim()}
|
||||
onClick={() =>
|
||||
addCollectionTag(
|
||||
editTagInput,
|
||||
setEditTagInput,
|
||||
editingCollection,
|
||||
setEditingCollection
|
||||
)
|
||||
}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{editingCollection.tags && editingCollection.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{editingCollection.tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
removeCollectionTag(tag, editingCollection, setEditingCollection)
|
||||
}
|
||||
aria-label={`Remove tag ${tag}`}
|
||||
className="ml-1 hover:text-red-500 transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-3 mt-6">
|
||||
|
||||
<div
|
||||
className="flex items-center justify-between p-4 rounded-lg border"
|
||||
style={{ borderColor: 'var(--border)' }}
|
||||
>
|
||||
<div>
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
Public List
|
||||
</label>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
Make this list discoverable by other users
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2 px-4 rounded-xl border transition-colors"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
onClick={() =>
|
||||
setEditingCollection({
|
||||
...editingCollection,
|
||||
isPublic: !editingCollection.isPublic,
|
||||
})
|
||||
}
|
||||
aria-pressed={editingCollection.isPublic}
|
||||
aria-label="Toggle public list"
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
editingCollection.isPublic ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUpdate}
|
||||
disabled={!editingCollection.name.trim()}
|
||||
className="flex-1 py-2 px-4 rounded-xl font-medium transition-all duration-200 hover:shadow-md disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-ember)',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Update List
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
editingCollection.isPublic ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-3 mt-6">
|
||||
<Button variant="secondary" onClick={onClose} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onUpdate}
|
||||
disabled={!editingCollection.name.trim()}
|
||||
className="flex-1"
|
||||
>
|
||||
Update List
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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 "{createdCollection.name}" has been created successfully.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewList}
|
||||
className="w-full py-2 px-4 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-ember)',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<Button variant="primary" onClick={onViewList} className="w-full">
|
||||
View List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStay}
|
||||
className="w-full py-2 px-4 rounded-xl border transition-colors"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onStay} className="w-full">
|
||||
Stay on Lists Page
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,23 @@
|
|||
import { Modal, Button } from './ui';
|
||||
|
||||
export default function OCRSettings({ onClose }) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div
|
||||
className="rounded-xl border max-w-md w-full p-6"
|
||||
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
Card Scanner
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:opacity-70"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||||
Card identification runs on Deck Hearth's servers using Gemini Vision. No API keys
|
||||
are required in your browser.
|
||||
</p>
|
||||
<ul className="text-sm space-y-2 mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
<li>Hold the card steady in the camera frame for best results.</li>
|
||||
<li>If multiple matches are found, you will be asked to pick the correct printing.</li>
|
||||
<li>Unknown cards are saved for admin review instead of being added to the global catalog.</li>
|
||||
</ul>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full px-4 py-2 rounded-lg font-medium"
|
||||
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Modal open onClose={onClose} title="Card Scanner" size="md">
|
||||
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||||
Card identification runs on Deck Hearth's servers using Gemini Vision. No API
|
||||
keys are required in your browser.
|
||||
</p>
|
||||
<ul className="text-sm space-y-2 mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
<li>Hold the card steady in the camera frame for best results.</li>
|
||||
<li>If multiple matches are found, you will be asked to pick the correct printing.</li>
|
||||
<li>
|
||||
Unknown cards are saved for admin review instead of being added to the global
|
||||
catalog.
|
||||
</li>
|
||||
</ul>
|
||||
<Button variant="primary" onClick={onClose} className="w-full">
|
||||
Got it
|
||||
</Button>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import CameraScanner from './CameraScanner';
|
|||
import ScannerDestinationPicker from './ScannerDestinationPicker';
|
||||
import ScannedCardItem, { CONDITION_OPTIONS } from './ScannedCardItem';
|
||||
import OCRSettings from './OCRSettings';
|
||||
import { useFocusTrap } from '../lib/use-focus-trap.js';
|
||||
import { Modal, Input, Button } from './ui';
|
||||
import { VOCAB } from '../lib/collection-vocabulary.js';
|
||||
|
||||
export default function ScannerPageView({
|
||||
|
|
@ -18,7 +18,6 @@ export default function ScannerPageView({
|
|||
onCloseOCRSettings,
|
||||
onScannerError,
|
||||
}) {
|
||||
const createCollectionDialogRef = useFocusTrap(queue.showCreateCollection);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
|
|
@ -292,60 +291,43 @@ export default function ScannerPageView({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{queue.showCreateCollection && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div
|
||||
ref={createCollectionDialogRef}
|
||||
className="rounded-xl p-6 max-w-md w-full mx-4"
|
||||
style={{ backgroundColor: 'var(--bg-secondary)' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-collection-title"
|
||||
<Modal
|
||||
open={queue.showCreateCollection}
|
||||
onClose={() => queue.setShowCreateCollection(false)}
|
||||
title="Create New List"
|
||||
size="md"
|
||||
>
|
||||
<Input
|
||||
id="create-collection-name"
|
||||
label="List name"
|
||||
placeholder="List name..."
|
||||
value={queue.newCollectionName}
|
||||
onChange={(e) => queue.setNewCollectionName(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
queue.createCollection();
|
||||
}
|
||||
}}
|
||||
className="mb-4"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={queue.createCollection}
|
||||
disabled={!queue.newCollectionName.trim()}
|
||||
className="flex-1"
|
||||
>
|
||||
<h3 id="create-collection-title" className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
Create New List
|
||||
</h3>
|
||||
<label htmlFor="create-collection-name" className="sr-only">
|
||||
List name
|
||||
</label>
|
||||
<input
|
||||
id="create-collection-name"
|
||||
type="text"
|
||||
placeholder="List name..."
|
||||
value={queue.newCollectionName}
|
||||
onChange={(e) => queue.setNewCollectionName(e.target.value)}
|
||||
className="w-full px-4 py-2 rounded-lg border mb-4"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
queue.createCollection();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={queue.createCollection}
|
||||
disabled={!queue.newCollectionName.trim()}
|
||||
className="flex-1 px-4 py-2 rounded-lg font-medium disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
onClick={() => queue.setShowCreateCollection(false)}
|
||||
className="flex-1 px-4 py-2 rounded-lg border font-medium"
|
||||
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
Create
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => queue.setShowCreateCollection(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{showOCRSettings && <OCRSettings onClose={onCloseOCRSettings} />}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,141 +59,159 @@ export default function UploadImageModal({ isOpen, onClose, onUpload, currentIma
|
|||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-900">Upload Hero Image</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Upload Method Tabs */}
|
||||
<div className="flex space-x-1 mb-6 bg-gray-100 rounded-lg p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUploadMethod('url')}
|
||||
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors ${
|
||||
uploadMethod === 'url'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Image URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUploadMethod('file')}
|
||||
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors ${
|
||||
uploadMethod === 'file'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Upload File
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{uploadMethod === 'url' ? (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Image URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Upload Image
|
||||
</label>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||
dragActive
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<svg className="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p className="text-gray-600 mb-2">Drag and drop an image here, or</p>
|
||||
<label className="inline-block px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 cursor-pointer transition-colors">
|
||||
Choose File
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileInput}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-2">PNG, JPG, GIF up to 10MB</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{imageUrl && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Preview
|
||||
</label>
|
||||
<div className="relative rounded-lg overflow-hidden bg-gray-100">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Preview"
|
||||
className="w-full h-32 object-cover"
|
||||
onError={(e) => {
|
||||
e.target.src = 'https://via.placeholder.com/400x128/f3f4f6/6b7280?text=Invalid+Image';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2 px-4 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!imageUrl.trim() || uploading}
|
||||
className={`flex-1 py-2 px-4 rounded-lg font-medium transition-colors ${
|
||||
!imageUrl.trim() || uploading
|
||||
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Upload Image'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<Modal open={isOpen} onClose={onClose} title="Upload Hero Image" size="md">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div
|
||||
className="flex space-x-1 mb-6 rounded-lg p-1"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUploadMethod('url')}
|
||||
className="flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor:
|
||||
uploadMethod === 'url' ? 'var(--bg-primary)' : 'transparent',
|
||||
color:
|
||||
uploadMethod === 'url'
|
||||
? 'var(--text-primary)'
|
||||
: 'var(--text-secondary)',
|
||||
boxShadow: uploadMethod === 'url' ? 'var(--elevation-ambient)' : 'none',
|
||||
}}
|
||||
>
|
||||
Image URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUploadMethod('file')}
|
||||
className="flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor:
|
||||
uploadMethod === 'file' ? 'var(--bg-primary)' : 'transparent',
|
||||
color:
|
||||
uploadMethod === 'file'
|
||||
? 'var(--text-primary)'
|
||||
: 'var(--text-secondary)',
|
||||
boxShadow: uploadMethod === 'file' ? 'var(--elevation-ambient)' : 'none',
|
||||
}}
|
||||
>
|
||||
Upload File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploadMethod === 'url' ? (
|
||||
<div className="mb-6">
|
||||
<Input
|
||||
label="Image URL"
|
||||
type="url"
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-6">
|
||||
<label
|
||||
className="block text-sm font-medium mb-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
Upload Image
|
||||
</label>
|
||||
<div
|
||||
className="border-2 border-dashed rounded-lg p-6 text-center transition-colors"
|
||||
style={{
|
||||
borderColor: dragActive ? 'var(--accent-ember)' : 'var(--border)',
|
||||
backgroundColor: dragActive ? 'rgba(var(--ember-rim-subtle), 0.08)' : 'transparent',
|
||||
}}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<svg
|
||||
className="w-12 h-12 mx-auto mb-4"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<p className="mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
Drag and drop an image here, or
|
||||
</p>
|
||||
<label
|
||||
className="inline-block px-4 py-2 rounded-lg cursor-pointer transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-ember)',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Choose File
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileInput}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs mt-2" style={{ color: 'var(--text-tertiary)' }}>
|
||||
PNG, JPG, GIF up to 10MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imageUrl && (
|
||||
<div className="mb-6">
|
||||
<label
|
||||
className="block text-sm font-medium mb-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
Preview
|
||||
</label>
|
||||
<div
|
||||
className="relative rounded-lg overflow-hidden"
|
||||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Preview"
|
||||
className="w-full h-32 object-cover"
|
||||
onError={(e) => {
|
||||
e.target.src =
|
||||
'https://via.placeholder.com/400x128/f3f4f6/6b7280?text=Invalid+Image';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<Button type="button" variant="secondary" onClick={onClose} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!imageUrl.trim() || uploading}
|
||||
loading={uploading}
|
||||
className="flex-1"
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Upload Image'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
321
pages/decks.js
321
pages/decks.js
|
|
@ -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,165 +300,171 @@ export default function Decks() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Deck Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-bg-primary rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold text-text-primary mb-4">Create New Deck</h2>
|
||||
<form onSubmit={handleCreateDeck}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-text-secondary text-sm font-medium mb-2">
|
||||
Deck Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={newDeck.name}
|
||||
onChange={(e) => setNewDeck({...newDeck, name: e.target.value})}
|
||||
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
||||
placeholder="Enter deck name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-text-secondary text-sm font-medium mb-2">
|
||||
Format
|
||||
</label>
|
||||
<select
|
||||
value={newDeck.format}
|
||||
onChange={(e) => setNewDeck({...newDeck, format: e.target.value})}
|
||||
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
||||
>
|
||||
<option value="Commander">Commander</option>
|
||||
<option value="Standard">Standard</option>
|
||||
<option value="Modern">Modern</option>
|
||||
<option value="Legacy">Legacy</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-text-secondary text-sm font-medium mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={newDeck.description}
|
||||
onChange={(e) => setNewDeck({...newDeck, description: e.target.value})}
|
||||
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
||||
rows="3"
|
||||
placeholder="Describe your deck strategy..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newDeck.is_public}
|
||||
onChange={(e) => setNewDeck({...newDeck, is_public: e.target.checked})}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-text-secondary text-sm">Make deck public</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="flex-1 px-4 py-2 border border-border rounded-lg text-text-secondary hover:bg-bg-secondary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors"
|
||||
>
|
||||
Create Deck
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<Modal
|
||||
open={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
title="Create New Deck"
|
||||
size="md"
|
||||
>
|
||||
<form onSubmit={handleCreateDeck} className="space-y-4">
|
||||
<Input
|
||||
label="Deck Name *"
|
||||
required
|
||||
value={newDeck.name}
|
||||
onChange={(e) => setNewDeck({ ...newDeck, name: e.target.value })}
|
||||
placeholder="Enter deck name"
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
htmlFor="create-deck-format"
|
||||
>
|
||||
Format
|
||||
</label>
|
||||
<select
|
||||
id="create-deck-format"
|
||||
value={newDeck.format}
|
||||
onChange={(e) => setNewDeck({ ...newDeck, format: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
||||
>
|
||||
<option value="Commander">Commander</option>
|
||||
<option value="Standard">Standard</option>
|
||||
<option value="Modern">Modern</option>
|
||||
<option value="Legacy">Legacy</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Deck Modal */}
|
||||
{editingDeck && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-bg-primary rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold text-text-primary mb-4">Edit Deck</h2>
|
||||
<form onSubmit={handleEditDeck}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-text-secondary text-sm font-medium mb-2">
|
||||
Deck Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editingDeck.name}
|
||||
onChange={(e) => setEditingDeck({...editingDeck, name: e.target.value})}
|
||||
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-text-secondary text-sm font-medium mb-2">
|
||||
Format
|
||||
</label>
|
||||
<select
|
||||
value={editingDeck.format}
|
||||
onChange={(e) => setEditingDeck({...editingDeck, format: e.target.value})}
|
||||
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
||||
>
|
||||
<option value="Commander">Commander</option>
|
||||
<option value="Standard">Standard</option>
|
||||
<option value="Modern">Modern</option>
|
||||
<option value="Legacy">Legacy</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-text-secondary text-sm font-medium mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={editingDeck.description || ''}
|
||||
onChange={(e) => setEditingDeck({...editingDeck, description: e.target.value})}
|
||||
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editingDeck.is_public}
|
||||
onChange={(e) => setEditingDeck({...editingDeck, is_public: e.target.checked})}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-text-secondary text-sm">Make deck public</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingDeck(null)}
|
||||
className="flex-1 px-4 py-2 border border-border rounded-lg text-text-secondary hover:bg-bg-secondary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
htmlFor="create-deck-description"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="create-deck-description"
|
||||
value={newDeck.description}
|
||||
onChange={(e) => setNewDeck({ ...newDeck, description: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
||||
rows="3"
|
||||
placeholder="Describe your deck strategy..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newDeck.is_public}
|
||||
onChange={(e) => setNewDeck({ ...newDeck, is_public: e.target.checked })}
|
||||
className="mr-2"
|
||||
style={{ accentColor: 'var(--accent-ember)' }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Make deck public
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex space-x-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" className="flex-1">
|
||||
Create Deck
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={!!editingDeck}
|
||||
onClose={() => setEditingDeck(null)}
|
||||
title="Edit Deck"
|
||||
size="md"
|
||||
>
|
||||
{editingDeck && (
|
||||
<form onSubmit={handleEditDeck} className="space-y-4">
|
||||
<Input
|
||||
label="Deck Name *"
|
||||
required
|
||||
value={editingDeck.name}
|
||||
onChange={(e) =>
|
||||
setEditingDeck({ ...editingDeck, name: e.target.value })
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
htmlFor="edit-deck-format"
|
||||
>
|
||||
Format
|
||||
</label>
|
||||
<select
|
||||
id="edit-deck-format"
|
||||
value={editingDeck.format}
|
||||
onChange={(e) =>
|
||||
setEditingDeck({ ...editingDeck, format: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
||||
>
|
||||
<option value="Commander">Commander</option>
|
||||
<option value="Standard">Standard</option>
|
||||
<option value="Modern">Modern</option>
|
||||
<option value="Legacy">Legacy</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
htmlFor="edit-deck-description"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="edit-deck-description"
|
||||
value={editingDeck.description || ''}
|
||||
onChange={(e) =>
|
||||
setEditingDeck({ ...editingDeck, description: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editingDeck.is_public}
|
||||
onChange={(e) =>
|
||||
setEditingDeck({ ...editingDeck, is_public: e.target.checked })
|
||||
}
|
||||
className="mr-2"
|
||||
style={{ accentColor: 'var(--accent-ember)' }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Make deck public
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex space-x-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setEditingDeck(null)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" className="flex-1">
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
|||
411
pages/index.js
411
pages/index.js
|
|
@ -4,6 +4,7 @@ import { useRouter } from 'next/router';
|
|||
import Link from 'next/link';
|
||||
import { useAuth } from '../lib/use-auth.js';
|
||||
import AnimatedFireLogo from '../components/AnimatedFireLogo';
|
||||
import { Button, GlassSurface } from '../components/ui';
|
||||
|
||||
export default function Home() {
|
||||
const { user, loading } = useAuth();
|
||||
|
|
@ -11,14 +12,12 @@ export default function Home() {
|
|||
const [featuredCollections, setFeaturedCollections] = useState([]);
|
||||
const [collectionsLoading, setCollectionsLoading] = useState(true);
|
||||
|
||||
// If user is logged in, redirect to dashboard
|
||||
useEffect(() => {
|
||||
if (!loading && user) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
// Fetch featured collections for public display
|
||||
useEffect(() => {
|
||||
const fetchFeaturedCollections = async () => {
|
||||
try {
|
||||
|
|
@ -33,31 +32,44 @@ export default function Home() {
|
|||
setCollectionsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFeaturedCollections();
|
||||
}, []);
|
||||
|
||||
// Show loading while checking auth
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: 'var(--bg-primary)' }}>
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center"
|
||||
style={{ backgroundColor: 'var(--bg-primary)' }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 mx-auto mb-4" style={{ borderColor: 'var(--accent-ember)' }}></div>
|
||||
<div
|
||||
className="motion-essential animate-spin rounded-full h-12 w-12 border-b-2 mx-auto mb-4"
|
||||
style={{ borderColor: 'var(--accent-ember)' }}
|
||||
/>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If user is logged in, don't show landing page (redirect handled above)
|
||||
if (user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--bg-primary)' }}>
|
||||
{/* Navigation Bar */}
|
||||
<nav className="border-b" style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}>
|
||||
<nav
|
||||
className="border-b"
|
||||
style={{
|
||||
background: 'var(--glass-surface-mid)',
|
||||
backdropFilter:
|
||||
'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
|
||||
WebkitBackdropFilter:
|
||||
'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
|
||||
borderColor: 'var(--border)',
|
||||
boxShadow: 'inset 0 1px 0 var(--rim-light-inner)',
|
||||
}}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center py-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
|
|
@ -65,29 +77,21 @@ export default function Home() {
|
|||
<h1 className="text-2xl font-bold gradient-text-flame">Deck Hearth</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-4 py-2 text-sm font-medium rounded-xl transition-all duration-200 hover:opacity-80"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
Sign In
|
||||
<Link href="/login">
|
||||
<Button variant="ghost" size="sm">
|
||||
Sign In
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
href="/signup"
|
||||
className="px-6 py-2 text-sm font-medium rounded-xl transition-all duration-200 hover:opacity-90"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-ember)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
Get Started
|
||||
<Link href="/signup">
|
||||
<Button variant="primary" size="sm">
|
||||
Get Started
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative py-20 px-4">
|
||||
<div className="max-w-6xl mx-auto text-center">
|
||||
<div className="mb-8">
|
||||
|
|
@ -96,86 +100,136 @@ export default function Home() {
|
|||
<h1 className="text-5xl md:text-7xl font-bold mb-6">
|
||||
<span className="gradient-text-flame">Deck Hearth</span>
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
|
||||
<p
|
||||
className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto leading-relaxed"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
The ultimate hub for trading card collectors. Organize your cards into lists,
|
||||
discover rare cards, and connect with fellow enthusiasts in one beautiful platform.
|
||||
discover rare cards, and connect with fellow enthusiasts in one beautiful
|
||||
platform.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<Link
|
||||
href="/signup"
|
||||
className="px-8 py-4 text-lg font-medium rounded-2xl transition-all duration-200 hover:opacity-90 hover:scale-105"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-ember)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
Start Your Collection
|
||||
<Link href="/signup">
|
||||
<Button variant="primary" size="lg">
|
||||
Start Your Collection
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
href="/community/collections"
|
||||
className="px-8 py-4 text-lg font-medium rounded-2xl border-2 transition-all duration-200 hover:opacity-80"
|
||||
style={{
|
||||
color: 'var(--text-primary)',
|
||||
borderColor: 'var(--accent-ember)'
|
||||
}}
|
||||
>
|
||||
Explore Lists
|
||||
<Link href="/community/collections">
|
||||
<Button variant="secondary" size="lg">
|
||||
Explore Lists
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="py-20 px-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h2 className="text-4xl font-bold text-center mb-16 gradient-text-gold">
|
||||
Everything You Need to Manage Your Cards
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
<div className="text-center p-8 rounded-2xl" style={{ backgroundColor: 'var(--bg-primary)' }}>
|
||||
<div className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--accent-ember)' }}>
|
||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
<GlassSurface
|
||||
tint="mid"
|
||||
rim="subtle"
|
||||
elevation="ambient"
|
||||
className="text-center p-8 rounded-2xl"
|
||||
>
|
||||
<div
|
||||
className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center"
|
||||
style={{ backgroundColor: 'var(--accent-ember)' }}
|
||||
>
|
||||
<svg
|
||||
className="w-8 h-8 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-4 gradient-text-flame">Organize Lists</h3>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>
|
||||
Create custom lists, track card values, and organize by sets, rarity, or any system that works for you.
|
||||
Create custom lists, track card values, and organize by sets, rarity, or any
|
||||
system that works for you.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-8 rounded-2xl" style={{ backgroundColor: 'var(--bg-primary)' }}>
|
||||
<div className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--accent-gold)' }}>
|
||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</GlassSurface>
|
||||
<GlassSurface
|
||||
tint="mid"
|
||||
rim="subtle"
|
||||
elevation="ambient"
|
||||
className="text-center p-8 rounded-2xl"
|
||||
>
|
||||
<div
|
||||
className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center"
|
||||
style={{ backgroundColor: 'var(--accent-gold)' }}
|
||||
>
|
||||
<svg
|
||||
className="w-8 h-8 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-4 gradient-text-gold">Discover Cards</h3>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>
|
||||
Search through thousands of cards across multiple TCGs. Find that missing piece for your binder.
|
||||
Search through thousands of cards across multiple TCGs. Find that missing
|
||||
piece for your binder.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-8 rounded-2xl" style={{ backgroundColor: 'var(--bg-primary)' }}>
|
||||
<div className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--accent-flame)' }}>
|
||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</GlassSurface>
|
||||
<GlassSurface
|
||||
tint="mid"
|
||||
rim="subtle"
|
||||
elevation="ambient"
|
||||
className="text-center p-8 rounded-2xl"
|
||||
>
|
||||
<div
|
||||
className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center"
|
||||
style={{ backgroundColor: 'var(--accent-flame)' }}
|
||||
>
|
||||
<svg
|
||||
className="w-8 h-8 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-4 gradient-text-ember">Connect & Share</h3>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>
|
||||
Share your lists with the community, collaborate with friends, and discover amazing lists from other collectors.
|
||||
Share your lists with the community, collaborate with friends, and discover
|
||||
amazing lists from other collectors.
|
||||
</p>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured Collections Section */}
|
||||
<section className="py-20 px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold mb-4 gradient-text-flame">
|
||||
Featured Lists
|
||||
</h2>
|
||||
<h2 className="text-4xl font-bold mb-4 gradient-text-flame">Featured Lists</h2>
|
||||
<p className="text-xl" style={{ color: 'var(--text-secondary)' }}>
|
||||
Discover amazing lists from our community
|
||||
</p>
|
||||
|
|
@ -184,13 +238,32 @@ export default function Home() {
|
|||
{collectionsLoading ? (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="rounded-2xl p-6 animate-pulse" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||
<div className="h-32 rounded-xl mb-4" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
|
||||
<div className="h-6 rounded mb-2" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
|
||||
<div className="h-4 rounded mb-4" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
|
||||
<div
|
||||
key={i}
|
||||
className="motion-essential rounded-2xl p-6 animate-pulse"
|
||||
style={{ backgroundColor: 'var(--bg-secondary)' }}
|
||||
>
|
||||
<div
|
||||
className="h-32 rounded-xl mb-4"
|
||||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||
/>
|
||||
<div
|
||||
className="h-6 rounded mb-2"
|
||||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||
/>
|
||||
<div
|
||||
className="h-4 rounded mb-4"
|
||||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||
/>
|
||||
<div className="flex justify-between">
|
||||
<div className="h-4 w-16 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
|
||||
<div className="h-4 w-20 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
|
||||
<div
|
||||
className="h-4 w-16 rounded"
|
||||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||
/>
|
||||
<div
|
||||
className="h-4 w-20 rounded"
|
||||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -201,98 +274,135 @@ 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)' }}>
|
||||
{collection.image ? (
|
||||
<img
|
||||
src={collection.image}
|
||||
alt={collection.name}
|
||||
className="w-full h-full object-cover rounded-xl"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-2 rounded-xl flex items-center justify-center" style={{ backgroundColor: 'var(--accent-ember)' }}>
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<GlassSurface
|
||||
tint="mid"
|
||||
rim="subtle"
|
||||
elevation="ambient"
|
||||
className="block rounded-2xl p-6 hover:scale-105"
|
||||
style={{
|
||||
transition:
|
||||
'transform var(--motion-duration-default) var(--motion-ease-out), box-shadow var(--motion-duration-default) var(--motion-ease-out)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-32 rounded-xl mb-4 flex items-center justify-center"
|
||||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||
>
|
||||
{collection.image ? (
|
||||
<img
|
||||
src={collection.image}
|
||||
alt={collection.name}
|
||||
className="w-full h-full object-cover rounded-xl"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="w-12 h-12 mx-auto mb-2 rounded-xl flex items-center justify-center"
|
||||
style={{ backgroundColor: 'var(--accent-ember)' }}
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{collection.cardCount} cards
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{collection.cardCount} cards</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-2 gradient-text-flame">{collection.name}</h3>
|
||||
<p className="text-sm mb-4 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{collection.description || 'A carefully curated list'}
|
||||
</p>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{collection.cardCount} cards
|
||||
</span>
|
||||
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
by {collection.creator}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-2 gradient-text-flame">
|
||||
{collection.name}
|
||||
</h3>
|
||||
<p
|
||||
className="text-sm mb-4 line-clamp-2"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{collection.description || 'A carefully curated list'}
|
||||
</p>
|
||||
<div className="flex justify-between items-center">
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
{collection.cardCount} cards
|
||||
</span>
|
||||
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
by {collection.creator}
|
||||
</span>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center mt-12">
|
||||
<Link
|
||||
href="/community/collections"
|
||||
className="inline-flex items-center px-6 py-3 text-lg font-medium rounded-xl transition-all duration-200 hover:opacity-80"
|
||||
style={{
|
||||
color: 'var(--accent-ember)',
|
||||
border: '2px solid var(--accent-ember)'
|
||||
}}
|
||||
>
|
||||
View All Lists
|
||||
<svg className="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
<Link href="/community/collections">
|
||||
<Button variant="secondary" size="lg">
|
||||
View All Lists
|
||||
<svg
|
||||
className="w-5 h-5 ml-2 inline-block"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-20 px-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h2 className="text-4xl font-bold mb-6 gradient-text-flame">
|
||||
Ready to Start Your Journey?
|
||||
</h2>
|
||||
<p className="text-xl mb-8" style={{ color: 'var(--text-secondary)' }}>
|
||||
Join thousands of collectors who trust Deck Hearth to manage their cards and lists.
|
||||
Join thousands of collectors who trust Deck Hearth to manage their cards and
|
||||
lists.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<Link
|
||||
href="/signup"
|
||||
className="px-8 py-4 text-lg font-medium rounded-2xl transition-all duration-200 hover:opacity-90 hover:scale-105"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-ember)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
Create Free Account
|
||||
<Link href="/signup">
|
||||
<Button variant="primary" size="lg">
|
||||
Create Free Account
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
href="/cards"
|
||||
className="px-8 py-4 text-lg font-medium rounded-2xl border-2 transition-all duration-200 hover:opacity-80"
|
||||
style={{
|
||||
color: 'var(--text-primary)',
|
||||
borderColor: 'var(--accent-ember)'
|
||||
}}
|
||||
>
|
||||
Browse Cards
|
||||
<Link href="/cards">
|
||||
<Button variant="secondary" size="lg">
|
||||
Browse Cards
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-12 px-4 border-t" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border)' }}>
|
||||
<footer
|
||||
className="py-12 px-4 border-t"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--border)',
|
||||
}}
|
||||
>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||
<div className="flex items-center space-x-3 mb-4 md:mb-0">
|
||||
|
|
@ -300,18 +410,33 @@ export default function Home() {
|
|||
<span className="text-xl font-bold gradient-text-flame">Deck Hearth</span>
|
||||
</div>
|
||||
<div className="flex space-x-6">
|
||||
<Link href="/community/collections" className="hover:opacity-80 transition-opacity" style={{ color: 'var(--text-secondary)' }}>
|
||||
<Link
|
||||
href="/community/collections"
|
||||
className="hover:opacity-80 transition-opacity"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
Community
|
||||
</Link>
|
||||
<Link href="/cards" className="hover:opacity-80 transition-opacity" style={{ color: 'var(--text-secondary)' }}>
|
||||
<Link
|
||||
href="/cards"
|
||||
className="hover:opacity-80 transition-opacity"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
Cards
|
||||
</Link>
|
||||
<Link href="/login" className="hover:opacity-80 transition-opacity" style={{ color: 'var(--text-secondary)' }}>
|
||||
<Link
|
||||
href="/login"
|
||||
className="hover:opacity-80 transition-opacity"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 pt-8 border-t text-center" style={{ borderColor: 'var(--border)' }}>
|
||||
<div
|
||||
className="mt-8 pt-8 border-t text-center"
|
||||
style={{ borderColor: 'var(--border)' }}
|
||||
>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>
|
||||
© 2024 Deck Hearth. Built for collectors, by collectors.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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 "{result.collection.name}".
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => router.push(`/collection/${result.collection.id}`)}
|
||||
className="w-full px-6 py-3 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200"
|
||||
className="w-full"
|
||||
>
|
||||
View List
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={() => router.push('/collections')}
|
||||
className="w-full px-6 py-3 rounded-lg font-medium border transition-all duration-200"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)'
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
View All Lists
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
) : (
|
||||
<div className="text-center p-8 rounded-2xl shadow-lg" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||
<div className="text-6xl mb-4">❌</div>
|
||||
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
<GlassSurface
|
||||
tint="mid"
|
||||
rim="subtle"
|
||||
elevation="pronounced"
|
||||
className="text-center p-8 rounded-2xl"
|
||||
>
|
||||
<div className="text-6xl mb-4" aria-hidden="true">❌</div>
|
||||
<h2
|
||||
className="text-2xl font-bold mb-4"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
Invitation Error
|
||||
</h2>
|
||||
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
{error}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => router.push('/collections')}
|
||||
className="w-full px-6 py-3 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200"
|
||||
className="w-full"
|
||||
>
|
||||
Browse Lists
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={() => router.push('/')}
|
||||
className="w-full px-6 py-3 rounded-lg font-medium border transition-all duration-200"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)'
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Go Home
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 "{result.collection.name}".
|
||||
You can always ask the list owner for another invitation if you change your mind.
|
||||
You can always ask the list owner for another invitation if you change
|
||||
your mind.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => router.push('/collections')}
|
||||
className="w-full px-6 py-3 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200"
|
||||
className="w-full"
|
||||
>
|
||||
Browse Public Lists
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={() => router.push('/')}
|
||||
className="w-full px-6 py-3 rounded-lg font-medium border transition-all duration-200"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)'
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Go Home
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
) : (
|
||||
<div className="text-center p-8 rounded-2xl shadow-lg" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||
<div className="text-6xl mb-4">❌</div>
|
||||
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
<GlassSurface
|
||||
tint="mid"
|
||||
rim="subtle"
|
||||
elevation="pronounced"
|
||||
className="text-center p-8 rounded-2xl"
|
||||
>
|
||||
<div className="text-6xl mb-4" aria-hidden="true">❌</div>
|
||||
<h2
|
||||
className="text-2xl font-bold mb-4"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
Error Processing Response
|
||||
</h2>
|
||||
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
{error}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => router.push('/collections')}
|
||||
className="w-full px-6 py-3 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200"
|
||||
className="w-full"
|
||||
>
|
||||
Browse Lists
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={() => router.push('/')}
|
||||
className="w-full px-6 py-3 rounded-lg font-medium border transition-all duration-200"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)'
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Go Home
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue