- {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
329 lines
12 KiB
JavaScript
329 lines
12 KiB
JavaScript
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import Link from 'next/link';
|
||
import { useRouter } from 'next/router';
|
||
import Layout from '../../components/Layout';
|
||
import CardFrame, { CARD_W, CARD_H } from '../../components/designer/CardFrame';
|
||
import { Button } from '../../components/ui';
|
||
import { useAuth } from '../../lib/use-auth';
|
||
|
||
// US Letter at 300 DPI. Standard TCG card = 63 x 88 mm -> 744 x 1040 px.
|
||
const SHEET_W = 2550;
|
||
const SHEET_H = 3300;
|
||
const CARD_PRINT_W = 744;
|
||
const CARD_PRINT_H = 1040;
|
||
const CARD_SCALE = CARD_PRINT_W / CARD_W; // 420 -> 744
|
||
|
||
const LAYOUTS = {
|
||
'3x3': { cols: 3, rows: 3, label: '3 × 3 (9 cards)' },
|
||
'2x2': { cols: 2, rows: 2, label: '2 × 2 (4 cards)' },
|
||
};
|
||
|
||
export default function PrintSheet() {
|
||
const router = useRouter();
|
||
const { user, loading: authLoading } = useAuth();
|
||
|
||
const [designs, setDesigns] = useState([]);
|
||
const [symbolsMap, setSymbolsMap] = useState({});
|
||
const [loading, setLoading] = useState(true);
|
||
const [selected, setSelected] = useState(new Set());
|
||
const [layout, setLayout] = useState('3x3');
|
||
const [exporting, setExporting] = useState(false);
|
||
const [error, setError] = useState(null);
|
||
|
||
const sheetRef = useRef(null);
|
||
|
||
useEffect(() => {
|
||
if (!authLoading && !user) {
|
||
router.push('/login');
|
||
}
|
||
}, [authLoading, user, router]);
|
||
|
||
useEffect(() => {
|
||
if (!user) return undefined;
|
||
|
||
const load = async () => {
|
||
try {
|
||
const token = localStorage.getItem('auth_token');
|
||
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
||
const [cardsRes, symbolsRes] = await Promise.all([
|
||
fetch('/api/custom-cards', { headers }),
|
||
fetch('/api/custom-symbols', { headers }),
|
||
]);
|
||
if (cardsRes.ok) {
|
||
const data = await cardsRes.json();
|
||
setDesigns(data.designs);
|
||
// Preselect via ?ids=1,2,3 — otherwise everything.
|
||
const idsParam = router.query.ids;
|
||
if (typeof idsParam === 'string' && idsParam.length > 0) {
|
||
const ids = new Set(
|
||
idsParam.split(',').map((n) => parseInt(n, 10)).filter(Number.isInteger)
|
||
);
|
||
setSelected(new Set(data.designs.filter((d) => ids.has(d.id)).map((d) => d.id)));
|
||
} else {
|
||
setSelected(new Set(data.designs.map((d) => d.id)));
|
||
}
|
||
} else {
|
||
setError('Failed to load your designs.');
|
||
}
|
||
if (symbolsRes.ok) {
|
||
const data = await symbolsRes.json();
|
||
setSymbolsMap(Object.fromEntries(data.symbols.map((s) => [s.code, s.image_url])));
|
||
}
|
||
} catch {
|
||
setError('Failed to load your designs.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
load();
|
||
return undefined;
|
||
}, [user, router.query.ids]);
|
||
|
||
const toggle = (id) => {
|
||
setSelected((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) next.delete(id);
|
||
else next.add(id);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const selectedDesigns = useMemo(
|
||
() => designs.filter((d) => selected.has(d.id)),
|
||
[designs, selected]
|
||
);
|
||
|
||
// Preview scale so the full sheet fits the preview pane.
|
||
const [previewScale, setPreviewScale] = useState(0.25);
|
||
const previewWrapRef = useRef(null);
|
||
|
||
const updatePreviewScale = useCallback(() => {
|
||
const el = previewWrapRef.current;
|
||
if (el) setPreviewScale(Math.min(0.35, el.clientWidth / SHEET_W));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
updatePreviewScale();
|
||
const observer = new ResizeObserver(updatePreviewScale);
|
||
if (previewWrapRef.current) observer.observe(previewWrapRef.current);
|
||
return () => observer.disconnect();
|
||
}, [updatePreviewScale]);
|
||
|
||
const handleExport = async () => {
|
||
if (!sheetRef.current || selectedDesigns.length === 0) return;
|
||
setExporting(true);
|
||
setError(null);
|
||
try {
|
||
const { toPng } = await import('html-to-image');
|
||
const dataUrl = await toPng(sheetRef.current, {
|
||
width: SHEET_W,
|
||
height: SHEET_H,
|
||
pixelRatio: 1,
|
||
cacheBust: true,
|
||
});
|
||
const link = document.createElement('a');
|
||
link.download = `deckhearth-print-sheet-${selectedDesigns.length}cards.png`;
|
||
link.href = dataUrl;
|
||
link.click();
|
||
} catch {
|
||
setError('Export failed. Please try again.');
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
};
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
const { cols, rows } = LAYOUTS[layout];
|
||
const perSheet = cols * rows;
|
||
const slots = Array.from({ length: perSheet }, (_, i) => selectedDesigns[i] || null);
|
||
const sheetCount = Math.max(1, Math.ceil(selectedDesigns.length / perSheet));
|
||
|
||
return (
|
||
<Layout user={user}>
|
||
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6">
|
||
<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)' }}>
|
||
Print Sheet
|
||
</h1>
|
||
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
|
||
Arrange cards on a US Letter sheet (63 × 88 mm cards, cut guides included).
|
||
</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button variant="secondary" onClick={() => router.push('/my-designs')}>Back</Button>
|
||
<Button
|
||
variant="primary"
|
||
onClick={handleExport}
|
||
disabled={exporting || selectedDesigns.length === 0}
|
||
>
|
||
{exporting ? 'Exporting…' : 'Download Sheet PNG'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="glass-panel rounded-xl px-4 py-3 text-sm" style={{ color: '#f87171' }} role="alert">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,380px)_minmax(0,1fr)] gap-6">
|
||
{/* Controls + card picker */}
|
||
<div className="space-y-5 min-w-0">
|
||
<section className="glass-panel rounded-2xl p-5 space-y-3">
|
||
<h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>
|
||
Layout
|
||
</h2>
|
||
<div className="flex rounded-xl overflow-hidden border" style={{ borderColor: 'var(--border)' }}>
|
||
{Object.entries(LAYOUTS).map(([key, cfg]) => (
|
||
<button
|
||
key={key}
|
||
type="button"
|
||
onClick={() => setLayout(key)}
|
||
className="flex-1 px-4 py-2 text-xs font-semibold transition-all duration-200"
|
||
style={{
|
||
backgroundColor: layout === key ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
|
||
color: layout === key ? 'white' : 'var(--text-secondary)',
|
||
}}
|
||
aria-pressed={layout === key}
|
||
>
|
||
{cfg.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||
{selectedDesigns.length} card{selectedDesigns.length === 1 ? '' : 's'} selected
|
||
{selectedDesigns.length > perSheet
|
||
? ` · ${sheetCount} sheets (export one at a time)`
|
||
: ''}
|
||
</p>
|
||
</section>
|
||
|
||
<section className="glass-panel rounded-2xl p-5">
|
||
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||
Cards
|
||
</h2>
|
||
{designs.length === 0 ? (
|
||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||
No designs yet — create some in the{' '}
|
||
<Link href="/designer" style={{ color: 'var(--accent-ember)' }}>designer</Link> first.
|
||
</p>
|
||
) : (
|
||
<div className="space-y-2 max-h-[480px] overflow-y-auto pr-1">
|
||
{designs.map((design) => (
|
||
<label
|
||
key={design.id}
|
||
className="flex items-center gap-3 rounded-xl px-3 py-2 cursor-pointer transition-all duration-150 hover:shadow-md"
|
||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={selected.has(design.id)}
|
||
onChange={() => toggle(design.id)}
|
||
className="w-4 h-4"
|
||
/>
|
||
<span className="text-sm truncate" style={{ color: 'var(--text-primary)' }}>
|
||
{design.name}
|
||
</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
|
||
{/* Sheet preview */}
|
||
<div>
|
||
<div className="glass-panel rounded-2xl p-5">
|
||
<h2 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||
Sheet Preview
|
||
</h2>
|
||
<div ref={previewWrapRef} style={{ width: '100%', overflow: 'hidden' }}>
|
||
<div
|
||
style={{
|
||
width: SHEET_W * previewScale,
|
||
height: SHEET_H * previewScale,
|
||
margin: '0 auto',
|
||
}}
|
||
>
|
||
<div style={{ transform: `scale(${previewScale})`, transformOrigin: 'top left' }}>
|
||
<PrintSheetSurface
|
||
slots={slots}
|
||
cols={cols}
|
||
rows={rows}
|
||
sheetRef={sheetRef}
|
||
symbolsMap={symbolsMap}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Layout>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The actual 2550x3300 print surface. Cards render at natural 420x588
|
||
* then scale up 1.771x to true print size; dashed guides mark cut lines.
|
||
*/
|
||
function PrintSheetSurface({ slots, cols, rows, sheetRef, symbolsMap }) {
|
||
const marginX = (SHEET_W - cols * CARD_PRINT_W) / 2;
|
||
const marginTop = (SHEET_H - rows * CARD_PRINT_H) / 2;
|
||
|
||
return (
|
||
<div
|
||
ref={sheetRef}
|
||
style={{
|
||
width: SHEET_W,
|
||
height: SHEET_H,
|
||
backgroundColor: '#ffffff',
|
||
position: 'relative',
|
||
fontFamily: 'Georgia, serif',
|
||
}}
|
||
>
|
||
{slots.map((design, i) => {
|
||
const col = i % cols;
|
||
const row = Math.floor(i / cols);
|
||
const x = marginX + col * CARD_PRINT_W;
|
||
const y = marginTop + row * CARD_PRINT_H;
|
||
return (
|
||
<div
|
||
key={design ? design.id : `empty-${i}`}
|
||
style={{
|
||
position: 'absolute',
|
||
left: x,
|
||
top: y,
|
||
width: CARD_PRINT_W,
|
||
height: CARD_PRINT_H,
|
||
outline: '2px dashed rgba(0,0,0,0.25)',
|
||
overflow: 'hidden',
|
||
backgroundColor: design ? 'transparent' : '#fafafa',
|
||
}}
|
||
>
|
||
{design && (
|
||
<div style={{ transform: `scale(${CARD_SCALE})`, transformOrigin: 'top left' }}>
|
||
<CardFrame design={design} symbols={symbolsMap} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|