feat(design-system): finish Liquid Glass — close all remaining sub-convoys #96
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")
|
aesthetic; modals blur the page behind them; reusable components")
|
||||||
is delivered TODAY by the merged work.
|
is delivered TODAY by the merged work.
|
||||||
|
|
||||||
|
### Finish-portfolio sweep (2026-06-03 follow-up)
|
||||||
|
|
||||||
|
After the operator promoted PR #95 to production, the follow-up
|
||||||
|
`finish-liquid-glass-design` PR closed out the remaining briefs in
|
||||||
|
a single push. **All 8 sub-convoys are now MERGED to main** (only
|
||||||
|
the deferred Card3D state-management scope remains queued as its
|
||||||
|
own convoy, blocking #5 Brief 1's pixel-level rim migration).
|
||||||
|
|
||||||
|
| # | Slug | Final status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 2 | `liquid-glass-modal-and-surface-primitive` | **Brief 2 MERGED** — every legacy `fixed inset-0 bg-black bg-opacity-` shell across `pages/` + `components/` migrated to `<Modal>`; CI grandfather list emptied to zero entries; the grep gate is now strict (no-allow-list) and bug-detects any reintroduction. Modals fully consolidated. |
|
||||||
|
| 3 | `liquid-glass-form-primitives` | **Brief 2 MERGED** — `<Button>` + `<SearchBar>` adopted by dashboard, my-cards, community/collections, and CollectionsPageView; the remaining native button consumers in card-grid / per-row toolbars are intentionally left native (per-row tiny icon buttons whose styling doesn't match `<Button>` variants). |
|
||||||
|
| 5 | `liquid-glass-card-surfaces` | **scope-revised + MERGED** — the planned migration was descoped after discovering `components/Card3D.js` was **dead code** (never imported from `pages/**` or `components/**`; only referenced in convoy docs). File deleted (-505 LOC). The actual card-grid component (`components/CardItem.js`) was intentionally left untouched in this sweep to preserve the per-rarity glow tuning that the visual-diff baseline locked in; a future implementer turn can apply rim-light tokens with a dedicated baseline re-seed. |
|
||||||
|
| 6 | `liquid-glass-public-and-auth` | **MERGED** — `pages/index.js` landing editorial fully glass-migrated: three feature cards + featured-list cards now use `<GlassSurface tint="mid" rim="subtle" elevation="ambient">`, top nav got the `--glass-surface-mid` treatment matching Layout's sidebar, and all 6 CTA buttons are now `<Button variant="primary"\|"secondary">`. `pages/invite/{accept,decline}.js` outcome panels wrapped in `<GlassSurface>` + all 8 buttons migrated to `<Button>`. |
|
||||||
|
| 8 | `cleanup-legacy-design-css` | **Brief 2 MERGED** — every consumer of `gradient-bg-purple` (13 occurrences across 8 files) swept to `gradient-bg-ember`; the now-dead CSS class definitions for `.gradient-text-blue`, `.gradient-text-purple`, and the three `[data-theme="dark"] .glow-{blue,purple,pink}` selectors deleted from `styles/globals.css`; the `forbidden-deprecated-color-aliases` CI gate graduated from WARN to **FAIL** with all 9 patterns blocking. |
|
||||||
|
|
||||||
|
**Vitest after the sweep:** 104 passing (unchanged — primitive migrations don't add new unit tests; integration coverage is via Playwright smoke + visual-diff). Lint: 0 errors, 2 pre-existing warnings (unrelated `CardEditorForm.js` + `CollectionsPageView.js` carry-overs that were noted in PR #95).
|
||||||
|
|
||||||
|
**CI graduation gates now blocking:**
|
||||||
|
|
||||||
|
- `forbidden-modal-shell-without-primitive` — zero allow-list; any new `fixed inset-0 bg-black bg-opacity-` shell fails the build.
|
||||||
|
- `forbidden-deprecated-color-aliases` — graduated WARN → FAIL; any new use of the 9 pre-Deck-Hearth alias classes fails the build.
|
||||||
|
|
||||||
|
**Queued for a future convoy** (no impact on the current ship-readiness state):
|
||||||
|
|
||||||
|
- **`fix-card3d-state` is no longer needed** — Card3D was dead code and is now deleted. The card-grid implementer turn that the original sub-convoy #5 envisioned can proceed against `components/CardItem.js` directly when an operator wants the rim-light polish on cards, with its own visual-diff baseline re-seed.
|
||||||
|
|
||||||
## Self-analytics
|
## Self-analytics
|
||||||
|
|
||||||
After each convoy, `scripts/log-convoy-event.sh` emits a record to `.convoys/.metrics.jsonl` (gitignored). After 3-5 convoys, run the upstream `agent-pipeline/analytics/` aggregator to see where token spend goes — that data feeds whether to add or remove rules.
|
After each convoy, `scripts/log-convoy-event.sh` emits a record to `.convoys/.metrics.jsonl` (gitignored). After 3-5 convoys, run the upstream `agent-pipeline/analytics/` aggregator to see where token spend goes — that data feeds whether to add or remove rules.
|
||||||
|
|
|
||||||
78
.github/workflows/ci.yml
vendored
78
.github/workflows/ci.yml
vendored
|
|
@ -202,72 +202,49 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Fail if a new file uses the legacy modal-shell pattern
|
- name: Fail if ANY file uses the legacy modal-shell pattern
|
||||||
run: |
|
run: |
|
||||||
# liquid-glass-modal-and-surface-primitive convoy — every modal
|
# liquid-glass-modal-and-surface-primitive convoy — every modal
|
||||||
# MUST compose the `<Modal>` primitive from components/ui/.
|
# MUST compose the `<Modal>` primitive from components/ui/.
|
||||||
# The 9 files below are grandfathered legacy shells queued for
|
# The original 9 grandfathered shells were swept in the
|
||||||
# sweep under liquid-glass-modal-and-surface-primitive Brief 2.
|
# finish-liquid-glass-design PR; the gate is now strict and
|
||||||
# New files must NOT introduce another legacy shell.
|
# has zero allow-list entries. If a new file legitimately
|
||||||
#
|
# needs the legacy pattern (rare — backdrop-blur sheets that
|
||||||
# If a new file legitimately needs the legacy pattern (rare),
|
# are not modals belong in a different primitive), revive the
|
||||||
# update this list AND open a tracking issue — do not silently
|
# allow-list pattern AND open a tracking issue.
|
||||||
# delete entries from this list.
|
|
||||||
GRANDFATHERED=(
|
|
||||||
"components/CollectionsSuccessModal.js"
|
|
||||||
"components/CollectionsEditModal.js"
|
|
||||||
"components/CollectionEditModal.js"
|
|
||||||
"components/CardDetailDeckModal.js"
|
|
||||||
"components/ScannerPageView.js"
|
|
||||||
"components/UploadImageModal.js"
|
|
||||||
"components/CollectionSelectionModal.js"
|
|
||||||
"components/OCRSettings.js"
|
|
||||||
"pages/decks.js"
|
|
||||||
)
|
|
||||||
FOUND=()
|
FOUND=()
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
allowed=false
|
FOUND+=("$file")
|
||||||
for grandfathered in "${GRANDFATHERED[@]}"; do
|
|
||||||
if [ "$file" = "$grandfathered" ]; then
|
|
||||||
allowed=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ "$allowed" = false ]; then
|
|
||||||
FOUND+=("$file")
|
|
||||||
fi
|
|
||||||
done < <(grep -lE 'fixed inset-0 bg-black bg-opacity-' \
|
done < <(grep -lE 'fixed inset-0 bg-black bg-opacity-' \
|
||||||
pages components -r --include='*.js' 2>/dev/null \
|
pages components -r --include='*.js' 2>/dev/null \
|
||||||
| sort -u || true)
|
| sort -u || true)
|
||||||
if [ ${#FOUND[@]} -gt 0 ]; then
|
if [ ${#FOUND[@]} -gt 0 ]; then
|
||||||
echo "::error::Legacy modal-shell pattern detected in non-grandfathered file(s). Use the <Modal> primitive from components/ui/."
|
echo "::error::Legacy modal-shell pattern detected. Use the <Modal> primitive from components/ui/."
|
||||||
for f in "${FOUND[@]}"; do
|
for f in "${FOUND[@]}"; do
|
||||||
echo "::error file=${f}::Replace 'fixed inset-0 bg-black bg-opacity-' with <Modal open={…} onClose={…} title=…> from components/ui."
|
echo "::error file=${f}::Replace 'fixed inset-0 bg-black bg-opacity-' with <Modal open={…} onClose={…} title=…> from components/ui."
|
||||||
done
|
done
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "OK: no new modal shells outside the grandfathered list."
|
echo "OK: no legacy modal shells anywhere in pages/ or components/."
|
||||||
|
|
||||||
forbidden-deprecated-color-aliases:
|
forbidden-deprecated-color-aliases:
|
||||||
name: No new use of deprecated color aliases
|
name: No use of deprecated color aliases
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Fail if NEW code uses pre-Deck-Hearth blue/purple/pink aliases
|
- name: Fail if ANY code uses pre-Deck-Hearth blue/purple/pink aliases
|
||||||
run: |
|
run: |
|
||||||
# liquid-glass-design-tokens + cleanup-legacy-design-css convoys.
|
# liquid-glass-design-tokens + cleanup-legacy-design-css convoys.
|
||||||
# Pre-Deck-Hearth alias tokens (--accent-blue, --accent-purple,
|
# Pre-Deck-Hearth alias utility classes (.gradient-text-blue /
|
||||||
# --accent-pink) and the .gradient-text-blue / .gradient-text-purple
|
# .gradient-text-purple / .glow-blue / .glow-purple / .glow-pink /
|
||||||
# / .glow-blue / .glow-purple / .glow-pink utility classes are
|
# .gradient-bg-purple / .gradient-bg-blue / .gradient-bg-pink) were
|
||||||
# SCHEDULED FOR DELETION under cleanup-legacy-design-css (#8).
|
# graduated from WARN to FAIL by the finish-liquid-glass-design PR
|
||||||
|
# after every consumer was swept to --accent-ember equivalents
|
||||||
|
# AND the CSS definitions were deleted from styles/globals.css.
|
||||||
#
|
#
|
||||||
# Allowed in styles/globals.css (the definitions themselves stay
|
# If a new consumer of one of these classes appears, the build
|
||||||
# until #8 sweeps them) but forbidden in pages/ + components/
|
# will fail — use --accent-ember, --accent-flame, --accent-gold
|
||||||
# for NEW consumers. Existing consumers are listed below as
|
# (the canonical warm-palette tokens) instead.
|
||||||
# grandfathered and will sweep with #8's deletion PR.
|
|
||||||
#
|
|
||||||
# Use --accent-ember, --accent-flame, --accent-gold (the canonical
|
|
||||||
# warm-palette tokens) instead.
|
|
||||||
PATTERNS=(
|
PATTERNS=(
|
||||||
'gradient-text-purple'
|
'gradient-text-purple'
|
||||||
'gradient-text-pink'
|
'gradient-text-pink'
|
||||||
|
|
@ -287,19 +264,16 @@ jobs:
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [ -n "$ALL_MATCHES" ]; then
|
if [ -n "$ALL_MATCHES" ]; then
|
||||||
# The current tree has known consumers; print as warnings + record
|
echo "::error::Deprecated color alias detected. Use --accent-ember / --accent-flame / --accent-gold (or the .gradient-bg-ember / .gradient-text-ember utility classes)."
|
||||||
# the baseline count. Once #8 sweeps them, change `exit 0` to
|
echo "$ALL_MATCHES" | sort -u | while IFS= read -r line; do
|
||||||
# `exit 1` to enforce zero.
|
|
||||||
BASELINE_COUNT=$(echo "$ALL_MATCHES" | grep -cE '.' || true)
|
|
||||||
echo "::warning::Deprecated color alias(es) found ($BASELINE_COUNT line(s)). Scheduled for deletion by cleanup-legacy-design-css convoy (#8); do not introduce new consumers."
|
|
||||||
echo "$ALL_MATCHES" | sort -u | head -20 | while IFS= read -r line; do
|
|
||||||
[ -z "$line" ] && continue
|
[ -z "$line" ] && continue
|
||||||
file=$(echo "$line" | cut -d: -f1)
|
file=$(echo "$line" | cut -d: -f1)
|
||||||
lineno=$(echo "$line" | cut -d: -f2)
|
lineno=$(echo "$line" | cut -d: -f2)
|
||||||
echo "::warning file=${file},line=${lineno}::Deprecated color alias — use --accent-ember / --accent-flame / --accent-gold."
|
echo "::error file=${file},line=${lineno}::Deprecated color alias — replace with the canonical Deck Hearth warm-palette equivalent."
|
||||||
done
|
done
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "OK: deprecated-color-alias audit complete."
|
echo "OK: no deprecated color aliases in pages/ or components/."
|
||||||
|
|
||||||
forbidden-stale-strings:
|
forbidden-stale-strings:
|
||||||
name: No stale ownership/collection copy
|
name: No stale ownership/collection copy
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ export default function AdminProtected({ children }) {
|
||||||
<div className="space-x-4">
|
<div className="space-x-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/login')}
|
onClick={() => router.push('/login')}
|
||||||
className="px-6 py-3 rounded-xl font-medium gradient-bg-purple text-white hover:shadow-lg transition-all duration-200"
|
className="px-6 py-3 rounded-xl font-medium gradient-bg-ember text-white hover:shadow-lg transition-all duration-200"
|
||||||
>
|
>
|
||||||
Login as Admin
|
Login as Admin
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -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({
|
export default function CardDetailDeckModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
card,
|
card,
|
||||||
|
|
@ -7,69 +9,54 @@ export default function CardDetailDeckModal({
|
||||||
onAdd,
|
onAdd,
|
||||||
onClose,
|
onClose,
|
||||||
}) {
|
}) {
|
||||||
if (!isOpen || !card) {
|
if (!card) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<Modal open={isOpen} onClose={onClose} title="Add to Deck" size="md">
|
||||||
<div
|
<div className="mb-6">
|
||||||
className="bg-white rounded-2xl p-6 max-w-md w-full mx-4"
|
<label
|
||||||
style={{ backgroundColor: 'var(--bg-primary)' }}
|
className="block text-sm font-medium mb-2"
|
||||||
>
|
style={{ color: 'var(--text-primary)' }}
|
||||||
<h3 className="text-xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
htmlFor="card-detail-deck-select"
|
||||||
Add to Deck
|
>
|
||||||
</h3>
|
Select Deck
|
||||||
<div className="mb-6">
|
</label>
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
<select
|
||||||
Select Deck
|
id="card-detail-deck-select"
|
||||||
</label>
|
value={selectedDeck}
|
||||||
<select
|
onChange={(e) => onSelectedDeckChange(e.target.value)}
|
||||||
value={selectedDeck}
|
className="w-full p-3 rounded-xl border transition-all"
|
||||||
onChange={(e) => onSelectedDeckChange(e.target.value)}
|
style={{
|
||||||
className="w-full p-3 rounded-xl border transition-all duration-200"
|
backgroundColor: 'var(--bg-secondary)',
|
||||||
style={{
|
borderColor: 'var(--border)',
|
||||||
backgroundColor: 'var(--bg-secondary)',
|
color: 'var(--text-primary)',
|
||||||
borderColor: 'var(--border)',
|
transitionDuration: 'var(--motion-duration-default)',
|
||||||
color: 'var(--text-primary)',
|
transitionTimingFunction: 'var(--motion-ease-out)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">Choose a deck...</option>
|
<option value="">Choose a deck...</option>
|
||||||
{decks
|
{decks
|
||||||
.filter((deck) => deck.game === card.game)
|
.filter((deck) => deck.game === card.game)
|
||||||
.map((deck) => (
|
.map((deck) => (
|
||||||
<option key={deck.id} value={deck.id}>
|
<option key={deck.id} value={deck.id}>
|
||||||
{deck.name}
|
{deck.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onAdd}
|
|
||||||
disabled={!selectedDeck}
|
|
||||||
className={`flex-1 px-4 py-2 rounded-xl font-medium transition-all duration-200 ${
|
|
||||||
selectedDeck
|
|
||||||
? 'gradient-bg-purple text-white hover:shadow-lg'
|
|
||||||
: 'opacity-50 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Add to Deck
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex-1 px-4 py-2 rounded-xl font-medium border transition-all duration-200"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--border)',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={onAdd}
|
||||||
|
disabled={!selectedDeck}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Add to Deck
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={onClose} className="flex-1">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ export default function CardDetailQuantityModal({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onConfirm(quantity)}
|
onClick={() => onConfirm(quantity)}
|
||||||
className="flex-1 px-4 py-2 rounded-xl font-medium gradient-bg-purple text-white hover:shadow-lg transition-all duration-200"
|
className="flex-1 px-4 py-2 rounded-xl font-medium gradient-bg-ember text-white hover:shadow-lg transition-all duration-200"
|
||||||
>
|
>
|
||||||
{ownedQuantity > 0 ? 'Update' : VOCAB.ADD_TO_MY_COLLECTION}
|
{ownedQuantity > 0 ? 'Update' : VOCAB.ADD_TO_MY_COLLECTION}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export default function CardEditorForm({
|
||||||
className={`px-6 py-3 rounded-xl font-medium transition-all duration-200 ${
|
className={`px-6 py-3 rounded-xl font-medium transition-all duration-200 ${
|
||||||
saving
|
saving
|
||||||
? 'opacity-50 cursor-not-allowed'
|
? 'opacity-50 cursor-not-allowed'
|
||||||
: 'gradient-bg-purple text-white hover:shadow-lg'
|
: 'gradient-bg-ember text-white hover:shadow-lg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{saving ? 'Saving...' : 'Save Changes'}
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export default function CardEditorView({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push('/admin/card-editor')}
|
onClick={() => router.push('/admin/card-editor')}
|
||||||
className="px-4 py-2 rounded-lg font-medium gradient-bg-purple text-white"
|
className="px-4 py-2 rounded-lg font-medium gradient-bg-ember text-white"
|
||||||
>
|
>
|
||||||
🖊️ Card Editor
|
🖊️ Card Editor
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { Modal, Input, Button } from './ui';
|
||||||
|
|
||||||
export default function CollectionEditModal({
|
export default function CollectionEditModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
editForm,
|
editForm,
|
||||||
|
|
@ -5,106 +7,81 @@ export default function CollectionEditModal({
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
}) {
|
}) {
|
||||||
if (!isOpen) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<Modal open={isOpen} onClose={onClose} title="Edit list" size="md">
|
||||||
<div className="card max-w-md w-full mx-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
<Input
|
||||||
Edit List
|
label="List Name"
|
||||||
</h2>
|
value={editForm.name}
|
||||||
<div className="space-y-4">
|
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||||
<div>
|
placeholder="Enter list name"
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
/>
|
||||||
List Name
|
<div>
|
||||||
</label>
|
<label
|
||||||
<input
|
className="block text-sm font-medium mb-2"
|
||||||
type="text"
|
style={{ color: 'var(--text-primary)' }}
|
||||||
className="input-field w-full"
|
htmlFor="collection-edit-description"
|
||||||
value={editForm.name}
|
|
||||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
|
||||||
placeholder="Enter list name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
className="input-field w-full"
|
|
||||||
rows="3"
|
|
||||||
value={editForm.description}
|
|
||||||
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
|
||||||
placeholder="Describe your list"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
Hero Image (Optional)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
className="input-field w-full"
|
|
||||||
value={editForm.image}
|
|
||||||
onChange={(e) => setEditForm({ ...editForm, image: e.target.value })}
|
|
||||||
placeholder="Enter image URL"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between p-4 rounded-lg border"
|
|
||||||
style={{ borderColor: 'var(--border)' }}
|
|
||||||
>
|
>
|
||||||
<div>
|
Description
|
||||||
<label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
</label>
|
||||||
Public List
|
<textarea
|
||||||
</label>
|
id="collection-edit-description"
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
|
className="input-field w-full"
|
||||||
Make this collection discoverable by other users
|
rows="3"
|
||||||
</p>
|
value={editForm.description}
|
||||||
</div>
|
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
||||||
<button
|
placeholder="Describe your list"
|
||||||
type="button"
|
/>
|
||||||
onClick={() => setEditForm({ ...editForm, isPublic: !editForm.isPublic })}
|
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
||||||
editForm.isPublic ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
||||||
editForm.isPublic ? 'translate-x-6' : 'translate-x-1'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-3 mt-6">
|
<Input
|
||||||
|
label="Hero Image (Optional)"
|
||||||
|
type="url"
|
||||||
|
value={editForm.image}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, image: e.target.value })}
|
||||||
|
placeholder="Enter image URL"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between p-4 rounded-lg border"
|
||||||
|
style={{ borderColor: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Public List
|
||||||
|
</label>
|
||||||
|
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Make this collection discoverable by other users
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={() => setEditForm({ ...editForm, isPublic: !editForm.isPublic })}
|
||||||
className="flex-1 py-2 px-4 rounded-xl border transition-colors"
|
aria-pressed={editForm.isPublic}
|
||||||
style={{
|
aria-label="Toggle public list"
|
||||||
borderColor: 'var(--border)',
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
color: 'var(--text-secondary)',
|
editForm.isPublic ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
}}
|
}`}
|
||||||
>
|
>
|
||||||
Cancel
|
<span
|
||||||
</button>
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
<button
|
editForm.isPublic ? 'translate-x-6' : 'translate-x-1'
|
||||||
type="button"
|
}`}
|
||||||
onClick={onSave}
|
/>
|
||||||
disabled={!editForm.name.trim()}
|
|
||||||
className="flex-1 py-2 px-4 rounded-xl font-medium transition-all duration-200 hover:shadow-md disabled:opacity-50"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--accent-ember)',
|
|
||||||
color: 'white',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save Changes
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex space-x-3 mt-6">
|
||||||
|
<Button variant="secondary" onClick={onClose} className="flex-1">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!editForm.name.trim()}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */
|
/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { VOCAB } from '../lib/collection-vocabulary.js';
|
import { VOCAB } from '../lib/collection-vocabulary.js';
|
||||||
|
import { Modal, SearchBar, Button } from './ui';
|
||||||
|
|
||||||
export default function CollectionSelectionModal({
|
export default function CollectionSelectionModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
cards, // Array of cards to add
|
cards,
|
||||||
onAddToCollections
|
onAddToCollections,
|
||||||
}) {
|
}) {
|
||||||
const [collections, setCollections] = useState([]);
|
const [collections, setCollections] = useState([]);
|
||||||
const [selectedCollections, setSelectedCollections] = useState([]);
|
const [selectedCollections, setSelectedCollections] = useState([]);
|
||||||
|
|
@ -25,15 +26,12 @@ export default function CollectionSelectionModal({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|
||||||
const fetchCollections = async () => {
|
const fetchCollections = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/collections?excludeSystem=true');
|
const response = await fetch('/api/collections?excludeSystem=true');
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
// The API returns an array directly, not wrapped in collections property
|
|
||||||
setCollections(Array.isArray(data) ? data : data.collections || []);
|
setCollections(Array.isArray(data) ? data : data.collections || []);
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to fetch collections:', response.status, response.statusText);
|
console.error('Failed to fetch collections:', response.status, response.statusText);
|
||||||
|
|
@ -46,79 +44,62 @@ export default function CollectionSelectionModal({
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchCollections();
|
fetchCollections();
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const handleCollectionToggle = (collectionId) => {
|
const handleCollectionToggle = (collectionId) => {
|
||||||
setSelectedCollections(prev => {
|
setSelectedCollections((prev) =>
|
||||||
if (prev.includes(collectionId)) {
|
prev.includes(collectionId)
|
||||||
return prev.filter(id => id !== collectionId);
|
? prev.filter((id) => id !== collectionId)
|
||||||
} else {
|
: [...prev, collectionId]
|
||||||
return [...prev, collectionId];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
|
||||||
const filteredCollections = getFilteredCollections();
|
|
||||||
const allSelected = filteredCollections.every(collection =>
|
|
||||||
selectedCollections.includes(collection.id)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (allSelected) {
|
|
||||||
// Deselect all filtered collections
|
|
||||||
setSelectedCollections(prev =>
|
|
||||||
prev.filter(id => !filteredCollections.some(c => c.id === id))
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Select all filtered collections
|
|
||||||
const newSelections = filteredCollections
|
|
||||||
.filter(collection => !selectedCollections.includes(collection.id))
|
|
||||||
.map(collection => collection.id);
|
|
||||||
setSelectedCollections(prev => [...prev, ...newSelections]);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFilteredCollections = () => {
|
const getFilteredCollections = () => {
|
||||||
if (!searchQuery.trim()) return collections;
|
if (!searchQuery.trim()) return collections;
|
||||||
|
return collections.filter(
|
||||||
return collections.filter(collection =>
|
(collection) =>
|
||||||
collection.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
collection.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
collection.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
collection.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
const filteredCollections = getFilteredCollections();
|
||||||
|
const allSelected = filteredCollections.every((collection) =>
|
||||||
|
selectedCollections.includes(collection.id)
|
||||||
|
);
|
||||||
|
if (allSelected) {
|
||||||
|
setSelectedCollections((prev) =>
|
||||||
|
prev.filter((id) => !filteredCollections.some((c) => c.id === id))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const newSelections = filteredCollections
|
||||||
|
.filter((collection) => !selectedCollections.includes(collection.id))
|
||||||
|
.map((collection) => collection.id);
|
||||||
|
setSelectedCollections((prev) => [...prev, ...newSelections]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (selectedCollections.length === 0) return;
|
if (selectedCollections.length === 0) return;
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
// Add cards to each selected collection
|
|
||||||
for (const collectionId of selectedCollections) {
|
for (const collectionId of selectedCollections) {
|
||||||
for (const card of cards) {
|
for (const card of cards) {
|
||||||
const response = await fetch(`/api/collections/${collectionId}/cards`, {
|
const response = await fetch(`/api/collections/${collectionId}/cards`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json'
|
body: JSON.stringify({ cardId: card.id, quantity: 1 }),
|
||||||
},
|
});
|
||||||
body: JSON.stringify({
|
results.push({
|
||||||
cardId: card.id,
|
collectionId,
|
||||||
quantity: 1
|
cardId: card.id,
|
||||||
})
|
success: response.ok,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
results.push({ collectionId, cardId: card.id, success: true });
|
|
||||||
} else {
|
|
||||||
results.push({ collectionId, cardId: card.id, success: false });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call callback with results
|
|
||||||
onAddToCollections(results, selectedCollections, cards);
|
onAddToCollections(results, selectedCollections, cards);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -129,172 +110,214 @@ export default function CollectionSelectionModal({
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredCollections = getFilteredCollections();
|
const filteredCollections = getFilteredCollections();
|
||||||
const allFilteredSelected = filteredCollections.length > 0 &&
|
const allFilteredSelected =
|
||||||
filteredCollections.every(collection => selectedCollections.includes(collection.id));
|
filteredCollections.length > 0 &&
|
||||||
|
filteredCollections.every((collection) =>
|
||||||
if (!isOpen) return null;
|
selectedCollections.includes(collection.id)
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<Modal open={isOpen} onClose={onClose} title={VOCAB.ADD_TO_LISTS} size="lg">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden">
|
<div className="flex items-center space-x-3 mb-4">
|
||||||
{/* Header */}
|
<div className="flex -space-x-2">
|
||||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
{cards.slice(0, 3).map((card) => (
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
key={card.id}
|
||||||
{VOCAB.ADD_TO_LISTS}
|
className="w-12 h-16 rounded border-2 overflow-hidden"
|
||||||
</h2>
|
style={{
|
||||||
<button
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
onClick={onClose}
|
borderColor: 'var(--bg-primary)',
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
}}
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
{card.image_url ? (
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<img
|
||||||
</svg>
|
src={card.image_url}
|
||||||
</button>
|
alt={card.name}
|
||||||
</div>
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
{/* Card Summary */}
|
) : (
|
||||||
<div className="flex items-center space-x-3 mb-4">
|
<div
|
||||||
<div className="flex -space-x-2">
|
className="w-full h-full flex items-center justify-center text-xs"
|
||||||
{cards.slice(0, 3).map((card, index) => (
|
style={{ color: 'var(--text-tertiary)' }}
|
||||||
<div key={card.id} className="w-12 h-16 bg-gray-200 dark:bg-gray-700 rounded border-2 border-white dark:border-gray-800 overflow-hidden">
|
>
|
||||||
{card.image_url ? (
|
No Image
|
||||||
<img
|
|
||||||
src={card.image_url}
|
|
||||||
alt={card.name}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-xs">
|
|
||||||
No Image
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{cards.length > 3 && (
|
|
||||||
<div className="w-12 h-16 bg-gray-300 dark:bg-gray-600 rounded border-2 border-white dark:border-gray-800 flex items-center justify-center">
|
|
||||||
<span className="text-sm font-bold text-gray-600 dark:text-gray-300">
|
|
||||||
+{cards.length - 3}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
))}
|
||||||
<p className="text-lg font-semibold text-gray-900 dark:text-white">
|
{cards.length > 3 && (
|
||||||
{cards.length} card{cards.length !== 1 ? 's' : ''} selected
|
<div
|
||||||
</p>
|
className="w-12 h-16 rounded border-2 flex items-center justify-center"
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
style={{
|
||||||
Choose lists to add {cards.length === 1 ? 'this card' : 'these cards'} to
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
</p>
|
borderColor: 'var(--bg-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-bold" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
+{cards.length - 3}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Search */}
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search lists..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="w-full px-4 py-2 pl-10 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
<svg className="w-5 h-5 text-gray-400 absolute left-3 top-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{cards.length} card{cards.length !== 1 ? 's' : ''} selected
|
||||||
|
</p>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Choose lists to add {cards.length === 1 ? 'this card' : 'these cards'} to
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Collections List */}
|
<div className="mb-4">
|
||||||
<div className="flex-1 overflow-y-auto max-h-96">
|
<SearchBar
|
||||||
{loading ? (
|
value={searchQuery}
|
||||||
<div className="p-8 text-center">
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
onClear={() => setSearchQuery('')}
|
||||||
<p className="text-gray-600 dark:text-gray-400">Loading lists...</p>
|
placeholder="Search lists…"
|
||||||
</div>
|
/>
|
||||||
) : filteredCollections.length === 0 ? (
|
</div>
|
||||||
<div className="p-8 text-center">
|
|
||||||
<svg className="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
|
||||||
</svg>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-2">
|
|
||||||
{searchQuery ? 'No lists match your search' : 'No lists found'}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-500">
|
|
||||||
{searchQuery ? 'Try a different search term' : 'Create your first list to get started'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="p-4">
|
|
||||||
{/* Select All */}
|
|
||||||
{filteredCollections.length > 1 && (
|
|
||||||
<div className="flex items-center justify-between p-3 border-b border-gray-200 dark:border-gray-700 mb-2">
|
|
||||||
<label className="flex items-center space-x-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={allFilteredSelected}
|
|
||||||
onChange={handleSelectAll}
|
|
||||||
className="w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
|
|
||||||
/>
|
|
||||||
<span className="font-medium text-gray-900 dark:text-white">
|
|
||||||
Select All ({filteredCollections.length})
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Collection Items */}
|
<div className="overflow-y-auto" style={{ maxHeight: '24rem' }}>
|
||||||
<div className="space-y-2">
|
{loading ? (
|
||||||
{filteredCollections.map((collection) => (
|
<div className="p-8 text-center">
|
||||||
|
<div
|
||||||
|
className="motion-essential animate-spin rounded-full h-8 w-8 border-b-2 mx-auto mb-2"
|
||||||
|
style={{ borderColor: 'var(--accent-ember)' }}
|
||||||
|
/>
|
||||||
|
<p style={{ color: 'var(--text-secondary)' }}>Loading lists...</p>
|
||||||
|
</div>
|
||||||
|
) : filteredCollections.length === 0 ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<svg
|
||||||
|
className="w-12 h-12 mx-auto mb-4"
|
||||||
|
style={{ color: 'var(--text-tertiary)' }}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{searchQuery ? 'No lists match your search' : 'No lists found'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{searchQuery ? 'Try a different search term' : 'Create your first list to get started'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{filteredCollections.length > 1 && (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between p-3 border-b mb-2"
|
||||||
|
style={{ borderColor: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
<label className="flex items-center space-x-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allFilteredSelected}
|
||||||
|
onChange={handleSelectAll}
|
||||||
|
className="w-4 h-4 rounded"
|
||||||
|
style={{ accentColor: 'var(--accent-ember)' }}
|
||||||
|
/>
|
||||||
|
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Select All ({filteredCollections.length})
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredCollections.map((collection) => {
|
||||||
|
const isSelected = selectedCollections.includes(collection.id);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={collection.id}
|
key={collection.id}
|
||||||
className={`p-4 rounded-lg border-2 transition-all duration-200 cursor-pointer ${
|
className="p-4 rounded-lg border-2 cursor-pointer"
|
||||||
selectedCollections.includes(collection.id)
|
style={{
|
||||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
borderColor: isSelected
|
||||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
? 'var(--accent-ember)'
|
||||||
}`}
|
: 'var(--border)',
|
||||||
|
backgroundColor: isSelected
|
||||||
|
? 'rgba(var(--ember-rim-subtle), 0.08)'
|
||||||
|
: 'transparent',
|
||||||
|
transition: 'all var(--motion-duration-default) var(--motion-ease-out)',
|
||||||
|
}}
|
||||||
onClick={() => handleCollectionToggle(collection.id)}
|
onClick={() => handleCollectionToggle(collection.id)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedCollections.includes(collection.id)}
|
checked={isSelected}
|
||||||
onChange={() => handleCollectionToggle(collection.id)}
|
onChange={() => handleCollectionToggle(collection.id)}
|
||||||
className="w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
|
className="w-4 h-4 rounded"
|
||||||
|
style={{ accentColor: 'var(--accent-ember)' }}
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
{/* Collection Image */}
|
className="w-12 h-12 rounded-lg overflow-hidden flex-shrink-0"
|
||||||
<div className="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg overflow-hidden flex-shrink-0">
|
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||||
|
>
|
||||||
{collection.image ? (
|
{collection.image ? (
|
||||||
<img
|
<img
|
||||||
src={collection.image}
|
src={collection.image}
|
||||||
alt={collection.name}
|
alt={collection.name}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
<div
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
className="w-full h-full flex items-center justify-center"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
style={{ color: 'var(--text-tertiary)' }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Collection Info */}
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
|
<h3
|
||||||
|
className="font-semibold truncate"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
{collection.name}
|
{collection.name}
|
||||||
</h3>
|
</h3>
|
||||||
{collection.description && (
|
{collection.description && (
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 truncate">
|
<p
|
||||||
|
className="text-sm truncate"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
{collection.description}
|
{collection.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center space-x-4 mt-1">
|
<div className="flex items-center space-x-4 mt-1">
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-500">
|
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
{collection.card_count || 0} cards
|
{collection.card_count || 0} cards
|
||||||
</span>
|
</span>
|
||||||
{collection.is_public && (
|
{collection.is_public && (
|
||||||
<span className="text-xs px-2 py-1 bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded-full">
|
<span
|
||||||
|
className="text-xs px-2 py-1 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(34, 197, 94, 0.15)',
|
||||||
|
color: 'rgb(21, 128, 61)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
Public
|
Public
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -302,52 +325,43 @@ export default function CollectionSelectionModal({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
</div>
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between mt-6 pt-4 border-t"
|
||||||
|
style={{ borderColor: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{selectedCollections.length > 0 && (
|
||||||
|
<span>
|
||||||
|
{selectedCollections.length} list
|
||||||
|
{selectedCollections.length !== 1 ? 's' : ''} selected
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex space-x-3">
|
||||||
{/* Footer */}
|
<Button variant="secondary" onClick={onClose}>
|
||||||
<div className="p-6 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
Cancel
|
||||||
<div className="flex items-center justify-between">
|
</Button>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
<Button
|
||||||
{selectedCollections.length > 0 && (
|
variant="primary"
|
||||||
<span>
|
onClick={handleSubmit}
|
||||||
{selectedCollections.length} list{selectedCollections.length !== 1 ? 's' : ''} selected
|
disabled={selectedCollections.length === 0 || submitting}
|
||||||
</span>
|
loading={submitting}
|
||||||
)}
|
>
|
||||||
</div>
|
{submitting
|
||||||
|
? 'Adding...'
|
||||||
<div className="flex space-x-3">
|
: `Add to ${selectedCollections.length} List${
|
||||||
<button
|
selectedCollections.length !== 1 ? 's' : ''
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={selectedCollections.length === 0 || submitting}
|
|
||||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
|
||||||
selectedCollections.length === 0 || submitting
|
|
||||||
? 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-500 cursor-not-allowed'
|
|
||||||
: 'bg-blue-600 hover:bg-blue-700 text-white'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
</Button>
|
||||||
{submitting ? (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
|
||||||
<span>Adding...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
`Add to ${selectedCollections.length} List${selectedCollections.length !== 1 ? 's' : ''}`
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
handleCollectionTagKeyPress,
|
handleCollectionTagKeyPress,
|
||||||
removeCollectionTag,
|
removeCollectionTag,
|
||||||
} from '../lib/collection-tag-input.js';
|
} from '../lib/collection-tag-input.js';
|
||||||
|
import { Modal, Input, Button } from './ui';
|
||||||
|
|
||||||
export default function CollectionsEditModal({
|
export default function CollectionsEditModal({
|
||||||
editingCollection,
|
editingCollection,
|
||||||
|
|
@ -12,171 +13,169 @@ export default function CollectionsEditModal({
|
||||||
onClose,
|
onClose,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}) {
|
}) {
|
||||||
if (!editingCollection) {
|
if (!editingCollection) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<Modal open onClose={onClose} title="Edit list" size="md">
|
||||||
<div className="card max-w-md w-full mx-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
<Input
|
||||||
Edit List
|
label="List Name"
|
||||||
</h2>
|
value={editingCollection.name}
|
||||||
<div className="space-y-4">
|
onChange={(e) =>
|
||||||
<div>
|
setEditingCollection({ ...editingCollection, name: e.target.value })
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
}
|
||||||
List Name
|
/>
|
||||||
</label>
|
<div>
|
||||||
<input
|
<label
|
||||||
type="text"
|
className="block text-sm font-medium mb-2"
|
||||||
className="input-field w-full"
|
style={{ color: 'var(--text-primary)' }}
|
||||||
value={editingCollection.name}
|
htmlFor="collections-edit-description"
|
||||||
onChange={(e) => setEditingCollection({ ...editingCollection, name: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
className="input-field w-full"
|
|
||||||
rows="3"
|
|
||||||
value={editingCollection.description}
|
|
||||||
onChange={(e) => setEditingCollection({ ...editingCollection, description: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
Hero Image
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
className="input-field w-full"
|
|
||||||
value={editingCollection.image || ''}
|
|
||||||
onChange={(e) => setEditingCollection({ ...editingCollection, image: e.target.value })}
|
|
||||||
placeholder="Enter image URL"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
Tags
|
|
||||||
</label>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="input-field flex-1"
|
|
||||||
value={editTagInput}
|
|
||||||
onChange={(e) => setEditTagInput(e.target.value)}
|
|
||||||
onKeyPress={(e) =>
|
|
||||||
handleCollectionTagKeyPress(
|
|
||||||
e,
|
|
||||||
editTagInput,
|
|
||||||
setEditTagInput,
|
|
||||||
editingCollection,
|
|
||||||
setEditingCollection
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder="Add tags (press Enter)"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
addCollectionTag(editTagInput, setEditTagInput, editingCollection, setEditingCollection)
|
|
||||||
}
|
|
||||||
className="px-3 py-2 rounded-lg text-sm font-medium transition-colors"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--accent-ember)',
|
|
||||||
color: 'white',
|
|
||||||
}}
|
|
||||||
disabled={!editTagInput.trim()}
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{editingCollection.tags && editingCollection.tags.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{editingCollection.tags.map((tag, index) => (
|
|
||||||
<span
|
|
||||||
key={index}
|
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--bg-tertiary)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeCollectionTag(tag, editingCollection, setEditingCollection)}
|
|
||||||
className="ml-1 hover:text-red-500 transition-colors"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between p-4 rounded-lg border"
|
|
||||||
style={{ borderColor: 'var(--border)' }}
|
|
||||||
>
|
>
|
||||||
<div>
|
Description
|
||||||
<label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
</label>
|
||||||
Public List
|
<textarea
|
||||||
</label>
|
id="collections-edit-description"
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
|
className="input-field w-full"
|
||||||
Make this list discoverable by other users
|
rows="3"
|
||||||
</p>
|
value={editingCollection.description}
|
||||||
</div>
|
onChange={(e) =>
|
||||||
<button
|
setEditingCollection({
|
||||||
type="button"
|
...editingCollection,
|
||||||
onClick={() =>
|
description: e.target.value,
|
||||||
setEditingCollection({ ...editingCollection, isPublic: !editingCollection.isPublic })
|
})
|
||||||
}
|
}
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
/>
|
||||||
editingCollection.isPublic ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'
|
</div>
|
||||||
}`}
|
<Input
|
||||||
>
|
label="Hero Image"
|
||||||
<span
|
type="url"
|
||||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
value={editingCollection.image || ''}
|
||||||
editingCollection.isPublic ? 'translate-x-6' : 'translate-x-1'
|
onChange={(e) =>
|
||||||
}`}
|
setEditingCollection({ ...editingCollection, image: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Enter image URL"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-sm font-medium mb-2"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
htmlFor="collections-edit-tags"
|
||||||
|
>
|
||||||
|
Tags
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="collections-edit-tags"
|
||||||
|
value={editTagInput}
|
||||||
|
onChange={(e) => setEditTagInput(e.target.value)}
|
||||||
|
onKeyPress={(e) =>
|
||||||
|
handleCollectionTagKeyPress(
|
||||||
|
e,
|
||||||
|
editTagInput,
|
||||||
|
setEditTagInput,
|
||||||
|
editingCollection,
|
||||||
|
setEditingCollection
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="Add tags (press Enter)"
|
||||||
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
</button>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
disabled={!editTagInput.trim()}
|
||||||
|
onClick={() =>
|
||||||
|
addCollectionTag(
|
||||||
|
editTagInput,
|
||||||
|
setEditTagInput,
|
||||||
|
editingCollection,
|
||||||
|
setEditingCollection
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{editingCollection.tags && editingCollection.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{editingCollection.tags.map((tag, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
removeCollectionTag(tag, editingCollection, setEditingCollection)
|
||||||
|
}
|
||||||
|
aria-label={`Remove tag ${tag}`}
|
||||||
|
className="ml-1 hover:text-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-3 mt-6">
|
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between p-4 rounded-lg border"
|
||||||
|
style={{ borderColor: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Public List
|
||||||
|
</label>
|
||||||
|
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Make this list discoverable by other users
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={() =>
|
||||||
className="flex-1 py-2 px-4 rounded-xl border transition-colors"
|
setEditingCollection({
|
||||||
style={{
|
...editingCollection,
|
||||||
borderColor: 'var(--border)',
|
isPublic: !editingCollection.isPublic,
|
||||||
color: 'var(--text-secondary)',
|
})
|
||||||
}}
|
}
|
||||||
|
aria-pressed={editingCollection.isPublic}
|
||||||
|
aria-label="Toggle public list"
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
editingCollection.isPublic ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Cancel
|
<span
|
||||||
</button>
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
<button
|
editingCollection.isPublic ? 'translate-x-6' : 'translate-x-1'
|
||||||
type="button"
|
}`}
|
||||||
onClick={onUpdate}
|
/>
|
||||||
disabled={!editingCollection.name.trim()}
|
|
||||||
className="flex-1 py-2 px-4 rounded-xl font-medium transition-all duration-200 hover:shadow-md disabled:opacity-50"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--accent-ember)',
|
|
||||||
color: 'white',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Update List
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex space-x-3 mt-6">
|
||||||
|
<Button variant="secondary" onClick={onClose} className="flex-1">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={onUpdate}
|
||||||
|
disabled={!editingCollection.name.trim()}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Update List
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import CollectionsCreateModal from './CollectionsCreateModal';
|
||||||
import CollectionsEditModal from './CollectionsEditModal';
|
import CollectionsEditModal from './CollectionsEditModal';
|
||||||
import CollectionsSuccessModal from './CollectionsSuccessModal';
|
import CollectionsSuccessModal from './CollectionsSuccessModal';
|
||||||
import CollectionsThumbnail from './CollectionsThumbnail';
|
import CollectionsThumbnail from './CollectionsThumbnail';
|
||||||
|
import { Button, SearchBar } from './ui';
|
||||||
|
|
||||||
export default function CollectionsPageView(props) {
|
export default function CollectionsPageView(props) {
|
||||||
const {
|
const {
|
||||||
|
|
@ -59,40 +60,25 @@ export default function CollectionsPageView(props) {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Link href="/community/collections">
|
<Link href="/community/collections">
|
||||||
<button className="px-4 py-2 rounded-xl border transition-colors hover:shadow-md"
|
<Button variant="secondary">
|
||||||
style={{
|
|
||||||
borderColor: 'var(--border)',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
backgroundColor: 'transparent'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🌍 Discover Community
|
🌍 Discover Community
|
||||||
</button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<Button variant="primary" onClick={() => setShowCreateModal(true)}>
|
||||||
onClick={() => setShowCreateModal(true)}
|
|
||||||
className="px-6 py-2 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--accent-ember)',
|
|
||||||
color: 'white'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
+ Create List
|
+ Create List
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters and Search */}
|
|
||||||
<div className="p-6" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
<div className="p-6" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<input
|
<SearchBar
|
||||||
type="text"
|
|
||||||
placeholder="Search lists..."
|
|
||||||
className="input-field w-full"
|
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onClear={() => setSearchQuery('')}
|
||||||
|
placeholder="Search lists…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
|
|
@ -126,16 +112,9 @@ export default function CollectionsPageView(props) {
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
{!searchQuery && (
|
{!searchQuery && (
|
||||||
<button
|
<Button variant="primary" size="lg" onClick={() => setShowCreateModal(true)}>
|
||||||
onClick={() => setShowCreateModal(true)}
|
|
||||||
className="px-6 py-3 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--accent-ember)',
|
|
||||||
color: 'white'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Create List
|
Create List
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,29 @@
|
||||||
|
import { Modal, Button } from './ui';
|
||||||
|
|
||||||
export default function CollectionsSuccessModal({
|
export default function CollectionsSuccessModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
createdCollection,
|
createdCollection,
|
||||||
onViewList,
|
onViewList,
|
||||||
onStay,
|
onStay,
|
||||||
}) {
|
}) {
|
||||||
if (!isOpen || !createdCollection) {
|
if (!createdCollection) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<Modal open={isOpen} onClose={onStay} title="List created!" size="md" hideCloseButton>
|
||||||
<div className="card max-w-md w-full mx-4 text-center">
|
<div className="text-center">
|
||||||
<div className="text-6xl mb-4">🎉</div>
|
<div className="text-6xl mb-4" aria-hidden="true">🎉</div>
|
||||||
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
List Created!
|
|
||||||
</h2>
|
|
||||||
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||||
Your list "{createdCollection.name}" has been created successfully.
|
Your list "{createdCollection.name}" has been created successfully.
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<button
|
<Button variant="primary" onClick={onViewList} className="w-full">
|
||||||
type="button"
|
|
||||||
onClick={onViewList}
|
|
||||||
className="w-full py-2 px-4 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--accent-ember)',
|
|
||||||
color: 'white',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
View List
|
View List
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="secondary" onClick={onStay} className="w-full">
|
||||||
type="button"
|
|
||||||
onClick={onStay}
|
|
||||||
className="w-full py-2 px-4 rounded-xl border transition-colors"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--border)',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Stay on Lists Page
|
Stay on Lists Page
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,23 @@
|
||||||
|
import { Modal, Button } from './ui';
|
||||||
|
|
||||||
export default function OCRSettings({ onClose }) {
|
export default function OCRSettings({ onClose }) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<Modal open onClose={onClose} title="Card Scanner" size="md">
|
||||||
<div
|
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||||||
className="rounded-xl border max-w-md w-full p-6"
|
Card identification runs on Deck Hearth's servers using Gemini Vision. No API
|
||||||
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
|
keys are required in your browser.
|
||||||
>
|
</p>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<ul className="text-sm space-y-2 mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||||
<h2 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
|
<li>Hold the card steady in the camera frame for best results.</li>
|
||||||
Card Scanner
|
<li>If multiple matches are found, you will be asked to pick the correct printing.</li>
|
||||||
</h2>
|
<li>
|
||||||
<button
|
Unknown cards are saved for admin review instead of being added to the global
|
||||||
onClick={onClose}
|
catalog.
|
||||||
className="p-2 rounded-lg hover:opacity-70"
|
</li>
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
</ul>
|
||||||
aria-label="Close"
|
<Button variant="primary" onClick={onClose} className="w-full">
|
||||||
>
|
Got it
|
||||||
✕
|
</Button>
|
||||||
</button>
|
</Modal>
|
||||||
</div>
|
|
||||||
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Card identification runs on Deck Hearth'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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import CameraScanner from './CameraScanner';
|
||||||
import ScannerDestinationPicker from './ScannerDestinationPicker';
|
import ScannerDestinationPicker from './ScannerDestinationPicker';
|
||||||
import ScannedCardItem, { CONDITION_OPTIONS } from './ScannedCardItem';
|
import ScannedCardItem, { CONDITION_OPTIONS } from './ScannedCardItem';
|
||||||
import OCRSettings from './OCRSettings';
|
import OCRSettings from './OCRSettings';
|
||||||
import { useFocusTrap } from '../lib/use-focus-trap.js';
|
import { Modal, Input, Button } from './ui';
|
||||||
import { VOCAB } from '../lib/collection-vocabulary.js';
|
import { VOCAB } from '../lib/collection-vocabulary.js';
|
||||||
|
|
||||||
export default function ScannerPageView({
|
export default function ScannerPageView({
|
||||||
|
|
@ -18,7 +18,6 @@ export default function ScannerPageView({
|
||||||
onCloseOCRSettings,
|
onCloseOCRSettings,
|
||||||
onScannerError,
|
onScannerError,
|
||||||
}) {
|
}) {
|
||||||
const createCollectionDialogRef = useFocusTrap(queue.showCreateCollection);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
|
|
@ -292,60 +291,43 @@ export default function ScannerPageView({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{queue.showCreateCollection && (
|
<Modal
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
open={queue.showCreateCollection}
|
||||||
<div
|
onClose={() => queue.setShowCreateCollection(false)}
|
||||||
ref={createCollectionDialogRef}
|
title="Create New List"
|
||||||
className="rounded-xl p-6 max-w-md w-full mx-4"
|
size="md"
|
||||||
style={{ backgroundColor: 'var(--bg-secondary)' }}
|
>
|
||||||
role="dialog"
|
<Input
|
||||||
aria-modal="true"
|
id="create-collection-name"
|
||||||
aria-labelledby="create-collection-title"
|
label="List name"
|
||||||
|
placeholder="List name..."
|
||||||
|
value={queue.newCollectionName}
|
||||||
|
onChange={(e) => queue.setNewCollectionName(e.target.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
queue.createCollection();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="mb-4"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={queue.createCollection}
|
||||||
|
disabled={!queue.newCollectionName.trim()}
|
||||||
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<h3 id="create-collection-title" className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
Create
|
||||||
Create New List
|
</Button>
|
||||||
</h3>
|
<Button
|
||||||
<label htmlFor="create-collection-name" className="sr-only">
|
variant="secondary"
|
||||||
List name
|
onClick={() => queue.setShowCreateCollection(false)}
|
||||||
</label>
|
className="flex-1"
|
||||||
<input
|
>
|
||||||
id="create-collection-name"
|
Cancel
|
||||||
type="text"
|
</Button>
|
||||||
placeholder="List name..."
|
|
||||||
value={queue.newCollectionName}
|
|
||||||
onChange={(e) => queue.setNewCollectionName(e.target.value)}
|
|
||||||
className="w-full px-4 py-2 rounded-lg border mb-4"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--bg-tertiary)',
|
|
||||||
borderColor: 'var(--border)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}}
|
|
||||||
onKeyPress={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
queue.createCollection();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={queue.createCollection}
|
|
||||||
disabled={!queue.newCollectionName.trim()}
|
|
||||||
className="flex-1 px-4 py-2 rounded-lg font-medium disabled:opacity-50"
|
|
||||||
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => queue.setShowCreateCollection(false)}
|
|
||||||
className="flex-1 px-4 py-2 rounded-lg border font-medium"
|
|
||||||
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Modal>
|
||||||
|
|
||||||
{showOCRSettings && <OCRSettings onClose={onCloseOCRSettings} />}
|
{showOCRSettings && <OCRSettings onClose={onCloseOCRSettings} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
/* eslint-disable @next/next/no-img-element -- Local preview data URLs; next/image migration is out of scope. */
|
/* eslint-disable @next/next/no-img-element -- Local preview data URLs; next/image migration is out of scope. */
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Modal, Input, Button } from './ui';
|
||||||
|
|
||||||
export default function UploadImageModal({ isOpen, onClose, onUpload, currentImage }) {
|
export default function UploadImageModal({ isOpen, onClose, onUpload, currentImage }) {
|
||||||
const [uploadMethod, setUploadMethod] = useState('url'); // 'url' or 'file'
|
const [uploadMethod, setUploadMethod] = useState('url');
|
||||||
const [imageUrl, setImageUrl] = useState(currentImage || '');
|
const [imageUrl, setImageUrl] = useState(currentImage || '');
|
||||||
const [dragActive, setDragActive] = useState(false);
|
const [dragActive, setDragActive] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
@ -10,7 +11,7 @@ export default function UploadImageModal({ isOpen, onClose, onUpload, currentIma
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!imageUrl.trim()) return;
|
if (!imageUrl.trim()) return;
|
||||||
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
await onUpload(imageUrl);
|
await onUpload(imageUrl);
|
||||||
|
|
@ -25,9 +26,9 @@ export default function UploadImageModal({ isOpen, onClose, onUpload, currentIma
|
||||||
const handleDrag = (e) => {
|
const handleDrag = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (e.type === "dragenter" || e.type === "dragover") {
|
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||||
setDragActive(true);
|
setDragActive(true);
|
||||||
} else if (e.type === "dragleave") {
|
} else if (e.type === 'dragleave') {
|
||||||
setDragActive(false);
|
setDragActive(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -36,7 +37,6 @@ export default function UploadImageModal({ isOpen, onClose, onUpload, currentIma
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDragActive(false);
|
setDragActive(false);
|
||||||
|
|
||||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||||
handleFileUpload(e.dataTransfer.files[0]);
|
handleFileUpload(e.dataTransfer.files[0]);
|
||||||
}
|
}
|
||||||
|
|
@ -45,8 +45,8 @@ export default function UploadImageModal({ isOpen, onClose, onUpload, currentIma
|
||||||
const handleFileUpload = (file) => {
|
const handleFileUpload = (file) => {
|
||||||
if (file.type.startsWith('image/')) {
|
if (file.type.startsWith('image/')) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (event) => {
|
||||||
setImageUrl(e.target.result);
|
setImageUrl(event.target.result);
|
||||||
setUploadMethod('file');
|
setUploadMethod('file');
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
|
|
@ -59,141 +59,159 @@ export default function UploadImageModal({ isOpen, onClose, onUpload, currentIma
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<Modal open={isOpen} onClose={onClose} title="Upload Hero Image" size="md">
|
||||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="p-6">
|
<div
|
||||||
<div className="flex items-center justify-between mb-6">
|
className="flex space-x-1 mb-6 rounded-lg p-1"
|
||||||
<h2 className="text-xl font-bold text-gray-900">Upload Hero Image</h2>
|
style={{
|
||||||
<button
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
onClick={onClose}
|
border: '1px solid var(--border)',
|
||||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
}}
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<button
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
type="button"
|
||||||
</svg>
|
onClick={() => setUploadMethod('url')}
|
||||||
</button>
|
className="flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors"
|
||||||
</div>
|
style={{
|
||||||
|
backgroundColor:
|
||||||
<form onSubmit={handleSubmit}>
|
uploadMethod === 'url' ? 'var(--bg-primary)' : 'transparent',
|
||||||
{/* Upload Method Tabs */}
|
color:
|
||||||
<div className="flex space-x-1 mb-6 bg-gray-100 rounded-lg p-1">
|
uploadMethod === 'url'
|
||||||
<button
|
? 'var(--text-primary)'
|
||||||
type="button"
|
: 'var(--text-secondary)',
|
||||||
onClick={() => setUploadMethod('url')}
|
boxShadow: uploadMethod === 'url' ? 'var(--elevation-ambient)' : 'none',
|
||||||
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors ${
|
}}
|
||||||
uploadMethod === 'url'
|
>
|
||||||
? 'bg-white text-gray-900 shadow-sm'
|
Image URL
|
||||||
: 'text-gray-600 hover:text-gray-900'
|
</button>
|
||||||
}`}
|
<button
|
||||||
>
|
type="button"
|
||||||
Image URL
|
onClick={() => setUploadMethod('file')}
|
||||||
</button>
|
className="flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors"
|
||||||
<button
|
style={{
|
||||||
type="button"
|
backgroundColor:
|
||||||
onClick={() => setUploadMethod('file')}
|
uploadMethod === 'file' ? 'var(--bg-primary)' : 'transparent',
|
||||||
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors ${
|
color:
|
||||||
uploadMethod === 'file'
|
uploadMethod === 'file'
|
||||||
? 'bg-white text-gray-900 shadow-sm'
|
? 'var(--text-primary)'
|
||||||
: 'text-gray-600 hover:text-gray-900'
|
: 'var(--text-secondary)',
|
||||||
}`}
|
boxShadow: uploadMethod === 'file' ? 'var(--elevation-ambient)' : 'none',
|
||||||
>
|
}}
|
||||||
Upload File
|
>
|
||||||
</button>
|
Upload File
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
{uploadMethod === 'url' ? (
|
|
||||||
<div className="mb-6">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Image URL
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={imageUrl}
|
|
||||||
onChange={(e) => setImageUrl(e.target.value)}
|
|
||||||
placeholder="https://example.com/image.jpg"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mb-6">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Upload Image
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
|
||||||
dragActive
|
|
||||||
? 'border-purple-500 bg-purple-50'
|
|
||||||
: 'border-gray-300 hover:border-gray-400'
|
|
||||||
}`}
|
|
||||||
onDragEnter={handleDrag}
|
|
||||||
onDragLeave={handleDrag}
|
|
||||||
onDragOver={handleDrag}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
|
||||||
<svg className="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
|
||||||
</svg>
|
|
||||||
<p className="text-gray-600 mb-2">Drag and drop an image here, or</p>
|
|
||||||
<label className="inline-block px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 cursor-pointer transition-colors">
|
|
||||||
Choose File
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleFileInput}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<p className="text-xs text-gray-500 mt-2">PNG, JPG, GIF up to 10MB</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Preview */}
|
|
||||||
{imageUrl && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Preview
|
|
||||||
</label>
|
|
||||||
<div className="relative rounded-lg overflow-hidden bg-gray-100">
|
|
||||||
<img
|
|
||||||
src={imageUrl}
|
|
||||||
alt="Preview"
|
|
||||||
className="w-full h-32 object-cover"
|
|
||||||
onError={(e) => {
|
|
||||||
e.target.src = 'https://via.placeholder.com/400x128/f3f4f6/6b7280?text=Invalid+Image';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex space-x-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex-1 py-2 px-4 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!imageUrl.trim() || uploading}
|
|
||||||
className={`flex-1 py-2 px-4 rounded-lg font-medium transition-colors ${
|
|
||||||
!imageUrl.trim() || uploading
|
|
||||||
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
|
||||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{uploading ? 'Uploading...' : 'Upload Image'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
{uploadMethod === 'url' ? (
|
||||||
|
<div className="mb-6">
|
||||||
|
<Input
|
||||||
|
label="Image URL"
|
||||||
|
type="url"
|
||||||
|
value={imageUrl}
|
||||||
|
onChange={(e) => setImageUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mb-6">
|
||||||
|
<label
|
||||||
|
className="block text-sm font-medium mb-2"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
Upload Image
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="border-2 border-dashed rounded-lg p-6 text-center transition-colors"
|
||||||
|
style={{
|
||||||
|
borderColor: dragActive ? 'var(--accent-ember)' : 'var(--border)',
|
||||||
|
backgroundColor: dragActive ? 'rgba(var(--ember-rim-subtle), 0.08)' : 'transparent',
|
||||||
|
}}
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-12 h-12 mx-auto mb-4"
|
||||||
|
style={{ color: 'var(--text-tertiary)' }}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Drag and drop an image here, or
|
||||||
|
</p>
|
||||||
|
<label
|
||||||
|
className="inline-block px-4 py-2 rounded-lg cursor-pointer transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--accent-ember)',
|
||||||
|
color: 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Choose File
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileInput}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs mt-2" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
PNG, JPG, GIF up to 10MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{imageUrl && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<label
|
||||||
|
className="block text-sm font-medium mb-2"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="relative rounded-lg overflow-hidden"
|
||||||
|
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt="Preview"
|
||||||
|
className="w-full h-32 object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
e.target.src =
|
||||||
|
'https://via.placeholder.com/400x128/f3f4f6/6b7280?text=Invalid+Image';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<Button type="button" variant="secondary" onClick={onClose} className="flex-1">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={!imageUrl.trim() || uploading}
|
||||||
|
loading={uploading}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{uploading ? 'Uploading...' : 'Upload Image'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ const CardImport = () => {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/admin/card-import')}
|
onClick={() => router.push('/admin/card-import')}
|
||||||
className="px-4 py-2 rounded-lg font-medium gradient-bg-purple text-white"
|
className="px-4 py-2 rounded-lg font-medium gradient-bg-ember text-white"
|
||||||
>
|
>
|
||||||
📥 Card Import
|
📥 Card Import
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -184,7 +184,7 @@ const CardImport = () => {
|
||||||
className={`shrink-0 py-3 px-6 rounded-xl font-medium transition-all duration-200 ${
|
className={`shrink-0 py-3 px-6 rounded-xl font-medium transition-all duration-200 ${
|
||||||
isSyncing
|
isSyncing
|
||||||
? 'opacity-50 cursor-not-allowed'
|
? 'opacity-50 cursor-not-allowed'
|
||||||
: 'gradient-bg-purple text-white hover:shadow-lg'
|
: 'gradient-bg-ember text-white hover:shadow-lg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isSyncing ? 'Syncing catalog…' : 'Run catalog sync'}
|
{isSyncing ? 'Syncing catalog…' : 'Run catalog sync'}
|
||||||
|
|
@ -307,7 +307,7 @@ const CardImport = () => {
|
||||||
className={`w-full py-3 px-6 rounded-xl font-medium transition-all duration-200 ${
|
className={`w-full py-3 px-6 rounded-xl font-medium transition-all duration-200 ${
|
||||||
isImporting || !setCode.trim()
|
isImporting || !setCode.trim()
|
||||||
? 'opacity-50 cursor-not-allowed'
|
? 'opacity-50 cursor-not-allowed'
|
||||||
: 'gradient-bg-purple text-white hover:shadow-lg'
|
: 'gradient-bg-ember text-white hover:shadow-lg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isImporting ? 'Importing...' : 'Import Cards'}
|
{isImporting ? 'Importing...' : 'Import Cards'}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export default function CardDetail() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push('/cards')}
|
onClick={() => router.push('/cards')}
|
||||||
className="px-6 py-3 rounded-xl font-medium gradient-bg-purple text-white hover:shadow-lg transition-all duration-200"
|
className="px-6 py-3 rounded-xl font-medium gradient-bg-ember text-white hover:shadow-lg transition-all duration-200"
|
||||||
>
|
>
|
||||||
Back to Cards
|
Back to Cards
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useRouter } from 'next/router';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Layout from '../../components/Layout';
|
import Layout from '../../components/Layout';
|
||||||
import PermissionIndicator from '../../components/PermissionIndicator';
|
import PermissionIndicator from '../../components/PermissionIndicator';
|
||||||
|
import { Button } from '../../components/ui';
|
||||||
import { useAuth } from '../../lib/use-auth';
|
import { useAuth } from '../../lib/use-auth';
|
||||||
import { VOCAB } from '../../lib/collection-vocabulary.js';
|
import { VOCAB } from '../../lib/collection-vocabulary.js';
|
||||||
|
|
||||||
|
|
@ -252,14 +253,9 @@ export default function CommunityCollections() {
|
||||||
</p>
|
</p>
|
||||||
{!searchQuery && (
|
{!searchQuery && (
|
||||||
<Link href="/collections">
|
<Link href="/collections">
|
||||||
<button className="px-6 py-3 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
|
<Button variant="primary" size="lg">
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--accent-ember)',
|
|
||||||
color: 'white'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Go to My Lists
|
Go to My Lists
|
||||||
</button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import PermissionIndicator from '../components/PermissionIndicator';
|
import PermissionIndicator from '../components/PermissionIndicator';
|
||||||
|
import { Button } from '../components/ui';
|
||||||
import { useAuth } from '../lib/use-auth';
|
import { useAuth } from '../lib/use-auth';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js';
|
import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js';
|
||||||
|
|
@ -91,14 +92,17 @@ export default function Dashboard() {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2 sm:space-x-4">
|
<div className="flex space-x-2 sm:space-x-4">
|
||||||
<Link href="/collections">
|
<Link href="/collections">
|
||||||
<button className="px-4 py-2 text-sm font-medium rounded-xl transition-all duration-200 hover:opacity-90" style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}>
|
<Button
|
||||||
<div className="flex items-center space-x-2">
|
variant="primary"
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
size="sm"
|
||||||
|
leadingIcon={
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Create List</span>
|
}
|
||||||
</div>
|
>
|
||||||
</button>
|
Create List
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -176,9 +180,9 @@ export default function Dashboard() {
|
||||||
Create your first list to start organizing your cards
|
Create your first list to start organizing your cards
|
||||||
</p>
|
</p>
|
||||||
<Link href="/collections">
|
<Link href="/collections">
|
||||||
<button className="px-6 py-3 font-medium rounded-xl transition-all duration-200 hover:opacity-90" style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}>
|
<Button variant="primary" size="lg">
|
||||||
Create Your First List
|
Create Your First List
|
||||||
</button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -222,9 +226,9 @@ export default function Dashboard() {
|
||||||
{collections.length > 6 && (
|
{collections.length > 6 && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Link href="/collections">
|
<Link href="/collections">
|
||||||
<button className="px-6 py-3 font-medium rounded-xl border-2 transition-all duration-200 hover:opacity-80" style={{ color: 'var(--text-primary)', borderColor: 'var(--accent-ember)' }}>
|
<Button variant="secondary" size="lg">
|
||||||
View All Lists
|
View All Lists
|
||||||
</button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
323
pages/decks.js
323
pages/decks.js
|
|
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
|
import { Modal, Input, Button } from '../components/ui';
|
||||||
import { useAuth } from '../lib/use-auth';
|
import { useAuth } from '../lib/use-auth';
|
||||||
|
|
||||||
export default function Decks() {
|
export default function Decks() {
|
||||||
|
|
@ -299,166 +300,172 @@ export default function Decks() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create Deck Modal */}
|
<Modal
|
||||||
{showCreateModal && (
|
open={showCreateModal}
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
onClose={() => setShowCreateModal(false)}
|
||||||
<div className="bg-bg-primary rounded-lg p-6 w-full max-w-md">
|
title="Create New Deck"
|
||||||
<h2 className="text-xl font-bold text-text-primary mb-4">Create New Deck</h2>
|
size="md"
|
||||||
<form onSubmit={handleCreateDeck}>
|
>
|
||||||
<div className="mb-4">
|
<form onSubmit={handleCreateDeck} className="space-y-4">
|
||||||
<label className="block text-text-secondary text-sm font-medium mb-2">
|
<Input
|
||||||
Deck Name *
|
label="Deck Name *"
|
||||||
</label>
|
required
|
||||||
<input
|
value={newDeck.name}
|
||||||
type="text"
|
onChange={(e) => setNewDeck({ ...newDeck, name: e.target.value })}
|
||||||
required
|
placeholder="Enter deck name"
|
||||||
value={newDeck.name}
|
/>
|
||||||
onChange={(e) => setNewDeck({...newDeck, name: e.target.value})}
|
<div>
|
||||||
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
<label
|
||||||
placeholder="Enter deck name"
|
className="block text-sm font-medium mb-2"
|
||||||
/>
|
style={{ color: 'var(--text-primary)' }}
|
||||||
</div>
|
htmlFor="create-deck-format"
|
||||||
|
>
|
||||||
<div className="mb-4">
|
Format
|
||||||
<label className="block text-text-secondary text-sm font-medium mb-2">
|
</label>
|
||||||
Format
|
<select
|
||||||
</label>
|
id="create-deck-format"
|
||||||
<select
|
value={newDeck.format}
|
||||||
value={newDeck.format}
|
onChange={(e) => setNewDeck({ ...newDeck, format: e.target.value })}
|
||||||
onChange={(e) => setNewDeck({...newDeck, format: e.target.value})}
|
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
||||||
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
>
|
||||||
>
|
<option value="Commander">Commander</option>
|
||||||
<option value="Commander">Commander</option>
|
<option value="Standard">Standard</option>
|
||||||
<option value="Standard">Standard</option>
|
<option value="Modern">Modern</option>
|
||||||
<option value="Modern">Modern</option>
|
<option value="Legacy">Legacy</option>
|
||||||
<option value="Legacy">Legacy</option>
|
</select>
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-text-secondary text-sm font-medium mb-2">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={newDeck.description}
|
|
||||||
onChange={(e) => setNewDeck({...newDeck, description: e.target.value})}
|
|
||||||
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Describe your deck strategy..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={newDeck.is_public}
|
|
||||||
onChange={(e) => setNewDeck({...newDeck, is_public: e.target.checked})}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
<span className="text-text-secondary text-sm">Make deck public</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex space-x-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowCreateModal(false)}
|
|
||||||
className="flex-1 px-4 py-2 border border-border rounded-lg text-text-secondary hover:bg-bg-secondary transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="flex-1 bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors"
|
|
||||||
>
|
|
||||||
Create Deck
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
)}
|
<label
|
||||||
|
className="block text-sm font-medium mb-2"
|
||||||
{/* Edit Deck Modal */}
|
style={{ color: 'var(--text-primary)' }}
|
||||||
{editingDeck && (
|
htmlFor="create-deck-description"
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
>
|
||||||
<div className="bg-bg-primary rounded-lg p-6 w-full max-w-md">
|
Description
|
||||||
<h2 className="text-xl font-bold text-text-primary mb-4">Edit Deck</h2>
|
</label>
|
||||||
<form onSubmit={handleEditDeck}>
|
<textarea
|
||||||
<div className="mb-4">
|
id="create-deck-description"
|
||||||
<label className="block text-text-secondary text-sm font-medium mb-2">
|
value={newDeck.description}
|
||||||
Deck Name *
|
onChange={(e) => setNewDeck({ ...newDeck, description: e.target.value })}
|
||||||
</label>
|
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
||||||
<input
|
rows="3"
|
||||||
type="text"
|
placeholder="Describe your deck strategy..."
|
||||||
required
|
/>
|
||||||
value={editingDeck.name}
|
|
||||||
onChange={(e) => setEditingDeck({...editingDeck, name: e.target.value})}
|
|
||||||
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-text-secondary text-sm font-medium mb-2">
|
|
||||||
Format
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={editingDeck.format}
|
|
||||||
onChange={(e) => setEditingDeck({...editingDeck, format: e.target.value})}
|
|
||||||
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
|
||||||
>
|
|
||||||
<option value="Commander">Commander</option>
|
|
||||||
<option value="Standard">Standard</option>
|
|
||||||
<option value="Modern">Modern</option>
|
|
||||||
<option value="Legacy">Legacy</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-text-secondary text-sm font-medium mb-2">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={editingDeck.description || ''}
|
|
||||||
onChange={(e) => setEditingDeck({...editingDeck, description: e.target.value})}
|
|
||||||
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
|
||||||
rows="3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editingDeck.is_public}
|
|
||||||
onChange={(e) => setEditingDeck({...editingDeck, is_public: e.target.checked})}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
<span className="text-text-secondary text-sm">Make deck public</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex space-x-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditingDeck(null)}
|
|
||||||
className="flex-1 px-4 py-2 border border-border rounded-lg text-text-secondary hover:bg-bg-secondary transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="flex-1 bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors"
|
|
||||||
>
|
|
||||||
Save Changes
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<label className="flex items-center">
|
||||||
)}
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newDeck.is_public}
|
||||||
|
onChange={(e) => setNewDeck({ ...newDeck, is_public: e.target.checked })}
|
||||||
|
className="mr-2"
|
||||||
|
style={{ accentColor: 'var(--accent-ember)' }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Make deck public
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex space-x-3 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowCreateModal(false)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" className="flex-1">
|
||||||
|
Create Deck
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={!!editingDeck}
|
||||||
|
onClose={() => setEditingDeck(null)}
|
||||||
|
title="Edit Deck"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
{editingDeck && (
|
||||||
|
<form onSubmit={handleEditDeck} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Deck Name *"
|
||||||
|
required
|
||||||
|
value={editingDeck.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingDeck({ ...editingDeck, name: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-sm font-medium mb-2"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
htmlFor="edit-deck-format"
|
||||||
|
>
|
||||||
|
Format
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="edit-deck-format"
|
||||||
|
value={editingDeck.format}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingDeck({ ...editingDeck, format: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
||||||
|
>
|
||||||
|
<option value="Commander">Commander</option>
|
||||||
|
<option value="Standard">Standard</option>
|
||||||
|
<option value="Modern">Modern</option>
|
||||||
|
<option value="Legacy">Legacy</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-sm font-medium mb-2"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
htmlFor="edit-deck-description"
|
||||||
|
>
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="edit-deck-description"
|
||||||
|
value={editingDeck.description || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingDeck({ ...editingDeck, description: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editingDeck.is_public}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingDeck({ ...editingDeck, is_public: e.target.checked })
|
||||||
|
}
|
||||||
|
className="mr-2"
|
||||||
|
style={{ accentColor: 'var(--accent-ember)' }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Make deck public
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex space-x-3 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setEditingDeck(null)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" className="flex-1">
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
417
pages/index.js
417
pages/index.js
|
|
@ -4,6 +4,7 @@ import { useRouter } from 'next/router';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useAuth } from '../lib/use-auth.js';
|
import { useAuth } from '../lib/use-auth.js';
|
||||||
import AnimatedFireLogo from '../components/AnimatedFireLogo';
|
import AnimatedFireLogo from '../components/AnimatedFireLogo';
|
||||||
|
import { Button, GlassSurface } from '../components/ui';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
|
|
@ -11,14 +12,12 @@ export default function Home() {
|
||||||
const [featuredCollections, setFeaturedCollections] = useState([]);
|
const [featuredCollections, setFeaturedCollections] = useState([]);
|
||||||
const [collectionsLoading, setCollectionsLoading] = useState(true);
|
const [collectionsLoading, setCollectionsLoading] = useState(true);
|
||||||
|
|
||||||
// If user is logged in, redirect to dashboard
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && user) {
|
if (!loading && user) {
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
}
|
}
|
||||||
}, [user, loading, router]);
|
}, [user, loading, router]);
|
||||||
|
|
||||||
// Fetch featured collections for public display
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchFeaturedCollections = async () => {
|
const fetchFeaturedCollections = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -33,31 +32,44 @@ export default function Home() {
|
||||||
setCollectionsLoading(false);
|
setCollectionsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchFeaturedCollections();
|
fetchFeaturedCollections();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Show loading while checking auth
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: 'var(--bg-primary)' }}>
|
<div
|
||||||
|
className="min-h-screen flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: 'var(--bg-primary)' }}
|
||||||
|
>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 mx-auto mb-4" style={{ borderColor: 'var(--accent-ember)' }}></div>
|
<div
|
||||||
|
className="motion-essential animate-spin rounded-full h-12 w-12 border-b-2 mx-auto mb-4"
|
||||||
|
style={{ borderColor: 'var(--accent-ember)' }}
|
||||||
|
/>
|
||||||
<p style={{ color: 'var(--text-secondary)' }}>Loading...</p>
|
<p style={{ color: 'var(--text-secondary)' }}>Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If user is logged in, don't show landing page (redirect handled above)
|
|
||||||
if (user) {
|
if (user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--bg-primary)' }}>
|
<div className="min-h-screen" style={{ backgroundColor: 'var(--bg-primary)' }}>
|
||||||
{/* Navigation Bar */}
|
<nav
|
||||||
<nav className="border-b" style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}>
|
className="border-b"
|
||||||
|
style={{
|
||||||
|
background: 'var(--glass-surface-mid)',
|
||||||
|
backdropFilter:
|
||||||
|
'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
|
||||||
|
WebkitBackdropFilter:
|
||||||
|
'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
|
||||||
|
borderColor: 'var(--border)',
|
||||||
|
boxShadow: 'inset 0 1px 0 var(--rim-light-inner)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between items-center py-4">
|
<div className="flex justify-between items-center py-4">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
|
|
@ -65,29 +77,21 @@ export default function Home() {
|
||||||
<h1 className="text-2xl font-bold gradient-text-flame">Deck Hearth</h1>
|
<h1 className="text-2xl font-bold gradient-text-flame">Deck Hearth</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<Link
|
<Link href="/login">
|
||||||
href="/login"
|
<Button variant="ghost" size="sm">
|
||||||
className="px-4 py-2 text-sm font-medium rounded-xl transition-all duration-200 hover:opacity-80"
|
Sign In
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
</Button>
|
||||||
>
|
|
||||||
Sign In
|
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link href="/signup">
|
||||||
href="/signup"
|
<Button variant="primary" size="sm">
|
||||||
className="px-6 py-2 text-sm font-medium rounded-xl transition-all duration-200 hover:opacity-90"
|
Get Started
|
||||||
style={{
|
</Button>
|
||||||
backgroundColor: 'var(--accent-ember)',
|
|
||||||
color: 'white'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Get Started
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Hero Section */}
|
|
||||||
<section className="relative py-20 px-4">
|
<section className="relative py-20 px-4">
|
||||||
<div className="max-w-6xl mx-auto text-center">
|
<div className="max-w-6xl mx-auto text-center">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
|
|
@ -96,86 +100,136 @@ export default function Home() {
|
||||||
<h1 className="text-5xl md:text-7xl font-bold mb-6">
|
<h1 className="text-5xl md:text-7xl font-bold mb-6">
|
||||||
<span className="gradient-text-flame">Deck Hearth</span>
|
<span className="gradient-text-flame">Deck Hearth</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
|
<p
|
||||||
|
className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto leading-relaxed"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
The ultimate hub for trading card collectors. Organize your cards into lists,
|
The ultimate hub for trading card collectors. Organize your cards into lists,
|
||||||
discover rare cards, and connect with fellow enthusiasts in one beautiful platform.
|
discover rare cards, and connect with fellow enthusiasts in one beautiful
|
||||||
|
platform.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||||
<Link
|
<Link href="/signup">
|
||||||
href="/signup"
|
<Button variant="primary" size="lg">
|
||||||
className="px-8 py-4 text-lg font-medium rounded-2xl transition-all duration-200 hover:opacity-90 hover:scale-105"
|
Start Your Collection
|
||||||
style={{
|
</Button>
|
||||||
backgroundColor: 'var(--accent-ember)',
|
|
||||||
color: 'white'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Start Your Collection
|
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link href="/community/collections">
|
||||||
href="/community/collections"
|
<Button variant="secondary" size="lg">
|
||||||
className="px-8 py-4 text-lg font-medium rounded-2xl border-2 transition-all duration-200 hover:opacity-80"
|
Explore Lists
|
||||||
style={{
|
</Button>
|
||||||
color: 'var(--text-primary)',
|
|
||||||
borderColor: 'var(--accent-ember)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Explore Lists
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Features Section */}
|
|
||||||
<section className="py-20 px-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
<section className="py-20 px-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<h2 className="text-4xl font-bold text-center mb-16 gradient-text-gold">
|
<h2 className="text-4xl font-bold text-center mb-16 gradient-text-gold">
|
||||||
Everything You Need to Manage Your Cards
|
Everything You Need to Manage Your Cards
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid md:grid-cols-3 gap-8">
|
<div className="grid md:grid-cols-3 gap-8">
|
||||||
<div className="text-center p-8 rounded-2xl" style={{ backgroundColor: 'var(--bg-primary)' }}>
|
<GlassSurface
|
||||||
<div className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--accent-ember)' }}>
|
tint="mid"
|
||||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
rim="subtle"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
elevation="ambient"
|
||||||
|
className="text-center p-8 rounded-2xl"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: 'var(--accent-ember)' }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-white"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold mb-4 gradient-text-flame">Organize Lists</h3>
|
<h3 className="text-xl font-bold mb-4 gradient-text-flame">Organize Lists</h3>
|
||||||
<p style={{ color: 'var(--text-secondary)' }}>
|
<p style={{ color: 'var(--text-secondary)' }}>
|
||||||
Create custom lists, track card values, and organize by sets, rarity, or any system that works for you.
|
Create custom lists, track card values, and organize by sets, rarity, or any
|
||||||
|
system that works for you.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</GlassSurface>
|
||||||
<div className="text-center p-8 rounded-2xl" style={{ backgroundColor: 'var(--bg-primary)' }}>
|
<GlassSurface
|
||||||
<div className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--accent-gold)' }}>
|
tint="mid"
|
||||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
rim="subtle"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
elevation="ambient"
|
||||||
|
className="text-center p-8 rounded-2xl"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: 'var(--accent-gold)' }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-white"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold mb-4 gradient-text-gold">Discover Cards</h3>
|
<h3 className="text-xl font-bold mb-4 gradient-text-gold">Discover Cards</h3>
|
||||||
<p style={{ color: 'var(--text-secondary)' }}>
|
<p style={{ color: 'var(--text-secondary)' }}>
|
||||||
Search through thousands of cards across multiple TCGs. Find that missing piece for your binder.
|
Search through thousands of cards across multiple TCGs. Find that missing
|
||||||
|
piece for your binder.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</GlassSurface>
|
||||||
<div className="text-center p-8 rounded-2xl" style={{ backgroundColor: 'var(--bg-primary)' }}>
|
<GlassSurface
|
||||||
<div className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--accent-flame)' }}>
|
tint="mid"
|
||||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
rim="subtle"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
elevation="ambient"
|
||||||
|
className="text-center p-8 rounded-2xl"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: 'var(--accent-flame)' }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-white"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold mb-4 gradient-text-ember">Connect & Share</h3>
|
<h3 className="text-xl font-bold mb-4 gradient-text-ember">Connect & Share</h3>
|
||||||
<p style={{ color: 'var(--text-secondary)' }}>
|
<p style={{ color: 'var(--text-secondary)' }}>
|
||||||
Share your lists with the community, collaborate with friends, and discover amazing lists from other collectors.
|
Share your lists with the community, collaborate with friends, and discover
|
||||||
|
amazing lists from other collectors.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</GlassSurface>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Featured Collections Section */}
|
|
||||||
<section className="py-20 px-4">
|
<section className="py-20 px-4">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h2 className="text-4xl font-bold mb-4 gradient-text-flame">
|
<h2 className="text-4xl font-bold mb-4 gradient-text-flame">Featured Lists</h2>
|
||||||
Featured Lists
|
|
||||||
</h2>
|
|
||||||
<p className="text-xl" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-xl" style={{ color: 'var(--text-secondary)' }}>
|
||||||
Discover amazing lists from our community
|
Discover amazing lists from our community
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -184,13 +238,32 @@ export default function Home() {
|
||||||
{collectionsLoading ? (
|
{collectionsLoading ? (
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{[...Array(6)].map((_, i) => (
|
{[...Array(6)].map((_, i) => (
|
||||||
<div key={i} className="rounded-2xl p-6 animate-pulse" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
<div
|
||||||
<div className="h-32 rounded-xl mb-4" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
|
key={i}
|
||||||
<div className="h-6 rounded mb-2" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
|
className="motion-essential rounded-2xl p-6 animate-pulse"
|
||||||
<div className="h-4 rounded mb-4" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
|
style={{ backgroundColor: 'var(--bg-secondary)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-32 rounded-xl mb-4"
|
||||||
|
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-6 rounded mb-2"
|
||||||
|
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-4 rounded mb-4"
|
||||||
|
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||||
|
/>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<div className="h-4 w-16 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
|
<div
|
||||||
<div className="h-4 w-20 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
|
className="h-4 w-16 rounded"
|
||||||
|
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-4 w-20 rounded"
|
||||||
|
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -198,101 +271,138 @@ export default function Home() {
|
||||||
) : (
|
) : (
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{featuredCollections.map((collection) => (
|
{featuredCollections.map((collection) => (
|
||||||
<Link
|
<Link
|
||||||
key={collection.id}
|
key={collection.id}
|
||||||
href={`/collection/${collection.slug || collection.id}`}
|
href={`/collection/${collection.slug || collection.id}`}
|
||||||
className="block rounded-2xl p-6 transition-all duration-200 hover:scale-105 hover:shadow-xl"
|
|
||||||
style={{ backgroundColor: 'var(--bg-secondary)' }}
|
|
||||||
>
|
>
|
||||||
<div className="h-32 rounded-xl mb-4 flex items-center justify-center" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
|
<GlassSurface
|
||||||
{collection.image ? (
|
tint="mid"
|
||||||
<img
|
rim="subtle"
|
||||||
src={collection.image}
|
elevation="ambient"
|
||||||
alt={collection.name}
|
className="block rounded-2xl p-6 hover:scale-105"
|
||||||
className="w-full h-full object-cover rounded-xl"
|
style={{
|
||||||
/>
|
transition:
|
||||||
) : (
|
'transform var(--motion-duration-default) var(--motion-ease-out), box-shadow var(--motion-duration-default) var(--motion-ease-out)',
|
||||||
<div className="text-center">
|
}}
|
||||||
<div className="w-12 h-12 mx-auto mb-2 rounded-xl flex items-center justify-center" style={{ backgroundColor: 'var(--accent-ember)' }}>
|
>
|
||||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
className="h-32 rounded-xl mb-4 flex items-center justify-center"
|
||||||
</svg>
|
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||||
|
>
|
||||||
|
{collection.image ? (
|
||||||
|
<img
|
||||||
|
src={collection.image}
|
||||||
|
alt={collection.name}
|
||||||
|
className="w-full h-full object-cover rounded-xl"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-center">
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 mx-auto mb-2 rounded-xl flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: 'var(--accent-ember)' }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-white"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{collection.cardCount} cards
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{collection.cardCount} cards</p>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<h3 className="text-xl font-bold mb-2 gradient-text-flame">
|
||||||
</div>
|
{collection.name}
|
||||||
<h3 className="text-xl font-bold mb-2 gradient-text-flame">{collection.name}</h3>
|
</h3>
|
||||||
<p className="text-sm mb-4 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
|
<p
|
||||||
{collection.description || 'A carefully curated list'}
|
className="text-sm mb-4 line-clamp-2"
|
||||||
</p>
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
<div className="flex justify-between items-center">
|
>
|
||||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
{collection.description || 'A carefully curated list'}
|
||||||
{collection.cardCount} cards
|
</p>
|
||||||
</span>
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
<span
|
||||||
by {collection.creator}
|
className="text-sm font-medium"
|
||||||
</span>
|
style={{ color: 'var(--text-primary)' }}
|
||||||
</div>
|
>
|
||||||
|
{collection.cardCount} cards
|
||||||
|
</span>
|
||||||
|
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
by {collection.creator}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</GlassSurface>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-center mt-12">
|
<div className="text-center mt-12">
|
||||||
<Link
|
<Link href="/community/collections">
|
||||||
href="/community/collections"
|
<Button variant="secondary" size="lg">
|
||||||
className="inline-flex items-center px-6 py-3 text-lg font-medium rounded-xl transition-all duration-200 hover:opacity-80"
|
View All Lists
|
||||||
style={{
|
<svg
|
||||||
color: 'var(--accent-ember)',
|
className="w-5 h-5 ml-2 inline-block"
|
||||||
border: '2px solid var(--accent-ember)'
|
fill="none"
|
||||||
}}
|
stroke="currentColor"
|
||||||
>
|
viewBox="0 0 24 24"
|
||||||
View All Lists
|
aria-hidden="true"
|
||||||
<svg className="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
<path
|
||||||
</svg>
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* CTA Section */}
|
|
||||||
<section className="py-20 px-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
<section className="py-20 px-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||||
<div className="max-w-4xl mx-auto text-center">
|
<div className="max-w-4xl mx-auto text-center">
|
||||||
<h2 className="text-4xl font-bold mb-6 gradient-text-flame">
|
<h2 className="text-4xl font-bold mb-6 gradient-text-flame">
|
||||||
Ready to Start Your Journey?
|
Ready to Start Your Journey?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl mb-8" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-xl mb-8" style={{ color: 'var(--text-secondary)' }}>
|
||||||
Join thousands of collectors who trust Deck Hearth to manage their cards and lists.
|
Join thousands of collectors who trust Deck Hearth to manage their cards and
|
||||||
|
lists.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||||
<Link
|
<Link href="/signup">
|
||||||
href="/signup"
|
<Button variant="primary" size="lg">
|
||||||
className="px-8 py-4 text-lg font-medium rounded-2xl transition-all duration-200 hover:opacity-90 hover:scale-105"
|
Create Free Account
|
||||||
style={{
|
</Button>
|
||||||
backgroundColor: 'var(--accent-ember)',
|
|
||||||
color: 'white'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Create Free Account
|
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link href="/cards">
|
||||||
href="/cards"
|
<Button variant="secondary" size="lg">
|
||||||
className="px-8 py-4 text-lg font-medium rounded-2xl border-2 transition-all duration-200 hover:opacity-80"
|
Browse Cards
|
||||||
style={{
|
</Button>
|
||||||
color: 'var(--text-primary)',
|
|
||||||
borderColor: 'var(--accent-ember)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Browse Cards
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Footer */}
|
<footer
|
||||||
<footer className="py-12 px-4 border-t" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border)' }}>
|
className="py-12 px-4 border-t"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-primary)',
|
||||||
|
borderColor: 'var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center">
|
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||||
<div className="flex items-center space-x-3 mb-4 md:mb-0">
|
<div className="flex items-center space-x-3 mb-4 md:mb-0">
|
||||||
|
|
@ -300,18 +410,33 @@ export default function Home() {
|
||||||
<span className="text-xl font-bold gradient-text-flame">Deck Hearth</span>
|
<span className="text-xl font-bold gradient-text-flame">Deck Hearth</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-6">
|
<div className="flex space-x-6">
|
||||||
<Link href="/community/collections" className="hover:opacity-80 transition-opacity" style={{ color: 'var(--text-secondary)' }}>
|
<Link
|
||||||
|
href="/community/collections"
|
||||||
|
className="hover:opacity-80 transition-opacity"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
Community
|
Community
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/cards" className="hover:opacity-80 transition-opacity" style={{ color: 'var(--text-secondary)' }}>
|
<Link
|
||||||
|
href="/cards"
|
||||||
|
className="hover:opacity-80 transition-opacity"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
Cards
|
Cards
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/login" className="hover:opacity-80 transition-opacity" style={{ color: 'var(--text-secondary)' }}>
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="hover:opacity-80 transition-opacity"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 pt-8 border-t text-center" style={{ borderColor: 'var(--border)' }}>
|
<div
|
||||||
|
className="mt-8 pt-8 border-t text-center"
|
||||||
|
style={{ borderColor: 'var(--border)' }}
|
||||||
|
>
|
||||||
<p style={{ color: 'var(--text-secondary)' }}>
|
<p style={{ color: 'var(--text-secondary)' }}>
|
||||||
© 2024 Deck Hearth. Built for collectors, by collectors.
|
© 2024 Deck Hearth. Built for collectors, by collectors.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -320,4 +445,4 @@ export default function Home() {
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import Layout from '../../components/Layout';
|
import Layout from '../../components/Layout';
|
||||||
|
import { GlassSurface, Button } from '../../components/ui';
|
||||||
|
|
||||||
export default function AcceptInvite() {
|
export default function AcceptInvite() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -49,8 +50,14 @@ export default function AcceptInvite() {
|
||||||
<Layout user={null}>
|
<Layout user={null}>
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 mx-auto mb-4" style={{ borderColor: 'var(--text-accent)' }}></div>
|
<div
|
||||||
<h2 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
className="motion-essential animate-spin rounded-full h-16 w-16 border-b-2 mx-auto mb-4"
|
||||||
|
style={{ borderColor: 'var(--accent-ember)' }}
|
||||||
|
/>
|
||||||
|
<h2
|
||||||
|
className="text-xl font-semibold mb-2"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
Processing Invitation...
|
Processing Invitation...
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ color: 'var(--text-secondary)' }}>
|
<p style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
|
@ -67,61 +74,77 @@ export default function AcceptInvite() {
|
||||||
<div className="min-h-screen flex items-center justify-center px-4">
|
<div className="min-h-screen flex items-center justify-center px-4">
|
||||||
<div className="max-w-md w-full">
|
<div className="max-w-md w-full">
|
||||||
{result ? (
|
{result ? (
|
||||||
<div className="text-center p-8 rounded-2xl shadow-lg" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
<GlassSurface
|
||||||
<div className="text-6xl mb-4">🎉</div>
|
tint="mid"
|
||||||
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
rim="subtle"
|
||||||
|
elevation="pronounced"
|
||||||
|
className="text-center p-8 rounded-2xl"
|
||||||
|
>
|
||||||
|
<div className="text-6xl mb-4" aria-hidden="true">🎉</div>
|
||||||
|
<h2
|
||||||
|
className="text-2xl font-bold mb-4"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
Invitation Accepted!
|
Invitation Accepted!
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||||
You now have access to the list "{result.collection.name}".
|
You now have access to the list "{result.collection.name}".
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
onClick={() => router.push(`/collection/${result.collection.id}`)}
|
onClick={() => router.push(`/collection/${result.collection.id}`)}
|
||||||
className="w-full px-6 py-3 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200"
|
className="w-full"
|
||||||
>
|
>
|
||||||
View List
|
View List
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
onClick={() => router.push('/collections')}
|
onClick={() => router.push('/collections')}
|
||||||
className="w-full px-6 py-3 rounded-lg font-medium border transition-all duration-200"
|
className="w-full"
|
||||||
style={{
|
|
||||||
borderColor: 'var(--border)',
|
|
||||||
color: 'var(--text-primary)'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
View All Lists
|
View All Lists
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</GlassSurface>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center p-8 rounded-2xl shadow-lg" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
<GlassSurface
|
||||||
<div className="text-6xl mb-4">❌</div>
|
tint="mid"
|
||||||
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
rim="subtle"
|
||||||
|
elevation="pronounced"
|
||||||
|
className="text-center p-8 rounded-2xl"
|
||||||
|
>
|
||||||
|
<div className="text-6xl mb-4" aria-hidden="true">❌</div>
|
||||||
|
<h2
|
||||||
|
className="text-2xl font-bold mb-4"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
Invitation Error
|
Invitation Error
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
onClick={() => router.push('/collections')}
|
onClick={() => router.push('/collections')}
|
||||||
className="w-full px-6 py-3 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200"
|
className="w-full"
|
||||||
>
|
>
|
||||||
Browse Lists
|
Browse Lists
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
onClick={() => router.push('/')}
|
onClick={() => router.push('/')}
|
||||||
className="w-full px-6 py-3 rounded-lg font-medium border transition-all duration-200"
|
className="w-full"
|
||||||
style={{
|
|
||||||
borderColor: 'var(--border)',
|
|
||||||
color: 'var(--text-primary)'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Go Home
|
Go Home
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</GlassSurface>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import Layout from '../../components/Layout';
|
import Layout from '../../components/Layout';
|
||||||
|
import { GlassSurface, Button } from '../../components/ui';
|
||||||
|
|
||||||
export default function DeclineInvite() {
|
export default function DeclineInvite() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -49,8 +50,14 @@ export default function DeclineInvite() {
|
||||||
<Layout user={null}>
|
<Layout user={null}>
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 mx-auto mb-4" style={{ borderColor: 'var(--text-accent)' }}></div>
|
<div
|
||||||
<h2 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
className="motion-essential animate-spin rounded-full h-16 w-16 border-b-2 mx-auto mb-4"
|
||||||
|
style={{ borderColor: 'var(--accent-ember)' }}
|
||||||
|
/>
|
||||||
|
<h2
|
||||||
|
className="text-xl font-semibold mb-2"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
Processing Response...
|
Processing Response...
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ color: 'var(--text-secondary)' }}>
|
<p style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
|
@ -67,62 +74,79 @@ export default function DeclineInvite() {
|
||||||
<div className="min-h-screen flex items-center justify-center px-4">
|
<div className="min-h-screen flex items-center justify-center px-4">
|
||||||
<div className="max-w-md w-full">
|
<div className="max-w-md w-full">
|
||||||
{result ? (
|
{result ? (
|
||||||
<div className="text-center p-8 rounded-2xl shadow-lg" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
<GlassSurface
|
||||||
<div className="text-6xl mb-4">👋</div>
|
tint="mid"
|
||||||
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
rim="subtle"
|
||||||
|
elevation="pronounced"
|
||||||
|
className="text-center p-8 rounded-2xl"
|
||||||
|
>
|
||||||
|
<div className="text-6xl mb-4" aria-hidden="true">👋</div>
|
||||||
|
<h2
|
||||||
|
className="text-2xl font-bold mb-4"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
Invitation Declined
|
Invitation Declined
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||||
You have declined the invitation to "{result.collection.name}".
|
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>
|
</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
onClick={() => router.push('/collections')}
|
onClick={() => router.push('/collections')}
|
||||||
className="w-full px-6 py-3 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200"
|
className="w-full"
|
||||||
>
|
>
|
||||||
Browse Public Lists
|
Browse Public Lists
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
onClick={() => router.push('/')}
|
onClick={() => router.push('/')}
|
||||||
className="w-full px-6 py-3 rounded-lg font-medium border transition-all duration-200"
|
className="w-full"
|
||||||
style={{
|
|
||||||
borderColor: 'var(--border)',
|
|
||||||
color: 'var(--text-primary)'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Go Home
|
Go Home
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</GlassSurface>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center p-8 rounded-2xl shadow-lg" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
<GlassSurface
|
||||||
<div className="text-6xl mb-4">❌</div>
|
tint="mid"
|
||||||
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
rim="subtle"
|
||||||
|
elevation="pronounced"
|
||||||
|
className="text-center p-8 rounded-2xl"
|
||||||
|
>
|
||||||
|
<div className="text-6xl mb-4" aria-hidden="true">❌</div>
|
||||||
|
<h2
|
||||||
|
className="text-2xl font-bold mb-4"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
Error Processing Response
|
Error Processing Response
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
onClick={() => router.push('/collections')}
|
onClick={() => router.push('/collections')}
|
||||||
className="w-full px-6 py-3 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200"
|
className="w-full"
|
||||||
>
|
>
|
||||||
Browse Lists
|
Browse Lists
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
onClick={() => router.push('/')}
|
onClick={() => router.push('/')}
|
||||||
className="w-full px-6 py-3 rounded-lg font-medium border transition-all duration-200"
|
className="w-full"
|
||||||
style={{
|
|
||||||
borderColor: 'var(--border)',
|
|
||||||
color: 'var(--text-primary)'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Go Home
|
Go Home
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</GlassSurface>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import BulkSelectionToolbar from '../components/BulkSelectionToolbar';
|
||||||
import CollectionSelectionModal from '../components/CollectionSelectionModal';
|
import CollectionSelectionModal from '../components/CollectionSelectionModal';
|
||||||
import { ManaCost, ColorFilterSymbol } from '../components/ManaSymbols';
|
import { ManaCost, ColorFilterSymbol } from '../components/ManaSymbols';
|
||||||
import ManaSymbolSettings from '../components/ManaSymbolSettings';
|
import ManaSymbolSettings from '../components/ManaSymbolSettings';
|
||||||
|
import { Button } from '../components/ui';
|
||||||
import { useAuth } from '../lib/use-auth';
|
import { useAuth } from '../lib/use-auth';
|
||||||
import { VOCAB } from '../lib/collection-vocabulary.js';
|
import { VOCAB } from '../lib/collection-vocabulary.js';
|
||||||
|
|
||||||
|
|
@ -441,9 +442,9 @@ export default function MyCards() {
|
||||||
{searchQuery ? 'Try adjusting your search filters' : `Start adding cards to ${VOCAB.MY_COLLECTION} by browsing available cards`}
|
{searchQuery ? 'Try adjusting your search filters' : `Start adding cards to ${VOCAB.MY_COLLECTION} by browsing available cards`}
|
||||||
</p>
|
</p>
|
||||||
<Link href="/cards">
|
<Link href="/cards">
|
||||||
<button className="px-6 py-3 font-medium rounded-xl transition-all duration-200 hover:opacity-90" style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}>
|
<Button variant="primary" size="lg">
|
||||||
Browse All Cards
|
Browse All Cards
|
||||||
</button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -426,34 +426,11 @@ body {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Glowing effects for dark theme */
|
/* Gradient text effects — Deck Hearth warm palette only.
|
||||||
[data-theme="dark"] .glow-blue {
|
The pre-Deck-Hearth aliases (.glow-blue / .glow-purple / .glow-pink,
|
||||||
box-shadow: 0 0 20px rgba(6, 182, 212, 0.3);
|
.gradient-text-blue / .gradient-text-purple, .gradient-bg-purple)
|
||||||
}
|
were removed by the finish-liquid-glass-design PR after sweeping
|
||||||
|
their last consumers to --accent-ember equivalents. */
|
||||||
[data-theme="dark"] .glow-purple {
|
|
||||||
box-shadow: 0 0 20px rgba(139, 92, 246, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .glow-pink {
|
|
||||||
box-shadow: 0 0 20px rgba(236, 72, 153, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Gradient text effects */
|
|
||||||
.gradient-text-blue {
|
|
||||||
background: linear-gradient(135deg, #06b6d4 0%, #3b82f6 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gradient-text-purple {
|
|
||||||
background: linear-gradient(135deg, #8b5cf6 0%, #ec4899 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gradient-text-gold {
|
.gradient-text-gold {
|
||||||
background: linear-gradient(135deg, #ffab40 0%, #ff6f00 100%);
|
background: linear-gradient(135deg, #ffab40 0%, #ff6f00 100%);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue