2026-08-24 15:53:06 -04:00
|
|
|
import { useEffect, useState } from 'react';
|
2026-08-24 22:01:04 -04:00
|
|
|
import Link from 'next/link';
|
2026-08-24 15:53:06 -04:00
|
|
|
import { useRouter } from 'next/router';
|
|
|
|
|
import Layout from '../components/Layout';
|
|
|
|
|
import CardPreview from '../components/designer/CardFrame';
|
2026-08-24 22:01:04 -04:00
|
|
|
import { gameLabel } from '../lib/designer-games.js';
|
2026-08-24 15:53:06 -04:00
|
|
|
import { Button } from '../components/ui';
|
|
|
|
|
import { useAuth } from '../lib/use-auth';
|
|
|
|
|
|
|
|
|
|
export default function MyDesigns() {
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
const { user, loading: authLoading } = useAuth();
|
|
|
|
|
|
|
|
|
|
const [designs, setDesigns] = useState([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [error, setError] = useState(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!authLoading && !user) {
|
|
|
|
|
router.push('/login');
|
|
|
|
|
}
|
|
|
|
|
}, [authLoading, user, router]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!user) return undefined;
|
|
|
|
|
|
|
|
|
|
const loadDesigns = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const token = localStorage.getItem('auth_token');
|
|
|
|
|
const response = await fetch('/api/custom-cards', {
|
|
|
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
|
|
|
});
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
setDesigns(data.designs);
|
|
|
|
|
} else {
|
|
|
|
|
setError('Failed to load your designs.');
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
setError('Failed to load your designs.');
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
loadDesigns();
|
|
|
|
|
return undefined;
|
|
|
|
|
}, [user]);
|
|
|
|
|
|
|
|
|
|
const handleDelete = async (id) => {
|
|
|
|
|
if (!window.confirm('Delete this design? The card will remain in your collection.')) return;
|
|
|
|
|
try {
|
|
|
|
|
const token = localStorage.getItem('auth_token');
|
|
|
|
|
const response = await fetch(`/api/custom-cards/${id}`, {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
|
|
|
});
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
setDesigns((prev) => prev.filter((d) => d.id !== id));
|
|
|
|
|
} else {
|
|
|
|
|
setError('Delete failed.');
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
setError('Delete failed.');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<Layout user={user}>
|
|
|
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
|
|
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--accent-ember)' }} />
|
|
|
|
|
</div>
|
|
|
|
|
</Layout>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Layout user={user}>
|
|
|
|
|
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6">
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div className="pt-2 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-2xl sm:text-3xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
My Designs
|
|
|
|
|
</h1>
|
|
|
|
|
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
|
2026-08-24 22:01:04 -04:00
|
|
|
{designs.length} custom card{designs.length === 1 ? '' : 's'} · also visible in your collection ·{' '}
|
|
|
|
|
<Link href="/games" style={{ color: 'var(--accent-ember)' }}>My Games</Link>
|
2026-08-24 15:53:06 -04:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Button variant="primary" onClick={() => router.push('/designer')}>
|
|
|
|
|
+ New Design
|
|
|
|
|
</Button>
|
feat(designer): inline symbol icons, frame textures, community sharing, print sheets
- {CODE} tokens in description/actions/flavor render as inline symbol
icons (RichText), including inside cost pips
- custom frames gain an optional background texture (upload/replace/
remove via /api/custom-frames/[id]/texture); renders behind panels
- custom games can be shared to the community (is_public): toggle in the
game space, public listing at /community/games, read-only game view,
/api/public/games endpoints (no auth, public rows only)
- /designer/print: multi-card print sheets on US Letter at 300dpi
(63x88mm cards, 3x3 or 2x2, dashed cut guides, full-sheet PNG export)
- migration 1787711511000
2026-08-24 22:48:52 -04:00
|
|
|
{designs.length > 0 && (
|
|
|
|
|
<Button variant="secondary" onClick={() => router.push('/designer/print')}>
|
|
|
|
|
Print Sheet
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-08-24 15:53:06 -04:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{error && (
|
|
|
|
|
<div className="glass-panel rounded-xl px-4 py-3 text-sm" style={{ color: '#f87171' }} role="alert">
|
|
|
|
|
{error}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Designs grid */}
|
|
|
|
|
{!loading && designs.length === 0 && !error && (
|
|
|
|
|
<div className="text-center py-20">
|
|
|
|
|
<div className="glass-panel w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center">
|
|
|
|
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
No designs yet
|
|
|
|
|
</h3>
|
|
|
|
|
<p className="mb-4" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
Create your first custom card in the designer.
|
|
|
|
|
</p>
|
|
|
|
|
<Button variant="primary" size="lg" onClick={() => router.push('/designer')}>
|
|
|
|
|
Open Card Designer
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-5">
|
|
|
|
|
{designs.map((design) => (
|
|
|
|
|
<div key={design.id} className="glass-panel rounded-2xl p-4 flex flex-col gap-3">
|
|
|
|
|
<div
|
|
|
|
|
className="cursor-pointer"
|
|
|
|
|
onClick={() => router.push(`/designer?id=${design.id}`)}
|
|
|
|
|
role="link"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
onKeyDown={(e) => e.key === 'Enter' && router.push(`/designer?id=${design.id}`)}
|
|
|
|
|
>
|
|
|
|
|
<CardPreview design={design} maxWidth={280} />
|
|
|
|
|
</div>
|
2026-08-24 22:01:04 -04:00
|
|
|
<div className="min-w-0">
|
|
|
|
|
<p className="font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
{design.name}
|
|
|
|
|
</p>
|
|
|
|
|
<p className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
{design.card_type || 'No type'} {design.mana_cost ? `· ${design.mana_cost}` : ''}
|
|
|
|
|
</p>
|
|
|
|
|
<p className="text-xs truncate" style={{ color: 'var(--accent-ember)' }}>
|
|
|
|
|
{design.custom_game_name
|
|
|
|
|
? design.custom_game_name
|
|
|
|
|
: design.game_target && design.game_target !== 'custom'
|
|
|
|
|
? gameLabel(design.game_target)
|
|
|
|
|
: 'Standalone'}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-08-24 15:53:06 -04:00
|
|
|
<div className="flex gap-2 mt-auto">
|
|
|
|
|
<Button variant="secondary" size="sm" onClick={() => router.push(`/designer?id=${design.id}`)}>
|
|
|
|
|
Edit
|
|
|
|
|
</Button>
|
|
|
|
|
<Button variant="secondary" size="sm" onClick={() => handleDelete(design.id)}>
|
|
|
|
|
Delete
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Layout>
|
|
|
|
|
);
|
|
|
|
|
}
|