diff --git a/app/api/pages/[slug]/pdf/route.tsx b/app/api/pages/[slug]/pdf/route.tsx new file mode 100644 index 00000000..e6380351 --- /dev/null +++ b/app/api/pages/[slug]/pdf/route.tsx @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getPayload } from 'payload'; +import configPromise from '@payload-config'; +import { renderToStream } from '@react-pdf/renderer'; +import React from 'react'; +import { PDFPage } from '@/lib/pdf-page'; + +export async function GET(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) { + try { + const { slug } = await params; + + // Get Payload App + const payload = await getPayload({ config: configPromise }); + + // Fetch the page + const pages = await payload.find({ + collection: 'pages', + where: { + slug: { equals: slug }, + _status: { equals: 'published' }, + }, + limit: 1, + }); + + if (pages.totalDocs === 0) { + return new NextResponse('Page not found', { status: 404 }); + } + + const page = pages.docs[0]; + + // Determine locale from searchParams or default to 'de' + const searchParams = req.nextUrl.searchParams; + const locale = (searchParams.get('locale') as 'en' | 'de') || 'de'; + + // Render the React-PDF document into a stream + const stream = await renderToStream(); + + // Pipe the Node.js Readable stream into a valid fetch/Web Response stream + const body = new ReadableStream({ + start(controller) { + stream.on('data', (chunk) => controller.enqueue(chunk)); + stream.on('end', () => controller.close()); + stream.on('error', (err) => controller.error(err)); + }, + cancel() { + (stream as any).destroy?.(); + }, + }); + + const filename = `${slug}.pdf`; + + return new NextResponse(body, { + status: 200, + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${filename}"`, + // Cache control if needed, skip for now. + }, + }); + } catch (error) { + console.error('Error generating PDF:', error); + return new NextResponse('Internal Server Error', { status: 500 }); + } +} diff --git a/lib/pdf-page.tsx b/lib/pdf-page.tsx new file mode 100644 index 00000000..b46627fd --- /dev/null +++ b/lib/pdf-page.tsx @@ -0,0 +1,289 @@ +import * as React from 'react'; +import { Document, Page, View, Text, Image, StyleSheet, Font, Link } from '@react-pdf/renderer'; + +// Register fonts (using system fonts for now, can be customized) +Font.register({ + family: 'Helvetica', + fonts: [ + { src: '/fonts/Helvetica.ttf', fontWeight: 400 }, + { src: '/fonts/Helvetica-Bold.ttf', fontWeight: 700 }, + ], +}); + +// ─── Brand Tokens ──────────────────────────────────────── + +const C = { + navy: '#001a4d', + navyDeep: '#000d26', + green: '#4da612', + white: '#FFFFFF', + offWhite: '#f8f9fa', + gray200: '#e5e7eb', + gray400: '#9ca3af', + gray600: '#4b5563', + gray900: '#111827', +}; + +const MARGIN = 56; +const HEADER_H = 52; +const FOOTER_H = 48; +const BODY_TOP = HEADER_H + 20; + +const styles = StyleSheet.create({ + page: { + color: C.gray900, + lineHeight: 1.5, + backgroundColor: C.white, + paddingTop: BODY_TOP, + paddingBottom: FOOTER_H + 40, + paddingHorizontal: MARGIN, + fontFamily: 'Helvetica', + }, + header: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: HEADER_H, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-end', + paddingHorizontal: MARGIN, + paddingBottom: 12, + borderBottomWidth: 0, + }, + logoText: { + fontSize: 16, + fontWeight: 700, + color: C.navy, + }, + docTitleLabel: { + fontSize: 7, + fontWeight: 700, + color: C.gray400, + letterSpacing: 1.2, + textTransform: 'uppercase', + }, + footer: { + position: 'absolute', + bottom: 20, + left: MARGIN, + right: MARGIN, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + borderTopWidth: 0.5, + borderTopColor: C.gray200, + paddingTop: 8, + }, + footerText: { + fontSize: 7, + color: C.gray400, + letterSpacing: 0.8, + textTransform: 'uppercase', + }, + pageTitle: { + fontSize: 24, + fontWeight: 700, + color: C.navyDeep, + marginBottom: 16, + textTransform: 'uppercase', + letterSpacing: -0.5, + }, + accentBar: { + width: 40, + height: 3, + backgroundColor: C.green, + marginBottom: 24, + }, + + // Lexical Elements + paragraph: { + fontSize: 10, + color: C.gray600, + lineHeight: 1.6, + marginBottom: 12, + }, + heading1: { + fontSize: 18, + fontWeight: 700, + color: C.navyDeep, + marginTop: 16, + marginBottom: 8, + }, + heading2: { + fontSize: 14, + fontWeight: 700, + color: C.navyDeep, + marginTop: 14, + marginBottom: 6, + }, + heading3: { + fontSize: 12, + fontWeight: 700, + color: C.navyDeep, + marginTop: 12, + marginBottom: 4, + }, + list: { + marginBottom: 12, + marginLeft: 8, + }, + listItem: { + flexDirection: 'row', + marginBottom: 4, + }, + listItemBullet: { + width: 12, + fontSize: 10, + color: C.green, + }, + listItemContent: { + flex: 1, + fontSize: 10, + color: C.gray600, + lineHeight: 1.6, + }, + link: { + color: C.green, + textDecoration: 'none', + }, + textBold: { + fontWeight: 700, + fontFamily: 'Helvetica-Bold', + color: C.navyDeep, + }, + textItalic: { + fontStyle: 'italic', // Not actually working without proper font set, but we will fallback + }, +}); + +// ─── Lexical to React-PDF Renderer ──────────────────────────────── + +const renderLexicalNode = (node: any, idx: number): React.ReactNode => { + if (!node) return null; + + switch (node.type) { + case 'text': { + const format = node.format || 0; + const isBold = (format & 1) !== 0; + const isItalic = (format & 2) !== 0; + + let elementStyle: any = {}; + if (isBold) elementStyle = { ...elementStyle, ...styles.textBold }; + if (isItalic) elementStyle = { ...elementStyle, ...styles.textItalic }; + + return ( + + {node.text} + + ); + } + + case 'paragraph': { + return ( + + {node.children?.map((child: any, i: number) => renderLexicalNode(child, i))} + + ); + } + + case 'heading': { + let hStyle = styles.heading3; + if (node.tag === 'h1') hStyle = styles.heading1; + if (node.tag === 'h2') hStyle = styles.heading2; + + return ( + + {node.children?.map((child: any, i: number) => renderLexicalNode(child, i))} + + ); + } + + case 'list': { + return ( + + {node.children?.map((child: any, i: number) => { + if (child.type === 'listitem') { + return ( + + + {node.listType === 'number' ? `${i + 1}.` : '•'} + + + {child.children?.map((c: any, ci: number) => renderLexicalNode(c, ci))} + + + ); + } + return renderLexicalNode(child, i); + })} + + ); + } + + case 'link': { + const href = node.fields?.url || node.url || '#'; + return ( + + {node.children?.map((child: any, i: number) => renderLexicalNode(child, i))} + + ); + } + + case 'linebreak': { + // React-PDF doesn't handle `
`, but a newline char usually works inside ``. + return {'\n'}; + } + + // Ignore payload blocks recursively to avoid crashing, as pages should mainly use rich text + case 'block': + return null; + + default: + if (node.children) { + return ( + + {node.children.map((child: any, i: number) => renderLexicalNode(child, i))} + + ); + } + return null; + } +}; + +interface PDFPageProps { + page: any; + locale?: string; +} + +export const PDFPage: React.FC = ({ page, locale = 'de' }) => { + const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + return ( + + + + KLZ + {locale === 'en' ? 'Document' : 'Dokument'} + + + {page.title} + + + + {page.content?.root?.children?.map((node: any, i: number) => renderLexicalNode(node, i))} + + + + KLZ CABLES + {dateStr} + + + + ); +};