feat: add QR code endpoint with PNG/SVG/PDF formats
GET /api/qrcode returns the QR for the upload URL of the event. ?format=png (default, 800px) for screen, ?format=svg for vector, and ?format=pdf for an A6 printable card with the couple's names, the event date (if set), and a "Aponte a câmera" instruction. The encoded URL defaults to the request's origin + /enviar so it works across environments without extra config; ?url= overrides for tests. https://claude.ai/code/session_01TPBqgcSJMppgrpiq7fLywL
This commit is contained in:
parent
a27782e4de
commit
516def235a
80
apps/api/src/lib/qr-pdf.ts
Normal file
80
apps/api/src/lib/qr-pdf.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
|
||||||
|
import { generateQrPng } from './qrcode.js';
|
||||||
|
|
||||||
|
export interface QrPdfOptions {
|
||||||
|
url: string;
|
||||||
|
coupleNames: string;
|
||||||
|
eventDate?: string;
|
||||||
|
callToAction?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PT_PER_MM = 2.834645669;
|
||||||
|
const A6_WIDTH = 105 * PT_PER_MM;
|
||||||
|
const A6_HEIGHT = 148 * PT_PER_MM;
|
||||||
|
|
||||||
|
function centerX(text: string, fontSize: number, font: import('pdf-lib').PDFFont): number {
|
||||||
|
const w = font.widthOfTextAtSize(text, fontSize);
|
||||||
|
return (A6_WIDTH - w) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateQrPdf(opts: QrPdfOptions): Promise<Uint8Array> {
|
||||||
|
const pdf = await PDFDocument.create();
|
||||||
|
const page = pdf.addPage([A6_WIDTH, A6_HEIGHT]);
|
||||||
|
|
||||||
|
const titleFont = await pdf.embedFont(StandardFonts.TimesRomanItalic);
|
||||||
|
const bodyFont = await pdf.embedFont(StandardFonts.Helvetica);
|
||||||
|
const boldFont = await pdf.embedFont(StandardFonts.HelveticaBold);
|
||||||
|
|
||||||
|
const ink = rgb(0.18, 0.16, 0.14);
|
||||||
|
const muted = rgb(0.45, 0.42, 0.38);
|
||||||
|
|
||||||
|
const titleSize = 28;
|
||||||
|
const titleY = A6_HEIGHT - 28 * PT_PER_MM;
|
||||||
|
page.drawText(opts.coupleNames, {
|
||||||
|
x: centerX(opts.coupleNames, titleSize, titleFont),
|
||||||
|
y: titleY,
|
||||||
|
size: titleSize,
|
||||||
|
font: titleFont,
|
||||||
|
color: ink,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (opts.eventDate) {
|
||||||
|
const dateSize = 11;
|
||||||
|
page.drawText(opts.eventDate, {
|
||||||
|
x: centerX(opts.eventDate, dateSize, bodyFont),
|
||||||
|
y: titleY - 7 * PT_PER_MM,
|
||||||
|
size: dateSize,
|
||||||
|
font: bodyFont,
|
||||||
|
color: muted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const qrPng = await generateQrPng(opts.url, 600);
|
||||||
|
const qrImage = await pdf.embedPng(qrPng);
|
||||||
|
const qrSize = 70 * PT_PER_MM;
|
||||||
|
const qrX = (A6_WIDTH - qrSize) / 2;
|
||||||
|
const qrY = (A6_HEIGHT - qrSize) / 2 - 8 * PT_PER_MM;
|
||||||
|
page.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize });
|
||||||
|
|
||||||
|
const cta = opts.callToAction ?? 'Aponte a câmera do celular';
|
||||||
|
const ctaSize = 12;
|
||||||
|
page.drawText(cta, {
|
||||||
|
x: centerX(cta, ctaSize, boldFont),
|
||||||
|
y: qrY - 9 * PT_PER_MM,
|
||||||
|
size: ctaSize,
|
||||||
|
font: boldFont,
|
||||||
|
color: ink,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sub = 'para enviar fotos e mensagem';
|
||||||
|
const subSize = 10;
|
||||||
|
page.drawText(sub, {
|
||||||
|
x: centerX(sub, subSize, bodyFont),
|
||||||
|
y: qrY - 14 * PT_PER_MM,
|
||||||
|
size: subSize,
|
||||||
|
font: bodyFont,
|
||||||
|
color: muted,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await pdf.save();
|
||||||
|
}
|
||||||
@ -4,6 +4,8 @@ import type { GalleryResponse } from '@leetete/shared';
|
|||||||
import { getDb } from '../db/client.js';
|
import { getDb } from '../db/client.js';
|
||||||
import { eventConfig, uploads } from '../db/schema.js';
|
import { eventConfig, uploads } from '../db/schema.js';
|
||||||
import type { Bindings } from '../env.js';
|
import type { Bindings } from '../env.js';
|
||||||
|
import { generateQrPng, generateQrSvg } from '../lib/qrcode.js';
|
||||||
|
import { generateQrPdf } from '../lib/qr-pdf.js';
|
||||||
import { createStorage } from '../lib/storage-factory.js';
|
import { createStorage } from '../lib/storage-factory.js';
|
||||||
|
|
||||||
export const publicRoutes = new Hono<Bindings>();
|
export const publicRoutes = new Hono<Bindings>();
|
||||||
@ -70,6 +72,53 @@ publicRoutes.get('/gallery', async (c) => {
|
|||||||
return c.json({ items, nextCursor } satisfies GalleryResponse);
|
return c.json({ items, nextCursor } satisfies GalleryResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/qrcode', async (c) => {
|
||||||
|
const format = (c.req.query('format') ?? 'png').toLowerCase();
|
||||||
|
const overrideUrl = c.req.query('url');
|
||||||
|
|
||||||
|
const reqUrl = new URL(c.req.url);
|
||||||
|
const base =
|
||||||
|
overrideUrl ??
|
||||||
|
c.env.PUBLIC_BASE_URL ??
|
||||||
|
`${reqUrl.protocol}//${reqUrl.host}`;
|
||||||
|
const target = base.replace(/\/$/, '') + '/enviar';
|
||||||
|
|
||||||
|
if (format === 'svg') {
|
||||||
|
const svg = await generateQrSvg(target);
|
||||||
|
return new Response(svg, {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'image/svg+xml; charset=utf-8',
|
||||||
|
'cache-control': 'public, max-age=300',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'pdf') {
|
||||||
|
const db = getDb(c.env.DB);
|
||||||
|
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)).get();
|
||||||
|
const pdf = await generateQrPdf({
|
||||||
|
url: target,
|
||||||
|
coupleNames: cfg?.coupleNames ?? c.env.COUPLE_NAMES,
|
||||||
|
eventDate: cfg?.eventDate ?? c.env.EVENT_DATE,
|
||||||
|
});
|
||||||
|
return new Response(pdf as BodyInit, {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/pdf',
|
||||||
|
'content-disposition': 'inline; filename="qr-mesa.pdf"',
|
||||||
|
'cache-control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const png = await generateQrPng(target, 800);
|
||||||
|
return new Response(png as BodyInit, {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'image/png',
|
||||||
|
'cache-control': 'public, max-age=300',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
publicRoutes.get('/stats', async (c) => {
|
publicRoutes.get('/stats', async (c) => {
|
||||||
const db = getDb(c.env.DB);
|
const db = getDb(c.env.DB);
|
||||||
const all = await db
|
const all = await db
|
||||||
|
|||||||
Reference in New Issue
Block a user