deckhearth/pages/designer/print.js
Randall Stillwell 5b9a278ca2 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 21:48:52 -05:00

329 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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