feat(card): redesign as a 'Credential' + unify to ONE canvas renderer
Design: the card read like a spec sheet (16 competing elements, 5-6px type). Rebuilt as an editorial credential: - Left full-height portrait panel; right column = brand, ONE huge name, a single identity line (style / level / local), nip05, crew monograms + witnessed badges, and a clean QR verify stub. Dropped the tiny stats row, npub clutter, 'Powered by Nostr' filler and the 'SKATER LICENSE' chrome. - Same system applied to Park / Crew / Event fronts; typed-up back side. Engine: the studio preview and /card/:npub now render the canvas PNG, so there is a SINGLE renderer (src/lib/riderCardCanvas.ts) — no React/canvas drift. The old React card components + CardQr are deleted. Holographic is now a canvas sheen. Also fixed several fillText calls missing an x-coord.
This commit is contained in:
parent
ce0e004a59
commit
84665adc8a
4 changed files with 604 additions and 1253 deletions
|
|
@ -1,11 +1,14 @@
|
|||
/**
|
||||
* RiderCard — the visual card used by both the `/card` editor and the public
|
||||
* `/card/:npub` view. Extracted so there is ONE React rendering of the card
|
||||
* (the canvas export in `RiderCardPage` remains the print renderer).
|
||||
* RiderCard — the card's data shape + the ONE preview component.
|
||||
*
|
||||
* The card itself is drawn by `@/lib/riderCardCanvas` (a single canvas
|
||||
* renderer), so the studio preview and the public `/#/card/:npub` view are
|
||||
* pixel-identical to what prints. There is intentionally NO second React
|
||||
* implementation of the card.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SKILL_OPTIONS, shortNpub, roleMeta, formatCardDate, passExpiry, type ThemeId } from '@/lib/riderCard';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { drawCardToCanvas, type CanvasCardData } from '@/lib/riderCardCanvas';
|
||||
import type { ThemeId } from '@/lib/riderCard';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface BadgeAward {
|
||||
|
|
@ -20,14 +23,12 @@ export interface CardData {
|
|||
type: 'rider' | 'park' | 'crew' | 'event';
|
||||
theme: ThemeId;
|
||||
holographic: boolean;
|
||||
// Rider
|
||||
name: string;
|
||||
nip05?: string;
|
||||
avatar?: string;
|
||||
pubkey: string;
|
||||
npub: string;
|
||||
style: string;
|
||||
/** Derived level from the Skater profile (kind 35000). */
|
||||
level?: string;
|
||||
skills: string[];
|
||||
sponsors: string;
|
||||
|
|
@ -40,554 +41,92 @@ export interface CardData {
|
|||
eventsCreated: number;
|
||||
memberYear: string;
|
||||
receivedBadges: BadgeAward[];
|
||||
/** When set, the printed QR opens the verify page for this card. */
|
||||
verifyUrl?: string;
|
||||
/** Custom accent hex overriding the theme's primary accent. */
|
||||
accent?: string;
|
||||
/** Optional second accent (stripe gradient end). */
|
||||
accent2?: string;
|
||||
/** Optional personal logo / crest URL. */
|
||||
logo?: string;
|
||||
/** Which side to render (default front). */
|
||||
side?: 'front' | 'back';
|
||||
// Identity facets (derived live, toggled on the card)
|
||||
crews?: { name: string; image?: string }[];
|
||||
local?: string;
|
||||
roles?: string[];
|
||||
showCrews?: boolean;
|
||||
showLocal?: boolean;
|
||||
showRoles?: boolean;
|
||||
// Crew card (derived from the crew's own signed kind 35006 event)
|
||||
crew?: {
|
||||
name: string;
|
||||
image?: string;
|
||||
pubkey: string;
|
||||
identifier: string;
|
||||
location?: string;
|
||||
founded?: string;
|
||||
members: number;
|
||||
focus?: string;
|
||||
vibes: string[];
|
||||
lookingFor: string[];
|
||||
spots: number;
|
||||
};
|
||||
// Park
|
||||
crew?: CanvasCardData['crew'];
|
||||
event?: CanvasCardData['event'];
|
||||
issuerName: string;
|
||||
recipientName: string;
|
||||
accessLevel: string;
|
||||
validThru: string;
|
||||
// Event pass / wristband (derived from the event's own signed kind 31923)
|
||||
event?: {
|
||||
title: string;
|
||||
start: number;
|
||||
end?: number;
|
||||
location?: string;
|
||||
image?: string;
|
||||
pubkey: string;
|
||||
identifier: string;
|
||||
going?: number;
|
||||
holder?: string;
|
||||
}
|
||||
|
||||
function toCanvasData(d: CardData): CanvasCardData {
|
||||
return {
|
||||
type: d.type,
|
||||
themeId: d.theme,
|
||||
accentOverride: d.accent,
|
||||
name: d.name,
|
||||
nip05: d.nip05,
|
||||
avatarUrl: d.avatar,
|
||||
logoUrl: d.logo,
|
||||
pubkey: d.pubkey,
|
||||
npub: d.npub,
|
||||
style: d.style,
|
||||
level: d.level,
|
||||
sponsors: d.sponsors,
|
||||
ridesFor: d.ridesFor,
|
||||
showSpots: d.showSpots,
|
||||
showZaps: d.showZaps,
|
||||
showEvents: d.showEvents,
|
||||
showCrews: d.showCrews ?? true,
|
||||
showLocal: d.showLocal ?? true,
|
||||
showRoles: d.showRoles ?? true,
|
||||
spotsAdded: d.spotsAdded,
|
||||
zapsReceived: d.zapsReceived,
|
||||
eventsCreated: d.eventsCreated,
|
||||
memberYear: d.memberYear,
|
||||
receivedBadges: d.receivedBadges,
|
||||
verifyUrl: d.verifyUrl,
|
||||
local: d.local,
|
||||
crews: (d.crews ?? []).map((c) => c.name),
|
||||
roles: d.roles,
|
||||
crew: d.crew,
|
||||
event: d.event,
|
||||
issuerName: d.issuerName,
|
||||
recipientName: d.recipientName,
|
||||
accessLevel: d.accessLevel,
|
||||
validThru: d.validThru,
|
||||
holographic: d.holographic,
|
||||
};
|
||||
}
|
||||
|
||||
/** Real QR (lazy-imported so canvas API access stays out of module load). */
|
||||
function CardQr({ url }: { url?: string }) {
|
||||
/**
|
||||
* Live canvas-backed card preview. Debounced so a fast typist doesn't kick off
|
||||
* a canvas + QR render on every keystroke.
|
||||
*/
|
||||
export function CardPreview({ d, className }: { d: CardData; className?: string }) {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!url) { setSrc(null); return; }
|
||||
(async () => {
|
||||
const handle = setTimeout(async () => {
|
||||
try {
|
||||
const QRCode = (await import('qrcode')).default;
|
||||
const dataUrl = await QRCode.toDataURL(url, {
|
||||
width: 88,
|
||||
margin: 0,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: { dark: '#000000', light: '#ffffff' },
|
||||
});
|
||||
if (!cancelled) setSrc(dataUrl);
|
||||
const url = await drawCardToCanvas(toCanvasData(d), d.side ?? 'front');
|
||||
if (!cancelled) setSrc(url);
|
||||
} catch {
|
||||
if (!cancelled) setSrc(null);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<div className="rounded-[3px] flex-shrink-0" style={{ background: '#fff', padding: '3px', width: '44px', height: '44px' }}>
|
||||
{src
|
||||
? <img src={src} alt="QR" width={38} height={38} style={{ display: 'block', width: '38px', height: '38px' }} />
|
||||
: <div style={{ width: '38px', height: '38px' }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Card visual: Rider front ─────────────────────────────────────────────────
|
||||
// Layout is STRICTLY pixel-fixed to match the 340×214px card container.
|
||||
function RiderFront({ d }: { d: CardData }) {
|
||||
const { t } = useTranslation();
|
||||
const witnessedBadges = d.receivedBadges.slice(0, 2);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
|
||||
|
||||
{/* Row 1: header */}
|
||||
<div className="flex items-start justify-between" style={{ height: '26px' }}>
|
||||
<div>
|
||||
<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]">{t('labUi.rider.previewLicense')}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[5px] card-muted uppercase tracking-wide leading-none">npub</div>
|
||||
<div className="text-[6px] font-bold card-accent font-mono tracking-tight leading-none mt-[1px]">
|
||||
{shortNpub(d.pubkey)}
|
||||
</div>
|
||||
<div className="text-[5px] card-muted mt-[1px]">{t('labUi.rider.previewSince', { year: d.memberYear })}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identity facets: local + crew crests */}
|
||||
{((d.showLocal !== false && d.local) || (d.showCrews !== false && (d.crews?.length ?? 0) > 0)) && (
|
||||
<div className="flex items-center gap-[6px] mt-[2px] overflow-hidden" style={{ height: '13px' }}>
|
||||
{d.showLocal !== false && d.local && (
|
||||
<span className="text-[6px] font-bold card-accent truncate flex-shrink-0" style={{ maxWidth: '150px' }}>📍 {d.local}</span>
|
||||
)}
|
||||
{d.showCrews !== false && (d.crews ?? []).slice(0, 3).map((c, i) => (
|
||||
<span key={i} className="flex items-center gap-[2px] min-w-0 flex-shrink-0">
|
||||
{c.image
|
||||
? <img src={c.image} alt="" crossOrigin="anonymous" className="w-[11px] h-[11px] rounded-full object-cover border border-white/25" />
|
||||
: <span className="w-[11px] h-[11px] rounded-full bg-white/15" />}
|
||||
<span className="text-[5.5px] card-muted truncate" style={{ maxWidth: '66px' }}>{c.name}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 2: main body */}
|
||||
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0 flex flex-col gap-[4px]">
|
||||
<div className="w-[52px] h-[52px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
|
||||
{d.avatar ? (
|
||||
<img src={d.avatar} alt={d.name} className="w-full h-full object-cover" crossOrigin="anonymous" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🛹</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-[2px]">
|
||||
{d.showSpots && (
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[5px] card-muted">{t('labUi.pyramid.spots')}</span>
|
||||
<span className="text-[7px] font-black card-accent leading-none">{d.spotsAdded}</span>
|
||||
</div>
|
||||
)}
|
||||
{d.showZaps && (
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[5px] card-muted">⚡</span>
|
||||
<span className="text-[7px] font-black card-accent2 leading-none">{d.zapsReceived}</span>
|
||||
</div>
|
||||
)}
|
||||
{d.showEvents && (
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[5px] card-muted">{t('labUi.rider.previewEvts')}</span>
|
||||
<span className="text-[7px] font-black leading-none" style={{ color: '#ff6600' }}>{d.eventsCreated}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identity block */}
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{d.logo && (
|
||||
<img src={d.logo} alt="" crossOrigin="anonymous" className="h-[13px] w-[13px] flex-shrink-0 rounded-[3px] object-cover border border-white/25" />
|
||||
)}
|
||||
<span className="text-[14px] font-black uppercase truncate leading-tight">{d.name}</span>
|
||||
</div>
|
||||
{d.nip05 && (
|
||||
<div className="text-[6.5px] card-accent truncate leading-none">@{d.nip05.split('@')[0]}</div>
|
||||
)}
|
||||
{d.style && (
|
||||
<span className="card-bg-chip text-[5.5px] font-bold uppercase px-[4px] py-[1px] rounded-[3px] card-accent w-fit">
|
||||
{d.style}
|
||||
</span>
|
||||
)}
|
||||
{d.level && (
|
||||
<span className="text-[5.5px] font-bold uppercase px-[4px] py-[1px] rounded-[3px] w-fit leading-none" style={{ background: 'rgba(255,102,0,0.18)', border: '1px solid rgba(255,102,0,0.45)', color: '#ff9944' }}>
|
||||
{d.level}
|
||||
</span>
|
||||
)}
|
||||
{d.ridesFor && (
|
||||
<div className="text-[5.5px] card-muted truncate leading-none">🏪 {d.ridesFor}</div>
|
||||
)}
|
||||
{d.sponsors && (
|
||||
<div className="text-[5.5px] card-muted truncate leading-none">🤝 {d.sponsors}</div>
|
||||
)}
|
||||
{/* Roles + witnessed badges only — self-claimed skills are gone */}
|
||||
<div className="flex flex-wrap gap-[2px] mt-[2px]">
|
||||
{d.showRoles !== false && (d.roles ?? []).slice(0, 3).map(r => {
|
||||
const m = roleMeta(r);
|
||||
return m ? (
|
||||
<span key={r} className="text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none" style={{ background: 'rgba(100,160,255,0.18)', border: '1px solid rgba(100,160,255,0.45)', color: '#9cc4ff' }}>
|
||||
{m.emoji} {t(`cardUi.roles.${m.role}`)}
|
||||
</span>
|
||||
) : null;
|
||||
})}
|
||||
{witnessedBadges.map((b, i) => {
|
||||
const s = SKILL_OPTIONS.find(o => o.id === b.skill);
|
||||
return s ? (
|
||||
<span
|
||||
key={`w${i}`}
|
||||
className="text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none"
|
||||
style={{ background: 'rgba(255,215,0,0.2)', border: '1px solid rgba(255,215,0,0.5)', color: '#ffd700' }}
|
||||
>
|
||||
⭐ {s.label}
|
||||
</span>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR */}
|
||||
<div className="flex-shrink-0 flex flex-col items-end justify-end">
|
||||
<CardQr url={d.verifyUrl} />
|
||||
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">
|
||||
{d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div
|
||||
className="absolute left-0 right-0 flex items-center justify-between px-[10px]"
|
||||
style={{ bottom: '6px' }}
|
||||
>
|
||||
<span className="card-muted text-[5px] uppercase tracking-widest">
|
||||
skate-spots.org · Powered by Nostr
|
||||
</span>
|
||||
<span className="card-muted text-[5px] font-mono" style={{ maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{shortNpub(d.pubkey)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Card visual: Park Access front ──────────────────────────────────────────
|
||||
function ParkFront({ d }: { d: CardData }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
|
||||
|
||||
<div className="flex items-center justify-between" style={{ height: '26px' }}>
|
||||
<div>
|
||||
<div className="text-[8px] font-black uppercase tracking-[0.15em] card-accent leading-none">
|
||||
🏟️ {d.issuerName || t('labUi.rider.previewSkatepark')}
|
||||
</div>
|
||||
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">{t('labUi.rider.previewAccessPass')}</div>
|
||||
</div>
|
||||
<div className="card-bg-chip px-[6px] py-[2px] rounded-[4px]">
|
||||
{passExpiry(d.validThru) === 'expired'
|
||||
? <span className="text-[7px] font-black uppercase" style={{ color: '#ff4d4d' }}>{t('cardUi.expired')}</span>
|
||||
: <span className="text-[7px] font-black uppercase card-accent">{d.accessLevel || 'Full Access'}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-[52px] h-[52px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
|
||||
{d.avatar ? (
|
||||
<img src={d.avatar} alt={d.recipientName} className="w-full h-full object-cover" crossOrigin="anonymous" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🛹</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
|
||||
<div className="text-[13px] font-black uppercase truncate leading-tight">{d.recipientName || t('labUi.rider.previewMemberName')}</div>
|
||||
<div className="text-[5.5px] card-muted font-mono leading-none truncate">{shortNpub(d.pubkey)}</div>
|
||||
<div className="text-[5.5px] card-muted leading-none">{t('labUi.rider.previewIssuedBy')}</div>
|
||||
{d.validThru && (
|
||||
<div className="flex items-center gap-1 mt-[3px]">
|
||||
<span className="text-[5.5px] card-muted uppercase">{t('labUi.rider.previewValidThru')}</span>
|
||||
<span className="text-[8px] font-black card-accent leading-none">{d.validThru}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 flex flex-col items-end justify-end">
|
||||
<CardQr url={d.verifyUrl} />
|
||||
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">
|
||||
{d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute left-0 right-0 flex items-center justify-between px-[10px]"
|
||||
style={{ bottom: '6px' }}
|
||||
>
|
||||
<span className="card-muted text-[5px] uppercase tracking-widest">
|
||||
skate-spots.org · Powered by Nostr
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="card-muted text-[5px] uppercase tracking-widest">{t('labUi.rider.previewNostrVerified')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Card visual: Crew front ─────────────────────────────────────────────────
|
||||
function CrewFront({ d }: { d: CardData }) {
|
||||
const { t } = useTranslation();
|
||||
const c = d.crew;
|
||||
if (!c) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
|
||||
|
||||
{/* Row 1: header */}
|
||||
<div className="flex items-start justify-between" style={{ height: '26px' }}>
|
||||
<div>
|
||||
<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]">{t('cardUi.crew.crewCard')}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[5px] card-muted uppercase tracking-wide leading-none">npub</div>
|
||||
<div className="text-[6px] font-bold card-accent font-mono tracking-tight leading-none mt-[1px]">
|
||||
{shortNpub(c.pubkey)}
|
||||
</div>
|
||||
{c.founded && <div className="text-[5px] card-muted mt-[1px]">{t('cardUi.crew.est', { year: c.founded })}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: main body */}
|
||||
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
|
||||
|
||||
{/* Logo + stats */}
|
||||
<div className="flex-shrink-0 flex flex-col gap-[4px]">
|
||||
<div className="w-[52px] h-[52px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
|
||||
{c.image ? (
|
||||
<img src={c.image} alt={c.name} className="w-full h-full object-cover" crossOrigin="anonymous" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🏕️</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-[2px]">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[5px] card-muted">{t('cardUi.crew.members')}</span>
|
||||
<span className="text-[7px] font-black card-accent leading-none">{c.members}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[5px] card-muted">{t('cardUi.crew.spots')}</span>
|
||||
<span className="text-[7px] font-black card-accent2 leading-none">{c.spots}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identity block */}
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
|
||||
<div className="text-[14px] font-black uppercase truncate leading-tight">{c.name}</div>
|
||||
{c.location && (
|
||||
<div className="text-[6.5px] card-accent truncate leading-none">📍 {c.location}</div>
|
||||
)}
|
||||
{c.lookingFor.length > 0 && (
|
||||
<span
|
||||
className="text-[5px] font-black uppercase px-[4px] py-[1px] rounded-[3px] w-fit leading-none"
|
||||
style={{ background: 'rgba(204,255,0,0.2)', border: '1px solid rgba(204,255,0,0.5)', color: '#ccff00' }}
|
||||
>
|
||||
{t('cardUi.crew.openCall')}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-[2px] mt-[2px]">
|
||||
{c.vibes.slice(0, 5).map(v => (
|
||||
<span key={v} className="card-bg-chip text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none">
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR */}
|
||||
<div className="flex-shrink-0 flex flex-col items-end justify-end">
|
||||
<CardQr url={d.verifyUrl} />
|
||||
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">
|
||||
{d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div
|
||||
className="absolute left-0 right-0 flex items-center justify-between px-[10px]"
|
||||
style={{ bottom: '6px' }}
|
||||
>
|
||||
<span className="card-muted text-[5px] uppercase tracking-widest">
|
||||
skate-spots.org · Powered by Nostr
|
||||
</span>
|
||||
<span className="card-muted text-[5px] uppercase tracking-widest">{t('cardUi.crew.verifiedCrew')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Card visual: Event pass / wristband ─────────────────────────────────────
|
||||
function EventFront({ d }: { d: CardData }) {
|
||||
const { t } = useTranslation();
|
||||
const e = d.event;
|
||||
if (!e) return null;
|
||||
const dateText = formatCardDate(e.start, e.end);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
|
||||
|
||||
{/* Row 1: header */}
|
||||
<div className="flex items-start justify-between" style={{ height: '26px' }}>
|
||||
<div>
|
||||
<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]">{t('cardUi.event.pass')}</div>
|
||||
</div>
|
||||
<div className="text-right min-w-0">
|
||||
<div className="text-[5px] card-muted uppercase tracking-wide leading-none">{t('cardUi.event.admits')}</div>
|
||||
<div className="text-[7px] font-black card-accent uppercase leading-none mt-[1px] truncate" style={{ maxWidth: '120px' }}>
|
||||
{e.holder || d.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: main body */}
|
||||
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-[52px] h-[52px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
|
||||
{e.image ? (
|
||||
<img src={e.image} alt="" className="w-full h-full object-cover" crossOrigin="anonymous" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🎟️</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
|
||||
<div className="text-[14px] font-black uppercase truncate leading-tight">{e.title}</div>
|
||||
<div className="text-[6.5px] card-accent truncate leading-none">🗓️ {dateText}</div>
|
||||
{e.location && (
|
||||
<div className="text-[5.5px] card-muted truncate leading-none">📍 {e.location}</div>
|
||||
)}
|
||||
{typeof e.going === 'number' && e.going > 0 && (
|
||||
<div className="text-[5.5px] card-muted leading-none">{t('cardUi.event.going', { count: e.going })}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 flex flex-col items-end justify-end">
|
||||
<CardQr url={d.verifyUrl} />
|
||||
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">
|
||||
{d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div
|
||||
className="absolute left-0 right-0 flex items-center justify-between px-[10px]"
|
||||
style={{ bottom: '6px' }}
|
||||
>
|
||||
<span className="card-muted text-[5px] uppercase tracking-widest">
|
||||
skate-spots.org · Powered by Nostr
|
||||
</span>
|
||||
<span className="card-muted text-[5px] uppercase tracking-widest">{t('cardUi.event.verifiedEvent')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Card back side ───────────────────────────────────────────────────────────
|
||||
function RiderCardBack({ d }: { d: CardData }) {
|
||||
const { t } = useTranslation();
|
||||
const crewNames = (d.crews ?? []).map(c => c.name);
|
||||
const roleLabels = (d.roles ?? []).map(r => {
|
||||
const m = roleMeta(r);
|
||||
return m ? `${m.emoji} ${t(`cardUi.roles.${m.role}`)}` : null;
|
||||
}).filter(Boolean) as string[];
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0" style={{ padding: '10px 10px 16px 10px' }}>
|
||||
<div className="flex items-center justify-between" style={{ height: '24px' }}>
|
||||
<span className="text-[8px] font-black uppercase tracking-[0.18em] card-accent">🛹 SkateSpots</span>
|
||||
<span className="text-[6px] card-muted uppercase tracking-widest">{t('cardUi.back.title')}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-[4px] flex gap-[8px]" style={{ height: '120px' }}>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-[3px] text-[6.5px] leading-tight">
|
||||
{crewNames.length > 0 && (
|
||||
<div className="truncate"><span className="card-muted uppercase">{t('cardUi.facets.crews')}: </span><span className="font-bold">{crewNames.join(' · ')}</span></div>
|
||||
)}
|
||||
{roleLabels.length > 0 && (
|
||||
<div className="truncate"><span className="card-muted uppercase">{t('cardUi.facets.roles')}: </span><span className="font-bold">{roleLabels.join(' · ')}</span></div>
|
||||
)}
|
||||
{d.local && <div className="truncate">📍 <span className="font-bold">{d.local}</span></div>}
|
||||
{d.ridesFor && <div className="truncate">🏪 <span className="font-bold">{d.ridesFor}</span></div>}
|
||||
{d.sponsors && <div className="truncate">🤝 <span className="font-bold">{d.sponsors}</span></div>}
|
||||
{passExpiry(d.validThru) !== 'none' && (
|
||||
<div className="truncate"><span className="card-muted uppercase">{t('cardUi.verify.validThru')}: </span><span className="font-bold">{d.validThru}</span></div>
|
||||
)}
|
||||
{crewNames.length === 0 && roleLabels.length === 0 && !d.local && !d.ridesFor && !d.sponsors && (
|
||||
<div className="card-muted">{t('cardUi.back.empty')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 flex flex-col items-end justify-end">
|
||||
<CardQr url={d.verifyUrl} />
|
||||
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">
|
||||
{d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute left-0 right-0 flex items-center justify-between px-[10px]" style={{ bottom: '6px' }}>
|
||||
<span className="card-muted text-[5px] uppercase tracking-widest">skate-spots.org · Powered by Nostr</span>
|
||||
<span className="card-muted text-[5px] font-mono">{shortNpub(d.pubkey)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObject<HTMLDivElement> }) {
|
||||
const themeClass = `card-theme-${d.theme}`;
|
||||
const holoClass = d.holographic ? 'rider-card-holo' : '';
|
||||
const customClass = d.accent ? 'card-custom-accent' : '';
|
||||
const side = d.side ?? 'front';
|
||||
const vars = d.accent
|
||||
? ({ '--ss-accent': d.accent, '--ss-accent2': d.accent2 || d.accent } as React.CSSProperties)
|
||||
: undefined;
|
||||
}, 180);
|
||||
return () => { cancelled = true; clearTimeout(handle); };
|
||||
}, [d]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={printRef}
|
||||
className={cn('rider-card', themeClass, holoClass, customClass, side === 'back' && 'rider-card-back', 'select-none')}
|
||||
style={{ fontFamily: "'Inter Variable', 'Inter', system-ui, sans-serif", ...vars }}
|
||||
className={cn('w-full max-w-[440px] overflow-hidden rounded-2xl', className)}
|
||||
style={{ aspectRatio: '856 / 540' }}
|
||||
>
|
||||
{side === 'front' && <div className="card-stripe absolute top-0 left-0 right-0 h-[4px]" />}
|
||||
{side === 'back'
|
||||
? <RiderCardBack d={d} />
|
||||
: <>
|
||||
{d.type === 'rider' && <RiderFront d={d} />}
|
||||
{d.type === 'park' && <ParkFront d={d} />}
|
||||
{d.type === 'crew' && <CrewFront d={d} />}
|
||||
{d.type === 'event' && <EventFront d={d} />}
|
||||
</>}
|
||||
{src
|
||||
? <img src={src} alt="Card preview" className="h-full w-full object-cover" style={{ display: 'block' }} />
|
||||
: <div className="h-full w-full animate-pulse rounded-2xl bg-white/5" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
501
src/lib/riderCardCanvas.ts
Normal file
501
src/lib/riderCardCanvas.ts
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
/**
|
||||
* riderCardCanvas — THE single card renderer.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.)
|
||||
*/
|
||||
import { roleMeta, formatCardDate, passExpiry } from '@/lib/riderCard';
|
||||
|
||||
export const CARD_W = 856;
|
||||
export const CARD_H = 540;
|
||||
|
||||
/** Theme palettes (also used by the CSS swatches in the studio picker). */
|
||||
export const THEME_PALETTE: Record<string, {
|
||||
bg1: string; bg2: string; bg3: string;
|
||||
accent: string; accent2: string;
|
||||
stripe1: string; stripe2: string;
|
||||
muted: string; chipBg: string; chipBorder: string;
|
||||
text: string; shopColor: string; sponsorColor: string;
|
||||
sidebarBg: string;
|
||||
}> = {
|
||||
thug: { bg1: '#0a0a0a', bg2: '#180006', bg3: '#0d0d0d', accent: '#ff0040', accent2: '#ccff00', stripe1: '#ff0040', stripe2: '#ff6600', muted: 'rgba(255,255,255,0.55)', chipBg: 'rgba(255,0,64,0.18)', chipBorder: 'rgba(255,0,64,0.45)', text: '#ffffff', shopColor: '#ccff00', sponsorColor: '#ff9900', sidebarBg: 'rgba(255,0,64,0.07)' },
|
||||
lime: { bg1: '#040c00', bg2: '#081500', bg3: '#040c00', accent: '#ccff00', accent2: '#ff6600', stripe1: '#ccff00', stripe2: '#88ff00', muted: 'rgba(255,255,255,0.55)', chipBg: 'rgba(204,255,0,0.14)', chipBorder: 'rgba(204,255,0,0.4)', text: '#ffffff', shopColor: '#ff6600', sponsorColor: '#ff0040', sidebarBg: 'rgba(204,255,0,0.05)' },
|
||||
chrome: { bg1: '#111111', bg2: '#252525', bg3: '#0e0e0e', accent: '#e6e6e6', accent2: '#ff6600', stripe1: '#777777', stripe2: '#cccccc', muted: 'rgba(220,220,220,0.6)', chipBg: 'rgba(200,200,200,0.12)', chipBorder: 'rgba(200,200,200,0.3)', text: '#f2f2f2', shopColor: '#ff9900', sponsorColor: '#64cfff', sidebarBg: 'rgba(200,200,200,0.04)' },
|
||||
wood: { bg1: '#2e1804', bg2: '#4a2a0c', bg3: '#261404', accent: '#f5c842', accent2: '#ff6600', stripe1: '#8b5e3c', stripe2: '#d4a05a', muted: 'rgba(245,230,200,0.65)', chipBg: 'rgba(160,100,40,0.25)', chipBorder: 'rgba(200,140,60,0.5)', text: '#f5e6c8', shopColor: '#ff9944', sponsorColor: '#aaffcc', sidebarBg: 'rgba(160,100,40,0.12)' },
|
||||
midnight: { bg1: '#010610', bg2: '#040c22', bg3: '#020814', accent: '#64a0ff', accent2: '#ccff00', stripe1: '#2040aa', stripe2: '#64a0ff', muted: 'rgba(200,220,255,0.6)', chipBg: 'rgba(100,160,255,0.12)', chipBorder: 'rgba(100,160,255,0.35)', text: '#d0e4ff', shopColor: '#ccff00', sponsorColor: '#ff88cc', sidebarBg: 'rgba(100,160,255,0.05)' },
|
||||
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;
|
||||
avatarUrl?: string;
|
||||
logoUrl?: string;
|
||||
pubkey: string;
|
||||
npub: string;
|
||||
style: string;
|
||||
level?: string;
|
||||
sponsors: string;
|
||||
ridesFor: string;
|
||||
showSpots: boolean;
|
||||
showZaps: boolean;
|
||||
showEvents: boolean;
|
||||
showCrews: boolean;
|
||||
showLocal: boolean;
|
||||
showRoles: boolean;
|
||||
spotsAdded: number;
|
||||
zapsReceived: number;
|
||||
eventsCreated: number;
|
||||
memberYear: string;
|
||||
receivedBadges: { skill: string }[];
|
||||
verifyUrl?: string;
|
||||
local?: string;
|
||||
crews?: string[];
|
||||
roles?: string[];
|
||||
crew?: {
|
||||
name: string; image?: string; pubkey: string; identifier: string; location?: string;
|
||||
founded?: string; members: number; focus?: string; vibes: string[]; lookingFor: string[]; spots: number;
|
||||
};
|
||||
event?: {
|
||||
title: string; start: number; end?: number; location?: string; image?: string;
|
||||
pubkey: string; identifier: string; going?: number; holder?: string;
|
||||
};
|
||||
issuerName: string;
|
||||
recipientName: string;
|
||||
accessLevel: string;
|
||||
validThru: string;
|
||||
holographic?: boolean;
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
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);
|
||||
if (url.startsWith('data:') || url.startsWith('blob:')) {
|
||||
img.src = url;
|
||||
} else {
|
||||
img.src = `https://proxy.shakespeare.diy/?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
setTimeout(() => resolve(null), 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
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;
|
||||
let dw = w, dh = h, dx = x, dy = y;
|
||||
if (ar > boxAr) { dw = h * ar; dx = x - (dw - w) / 2; }
|
||||
else { dh = w / ar; dy = y - (dh - h) / 2; }
|
||||
ctx.save();
|
||||
roundRect(ctx, x, y, w, h, r);
|
||||
ctx.clip();
|
||||
ctx.drawImage(img, dx, dy, dw, dh);
|
||||
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 */ }
|
||||
ctx.fillText(text, x, y);
|
||||
try { (ctx as CanvasRenderingContext2D & { letterSpacing?: string }).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) {
|
||||
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.fillStyle = p.muted;
|
||||
wordmark(ctx, kind, x + 1, y + 24, 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;
|
||||
ctx.stroke();
|
||||
|
||||
const pad = 16;
|
||||
if (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 img = await loadImage(dataUrl);
|
||||
if (img) ctx.drawImage(img, x + pad, y + pad, size - pad * 2, size - pad * 2);
|
||||
} 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.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, RX, 74, p, 'RIDER CARD');
|
||||
|
||||
// Name — the focal point.
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 82px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, data.name.toUpperCase(), RW), RX, 214);
|
||||
|
||||
// One identity line: style · level · local.
|
||||
const identity = [data.style, data.level, data.showLocal ? data.local : ''].filter(Boolean).join(' · ');
|
||||
let y = 262;
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
const crewNames = data.showCrews ? (data.crews ?? []).slice(0, 2) : [];
|
||||
|
||||
// crew monogram circles just above the bottom band
|
||||
const circleR = 17;
|
||||
let cx = RX + circleR;
|
||||
crewNames.forEach((name) => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, CARD_H - 66, 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.textAlign = 'center';
|
||||
ctx.fillText((name[0] ?? '?').toUpperCase(), cx, CARD_H - 60);
|
||||
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;
|
||||
});
|
||||
|
||||
// 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';
|
||||
ctx.fillStyle = p.accent2;
|
||||
ctx.fillText(fitText(ctx, bottomChips.join(' '), RW - 180), RX, CARD_H - 60);
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 64px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, (data.issuerName || 'SKATEPARK').toUpperCase(), RW), RX, 186);
|
||||
|
||||
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.fillStyle = p.muted;
|
||||
ctx.font = '17px system-ui, sans-serif';
|
||||
ctx.fillText('ADMITS', RX, 296);
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 34px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, data.recipientName || data.name, RW), RX, 334);
|
||||
|
||||
if (data.validThru) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '16px system-ui, sans-serif';
|
||||
ctx.fillText(`VALID THRU ${data.validThru}`, RX, CARD_H - 60);
|
||||
}
|
||||
|
||||
await drawVerifyStub(ctx, CARD_W - PAD - 156, CARD_H - PAD - 156 - 8, 156, data.verifyUrl, 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');
|
||||
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 68px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, (c?.name ?? '').toUpperCase(), RW), RX, 192);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (c) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '20px system-ui, sans-serif';
|
||||
ctx.fillText(`${c.members} MEMBERS · ${c.spots} SPOTS`, RX, 292);
|
||||
if (c.lookingFor.length > 0) {
|
||||
ctx.fillStyle = p.accent2;
|
||||
ctx.font = 'bold 20px system-ui, sans-serif';
|
||||
ctx.fillText('OPEN CALL', RX, 328);
|
||||
}
|
||||
}
|
||||
|
||||
await drawVerifyStub(ctx, CARD_W - PAD - 156, CARD_H - PAD - 156 - 8, 156, data.verifyUrl, 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');
|
||||
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 60px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, (e?.title ?? '').toUpperCase(), RW), RX, 184);
|
||||
|
||||
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);
|
||||
if (e.location) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '19px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, `📍 ${e.location}`, RW), RX, 264);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '17px system-ui, sans-serif';
|
||||
ctx.fillText('ADMITS', RX, 320);
|
||||
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);
|
||||
}
|
||||
|
||||
// ─── back ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function drawBack(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
|
||||
const lines: Array<[string, string]> = [];
|
||||
const roles = (data.roles ?? []).map(r => roleMeta(r)).filter(Boolean).map(m => `${m!.emoji} ${m!.label}`).join(' · ');
|
||||
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]);
|
||||
if (data.ridesFor) lines.push(['RIDES FOR', data.ridesFor]);
|
||||
if (data.sponsors) lines.push(['SPONSORS', data.sponsors]);
|
||||
if (data.validThru) lines.push(['VALID THRU', data.validThru]);
|
||||
|
||||
drawBrand(ctx, PAD, 86, p, 'CARD BACK');
|
||||
|
||||
let y = 200;
|
||||
for (const [label, value] of lines.slice(0, 6)) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = 'bold 15px 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;
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '22px 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();
|
||||
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);
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
// ─── entry point ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'back' = 'front'): Promise<string> {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = CARD_W;
|
||||
canvas.height = CARD_H;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
const theme = THEME_PALETTE[data.themeId] ?? THEME_PALETTE.thug;
|
||||
const p = data.accentOverride
|
||||
? { ...theme, accent: data.accentOverride, stripe1: data.accentOverride, stripe2: data.accentOverride }
|
||||
: theme;
|
||||
|
||||
if (side === 'back') {
|
||||
ctx.fillStyle = '#0b0b0b';
|
||||
roundRect(ctx, 0, 0, CARD_W, CARD_H, 28);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.fillRect(0, 0, CARD_W, 8);
|
||||
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);
|
||||
bg.addColorStop(1, p.bg3);
|
||||
roundRect(ctx, 0, 0, CARD_W, CARD_H, 28);
|
||||
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();
|
||||
roundRect(ctx, 0, 0, PORTRAIT_W, CARD_H, 28);
|
||||
ctx.clip();
|
||||
if (portrait) {
|
||||
drawCover(ctx, portrait, 0, 0, PORTRAIT_W, CARD_H, 28);
|
||||
} else {
|
||||
ctx.fillStyle = p.sidebarBg;
|
||||
ctx.fillRect(0, 0, PORTRAIT_W, CARD_H);
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = '96px system-ui, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('🛹', PORTRAIT_W / 2, CARD_H / 2 + 30);
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
// darken the portrait's lower band so crew circles read
|
||||
const shade = ctx.createLinearGradient(0, CARD_H - 200, 0, CARD_H);
|
||||
shade.addColorStop(0, 'rgba(0,0,0,0)');
|
||||
shade.addColorStop(1, 'rgba(0,0,0,0.78)');
|
||||
ctx.fillStyle = shade;
|
||||
ctx.fillRect(0, CARD_H - 200, PORTRAIT_W, 200);
|
||||
ctx.restore();
|
||||
|
||||
// separator
|
||||
ctx.strokeStyle = p.accent;
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(PORTRAIT_W, 0); ctx.lineTo(PORTRAIT_W, CARD_H); ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
const logo = data.type === 'rider' && data.logoUrl ? await loadImage(data.logoUrl) : null;
|
||||
|
||||
if (data.type === 'rider') await drawRiderFront(ctx, data, p, logo);
|
||||
else if (data.type === 'park') await drawParkFront(ctx, data, p);
|
||||
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);
|
||||
ctx.clip();
|
||||
const sheen = ctx.createLinearGradient(0, CARD_H, CARD_W, 0);
|
||||
sheen.addColorStop(0, 'rgba(255,255,255,0)');
|
||||
sheen.addColorStop(0.42, 'rgba(255,255,255,0)');
|
||||
sheen.addColorStop(0.5, 'rgba(255,255,255,0.10)');
|
||||
sheen.addColorStop(0.58, 'rgba(255,255,255,0)');
|
||||
sheen.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
ctx.fillStyle = sheen;
|
||||
ctx.fillRect(0, 0, CARD_W, CARD_H);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
import { useTranslation } from '@/contexts/language';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RiderCardFront, type CardData, type BadgeAward } from '@/components/RiderCard';
|
||||
import { CardPreview, type CardData, type BadgeAward } from '@/components/RiderCard';
|
||||
import { RIDER_CARD_KIND, RIDER_CARD_D, DEFAULT_RIDER_CARD, cardVerifyUrl, memberSince } from '@/lib/riderCard';
|
||||
|
||||
function decodePubkey(value: string): string | null {
|
||||
|
|
@ -96,7 +96,6 @@ export default function PublicCardPage() {
|
|||
receivedBadges,
|
||||
verifyUrl: cardVerifyUrl(RIDER_CARD_KIND, pubkey, RIDER_CARD_D),
|
||||
accent: config.accent || undefined,
|
||||
accent2: config.accent || undefined,
|
||||
logo: config.logo || undefined,
|
||||
side,
|
||||
crews: memberships.map(m => ({ name: m.crew.name, image: m.crew.image })),
|
||||
|
|
@ -122,7 +121,7 @@ export default function PublicCardPage() {
|
|||
className="w-full rounded-2xl flex items-center justify-center py-8 relative overflow-hidden"
|
||||
style={{ background: 'radial-gradient(ellipse at 50% 60%, rgba(255,0,64,0.08) 0%, rgba(0,0,0,0) 70%), #0a0a0a', border: '1px solid rgba(255,255,255,0.06)' }}
|
||||
>
|
||||
<RiderCardFront d={cardData} />
|
||||
<CardPreview d={cardData} />
|
||||
</div>
|
||||
|
||||
{config.back && (
|
||||
|
|
|
|||
|
|
@ -20,8 +20,9 @@ import { nip19 } from 'nostr-tools';
|
|||
// access at module load time (crashes the preview environment)
|
||||
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { RiderCardFront, type CardData, type BadgeAward } from '@/components/RiderCard';
|
||||
import { THEMES, SKILL_OPTIONS, fullNpub, memberSince, roleMeta, formatCardDate, type ThemeId } from '@/lib/riderCard';
|
||||
import { CardPreview, type CardData, type BadgeAward } from '@/components/RiderCard';
|
||||
import { drawCardToCanvas, type CanvasCardData } from '@/lib/riderCardCanvas';
|
||||
import { THEMES, SKILL_OPTIONS, fullNpub, memberSince, type ThemeId } from '@/lib/riderCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
|
@ -53,714 +54,6 @@ import { SKATE_SPOT_KIND, SKATE_CREW_KIND, CALENDAR_EVENT_KIND, SKATER_STYLES, S
|
|||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Printer, Loader2, Share2, Download, Check, Copy, Award, Zap, Info, Palette, ChevronUp, ChevronDown, User, Building2, Users, Ticket, MoreHorizontal, Pencil, CheckCircle2, RefreshCw, Upload } from 'lucide-react';
|
||||
// ─── 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' | 'crew' | 'event';
|
||||
themeId: string;
|
||||
name: string;
|
||||
nip05?: string;
|
||||
avatarUrl?: string;
|
||||
pubkey: string;
|
||||
npub: string;
|
||||
style: string;
|
||||
level?: string;
|
||||
skills: string[];
|
||||
sponsors: string;
|
||||
ridesFor: string;
|
||||
showSpots: boolean;
|
||||
showZaps: boolean;
|
||||
showEvents: boolean;
|
||||
spotsAdded: number;
|
||||
zapsReceived: number;
|
||||
eventsCreated: number;
|
||||
memberYear: string;
|
||||
receivedBadges: BadgeAward[];
|
||||
/** When set, the printed QR opens the verify page for this card. */
|
||||
verifyUrl?: string;
|
||||
local?: string;
|
||||
crews?: string[];
|
||||
roles?: string[];
|
||||
crew?: CardData['crew'];
|
||||
event?: CardData['event'];
|
||||
// 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; chipBorder: string;
|
||||
text: string; shopColor: string; sponsorColor: string;
|
||||
sidebarBg: string;
|
||||
}> = {
|
||||
thug: { bg1: '#0a0a0a', bg2: '#180006', bg3: '#0d0d0d', accent: '#ff0040', accent2: '#ccff00', stripe1: '#ff0040', stripe2: '#ff6600', muted: 'rgba(255,255,255,0.5)', chipBg: 'rgba(255,0,64,0.18)', chipBorder: 'rgba(255,0,64,0.45)', text: '#ffffff', shopColor: '#ccff00', sponsorColor: '#ff9900', sidebarBg: 'rgba(255,0,64,0.07)' },
|
||||
lime: { bg1: '#040c00', bg2: '#081500', bg3: '#040c00', accent: '#ccff00', accent2: '#ff6600', stripe1: '#ccff00', stripe2: '#88ff00', muted: 'rgba(255,255,255,0.5)', chipBg: 'rgba(204,255,0,0.14)', chipBorder: 'rgba(204,255,0,0.4)', text: '#ffffff', shopColor: '#ff6600', sponsorColor: '#ff0040', sidebarBg: 'rgba(204,255,0,0.05)' },
|
||||
chrome: { bg1: '#111111', bg2: '#252525', bg3: '#0e0e0e', accent: '#d0d0d0', accent2: '#ff6600', stripe1: '#777777', stripe2: '#cccccc', muted: 'rgba(220,220,220,0.55)', chipBg: 'rgba(200,200,200,0.12)', chipBorder: 'rgba(200,200,200,0.3)', text: '#e8e8e8', shopColor: '#ff9900', sponsorColor: '#64cfff', sidebarBg: 'rgba(200,200,200,0.04)' },
|
||||
wood: { bg1: '#2e1804', bg2: '#4a2a0c', bg3: '#261404', accent: '#f5c842', accent2: '#ff6600', stripe1: '#8b5e3c', stripe2: '#d4a05a', muted: 'rgba(245,230,200,0.6)', chipBg: 'rgba(160,100,40,0.25)', chipBorder: 'rgba(200,140,60,0.5)', text: '#f5e6c8', shopColor: '#ff9944', sponsorColor: '#aaffcc', sidebarBg: 'rgba(160,100,40,0.12)' },
|
||||
midnight: { bg1: '#010610', bg2: '#040c22', bg3: '#020814', accent: '#64a0ff', accent2: '#ccff00', stripe1: '#2040aa', stripe2: '#64a0ff', muted: 'rgba(200,220,255,0.55)', chipBg: 'rgba(100,160,255,0.12)', chipBorder: 'rgba(100,160,255,0.35)', text: '#d0e4ff', shopColor: '#ccff00', sponsorColor: '#ff88cc', sidebarBg: 'rgba(100,160,255,0.05)' },
|
||||
sunset: { bg1: '#0e0004', bg2: '#160606', bg3: '#0a040c', accent: '#ff6020', accent2: '#ff0080', stripe1: '#ff0040', stripe2: '#ffaa00', muted: 'rgba(255,200,150,0.6)', 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)' },
|
||||
};
|
||||
|
||||
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);
|
||||
// Only proxy actual HTTP URLs — data: URIs must never go through proxy
|
||||
if (url.startsWith('data:') || url.startsWith('blob:')) {
|
||||
img.src = url;
|
||||
} else {
|
||||
img.src = `https://proxy.shakespeare.diy/?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
setTimeout(() => resolve(null), 5000);
|
||||
});
|
||||
}
|
||||
|
||||
// Draw text that wraps or truncates to fit maxWidth, returns lines drawn
|
||||
function fillTextFit(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, maxWidth: number): void {
|
||||
let t = text;
|
||||
while (ctx.measureText(t).width > maxWidth && t.length > 2) {
|
||||
t = t.slice(0, -1);
|
||||
}
|
||||
if (t.length < text.length) t = t.slice(0, -1) + '…';
|
||||
ctx.fillText(t, x, y);
|
||||
}
|
||||
|
||||
// Draw a labeled row: small muted label above, value below
|
||||
function _drawLabeledRow(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
label: string, value: string,
|
||||
x: number, y: number, maxW: number,
|
||||
labelColor: string, valueColor: string,
|
||||
labelSize: number, valueSize: number,
|
||||
bold = false,
|
||||
) {
|
||||
ctx.fillStyle = labelColor;
|
||||
ctx.font = `${labelSize}px system-ui, sans-serif`;
|
||||
ctx.fillText(label.toUpperCase(), x, y);
|
||||
ctx.fillStyle = valueColor;
|
||||
ctx.font = `${bold ? 'bold ' : ''}${valueSize}px system-ui, sans-serif`;
|
||||
fillTextFit(ctx, value, x, y + valueSize + 2, maxW);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/** The card back — printed in reverse (no QR; the front carries it). */
|
||||
function drawCardBack(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]): string {
|
||||
ctx.fillStyle = '#0b0b0b';
|
||||
roundRect(ctx, 0, 0, CARD_W, CARD_H, 40);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.fillRect(0, 0, CARD_W, 10);
|
||||
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 22px system-ui, sans-serif';
|
||||
ctx.fillText('🛹 SKATESPOTS', 40, 70);
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '15px system-ui, sans-serif';
|
||||
ctx.fillText('CARD BACK', 40, 96);
|
||||
|
||||
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]);
|
||||
if (data.ridesFor) lines.push(['RIDES FOR', data.ridesFor]);
|
||||
if (data.sponsors) lines.push(['SPONSORS', data.sponsors]);
|
||||
if (data.validThru) lines.push(['VALID THRU', data.validThru]);
|
||||
|
||||
let y = 168;
|
||||
ctx.font = '15px system-ui, sans-serif';
|
||||
for (const [label, value] of lines.slice(0, 6)) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.fillText(label, 40, y);
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 20px system-ui, sans-serif';
|
||||
fillTextFit(ctx, value, 190, y, CARD_W - 230);
|
||||
ctx.font = '15px system-ui, sans-serif';
|
||||
y += 44;
|
||||
}
|
||||
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '14px system-ui, sans-serif';
|
||||
ctx.fillText('SKATE-SPOTS.ORG · POWERED BY NOSTR', 40, CARD_H - 40);
|
||||
|
||||
return ctx.canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'back' = 'front'): 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;
|
||||
|
||||
if (side === 'back') return drawCardBack(ctx, data, p);
|
||||
|
||||
// ─── 1. Background ───────────────────────────────────────────────────────
|
||||
const bgGrad = ctx.createLinearGradient(0, 0, CARD_W, CARD_H);
|
||||
bgGrad.addColorStop(0, p.bg1);
|
||||
bgGrad.addColorStop(0.5, p.bg2);
|
||||
bgGrad.addColorStop(1, p.bg3);
|
||||
roundRect(ctx, 0, 0, CARD_W, CARD_H, 40);
|
||||
ctx.fillStyle = bgGrad;
|
||||
ctx.fill();
|
||||
|
||||
// ─── 2. Left sidebar tint ────────────────────────────────────────────────
|
||||
const SIDEBAR_W = 240;
|
||||
roundRect(ctx, 0, 0, SIDEBAR_W, CARD_H, 40);
|
||||
ctx.fillStyle = p.sidebarBg;
|
||||
ctx.fill();
|
||||
// vertical separator
|
||||
ctx.strokeStyle = p.accent;
|
||||
ctx.globalAlpha = 0.15;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(SIDEBAR_W, 40); ctx.lineTo(SIDEBAR_W, CARD_H - 40); ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// ─── 3. 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, 10);
|
||||
|
||||
// ─── 4. QR code (top-right, generated first — data: URI safe) ────────────
|
||||
// The QR opens the card's verify page when published; otherwise the profile.
|
||||
const qrUrl = data.verifyUrl || `https://skate-spots.org/${data.npub}`;
|
||||
const qrSize = 168;
|
||||
const qrX = CARD_W - 32 - qrSize;
|
||||
const qrY = 28;
|
||||
let qrImg: HTMLImageElement | null = null;
|
||||
try {
|
||||
// Dynamic import — avoids canvas API access at module load time
|
||||
const QRCode = (await import('qrcode')).default;
|
||||
const qrDataUrl = await QRCode.toDataURL(qrUrl, {
|
||||
width: qrSize * 2,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: { dark: '#000000', light: '#ffffff' },
|
||||
});
|
||||
qrImg = await loadImage(qrDataUrl); // data: URI — no proxy
|
||||
} catch { /* skip */ }
|
||||
|
||||
if (qrImg) {
|
||||
roundRect(ctx, qrX - 8, qrY - 8, qrSize + 16, qrSize + 16, 10);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fill();
|
||||
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
|
||||
}
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '13px system-ui, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('SCAN TO VERIFY', qrX + qrSize / 2, qrY + qrSize + 20);
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
// ─── 5. Load avatar ───────────────────────────────────────────────────────
|
||||
let avatarImg: HTMLImageElement | null = null;
|
||||
if (data.avatarUrl) avatarImg = await loadImage(data.avatarUrl);
|
||||
|
||||
if (data.type === 'rider') {
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// SKATER LICENSE LAYOUT
|
||||
// Left sidebar: avatar + stats + since
|
||||
// Right zone: name / nip05 / style / shop / sponsors / skills
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ── Left: Avatar ─────────────────────────────────────────────────────
|
||||
const avSize = 176;
|
||||
const avX = (SIDEBAR_W - avSize) / 2;
|
||||
const avY = 22;
|
||||
roundRect(ctx, avX, avY, avSize, avSize, 14);
|
||||
ctx.fillStyle = p.chipBg;
|
||||
ctx.fill();
|
||||
if (avatarImg) {
|
||||
ctx.save();
|
||||
roundRect(ctx, avX, avY, avSize, avSize, 14);
|
||||
ctx.clip();
|
||||
ctx.drawImage(avatarImg, avX, avY, avSize, avSize);
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.font = '72px serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.fillText('🛹', avX + avSize / 2, avY + avSize / 2 + 24);
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
// ── Left: Stats block ─────────────────────────────────────────────────
|
||||
const statsStartY = avY + avSize + 20;
|
||||
const statItems = [
|
||||
data.showSpots ? { label: 'SPOTS', value: String(data.spotsAdded), color: p.accent } : null,
|
||||
data.showZaps ? { label: 'ZAPS ⚡', value: String(data.zapsReceived), color: p.accent2 } : null,
|
||||
data.showEvents ? { label: 'EVENTS', value: String(data.eventsCreated), color: '#ff7733' } : null,
|
||||
].filter(Boolean) as { label: string; value: string; color: string }[];
|
||||
|
||||
const statColW = SIDEBAR_W / Math.max(statItems.length, 1);
|
||||
statItems.forEach((s, i) => {
|
||||
const sx = i * statColW + statColW / 2;
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '12px system-ui, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(s.label, sx, statsStartY);
|
||||
ctx.fillStyle = s.color;
|
||||
ctx.font = 'bold 30px system-ui, sans-serif';
|
||||
ctx.fillText(s.value, sx, statsStartY + 34);
|
||||
});
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
// ── Left: Since + npub ────────────────────────────────────────────────
|
||||
const sinceY = statsStartY + (statItems.length > 0 ? 56 : 0);
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '13px system-ui, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`SINCE ${data.memberYear}`, SIDEBAR_W / 2, sinceY);
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
// ── Right zone ────────────────────────────────────────────────────────
|
||||
const RX = SIDEBAR_W + 28; // right content X start
|
||||
const RMaxW = qrX - RX - 20; // max width before QR
|
||||
let ry = 52; // right Y cursor
|
||||
|
||||
// Brand tag (small, above name)
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 14px system-ui, sans-serif';
|
||||
ctx.fillText('🛹 SKATESPOTS', RX, ry);
|
||||
ry += 6;
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '12px system-ui, sans-serif';
|
||||
ctx.fillText('SKATER LICENSE', RX, ry + 14);
|
||||
ry += 30;
|
||||
|
||||
// Name — big and bold
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 52px system-ui, sans-serif';
|
||||
fillTextFit(ctx, data.name.toUpperCase(), RX, ry + 48, RMaxW);
|
||||
ry += 58;
|
||||
|
||||
// nip05
|
||||
if (data.nip05) {
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'italic 20px Georgia, serif';
|
||||
fillTextFit(ctx, `@${data.nip05.split('@')[0]}`, RX, ry, RMaxW);
|
||||
ry += 28;
|
||||
}
|
||||
|
||||
// Thin divider
|
||||
ctx.strokeStyle = p.accent;
|
||||
ctx.globalAlpha = 0.2;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(RX, ry); ctx.lineTo(RX + RMaxW, ry); ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
ry += 18;
|
||||
|
||||
// Style chip
|
||||
if (data.style) {
|
||||
ctx.font = 'bold 16px system-ui, sans-serif';
|
||||
const chipTxt = data.style.toUpperCase();
|
||||
const chipW = ctx.measureText(chipTxt).width + 24;
|
||||
roundRect(ctx, RX, ry - 18, chipW, 26, 6);
|
||||
ctx.fillStyle = p.chipBg;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = p.chipBorder;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.fillText(chipTxt, RX + 12, ry - 1);
|
||||
ry += 22;
|
||||
}
|
||||
|
||||
// Level chip (derived from the Skater profile)
|
||||
if (data.level) {
|
||||
ctx.font = 'bold 16px system-ui, sans-serif';
|
||||
const lvlTxt = data.level.toUpperCase();
|
||||
const lvlW = ctx.measureText(lvlTxt).width + 24;
|
||||
roundRect(ctx, RX, ry - 18, lvlW, 26, 6);
|
||||
ctx.fillStyle = 'rgba(255,102,0,0.18)';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,102,0,0.45)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#ff9944';
|
||||
ctx.fillText(lvlTxt, RX + 12, ry - 1);
|
||||
ry += 22;
|
||||
}
|
||||
|
||||
// Local + crew crests (identity facets)
|
||||
if (data.local) {
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 17px system-ui, sans-serif';
|
||||
fillTextFit(ctx, `📍 ${data.local.toUpperCase()}`, RX, ry, RMaxW);
|
||||
ry += 22;
|
||||
}
|
||||
if (data.crews && data.crews.length > 0) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '14px system-ui, sans-serif';
|
||||
fillTextFit(ctx, `⛺ ${data.crews.slice(0, 3).join(' · ')}`, RX, ry, RMaxW);
|
||||
ry += 20;
|
||||
}
|
||||
if (data.ridesFor) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '11px system-ui, sans-serif';
|
||||
ctx.fillText('RIDES FOR', RX, ry);
|
||||
ry += 4;
|
||||
ctx.fillStyle = p.shopColor;
|
||||
ctx.font = 'bold italic 22px Georgia, serif';
|
||||
fillTextFit(ctx, data.ridesFor, RX, ry + 20, RMaxW);
|
||||
ry += 32;
|
||||
}
|
||||
|
||||
// ── SPONSORS — different color + lighter weight ────────────────────────
|
||||
if (data.sponsors) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '11px system-ui, sans-serif';
|
||||
ctx.fillText('SPONSORS', RX, ry);
|
||||
ry += 4;
|
||||
ctx.fillStyle = p.sponsorColor;
|
||||
ctx.font = '19px system-ui, sans-serif';
|
||||
fillTextFit(ctx, data.sponsors, RX, ry + 18, RMaxW);
|
||||
ry += 30;
|
||||
}
|
||||
|
||||
// Thin divider before skills
|
||||
if (data.skills.length > 0 || data.receivedBadges.length > 0 || (data.roles?.length ?? 0) > 0) {
|
||||
ctx.strokeStyle = p.accent;
|
||||
ctx.globalAlpha = 0.15;
|
||||
ctx.lineWidth = 1;
|
||||
const divY = Math.max(ry + 4, CARD_H - 130);
|
||||
ctx.beginPath(); ctx.moveTo(RX, divY); ctx.lineTo(RX + RMaxW, divY); ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// ── Skill chips ────────────────────────────────────────────────────
|
||||
const skillsToShow = [
|
||||
...(data.roles ?? []).slice(0, 3).map(r => {
|
||||
const m = roleMeta(r);
|
||||
return m ? { label: `${m.emoji} ${m.label}`, gold: false } : null;
|
||||
}).filter(Boolean),
|
||||
...data.receivedBadges.slice(0, 4).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 = RX;
|
||||
let chipY = divY + 14;
|
||||
ctx.font = 'bold 14px system-ui, sans-serif';
|
||||
skillsToShow.forEach(chip => {
|
||||
const chipW = ctx.measureText(chip.label).width + 18;
|
||||
if (chipX + chipW > RX + RMaxW) { chipX = RX; chipY += 28; }
|
||||
roundRect(ctx, chipX, chipY, chipW, 22, 5);
|
||||
ctx.fillStyle = chip.gold ? 'rgba(255,215,0,0.18)' : p.chipBg;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = chip.gold ? 'rgba(255,215,0,0.5)' : p.chipBorder;
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = chip.gold ? '#ffd700' : p.accent;
|
||||
ctx.fillText(chip.label, chipX + 9, chipY + 16);
|
||||
chipX += chipW + 6;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Full npub — bottom of right zone ──────────────────────────────────
|
||||
const npubY = CARD_H - 44;
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '12px monospace';
|
||||
ctx.fillText(data.npub.slice(0, 44), RX, npubY);
|
||||
ctx.fillText(data.npub.slice(44), RX, npubY + 16);
|
||||
|
||||
} else if (data.type === 'park') {
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// PARK ACCESS LAYOUT
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ── Left: Avatar ─────────────────────────────────────────────────────
|
||||
const avSize = 176;
|
||||
const avX = (SIDEBAR_W - avSize) / 2;
|
||||
const avY = 22;
|
||||
roundRect(ctx, avX, avY, avSize, avSize, 14);
|
||||
ctx.fillStyle = p.chipBg;
|
||||
ctx.fill();
|
||||
if (avatarImg) {
|
||||
ctx.save();
|
||||
roundRect(ctx, avX, avY, avSize, avSize, 14);
|
||||
ctx.clip();
|
||||
ctx.drawImage(avatarImg, avX, avY, avSize, avSize);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Access level chip under avatar
|
||||
const alTxt = (data.accessLevel || 'FULL ACCESS').toUpperCase();
|
||||
ctx.font = 'bold 16px system-ui, sans-serif';
|
||||
const alW = ctx.measureText(alTxt).width + 24;
|
||||
const alX = (SIDEBAR_W - alW) / 2;
|
||||
const alY = avY + avSize + 16;
|
||||
roundRect(ctx, alX, alY, alW, 30, 7);
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.fill();
|
||||
ctx.fillStyle = p.bg1;
|
||||
ctx.fillText(alTxt, alX + 12, alY + 21);
|
||||
|
||||
// valid thru under access level
|
||||
if (data.validThru) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '12px system-ui, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('VALID THRU', SIDEBAR_W / 2, alY + 52);
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 20px system-ui, sans-serif';
|
||||
ctx.fillText(data.validThru, SIDEBAR_W / 2, alY + 76);
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
// ── Right zone ────────────────────────────────────────────────────────
|
||||
const RX = SIDEBAR_W + 28;
|
||||
const RMaxW = qrX - RX - 20;
|
||||
let ry = 52;
|
||||
|
||||
// Park name + label
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 14px system-ui, sans-serif';
|
||||
ctx.fillText(`🏟️ ${(data.issuerName || 'SKATEPARK').toUpperCase()}`, RX, ry);
|
||||
ry += 6;
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '12px system-ui, sans-serif';
|
||||
ctx.fillText('PARK ACCESS CARD', RX, ry + 14);
|
||||
ry += 34;
|
||||
|
||||
// Member name
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 50px system-ui, sans-serif';
|
||||
fillTextFit(ctx, (data.recipientName || 'MEMBER').toUpperCase(), RX, ry + 46, RMaxW);
|
||||
ry += 60;
|
||||
|
||||
// Divider
|
||||
ctx.strokeStyle = p.accent;
|
||||
ctx.globalAlpha = 0.2;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(RX, ry); ctx.lineTo(RX + RMaxW, ry); ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
ry += 22;
|
||||
|
||||
// Issued by
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '13px system-ui, sans-serif';
|
||||
ctx.fillText('ISSUED BY PARK OWNER · NOSTR-SIGNED', RX, ry);
|
||||
ry += 26;
|
||||
|
||||
// Full npub
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = '14px monospace';
|
||||
ctx.fillText(data.npub.slice(0, 44), RX, ry);
|
||||
ry += 20;
|
||||
ctx.fillText(data.npub.slice(44), RX, ry);
|
||||
} else if (data.type === 'crew') {
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// CREW LAYOUT
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
const c = data.crew;
|
||||
const avSize = 176;
|
||||
const avX = (SIDEBAR_W - avSize) / 2;
|
||||
const avY = 22;
|
||||
roundRect(ctx, avX, avY, avSize, avSize, 14);
|
||||
ctx.fillStyle = p.chipBg;
|
||||
ctx.fill();
|
||||
if (avatarImg) {
|
||||
ctx.save();
|
||||
roundRect(ctx, avX, avY, avSize, avSize, 14);
|
||||
ctx.clip();
|
||||
ctx.drawImage(avatarImg, avX, avY, avSize, avSize);
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = '80px system-ui, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('🏕️', SIDEBAR_W / 2, avY + 118);
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
// Left: members + spots
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '12px system-ui, sans-serif';
|
||||
ctx.fillText('MEMBERS', SIDEBAR_W / 4 + 8, avY + avSize + 32);
|
||||
ctx.fillText('SPOTS', (SIDEBAR_W * 3) / 4 - 8, avY + avSize + 32);
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 34px system-ui, sans-serif';
|
||||
ctx.fillText(String(c?.members ?? 0), SIDEBAR_W / 4 + 8, avY + avSize + 68);
|
||||
ctx.fillStyle = p.accent2;
|
||||
ctx.fillText(String(c?.spots ?? 0), (SIDEBAR_W * 3) / 4 - 8, avY + avSize + 68);
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
// Right zone
|
||||
const RX = SIDEBAR_W + 28;
|
||||
const RMaxW = qrX - RX - 20;
|
||||
let ry = 52;
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 14px system-ui, sans-serif';
|
||||
ctx.fillText('🛹 SKATESPOTS', RX, ry);
|
||||
ry += 6;
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '12px system-ui, sans-serif';
|
||||
ctx.fillText('CREW CARD', RX, ry + 14);
|
||||
ry += 30;
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 46px system-ui, sans-serif';
|
||||
fillTextFit(ctx, (c?.name ?? '').toUpperCase(), RX, ry + 44, RMaxW);
|
||||
ry += 56;
|
||||
if (c?.location) {
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 18px system-ui, sans-serif';
|
||||
fillTextFit(ctx, `📍 ${c.location}`, RX, ry, RMaxW);
|
||||
ry += 24;
|
||||
}
|
||||
if (c?.founded) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '13px system-ui, sans-serif';
|
||||
ctx.fillText(`EST. ${c.founded}`, RX, ry);
|
||||
ry += 22;
|
||||
}
|
||||
if (c && c.lookingFor.length > 0) {
|
||||
ctx.fillStyle = p.accent2;
|
||||
ctx.font = 'bold 15px system-ui, sans-serif';
|
||||
ctx.fillText('OPEN CALL', RX, ry);
|
||||
ry += 24;
|
||||
}
|
||||
if (c && c.vibes.length > 0) {
|
||||
let chipX = RX;
|
||||
let chipY = ry + 4;
|
||||
ctx.font = 'bold 14px system-ui, sans-serif';
|
||||
c.vibes.slice(0, 5).forEach(v => {
|
||||
const label = v.toUpperCase();
|
||||
const chipW = ctx.measureText(label).width + 18;
|
||||
if (chipX + chipW > RX + RMaxW) { chipX = RX; chipY += 28; }
|
||||
roundRect(ctx, chipX, chipY, chipW, 22, 5);
|
||||
ctx.fillStyle = p.chipBg;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = p.chipBorder;
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.fillText(label, chipX + 9, chipY + 16);
|
||||
chipX += chipW + 6;
|
||||
});
|
||||
}
|
||||
|
||||
// Full npub
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '12px monospace';
|
||||
ctx.fillText(data.npub.slice(0, 44), RX, CARD_H - 64);
|
||||
ctx.fillText(data.npub.slice(44), RX, CARD_H - 48);
|
||||
} else {
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// EVENT PASS LAYOUT
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
const e = data.event;
|
||||
const avSize = 176;
|
||||
const avX = (SIDEBAR_W - avSize) / 2;
|
||||
const avY = 22;
|
||||
roundRect(ctx, avX, avY, avSize, avSize, 14);
|
||||
ctx.fillStyle = p.chipBg;
|
||||
ctx.fill();
|
||||
if (avatarImg) {
|
||||
ctx.save();
|
||||
roundRect(ctx, avX, avY, avSize, avSize, 14);
|
||||
ctx.clip();
|
||||
ctx.drawImage(avatarImg, avX, avY, avSize, avSize);
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = '80px system-ui, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('🎟️', SIDEBAR_W / 2, avY + 118);
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
// Right zone
|
||||
const RX = SIDEBAR_W + 28;
|
||||
const RMaxW = qrX - RX - 20;
|
||||
let ry = 52;
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 14px system-ui, sans-serif';
|
||||
ctx.fillText('🛹 SKATESPOTS', RX, ry);
|
||||
ry += 6;
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '12px system-ui, sans-serif';
|
||||
ctx.fillText('EVENT PASS', RX, ry + 14);
|
||||
ry += 30;
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 44px system-ui, sans-serif';
|
||||
fillTextFit(ctx, (e?.title ?? '').toUpperCase(), RX, ry + 42, RMaxW);
|
||||
ry += 54;
|
||||
if (e) {
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 18px system-ui, sans-serif';
|
||||
fillTextFit(ctx, formatCardDate(e.start, e.end), RX, ry, RMaxW);
|
||||
ry += 26;
|
||||
if (e.location) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '15px system-ui, sans-serif';
|
||||
fillTextFit(ctx, `📍 ${e.location}`, RX, ry, RMaxW);
|
||||
ry += 24;
|
||||
}
|
||||
}
|
||||
// Holder
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '13px system-ui, sans-serif';
|
||||
ctx.fillText('ADMITS', RX, ry);
|
||||
ry += 4;
|
||||
ctx.fillStyle = p.accent2;
|
||||
ctx.font = 'bold 22px system-ui, sans-serif';
|
||||
fillTextFit(ctx, e?.holder ?? '', RX, ry + 20, RMaxW);
|
||||
ry += 34;
|
||||
|
||||
// Full npub
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '12px monospace';
|
||||
ctx.fillText(data.npub.slice(0, 44), RX, CARD_H - 64);
|
||||
ctx.fillText(data.npub.slice(44), RX, CARD_H - 48);
|
||||
}
|
||||
|
||||
// ─── 6. Bottom bar ───────────────────────────────────────────────────────
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.globalAlpha = 0.18;
|
||||
ctx.fillRect(0, CARD_H - 36, CARD_W, 36);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '13px system-ui, sans-serif';
|
||||
ctx.fillText('SKATE-SPOTS.ORG · POWERED BY NOSTR', 32, CARD_H - 13);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(data.memberYear, CARD_W - 32, CARD_H - 13);
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
// ─── Hook: fetch skill badges received by a pubkey ────────────────────────────
|
||||
function useReceivedBadges(pubkey: string) {
|
||||
const { nostr } = useNostr();
|
||||
|
|
@ -896,7 +189,6 @@ export default function RiderCardPage() {
|
|||
const { mutate: publishEvent } = useNostrPublish();
|
||||
const { mutateAsync: uploadFile, isPending: uploadingLogo } = useUploadFile();
|
||||
const { toast } = useToast();
|
||||
const printRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const displayName = metadata?.name ?? genUserName(user?.pubkey ?? '');
|
||||
const npub = user?.pubkey ? nip19.npubEncode(user.pubkey) : '';
|
||||
|
|
@ -1092,24 +384,44 @@ export default function RiderCardPage() {
|
|||
const renderCardToImage = useCallback(async (side: 'front' | 'back' = 'front'): Promise<string | null> => {
|
||||
setRendering(true);
|
||||
try {
|
||||
return 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,
|
||||
const canvasData: CanvasCardData = {
|
||||
type: cardData.type,
|
||||
themeId: cardData.theme,
|
||||
accentOverride: cardData.accent,
|
||||
name: cardData.name,
|
||||
nip05: cardData.nip05,
|
||||
avatarUrl: cardData.avatar,
|
||||
logoUrl: cardData.logo,
|
||||
pubkey: cardData.pubkey,
|
||||
npub: cardData.npub,
|
||||
style: cardData.style,
|
||||
level: cardData.level,
|
||||
sponsors: cardData.sponsors,
|
||||
ridesFor: cardData.ridesFor,
|
||||
showSpots: cardData.showSpots,
|
||||
showZaps: cardData.showZaps,
|
||||
showEvents: cardData.showEvents,
|
||||
showCrews: cardData.showCrews ?? true,
|
||||
showLocal: cardData.showLocal ?? true,
|
||||
showRoles: cardData.showRoles ?? true,
|
||||
spotsAdded: cardData.spotsAdded,
|
||||
zapsReceived: cardData.zapsReceived,
|
||||
eventsCreated: cardData.eventsCreated,
|
||||
memberYear: cardData.memberYear,
|
||||
receivedBadges: cardData.receivedBadges,
|
||||
verifyUrl: cardData.verifyUrl,
|
||||
local: cardData.local,
|
||||
crews: (cardData.crews ?? []).map(c => c.name),
|
||||
roles: cardData.roles,
|
||||
crew: cardData.crew,
|
||||
event: cardData.event,
|
||||
issuerName: cardData.issuerName, recipientName: cardData.recipientName,
|
||||
accessLevel: cardData.accessLevel, validThru: cardData.validThru,
|
||||
}, side);
|
||||
issuerName: cardData.issuerName,
|
||||
recipientName: cardData.recipientName,
|
||||
accessLevel: cardData.accessLevel,
|
||||
validThru: cardData.validThru,
|
||||
holographic: cardData.holographic,
|
||||
};
|
||||
return await drawCardToCanvas(canvasData, side);
|
||||
} catch (err) { console.error('Card render failed:', err); return null; }
|
||||
finally { setRendering(false); }
|
||||
}, [cardData]);
|
||||
|
|
@ -1270,7 +582,7 @@ export default function RiderCardPage() {
|
|||
className="absolute inset-x-0 top-1/2 h-32 -translate-y-1/2 opacity-30 blur-3xl"
|
||||
style={{ background: `radial-gradient(ellipse, ${theme === 'lime' ? '#ccff00' : theme === 'midnight' ? '#64a0ff' : theme === 'wood' ? '#f5c842' : theme === 'chrome' ? '#888' : theme === 'sunset' ? '#ff6020' : '#ff0040'}, transparent)` }}
|
||||
/>
|
||||
<RiderCardFront d={cardData} printRef={printRef} />
|
||||
<CardPreview d={cardData} />
|
||||
</div>
|
||||
|
||||
{/* Flip front/back (personal card) */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue