From a7158d4901dc494233fc9776e0b5e8562cc24290 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Tue, 16 Jun 2026 15:40:28 +0700 Subject: [PATCH] Replace html2canvas with Canvas 2D renderer for card export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ - 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 --- src/pages/RiderCardPage.tsx | 433 +++++++++++++++++++++++++++++++++--- 1 file changed, 399 insertions(+), 34 deletions(-) diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx index f7f3ae2..13b3e91 100644 --- a/src/pages/RiderCardPage.tsx +++ b/src/pages/RiderCardPage.tsx @@ -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 = { + 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 { + 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 { + 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 }) {
🛹 SkateSpots
-
Rider License
+
Skater License
npub
@@ -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 => { - 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 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() { SKATE CARDS -

- Print-ready credit-card size (85.6 × 54 mm). Cryptographically signed on Nostr. -

+

+ Print-ready credit-card size (85.6 × 54 mm). QR-verified via Nostr. +

{/* Inner tab nav */} @@ -967,7 +1332,7 @@ export default function RiderCardPage() {
{([ - { 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 }) => (