Fix card layout, add real QR code, npub as identity
Problems fixed: - Card was rendering too tall: replaced flex-1/justify-between layout with strict pixel-height rows (row1: 26px header, row2: 118px body, fixed bottom bar) — content no longer floats or overflows - No QR code: added QRCodeCanvas (existing component) in bottom-right of card body, encoding https://skate-spots.org/<npub> — scanning takes you to the rider's profile for verification - License ID removed: replaced SS-XXXXXXX with shortNpub (npub1xxx...yyyy) in top-right. The npub IS the identity — no made-up number needed - Added crossOrigin="anonymous" on avatar img for html2canvas CORS handling - Updated html2canvas onclone to lock card to 340×214px during capture - Download filename uses npub prefix instead of licenseId - verifyUrl() helper generates skate-spots.org/<npub> for QR content Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
046a109eca
commit
68922280cb
1 changed files with 171 additions and 147 deletions
|
|
@ -41,6 +41,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||
|
||||
import {
|
||||
Printer, Download, Copy, Palette, Check, Building2,
|
||||
|
|
@ -87,19 +88,24 @@ const STYLES = ['Street', 'Park', 'Vert', 'Bowl', 'DIY', 'All-terrain'] as const
|
|||
function shortNpub(pubkey: string) {
|
||||
if (!pubkey) return '—';
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
return `${npub.slice(0, 10)}…${npub.slice(-4)}`;
|
||||
return `${npub.slice(0, 12)}…${npub.slice(-6)}`;
|
||||
}
|
||||
|
||||
function riderLicenseId(pubkey: string) {
|
||||
// Deterministic license ID from pubkey — human-readable, not a secret
|
||||
const n = parseInt(pubkey.slice(0, 8), 16) % 9000000 + 1000000;
|
||||
return `SS-${n}`;
|
||||
function fullNpub(pubkey: string) {
|
||||
if (!pubkey) return '';
|
||||
return nip19.npubEncode(pubkey);
|
||||
}
|
||||
|
||||
function memberSince(createdAt: number) {
|
||||
return new Date(createdAt * 1000).getFullYear().toString();
|
||||
}
|
||||
|
||||
// Verification URL — scanning this on a phone opens the profile on skate-spots.org
|
||||
function verifyUrl(pubkey: string) {
|
||||
if (!pubkey) return 'https://skate-spots.org';
|
||||
return `https://skate-spots.org/${nip19.npubEncode(pubkey)}`;
|
||||
}
|
||||
|
||||
// ─── Badge award event type ───────────────────────────────────────────────────
|
||||
interface BadgeAward {
|
||||
witnessName: string;
|
||||
|
|
@ -119,6 +125,7 @@ interface CardData {
|
|||
nip05?: string;
|
||||
avatar?: string;
|
||||
pubkey: string;
|
||||
npub: string;
|
||||
style: string;
|
||||
skills: string[];
|
||||
sponsors: string;
|
||||
|
|
@ -130,7 +137,6 @@ interface CardData {
|
|||
zapsReceived: number;
|
||||
eventsCreated: number;
|
||||
memberYear: string;
|
||||
licenseId: string;
|
||||
receivedBadges: BadgeAward[];
|
||||
// Park
|
||||
issuerName: string;
|
||||
|
|
@ -167,175 +173,202 @@ function useWitnessName(pubkey: string) {
|
|||
}
|
||||
|
||||
// ─── Card visual: Rider front ─────────────────────────────────────────────────
|
||||
// Layout is STRICTLY pixel-fixed to match the 340×214px card container.
|
||||
// No flex-1 / flex-grow — every row has a fixed pixel height.
|
||||
function RiderFront({ d }: { d: CardData }) {
|
||||
// Only show first 2 witnessed badges on the card itself
|
||||
const witnessedBadges = d.receivedBadges.slice(0, 3);
|
||||
const witnessedBadges = d.receivedBadges.slice(0, 2);
|
||||
const qrUrl = verifyUrl(d.pubkey);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 p-[10px] flex flex-col justify-between">
|
||||
{/* Header row */}
|
||||
<div className="flex items-start justify-between">
|
||||
// absolute inset — fills the card exactly
|
||||
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
|
||||
|
||||
{/* ── Row 1: header (26px) ── */}
|
||||
<div className="flex items-start justify-between" style={{ height: '26px' }}>
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-0.5">
|
||||
<span className="text-[8px] font-black uppercase tracking-[0.2em] card-accent">
|
||||
<div className="text-[8px] font-black uppercase tracking-[0.18em] card-accent leading-none">
|
||||
🛹 SkateSpots
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[6px] card-muted uppercase tracking-widest">Rider License</div>
|
||||
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">Rider License</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[5.5px] card-muted uppercase tracking-wide">LICENSE ID</div>
|
||||
<div className="text-[7.5px] font-black card-accent tracking-wider">{d.licenseId}</div>
|
||||
<div className="text-[5px] card-muted font-mono mt-0.5">{shortNpub(d.pubkey)}</div>
|
||||
<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]">Since {d.memberYear}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex items-center gap-[8px] flex-1 my-[4px]">
|
||||
{/* ── Row 2: main body (118px) ── */}
|
||||
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-[44px] h-[44px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
|
||||
<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" />
|
||||
<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-[14px]">
|
||||
🛹
|
||||
</div>
|
||||
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🛹</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identity */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[12px] font-black uppercase truncate leading-tight">{d.name}</div>
|
||||
{d.nip05 && (
|
||||
<div className="text-[6.5px] card-accent truncate mt-0.5">@{d.nip05.split('@')[0]}</div>
|
||||
)}
|
||||
|
||||
{/* Style + year */}
|
||||
<div className="flex items-center gap-1.5 mt-[3px]">
|
||||
{d.style && (
|
||||
<span className="card-bg-chip text-[5.5px] font-bold uppercase px-[4px] py-[1px] rounded-[3px] card-accent">
|
||||
{d.style}
|
||||
</span>
|
||||
)}
|
||||
<span className="card-muted text-[5.5px]">Since {d.memberYear}</span>
|
||||
</div>
|
||||
|
||||
{/* Rides for */}
|
||||
{d.ridesFor && (
|
||||
<div className="text-[5.5px] card-muted truncate mt-[2px]">
|
||||
🏪 {d.ridesFor}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats column */}
|
||||
<div className="flex-shrink-0 text-right space-y-[3px]">
|
||||
{/* Stats under avatar */}
|
||||
<div className="space-y-[2px]">
|
||||
{d.showSpots && (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<span className="text-[5.5px] card-muted">Spots</span>
|
||||
<span className="text-[7.5px] font-black card-accent">{d.spotsAdded}</span>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[5px] card-muted">Spots</span>
|
||||
<span className="text-[7px] font-black card-accent leading-none">{d.spotsAdded}</span>
|
||||
</div>
|
||||
)}
|
||||
{d.showZaps && (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<span className="text-[5.5px] card-muted">⚡</span>
|
||||
<span className="text-[7.5px] font-black card-accent2">{d.zapsReceived}</span>
|
||||
<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-end gap-1">
|
||||
<span className="text-[5.5px] card-muted">Events</span>
|
||||
<span className="text-[7.5px] font-black" style={{ color: '#ff6600' }}>{d.eventsCreated}</span>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[5px] card-muted">Evts</span>
|
||||
<span className="text-[7px] font-black leading-none" style={{ color: '#ff6600' }}>{d.eventsCreated}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skills + witnessed badges row */}
|
||||
<div className="flex flex-wrap gap-[2.5px]">
|
||||
{/* Self-selected skills */}
|
||||
{d.skills.slice(0, 5).map(sk => {
|
||||
{/* 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">{d.name}</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.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>
|
||||
)}
|
||||
{/* Skills */}
|
||||
<div className="flex flex-wrap gap-[2px] mt-[2px]">
|
||||
{d.skills.slice(0, 4).map(sk => {
|
||||
const s = SKILL_OPTIONS.find(o => o.id === sk);
|
||||
return s ? (
|
||||
<span key={sk} className="card-bg-chip text-[5px] font-bold px-[3px] py-[1px] rounded-[2px]">
|
||||
<span key={sk} className="card-bg-chip text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none">
|
||||
{s.icon} {s.label}
|
||||
</span>
|
||||
) : null;
|
||||
})}
|
||||
{/* Witnessed badges */}
|
||||
{witnessedBadges.map((b, i) => {
|
||||
const s = SKILL_OPTIONS.find(o => o.id === b.skill);
|
||||
return s ? (
|
||||
<span
|
||||
key={i}
|
||||
className="text-[5px] font-bold px-[3px] py-[1px] rounded-[2px]"
|
||||
style={{ background: 'rgba(255,215,0,0.15)', border: '1px solid rgba(255,215,0,0.5)', color: '#ffd700' }}
|
||||
title={`Witnessed by ${b.witnessName}${b.spotName ? ` at ${b.spotName}` : ''}`}
|
||||
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>
|
||||
|
||||
{/* Sponsors row */}
|
||||
{d.sponsors && (
|
||||
<div className="text-[5.5px] card-muted truncate mt-[2px]">
|
||||
🤝 {d.sponsors}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR code — bottom-right of body */}
|
||||
<div className="flex-shrink-0 flex flex-col items-end justify-end">
|
||||
<div className="rounded-[3px] overflow-hidden" style={{ background: '#fff', padding: '2px' }}>
|
||||
<QRCodeCanvas value={qrUrl} size={44} level="M" />
|
||||
</div>
|
||||
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">Scan to verify</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Bottom bar: npub + website (absolute at bottom) ── */}
|
||||
<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 qrUrl = verifyUrl(d.pubkey);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 p-[10px] flex flex-col justify-between">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
|
||||
|
||||
{/* Row 1: header */}
|
||||
<div className="flex items-center justify-between" style={{ height: '26px' }}>
|
||||
<div>
|
||||
<div className="text-[8px] font-black uppercase tracking-[0.15em] card-accent">
|
||||
<div className="text-[8px] font-black uppercase tracking-[0.15em] card-accent leading-none">
|
||||
🏟️ {d.issuerName || 'Skatepark'}
|
||||
</div>
|
||||
<div className="text-[6px] card-muted uppercase tracking-widest">Access Pass</div>
|
||||
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">Access Pass</div>
|
||||
</div>
|
||||
<div className="card-bg-chip px-[6px] py-[2px] rounded-[4px]">
|
||||
<span className="text-[7px] font-black uppercase card-accent">{d.accessLevel || 'Full Access'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Member info */}
|
||||
<div className="flex items-center gap-[8px] flex-1 my-[6px]">
|
||||
<div className="w-[46px] h-[46px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20 flex-shrink-0">
|
||||
{/* Row 2: main body */}
|
||||
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
|
||||
|
||||
{/* Avatar */}
|
||||
<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" />
|
||||
<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-[14px]">🛹</div>
|
||||
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🛹</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[12px] font-black uppercase truncate">{d.recipientName || 'Member Name'}</div>
|
||||
<div className="text-[5.5px] card-muted font-mono mt-0.5 truncate">{shortNpub(d.pubkey)}</div>
|
||||
<div className="text-[5.5px] card-muted mt-1">Issued by park owner · Nostr-signed</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<div className="text-[6px] card-muted uppercase">Valid thru</div>
|
||||
<div className="text-[9px] font-black card-accent">{d.validThru || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* License ID + shield */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-[5.5px] card-muted uppercase">License ID</div>
|
||||
<div className="text-[8px] font-black card-accent tracking-wider">{d.licenseId}</div>
|
||||
{/* Identity */}
|
||||
<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 || 'Member Name'}</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">Issued by park owner · Nostr-signed</div>
|
||||
{d.validThru && (
|
||||
<div className="flex items-center gap-1 mt-[3px]">
|
||||
<span className="text-[5.5px] card-muted uppercase">Valid thru</span>
|
||||
<span className="text-[8px] font-black card-accent leading-none">{d.validThru}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* QR */}
|
||||
<div className="flex-shrink-0 flex flex-col items-end justify-end">
|
||||
<div className="rounded-[3px] overflow-hidden" style={{ background: '#fff', padding: '2px' }}>
|
||||
<QRCodeCanvas value={qrUrl} size={44} level="M" />
|
||||
</div>
|
||||
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">Scan to verify</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>
|
||||
<div className="flex items-center gap-1">
|
||||
<Shield className="h-[10px] w-[10px] card-accent opacity-60" />
|
||||
<span className="text-[6px] card-muted">Nostr-verified</span>
|
||||
<Shield className="card-muted" style={{ width: '8px', height: '8px', opacity: 0.5 }} />
|
||||
<span className="card-muted text-[5px] uppercase tracking-widest">Nostr-verified</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -358,16 +391,6 @@ function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObje
|
|||
|
||||
{d.type === 'rider' && <RiderFront d={d} />}
|
||||
{d.type === 'park' && <ParkFront d={d} />}
|
||||
|
||||
{/* Bottom micro-text */}
|
||||
<div className="absolute bottom-[6px] left-[10px] right-[10px] flex items-center justify-between">
|
||||
<span className="card-muted text-[5.5px] uppercase tracking-widest">
|
||||
skate-spots.org · Powered by Nostr
|
||||
</span>
|
||||
<span className="card-muted text-[5.5px] uppercase tracking-widest">
|
||||
Scan to verify
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -658,6 +681,7 @@ export default function RiderCardPage() {
|
|||
})();
|
||||
|
||||
// ── Computed card data ───────────────────────────────────────────────────────
|
||||
const cardPubkey = cardType === 'rider' ? (user?.pubkey ?? '') : recipientPubkey;
|
||||
const cardData: CardData = {
|
||||
type: cardType,
|
||||
theme,
|
||||
|
|
@ -665,7 +689,8 @@ export default function RiderCardPage() {
|
|||
name: cardType === 'rider' ? displayName : recipientName,
|
||||
nip05: metadata?.nip05,
|
||||
avatar: metadata?.picture,
|
||||
pubkey: cardType === 'rider' ? (user?.pubkey ?? '') : recipientPubkey,
|
||||
pubkey: cardPubkey,
|
||||
npub: cardPubkey ? fullNpub(cardPubkey) : '',
|
||||
style,
|
||||
skills,
|
||||
sponsors,
|
||||
|
|
@ -677,7 +702,6 @@ export default function RiderCardPage() {
|
|||
memberYear: metadata?.created_at
|
||||
? memberSince(metadata.created_at as number)
|
||||
: new Date().getFullYear().toString(),
|
||||
licenseId: user?.pubkey ? riderLicenseId(user.pubkey) : 'SS-0000000',
|
||||
receivedBadges,
|
||||
issuerName,
|
||||
recipientName,
|
||||
|
|
@ -695,18 +719,18 @@ export default function RiderCardPage() {
|
|||
cardEl.classList.remove('rider-card-holo');
|
||||
|
||||
const canvas = await html2canvas(cardEl, {
|
||||
scale: 4, // 4× for crisp print quality
|
||||
useCORS: true, // allow cross-origin avatars
|
||||
scale: 4, // 4× = ~1344×848px — crisp for print
|
||||
useCORS: true, // allow cross-origin avatar images
|
||||
allowTaint: true,
|
||||
backgroundColor: null,
|
||||
logging: false,
|
||||
onclone: (doc) => {
|
||||
// In the cloned doc, force all backgrounds to render
|
||||
const style = doc.createElement('style');
|
||||
style.textContent = `
|
||||
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
|
||||
`;
|
||||
doc.head.appendChild(style);
|
||||
// html2canvas renders <canvas> elements by copying their pixel data
|
||||
// (works with QRCodeCanvas since it draws synchronously via qrcode lib)
|
||||
onclone: (_doc, el) => {
|
||||
// Ensure the cloned element has exact dimensions matching the original
|
||||
el.style.width = '340px';
|
||||
el.style.height = '214px';
|
||||
el.style.overflow = 'hidden';
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -772,7 +796,7 @@ export default function RiderCardPage() {
|
|||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = dataUrl;
|
||||
a.download = `skatespot-card-${cardData.licenseId}.png`;
|
||||
a.download = `skatespot-card-${cardData.npub.slice(0, 16)}.png`;
|
||||
a.click();
|
||||
}, [renderCardToImage, cardData.licenseId, toast]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue