fix(card): smaller name, big right-side QR, cleaner geometry
- Name 82px -> 46px (was truncating 'KEEN HAWK' and dominating). - QR now a proper right-hand verify stub: 214px band with a dashed divider and a 156px QR (was a small bottom-corner stub). Applied to all front types. - Rebalanced geometry (portrait 244, text 338, stub 214); identity/badges sit on the lower band; back side stays plain typographic with a small right QR. - Fixed a string of fillText(fitText(...)) calls that were missing their x.
This commit is contained in:
parent
84665adc8a
commit
3b542caade
1 changed files with 148 additions and 167 deletions
|
|
@ -3,12 +3,10 @@
|
|||
*
|
||||
* The card is drawn once, here, on a 856×540 canvas (10× a credit card at
|
||||
* 85.6×54mm). The studio preview and the public `/#/card/:npub` view render the
|
||||
* resulting PNG, so what you see is exactly what prints — no second React
|
||||
* implementation to drift from it.
|
||||
* resulting PNG, so what you see is exactly what prints.
|
||||
*
|
||||
* Design: "Credential" — a large portrait, one huge name, a single identity
|
||||
* line, and a clean verify stub. Nothing else. (No tiny stats, no npub clutter,
|
||||
* no "Powered by Nostr" filler.)
|
||||
* Layout: portrait panel (left) · text column (middle) · verify stub with a
|
||||
* large QR (right). Three type sizes, no filler.
|
||||
*/
|
||||
import { roleMeta, formatCardDate, passExpiry } from '@/lib/riderCard';
|
||||
|
||||
|
|
@ -32,12 +30,9 @@ export const THEME_PALETTE: Record<string, {
|
|||
sunset: { bg1: '#0e0004', bg2: '#160606', bg3: '#0a040c', accent: '#ff6020', accent2: '#ff0080', stripe1: '#ff0040', stripe2: '#ffaa00', muted: 'rgba(255,200,150,0.65)', chipBg: 'rgba(255,80,0,0.16)', chipBorder: 'rgba(255,80,0,0.4)', text: '#ffd0a0', shopColor: '#ffdd00', sponsorColor: '#ff88ff', sidebarBg: 'rgba(255,80,0,0.07)' },
|
||||
};
|
||||
|
||||
export interface CanvasCrew { name: string }
|
||||
|
||||
export interface CanvasCardData {
|
||||
type: 'rider' | 'park' | 'crew' | 'event';
|
||||
themeId: string;
|
||||
/** Custom accent hex overriding the theme's primary accent. */
|
||||
accentOverride?: string;
|
||||
name: string;
|
||||
nip05?: string;
|
||||
|
|
@ -79,6 +74,14 @@ export interface CanvasCardData {
|
|||
holographic?: boolean;
|
||||
}
|
||||
|
||||
// ─── geometry ─────────────────────────────────────────────────────────────────
|
||||
const PAD = 34;
|
||||
const PORTRAIT_W = 244; // left portrait panel
|
||||
const STUB_W = 214; // right verify stub (held the large QR)
|
||||
const STUB_X = CARD_W - STUB_W; // 642
|
||||
const TEXT_X = PORTRAIT_W + 34; // 278
|
||||
const TEXT_W = STUB_X - TEXT_X - 26; // ~338
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
async function loadImage(url: string): Promise<HTMLImageElement | null> {
|
||||
return new Promise((resolve) => {
|
||||
|
|
@ -105,14 +108,12 @@ function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: numbe
|
|||
ctx.closePath();
|
||||
}
|
||||
|
||||
/** Shrink text until it fits maxWidth (adds an ellipsis if truncated). */
|
||||
function fitText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string {
|
||||
let t = text;
|
||||
while (ctx.measureText(t).width > maxWidth && t.length > 2) t = t.slice(0, -1);
|
||||
return t.length < text.length ? t.slice(0, -1) + '…' : t;
|
||||
}
|
||||
|
||||
/** Draw an image covering a box (object-fit: cover), clipped to radius. */
|
||||
function drawCover(ctx: CanvasRenderingContext2D, img: HTMLImageElement, x: number, y: number, w: number, h: number, r: number) {
|
||||
const ar = img.width / img.height;
|
||||
const boxAr = w / h;
|
||||
|
|
@ -126,244 +127,214 @@ function drawCover(ctx: CanvasRenderingContext2D, img: HTMLImageElement, x: numb
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
/** Letter-spaced uppercase text (ctx.letterSpacing is Chromium-supported). */
|
||||
function wordmark(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, spacing: number) {
|
||||
const prev = (ctx as CanvasRenderingContext2D & { letterSpacing?: string }).letterSpacing;
|
||||
try { (ctx as CanvasRenderingContext2D & { letterSpacing?: string }).letterSpacing = `${spacing}px`; } catch { /* unsupported */ }
|
||||
const c = ctx as CanvasRenderingContext2D & { letterSpacing?: string };
|
||||
const prev = c.letterSpacing;
|
||||
try { c.letterSpacing = `${spacing}px`; } catch { /* unsupported */ }
|
||||
ctx.fillText(text, x, y);
|
||||
try { (ctx as CanvasRenderingContext2D & { letterSpacing?: string }).letterSpacing = prev; } catch { /* ignore */ }
|
||||
try { c.letterSpacing = prev; } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ─── shared blocks ────────────────────────────────────────────────────────────
|
||||
|
||||
const PAD = 40;
|
||||
const PORTRAIT_W = 296;
|
||||
|
||||
/** The brand lockup, top-left of the text column. */
|
||||
function drawBrand(ctx: CanvasRenderingContext2D, x: number, y: number, p: typeof THEME_PALETTE[string], kind: string) {
|
||||
function drawBrand(ctx: CanvasRenderingContext2D, p: typeof THEME_PALETTE[string], kind: string) {
|
||||
ctx.textAlign = 'left';
|
||||
ctx.font = 'bold 17px system-ui, sans-serif';
|
||||
ctx.fillStyle = p.accent;
|
||||
wordmark(ctx, 'SKATESPOTS', x, y, 4);
|
||||
ctx.font = 'bold 13px system-ui, sans-serif';
|
||||
ctx.font = 'bold 16px system-ui, sans-serif';
|
||||
wordmark(ctx, 'SKATESPOTS', TEXT_X, 66, 4);
|
||||
ctx.fillStyle = p.muted;
|
||||
wordmark(ctx, kind, x + 1, y + 24, 3);
|
||||
ctx.font = 'bold 12px system-ui, sans-serif';
|
||||
wordmark(ctx, kind, TEXT_X + 1, 88, 3);
|
||||
}
|
||||
|
||||
/** The QR / verify stub, bottom-right. */
|
||||
async function drawVerifyStub(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
verifyUrl: string | undefined,
|
||||
p: typeof THEME_PALETTE[string],
|
||||
dark = false,
|
||||
) {
|
||||
// panel
|
||||
roundRect(ctx, x, y, size, size, 16);
|
||||
ctx.fillStyle = dark ? 'rgba(255,255,255,0.06)' : '#ffffff';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = dark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.08)';
|
||||
ctx.lineWidth = 1;
|
||||
/** The right verify stub: dashed divider + a large QR. */
|
||||
async function drawStub(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.16)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([9, 11]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(STUB_X, 26);
|
||||
ctx.lineTo(STUB_X, CARD_H - 26);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
const pad = 16;
|
||||
if (verifyUrl) {
|
||||
const size = 156;
|
||||
const qx = STUB_X + (STUB_W - size) / 2;
|
||||
const qy = 128;
|
||||
|
||||
roundRect(ctx, qx - 12, qy - 12, size + 24, size + 24, 14);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fill();
|
||||
|
||||
if (data.verifyUrl) {
|
||||
try {
|
||||
const QRCode = (await import('qrcode')).default;
|
||||
const dataUrl = await QRCode.toDataURL(verifyUrl, { width: (size - pad * 2) * 2, margin: 0, errorCorrectionLevel: 'M', color: { dark: '#000000', light: '#ffffff' } });
|
||||
const dataUrl = await QRCode.toDataURL(data.verifyUrl, { width: size * 2, margin: 0, errorCorrectionLevel: 'M', color: { dark: '#000000', light: '#ffffff' } });
|
||||
const img = await loadImage(dataUrl);
|
||||
if (img) ctx.drawImage(img, x + pad, y + pad, size - pad * 2, size - pad * 2);
|
||||
if (img) ctx.drawImage(img, qx, qy, size, size);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
// caption under the stub
|
||||
ctx.textAlign = 'center';
|
||||
ctx.font = 'bold 12px system-ui, sans-serif';
|
||||
ctx.fillStyle = p.muted;
|
||||
wordmark(ctx, verifyUrl ? 'SCAN TO VERIFY' : 'NOT PUBLISHED', x + size / 2, y + size + 20, 2);
|
||||
ctx.font = 'bold 12px system-ui, sans-serif';
|
||||
wordmark(ctx, data.verifyUrl ? 'SCAN TO VERIFY' : 'NOT PUBLISHED', STUB_X + STUB_W / 2, qy + size + 34, 2);
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
// ─── front sides ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function drawRiderFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string], logo: HTMLImageElement | null) {
|
||||
const RX = PORTRAIT_W + PAD;
|
||||
const RW = CARD_W - RX - PAD;
|
||||
drawBrand(ctx, p, 'RIDER CARD');
|
||||
|
||||
drawBrand(ctx, RX, 74, p, 'RIDER CARD');
|
||||
|
||||
// Name — the focal point.
|
||||
// Name — the focal point (sized to leave the QR room).
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 82px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, data.name.toUpperCase(), RW), RX, 214);
|
||||
ctx.font = 'bold 46px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, data.name.toUpperCase(), TEXT_W), TEXT_X, 156);
|
||||
|
||||
// One identity line: style · level · local.
|
||||
const identity = [data.style, data.level, data.showLocal ? data.local : ''].filter(Boolean).join(' · ');
|
||||
let y = 262;
|
||||
let y = 196;
|
||||
if (identity) {
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 25px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, identity.toUpperCase(), RW), RX, y);
|
||||
y += 40;
|
||||
ctx.font = 'bold 20px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, identity.toUpperCase(), TEXT_W), TEXT_X, y);
|
||||
y += 30;
|
||||
}
|
||||
|
||||
// nip05
|
||||
if (data.nip05) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = 'italic 21px Georgia, serif';
|
||||
ctx.fillText(fitText(ctx, `@${data.nip05.split('@')[0]}`, RW), RX, y);
|
||||
y += 34;
|
||||
} else {
|
||||
y += 8;
|
||||
ctx.font = 'italic 17px Georgia, serif';
|
||||
ctx.fillText(fitText(ctx, `@${data.nip05.split('@')[0]}`, TEXT_W), TEXT_X, y);
|
||||
}
|
||||
|
||||
// Crews (monogram circles) + roles, as one compact row.
|
||||
const chips: string[] = [];
|
||||
if (data.showRoles) for (const r of (data.roles ?? []).slice(0, 2)) { const m = roleMeta(r); if (m) chips.push(`${m.emoji} ${m.label}`); }
|
||||
const witnessed = (data.receivedBadges ?? []).slice(0, 3).map(b => `★ ${b.skill}`);
|
||||
// Crew monogram circles on the lower band.
|
||||
const crewNames = data.showCrews ? (data.crews ?? []).slice(0, 2) : [];
|
||||
|
||||
// crew monogram circles just above the bottom band
|
||||
const circleR = 17;
|
||||
let cx = RX + circleR;
|
||||
const circleR = 15;
|
||||
let cx = TEXT_X + circleR;
|
||||
const cy = CARD_H - 66;
|
||||
crewNames.forEach((name) => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, CARD_H - 66, circleR, 0, Math.PI * 2);
|
||||
ctx.arc(cx, cy, circleR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = p.chipBg;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = p.chipBorder;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 16px system-ui, sans-serif';
|
||||
ctx.font = 'bold 14px system-ui, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText((name[0] ?? '?').toUpperCase(), cx, CARD_H - 60);
|
||||
ctx.fillText((name[0] ?? '?').toUpperCase(), cx, cy + 5);
|
||||
ctx.textAlign = 'left';
|
||||
// name
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '15px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, name, 96), cx + circleR + 8, CARD_H - 60);
|
||||
cx += circleR * 2 + 16 + ctx.measureText(fitText(ctx, name, 96)).width + 12;
|
||||
ctx.font = '14px system-ui, sans-serif';
|
||||
const nm = fitText(ctx, name, 82);
|
||||
ctx.fillText(nm, cx + circleR + 8, cy + 5);
|
||||
cx += circleR * 2 + 14 + ctx.measureText(nm).width + 12;
|
||||
});
|
||||
|
||||
// roles + witnessed chips, right-aligned on the bottom band
|
||||
const bottomChips = [...chips, ...witnessed];
|
||||
if (bottomChips.length > 0) {
|
||||
ctx.textAlign = 'right';
|
||||
ctx.font = 'bold 15px system-ui, sans-serif';
|
||||
// Roles + witnessed badges, on their own line just above the circles.
|
||||
const chips: string[] = [];
|
||||
if (data.showRoles) for (const r of (data.roles ?? []).slice(0, 2)) { const m = roleMeta(r); if (m) chips.push(`${m.emoji} ${m.label}`); }
|
||||
for (const b of (data.receivedBadges ?? []).slice(0, 3)) chips.push(`★ ${b.skill}`);
|
||||
if (chips.length > 0 && crewNames.length === 0) {
|
||||
ctx.fillStyle = p.accent2;
|
||||
ctx.fillText(fitText(ctx, bottomChips.join(' '), RW - 180), RX, CARD_H - 60);
|
||||
ctx.textAlign = 'left';
|
||||
ctx.font = 'bold 15px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, chips.join(' '), TEXT_W), TEXT_X, cy + 5);
|
||||
}
|
||||
|
||||
// Crest logo top-left of the portrait (if any)
|
||||
if (logo) drawCover(ctx, logo, 24, 24, 62, 62, 14);
|
||||
|
||||
await drawVerifyStub(ctx, CARD_W - PAD - 156, CARD_H - PAD - 156 - 8, 156, data.verifyUrl, p);
|
||||
if (logo) drawCover(ctx, logo, 22, 22, 58, 58, 12);
|
||||
await drawStub(ctx, data, p);
|
||||
}
|
||||
|
||||
async function drawParkFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
|
||||
const RX = PORTRAIT_W + PAD;
|
||||
const RW = CARD_W - RX - PAD;
|
||||
const expired = passExpiry(data.validThru) === 'expired';
|
||||
|
||||
drawBrand(ctx, RX, 74, p, 'ACCESS PASS');
|
||||
drawBrand(ctx, p, 'ACCESS PASS');
|
||||
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 64px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, (data.issuerName || 'SKATEPARK').toUpperCase(), RW), RX, 186);
|
||||
ctx.font = 'bold 40px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, (data.issuerName || 'SKATEPARK').toUpperCase(), TEXT_W), TEXT_X, 150);
|
||||
|
||||
ctx.fillStyle = expired ? '#ff5555' : p.accent;
|
||||
ctx.font = 'bold 26px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, expired ? 'EXPIRED' : (data.accessLevel || 'FULL ACCESS').toUpperCase(), RW), RX, 234);
|
||||
ctx.font = 'bold 22px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, expired ? 'EXPIRED' : (data.accessLevel || 'FULL ACCESS').toUpperCase(), TEXT_W), TEXT_X, 190);
|
||||
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '17px system-ui, sans-serif';
|
||||
ctx.fillText('ADMITS', RX, 296);
|
||||
ctx.font = '15px system-ui, sans-serif';
|
||||
ctx.fillText('ADMITS', TEXT_X, 240);
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 34px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, data.recipientName || data.name, RW), RX, 334);
|
||||
ctx.font = 'bold 30px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, data.recipientName || data.name, TEXT_W), TEXT_X, 278);
|
||||
|
||||
if (data.validThru) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '16px system-ui, sans-serif';
|
||||
ctx.fillText(`VALID THRU ${data.validThru}`, RX, CARD_H - 60);
|
||||
ctx.font = '14px system-ui, sans-serif';
|
||||
ctx.fillText(`VALID THRU ${data.validThru}`, TEXT_X, CARD_H - 62);
|
||||
}
|
||||
|
||||
await drawVerifyStub(ctx, CARD_W - PAD - 156, CARD_H - PAD - 156 - 8, 156, data.verifyUrl, p);
|
||||
await drawStub(ctx, data, p);
|
||||
}
|
||||
|
||||
async function drawCrewFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
|
||||
const RX = PORTRAIT_W + PAD;
|
||||
const RW = CARD_W - RX - PAD;
|
||||
const c = data.crew;
|
||||
|
||||
drawBrand(ctx, RX, 74, p, 'CREW CARD');
|
||||
drawBrand(ctx, p, 'CREW CARD');
|
||||
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 68px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, (c?.name ?? '').toUpperCase(), RW), RX, 192);
|
||||
ctx.font = 'bold 42px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, (c?.name ?? '').toUpperCase(), TEXT_W), TEXT_X, 154);
|
||||
|
||||
const line2 = [c?.location, c?.founded ? `EST. ${c.founded}` : ''].filter(Boolean).join(' · ');
|
||||
if (line2) {
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 24px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, line2.toUpperCase(), RW), RX, 240);
|
||||
ctx.font = 'bold 19px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, line2.toUpperCase(), TEXT_W), TEXT_X, 194);
|
||||
}
|
||||
|
||||
if (c) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '20px system-ui, sans-serif';
|
||||
ctx.fillText(`${c.members} MEMBERS · ${c.spots} SPOTS`, RX, 292);
|
||||
ctx.font = '17px system-ui, sans-serif';
|
||||
ctx.fillText(`${c.members} MEMBERS · ${c.spots} SPOTS`, TEXT_X, 244);
|
||||
if (c.lookingFor.length > 0) {
|
||||
ctx.fillStyle = p.accent2;
|
||||
ctx.font = 'bold 20px system-ui, sans-serif';
|
||||
ctx.fillText('OPEN CALL', RX, 328);
|
||||
ctx.font = 'bold 18px system-ui, sans-serif';
|
||||
ctx.fillText('OPEN CALL', TEXT_X, 284);
|
||||
}
|
||||
}
|
||||
|
||||
await drawVerifyStub(ctx, CARD_W - PAD - 156, CARD_H - PAD - 156 - 8, 156, data.verifyUrl, p);
|
||||
await drawStub(ctx, data, p);
|
||||
}
|
||||
|
||||
async function drawEventFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
|
||||
const RX = PORTRAIT_W + PAD;
|
||||
const RW = CARD_W - RX - PAD;
|
||||
const e = data.event;
|
||||
|
||||
drawBrand(ctx, RX, 74, p, 'EVENT PASS');
|
||||
drawBrand(ctx, p, 'EVENT PASS');
|
||||
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 60px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, (e?.title ?? '').toUpperCase(), RW), RX, 184);
|
||||
ctx.font = 'bold 38px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, (e?.title ?? '').toUpperCase(), TEXT_W), TEXT_X, 150);
|
||||
|
||||
if (e) {
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 23px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, formatCardDate(e.start, e.end), RW), RX, 228);
|
||||
ctx.font = 'bold 19px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, formatCardDate(e.start, e.end), TEXT_W), TEXT_X, 190);
|
||||
if (e.location) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '19px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, `📍 ${e.location}`, RW), RX, 264);
|
||||
ctx.font = '16px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, `📍 ${e.location}`, TEXT_W), TEXT_X, 222);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '17px system-ui, sans-serif';
|
||||
ctx.fillText('ADMITS', RX, 320);
|
||||
ctx.font = '15px system-ui, sans-serif';
|
||||
ctx.fillText('ADMITS', TEXT_X, 274);
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 32px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, e?.holder ?? data.name, RW), RX, 356);
|
||||
|
||||
await drawVerifyStub(ctx, CARD_W - PAD - 156, CARD_H - PAD - 156 - 8, 156, data.verifyUrl, p);
|
||||
ctx.font = 'bold 28px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, e?.holder ?? data.name, TEXT_W), TEXT_X, 310);
|
||||
await drawStub(ctx, data, p);
|
||||
}
|
||||
|
||||
// ─── back ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function drawBack(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
|
||||
const lines: Array<[string, string]> = [];
|
||||
async function drawBack(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
|
||||
const roles = (data.roles ?? []).map(r => roleMeta(r)).filter(Boolean).map(m => `${m!.emoji} ${m!.label}`).join(' · ');
|
||||
const lines: Array<[string, string]> = [];
|
||||
if (data.crews?.length) lines.push(['CREWS', data.crews.join(' · ')]);
|
||||
if (roles) lines.push(['LAB ROLES', roles]);
|
||||
if (data.local) lines.push(['LOCAL', data.local]);
|
||||
|
|
@ -371,35 +342,51 @@ function drawBack(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof
|
|||
if (data.sponsors) lines.push(['SPONSORS', data.sponsors]);
|
||||
if (data.validThru) lines.push(['VALID THRU', data.validThru]);
|
||||
|
||||
drawBrand(ctx, PAD, 86, p, 'CARD BACK');
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 16px system-ui, sans-serif';
|
||||
wordmark(ctx, 'SKATESPOTS', PAD, 62, 4);
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = 'bold 12px system-ui, sans-serif';
|
||||
wordmark(ctx, 'CARD BACK', PAD + 1, 84, 3);
|
||||
|
||||
let y = 200;
|
||||
let y = 160;
|
||||
for (const [label, value] of lines.slice(0, 6)) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = 'bold 15px system-ui, sans-serif';
|
||||
ctx.font = 'bold 13px system-ui, sans-serif';
|
||||
wordmark(ctx, label, PAD, y, 3);
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 26px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, value, CARD_W - PAD * 2 - 220), PAD, y + 40);
|
||||
y += 92;
|
||||
ctx.font = 'bold 24px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, value, STUB_X - PAD - 24), PAD, y + 36);
|
||||
y += 74;
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '22px system-ui, sans-serif';
|
||||
ctx.font = '20px system-ui, sans-serif';
|
||||
ctx.fillText('Add crews, roles or sponsors to fill the back.', PAD, y);
|
||||
}
|
||||
|
||||
// Verify stub on the right
|
||||
roundRect(ctx, CARD_W - PAD - 150, CARD_H - PAD - 150, 150, 150, 16);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.06)';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
// right stub: the website + a small QR
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.16)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([9, 11]);
|
||||
ctx.beginPath(); ctx.moveTo(STUB_X, 26); ctx.lineTo(STUB_X, CARD_H - 26); ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
const size = 120;
|
||||
const qx = STUB_X + (STUB_W - size) / 2;
|
||||
if (data.verifyUrl) {
|
||||
try {
|
||||
const QRCode = (await import('qrcode')).default;
|
||||
const dataUrl = await QRCode.toDataURL(data.verifyUrl, { width: size * 2, margin: 0, errorCorrectionLevel: 'M', color: { dark: '#ffffff', light: '#00000000' } });
|
||||
const img = await loadImage(dataUrl);
|
||||
if (img) ctx.drawImage(img, qx, 150, size, size);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = 'bold 12px system-ui, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
wordmark(ctx, 'skate-spots.org', CARD_W - PAD - 75, CARD_H - PAD + 22, 2);
|
||||
wordmark(ctx, 'skate-spots.org', STUB_X + STUB_W / 2, 300, 2);
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
|
|
@ -422,11 +409,10 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
|
|||
ctx.fill();
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.fillRect(0, 0, CARD_W, 8);
|
||||
drawBack(ctx, data, p);
|
||||
await drawBack(ctx, data, p);
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
// Background
|
||||
const bg = ctx.createLinearGradient(0, 0, CARD_W, CARD_H);
|
||||
bg.addColorStop(0, p.bg1);
|
||||
bg.addColorStop(0.55, p.bg2);
|
||||
|
|
@ -435,7 +421,6 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
|
|||
ctx.fillStyle = bg;
|
||||
ctx.fill();
|
||||
|
||||
// Portrait panel (full height left)
|
||||
const portraitUrl = data.type === 'crew' ? data.crew?.image : data.type === 'event' ? data.event?.image : data.avatarUrl;
|
||||
const portrait = portraitUrl ? await loadImage(portraitUrl) : null;
|
||||
ctx.save();
|
||||
|
|
@ -447,22 +432,20 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
|
|||
ctx.fillStyle = p.sidebarBg;
|
||||
ctx.fillRect(0, 0, PORTRAIT_W, CARD_H);
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = '96px system-ui, sans-serif';
|
||||
ctx.font = '84px system-ui, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('🛹', PORTRAIT_W / 2, CARD_H / 2 + 30);
|
||||
ctx.fillText('🛹', PORTRAIT_W / 2, CARD_H / 2 + 26);
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
// darken the portrait's lower band so crew circles read
|
||||
const shade = ctx.createLinearGradient(0, CARD_H - 200, 0, CARD_H);
|
||||
const shade = ctx.createLinearGradient(0, CARD_H - 180, 0, CARD_H);
|
||||
shade.addColorStop(0, 'rgba(0,0,0,0)');
|
||||
shade.addColorStop(1, 'rgba(0,0,0,0.78)');
|
||||
shade.addColorStop(1, 'rgba(0,0,0,0.72)');
|
||||
ctx.fillStyle = shade;
|
||||
ctx.fillRect(0, CARD_H - 200, PORTRAIT_W, 200);
|
||||
ctx.fillRect(0, CARD_H - 180, PORTRAIT_W, 180);
|
||||
ctx.restore();
|
||||
|
||||
// separator
|
||||
ctx.strokeStyle = p.accent;
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.globalAlpha = 0.25;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(PORTRAIT_W, 0); ctx.lineTo(PORTRAIT_W, CARD_H); ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
|
|
@ -474,14 +457,12 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
|
|||
else if (data.type === 'crew') await drawCrewFront(ctx, data, p);
|
||||
else await drawEventFront(ctx, data, p);
|
||||
|
||||
// Signature bottom accent bar
|
||||
const bar = ctx.createLinearGradient(0, 0, CARD_W, 0);
|
||||
bar.addColorStop(0, p.stripe1);
|
||||
bar.addColorStop(1, p.stripe2);
|
||||
ctx.fillStyle = bar;
|
||||
ctx.fillRect(0, CARD_H - 10, CARD_W, 10);
|
||||
|
||||
// Holographic sheen (optional) — a soft diagonal highlight.
|
||||
if (data.holographic) {
|
||||
ctx.save();
|
||||
roundRect(ctx, 0, 0, CARD_W, CARD_H, 28);
|
||||
|
|
|
|||
Loading…
Reference in a new issue