330 lines
12 KiB
JavaScript
330 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>
|
|||
|
|
);
|
|||
|
|
}
|