Replace html2canvas with Canvas 2D renderer for card export

html2canvas was capturing the full browser viewport instead of just the
340×214px card element — causing the giant oversized/wrong layout in the PNG.

New approach: drawCardToCanvas() draws the card entirely via Canvas 2D API:
- Creates an 856×540px canvas (10× credit-card mm dimensions, print-ready)
- Draws gradient background, top stripe, avatar (with CORS proxy), text,
  skill chips, stats, and QR code using direct canvas drawing commands
- QR code generated via qrcode lib toDataURL(), then drawn as image on canvas
- Avatar loaded via proxy.shakespeare.diy for CORS, with 4s timeout fallback
- Theme colors from THEME_PALETTE map — same 6 themes as the screen card
- No DOM capture, no scaling issues, no CSS stripping — output is pixel-perfect

QR code explanation:
- Encodes https://skate-spots.org/<npub>
- Scanning opens the rider's SkateSpots profile (NIP19Page handles /npub1...)
- White background patch behind QR for reliable scanning on dark card themes

Other changes:
- "Rider License" → "Skater License" everywhere
- Remove html2canvas dependency usage (kept package but unused for now)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-16 15:40:28 +07:00
parent 68922280cb
commit a7158d4901

View file

@ -16,7 +16,7 @@
import { useState, useRef, useCallback } from 'react';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
import html2canvas from 'html2canvas';
import QRCode from 'qrcode';
import { Layout } from '@/components/Layout';
import { LoginArea } from '@/components/auth/LoginArea';
@ -43,6 +43,370 @@ import { cn } from '@/lib/utils';
import type { NostrEvent } from '@nostrify/nostrify';
import { QRCodeCanvas } from '@/components/ui/qrcode';
// ─── Card canvas renderer ─────────────────────────────────────────────────────
// Draws the card entirely via Canvas 2D API — 100% reliable export,
// no CSS capture, no scaling issues, no background-stripping.
// Card is 856×540px (10× credit-card mm, high-res for printing).
const CARD_W = 856;
const CARD_H = 540;
interface CanvasCardData {
type: 'rider' | 'park';
themeId: string;
name: string;
nip05?: string;
avatarUrl?: string;
pubkey: string;
npub: string;
style: string;
skills: string[];
sponsors: string;
ridesFor: string;
showSpots: boolean;
showZaps: boolean;
showEvents: boolean;
spotsAdded: number;
zapsReceived: number;
eventsCreated: number;
memberYear: string;
receivedBadges: BadgeAward[];
// Park
issuerName: string;
recipientName: string;
accessLevel: string;
validThru: string;
}
// Theme palette for canvas drawing
const THEME_PALETTE: Record<string, {
bg1: string; bg2: string; bg3: string;
accent: string; accent2: string;
stripe1: string; stripe2: string;
muted: string; chipBg: string;
text: string;
}> = {
thug: { bg1: '#0a0a0a', bg2: '#1a0008', bg3: '#0d0d0d', accent: '#ff0040', accent2: '#ccff00', stripe1: '#ff0040', stripe2: '#ff6600', muted: 'rgba(255,255,255,0.55)', chipBg: 'rgba(255,0,64,0.25)', text: '#ffffff' },
lime: { bg1: '#050f00', bg2: '#0a1a00', bg3: '#050f00', accent: '#ccff00', accent2: '#ff6600', stripe1: '#ccff00', stripe2: '#88ff00', muted: 'rgba(255,255,255,0.55)', chipBg: 'rgba(204,255,0,0.2)', text: '#ffffff' },
chrome: { bg1: '#1a1a1a', bg2: '#2e2e2e', bg3: '#111111', accent: '#c8c8c8', accent2: '#ff6600', stripe1: '#888888', stripe2: '#cccccc', muted: 'rgba(232,232,232,0.6)', chipBg: 'rgba(200,200,200,0.15)', text: '#e8e8e8' },
wood: { bg1: '#3d2008', bg2: '#5c3210', bg3: '#2e1804', accent: '#f5c842', accent2: '#ff6600', stripe1: '#8b5e3c', stripe2: '#d4a05a', muted: 'rgba(245,230,200,0.65)', chipBg: 'rgba(160,100,40,0.3)', text: '#f5e6c8' },
midnight: { bg1: '#020818', bg2: '#050e2a', bg3: '#030b1a', accent: '#64a0ff', accent2: '#ccff00', stripe1: '#3060cc', stripe2: '#64a0ff', muted: 'rgba(208,228,255,0.6)', chipBg: 'rgba(100,160,255,0.15)', text: '#d0e4ff' },
sunset: { bg1: '#120006', bg2: '#1a0808', bg3: '#0e0810', accent: '#ff6020', accent2: '#ff0080', stripe1: '#ff0040', stripe2: '#ffaa00', muted: 'rgba(255,208,160,0.65)', chipBg: 'rgba(255,80,0,0.2)', text: '#ffd0a0' },
};
async function loadImage(url: string): Promise<HTMLImageElement | null> {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
// Proxy for CORS
img.src = url.startsWith('http') ? `https://proxy.shakespeare.diy/?url=${encodeURIComponent(url)}` : url;
setTimeout(() => resolve(null), 4000);
});
}
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
const canvas = document.createElement('canvas');
canvas.width = CARD_W;
canvas.height = CARD_H;
const ctx = canvas.getContext('2d')!;
const p = THEME_PALETTE[data.themeId] ?? THEME_PALETTE.thug;
// ── Background gradient ──
const grad = ctx.createLinearGradient(0, 0, CARD_W, CARD_H);
grad.addColorStop(0, p.bg1);
grad.addColorStop(0.6, p.bg2);
grad.addColorStop(1, p.bg3);
roundRect(ctx, 0, 0, CARD_W, CARD_H, 40);
ctx.fillStyle = grad;
ctx.fill();
// ── Top stripe ──
const stripe = ctx.createLinearGradient(0, 0, CARD_W, 0);
stripe.addColorStop(0, p.stripe1);
stripe.addColorStop(1, p.stripe2);
ctx.fillStyle = stripe;
ctx.fillRect(0, 0, CARD_W, 20);
const PAD = 44;
if (data.type === 'rider') {
// ── Brand + label ──
ctx.fillStyle = p.accent;
ctx.font = 'bold 28px system-ui, sans-serif';
ctx.fillText('🛹 SKATESPOTS', PAD, 72);
ctx.fillStyle = p.muted;
ctx.font = '18px system-ui, sans-serif';
ctx.fillText('SKATER LICENSE', PAD, 95);
// ── npub top-right ──
const shortN = `${data.npub.slice(0, 14)}…${data.npub.slice(-6)}`;
ctx.fillStyle = p.muted;
ctx.font = '16px system-ui, sans-serif';
ctx.textAlign = 'right';
ctx.fillText('NPUB', CARD_W - PAD, 60);
ctx.fillStyle = p.accent;
ctx.font = 'bold 18px monospace';
ctx.fillText(shortN, CARD_W - PAD, 82);
ctx.fillStyle = p.muted;
ctx.font = '16px system-ui, sans-serif';
ctx.fillText(`Since ${data.memberYear}`, CARD_W - PAD, 102);
ctx.textAlign = 'left';
// ── Avatar ──
const avatarSize = 200;
const avatarX = PAD;
const avatarY = 120;
roundRect(ctx, avatarX, avatarY, avatarSize, avatarSize, 16);
ctx.fillStyle = p.chipBg;
ctx.fill();
if (data.avatarUrl) {
const img = await loadImage(data.avatarUrl);
if (img) {
ctx.save();
roundRect(ctx, avatarX, avatarY, avatarSize, avatarSize, 16);
ctx.clip();
ctx.drawImage(img, avatarX, avatarY, avatarSize, avatarSize);
ctx.restore();
} else {
ctx.fillStyle = p.accent;
ctx.font = '80px serif';
ctx.textAlign = 'center';
ctx.fillText('🛹', avatarX + avatarSize / 2, avatarY + avatarSize / 2 + 28);
ctx.textAlign = 'left';
}
} else {
ctx.fillStyle = p.accent;
ctx.font = '80px serif';
ctx.textAlign = 'center';
ctx.fillText('🛹', avatarX + avatarSize / 2, avatarY + avatarSize / 2 + 28);
ctx.textAlign = 'left';
}
// ── Stats under avatar ──
const statsY = avatarY + avatarSize + 16;
const statItems = [
data.showSpots ? { label: 'Spots', value: String(data.spotsAdded), color: p.accent } : null,
data.showZaps ? { label: '⚡', value: String(data.zapsReceived), color: p.accent2 } : null,
data.showEvents ? { label: 'Events', value: String(data.eventsCreated), color: '#ff6600' } : null,
].filter(Boolean) as { label: string; value: string; color: string }[];
statItems.forEach((s, i) => {
const sy = statsY + i * 30;
ctx.fillStyle = p.muted;
ctx.font = '16px system-ui, sans-serif';
ctx.fillText(s.label, PAD, sy);
ctx.fillStyle = s.color;
ctx.font = 'bold 22px system-ui, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(s.value, PAD + avatarSize, sy);
ctx.textAlign = 'left';
});
// ── Identity: name ──
const idX = PAD + avatarSize + 36;
const idMaxW = CARD_W - idX - PAD - 160; // leave room for QR
ctx.fillStyle = p.text;
ctx.font = 'bold 52px system-ui, sans-serif';
// Truncate name to fit
let nameText = data.name.toUpperCase();
while (ctx.measureText(nameText).width > idMaxW && nameText.length > 3) {
nameText = nameText.slice(0, -1);
}
if (nameText !== data.name.toUpperCase()) nameText += '…';
ctx.fillText(nameText, idX, 175);
// ── nip05 ──
if (data.nip05) {
ctx.fillStyle = p.accent;
ctx.font = '20px system-ui, sans-serif';
ctx.fillText(`@${data.nip05.split('@')[0]}`, idX, 205);
}
// ── Style chip ──
if (data.style) {
ctx.font = 'bold 18px system-ui, sans-serif';
const chipW = ctx.measureText(data.style.toUpperCase()).width + 24;
roundRect(ctx, idX, 218, chipW, 30, 6);
ctx.fillStyle = p.chipBg;
ctx.fill();
ctx.fillStyle = p.accent;
ctx.fillText(data.style.toUpperCase(), idX + 12, 238);
}
// ── Rides for ──
if (data.ridesFor) {
ctx.fillStyle = p.muted;
ctx.font = '18px system-ui, sans-serif';
ctx.fillText(`🏪 ${data.ridesFor}`, idX, 270);
}
// ── Sponsors ──
if (data.sponsors) {
ctx.fillStyle = p.muted;
ctx.font = '18px system-ui, sans-serif';
ctx.fillText(`🤝 ${data.sponsors}`, idX, data.ridesFor ? 296 : 270);
}
// ── Skill chips ──
const skillsToShow = [
...data.skills.slice(0, 4).map(id => {
const s = SKILL_OPTIONS.find(o => o.id === id);
return s ? { label: `${s.icon} ${s.label}`, gold: false } : null;
}).filter(Boolean),
...data.receivedBadges.slice(0, 2).map(b => {
const s = SKILL_OPTIONS.find(o => o.id === b.skill);
return s ? { label: `⭐ ${s.label}`, gold: true } : null;
}).filter(Boolean),
] as { label: string; gold: boolean }[];
let chipX = idX;
let chipY = 330;
ctx.font = 'bold 16px system-ui, sans-serif';
skillsToShow.forEach(chip => {
const chipW = ctx.measureText(chip.label).width + 20;
if (chipX + chipW > CARD_W - PAD - 160) { chipX = idX; chipY += 34; }
roundRect(ctx, chipX, chipY, chipW, 26, 5);
ctx.fillStyle = chip.gold ? 'rgba(255,215,0,0.2)' : p.chipBg;
ctx.fill();
ctx.fillStyle = chip.gold ? '#ffd700' : p.accent;
ctx.fillText(chip.label, chipX + 10, chipY + 18);
chipX += chipW + 8;
});
// ── QR code ──
const qrSize = 160;
const qrX = CARD_W - PAD - qrSize;
const qrY = 120;
const qrUrl = `https://skate-spots.org/${data.npub}`;
try {
const qrDataUrl = await QRCode.toDataURL(qrUrl, { width: qrSize, margin: 1, errorCorrectionLevel: 'M', color: { dark: '#000000', light: '#ffffff' } });
const qrImg = await loadImage(qrDataUrl);
if (qrImg) {
// White background patch
roundRect(ctx, qrX - 6, qrY - 6, qrSize + 12, qrSize + 12, 8);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
}
} catch { /* skip QR if fails */ }
ctx.fillStyle = p.muted;
ctx.font = '14px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('SCAN TO VERIFY', qrX + qrSize / 2, qrY + qrSize + 22);
ctx.textAlign = 'left';
} else {
// ── PARK ACCESS card ──
ctx.fillStyle = p.accent;
ctx.font = 'bold 28px system-ui, sans-serif';
ctx.fillText(`🏟️ ${(data.issuerName || 'SKATEPARK').toUpperCase()}`, PAD, 72);
ctx.fillStyle = p.muted;
ctx.font = '18px system-ui, sans-serif';
ctx.fillText('ACCESS PASS', PAD, 95);
// Access level chip (top right)
ctx.font = 'bold 20px system-ui, sans-serif';
const alW = ctx.measureText((data.accessLevel || 'FULL ACCESS').toUpperCase()).width + 28;
roundRect(ctx, CARD_W - PAD - alW, 52, alW, 36, 8);
ctx.fillStyle = p.chipBg;
ctx.fill();
ctx.fillStyle = p.accent;
ctx.fillText((data.accessLevel || 'FULL ACCESS').toUpperCase(), CARD_W - PAD - alW + 14, 76);
// Avatar
const avatarSize = 200;
const avatarX = PAD;
const avatarY = 120;
roundRect(ctx, avatarX, avatarY, avatarSize, avatarSize, 16);
ctx.fillStyle = p.chipBg;
ctx.fill();
if (data.avatarUrl) {
const img = await loadImage(data.avatarUrl);
if (img) {
ctx.save();
roundRect(ctx, avatarX, avatarY, avatarSize, avatarSize, 16);
ctx.clip();
ctx.drawImage(img, avatarX, avatarY, avatarSize, avatarSize);
ctx.restore();
}
}
// Recipient name
const idX = PAD + avatarSize + 36;
ctx.fillStyle = p.text;
ctx.font = 'bold 46px system-ui, sans-serif';
ctx.fillText((data.recipientName || 'MEMBER NAME').toUpperCase(), idX, 175);
ctx.fillStyle = p.muted;
ctx.font = '18px monospace';
ctx.fillText(`${data.npub.slice(0, 20)}…`, idX, 208);
ctx.font = '18px system-ui, sans-serif';
ctx.fillText('Issued by park owner · Nostr-signed', idX, 236);
if (data.validThru) {
ctx.fillStyle = p.muted;
ctx.font = '18px system-ui, sans-serif';
ctx.fillText('Valid thru', idX, 270);
ctx.fillStyle = p.accent;
ctx.font = 'bold 28px system-ui, sans-serif';
ctx.fillText(data.validThru, idX, 298);
}
// QR
const qrSize = 160;
const qrX = CARD_W - PAD - qrSize;
const qrY = 120;
const qrUrl = `https://skate-spots.org/${data.npub}`;
try {
const qrDataUrl = await QRCode.toDataURL(qrUrl, { width: qrSize, margin: 1, errorCorrectionLevel: 'M', color: { dark: '#000000', light: '#ffffff' } });
const qrImg = await loadImage(qrDataUrl);
if (qrImg) {
roundRect(ctx, qrX - 6, qrY - 6, qrSize + 12, qrSize + 12, 8);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
}
} catch { /* skip */ }
ctx.fillStyle = p.muted;
ctx.font = '14px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('SCAN TO VERIFY', qrX + qrSize / 2, qrY + qrSize + 22);
ctx.textAlign = 'left';
}
// ── Bottom bar (both types) ──
const shortN = `${data.npub.slice(0, 14)}…${data.npub.slice(-6)}`;
ctx.fillStyle = p.muted;
ctx.font = '15px system-ui, sans-serif';
ctx.fillText('SKATE-SPOTS.ORG · POWERED BY NOSTR', PAD, CARD_H - 18);
ctx.textAlign = 'right';
ctx.fillText(shortN, CARD_W - PAD, CARD_H - 18);
ctx.textAlign = 'left';
return canvas.toDataURL('image/png');
}
import {
Printer, Download, Copy, Palette, Check, Building2,
User, Shield, Award, Zap,
@ -189,7 +553,7 @@ function RiderFront({ d }: { d: CardData }) {
<div className="text-[8px] font-black uppercase tracking-[0.18em] card-accent leading-none">
🛹 SkateSpots
</div>
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">Rider License</div>
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">Skater License</div>
</div>
<div className="text-right">
<div className="text-[5px] card-muted uppercase tracking-wide leading-none">npub</div>
@ -612,8 +976,8 @@ function BadgeEventRow({ event }: { event: NostrEvent }) {
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function RiderCardPage() {
useSeoMeta({
title: 'Rider Card — SkateSpots',
description: 'Create your printable SkateSpots Rider License or Park Access Card.',
title: 'Skater Card — SkateSpots',
description: 'Create your printable SkateSpots Skater License or Park Access Card.',
});
const { user } = useCurrentUser();
@ -709,42 +1073,43 @@ export default function RiderCardPage() {
validThru,
};
// ── Render card to canvas → returns a PNG data URL ───────────────────────
// ── Render card via Canvas 2D API → returns a PNG data URL ──────────────
const renderCardToImage = useCallback(async (): Promise<string | null> => {
if (!printRef.current) return null;
setRendering(true);
try {
// Temporarily disable holographic animation for clean capture
const cardEl = printRef.current;
cardEl.classList.remove('rider-card-holo');
const canvas = await html2canvas(cardEl, {
scale: 4, // 4× = ~1344×848px — crisp for print
useCORS: true, // allow cross-origin avatar images
allowTaint: true,
backgroundColor: null,
logging: false,
// html2canvas renders <canvas> elements by copying their pixel data
// (works with QRCodeCanvas since it draws synchronously via qrcode lib)
onclone: (_doc, el) => {
// Ensure the cloned element has exact dimensions matching the original
el.style.width = '340px';
el.style.height = '214px';
el.style.overflow = 'hidden';
},
const dataUrl = await drawCardToCanvas({
type: cardData.type,
themeId: cardData.theme,
name: cardData.name,
nip05: cardData.nip05,
avatarUrl: cardData.avatar,
pubkey: cardData.pubkey,
npub: cardData.npub,
style: cardData.style,
skills: cardData.skills,
sponsors: cardData.sponsors,
ridesFor: cardData.ridesFor,
showSpots: cardData.showSpots,
showZaps: cardData.showZaps,
showEvents: cardData.showEvents,
spotsAdded: cardData.spotsAdded,
zapsReceived: cardData.zapsReceived,
eventsCreated: cardData.eventsCreated,
memberYear: cardData.memberYear,
receivedBadges: cardData.receivedBadges,
issuerName: cardData.issuerName,
recipientName: cardData.recipientName,
accessLevel: cardData.accessLevel,
validThru: cardData.validThru,
});
// Restore holo class if it was on
if (cardData.holographic) cardEl.classList.add('rider-card-holo');
return canvas.toDataURL('image/png');
return dataUrl;
} catch (err) {
console.error('Card render failed:', err);
return null;
} finally {
setRendering(false);
}
}, [printRef, cardData.holographic]);
}, [cardData]);
// ── Print: render to image, open print window with the image ─────────────
const handlePrint = useCallback(async () => {
@ -859,9 +1224,9 @@ export default function RiderCardPage() {
<span className="neon-red">SKATE</span>
<span className="text-white"> CARDS</span>
</h1>
<p className="text-gray-400 text-sm">
Print-ready credit-card size (85.6 × 54 mm). Cryptographically signed on Nostr.
</p>
<p className="text-gray-400 text-sm">
Print-ready credit-card size (85.6 × 54 mm). QR-verified via Nostr.
</p>
</div>
{/* Inner tab nav */}
@ -967,7 +1332,7 @@ export default function RiderCardPage() {
<Label className="mb-2 block text-xs uppercase tracking-widest text-gray-500">Card Type</Label>
<div className="grid grid-cols-2 gap-2">
{([
{ id: 'rider', label: 'Rider License', icon: User },
{ id: 'rider', label: 'Skater License', icon: User },
{ id: 'park', label: 'Park Access', icon: Building2 },
] as const).map(({ id, label, icon: Icon }) => (
<button