deckhearth/pages/designer/print.js

330 lines
12 KiB
JavaScript
Raw Normal View History

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>
);
}