feat(branding): port logo upload pipeline for internal PDFs
Phase 1 / commit 2 of 14 — adds the admin-facing logo upload that the
brand-kit Header pulls in for every internal-only PDF.
Server pipeline (src/lib/services/logo.service.ts):
- magic-byte format check via sharp metadata
- rejects animated/multi-frame inputs
- SVGs sanitized via svgo preset-default + post-pass regex check
(rejects <script>, on*=, javascript:, external href, <foreignObject>),
then rasterized to PNG at 300 DPI
- HEIC/HEIF/AVIF/WEBP all auto-converted to PNG by sharp
- optional crop coords applied server-side (bounds-checked first)
- auto-trim near-white borders
- resize so longest edge <= 1200px, sRGB, palette-PNG
- rejects undersized output (< 200px any side) or > 1MB
- atomic system_settings upsert; soft-archives prior file row + storage object
API:
GET /api/v1/admin/branding/logo current logo metadata
POST /api/v1/admin/branding/logo multipart upload + crop
DELETE /api/v1/admin/branding/logo clear; future PDFs fall back
to port-name text header
GET /api/v1/admin/branding/logo/sample-pdf renders branding-sample.tsx
with the current logo so
admins can spot-check
letterboxing in real shell
UI:
src/components/admin/branding/pdf-logo-uploader.tsx
- react-image-crop with Wide 3:1 / Square 1:1 / Freeform aspect toggle
- file picker accepts PNG/JPEG/WEBP/SVG/HEIC/HEIF/AVIF (up to 5 MB)
- dark-band preview swatch shows how the logo lands in the header
- post-upload warnings panel surfaces every server-side normalization
(resized, trimmed, JPEG no-alpha warning, SVG rasterized, etc.)
- "Test with sample PDF" button streams a real PDF for spot-check
- "Remove" tears down the file + storage object + setting
Wired into the existing /admin/branding settings page beneath the
Identity and Email-branding cards.
Audit:
Two new AuditAction enum values added: branding.logo.uploaded and
branding.logo.archived. Captured per upload + per archived prior logo.
Tests:
tests/unit/logo-service.test.ts (11 tests): sharp pipeline happy path,
undersized rejection, empty/oversized rejection, non-image rejection,
out-of-bounds crop rejection, in-bounds crop, SVG rasterization, SVG
with embedded script rejection, SVG with external href rejection,
JPEG-with-no-alpha warning collection.
1308/1308 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
87
src/lib/pdf/templates/branding-sample.tsx
Normal file
87
src/lib/pdf/templates/branding-sample.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
BarChart,
|
||||
DataTable,
|
||||
DocumentShell,
|
||||
KeyValueGrid,
|
||||
PieChart,
|
||||
Section,
|
||||
} from '@/lib/pdf/brand-kit';
|
||||
|
||||
export interface BrandingSamplePdfProps {
|
||||
portName: string;
|
||||
logoBuffer: Buffer | null;
|
||||
}
|
||||
|
||||
const SAMPLE_BARS = [
|
||||
{ label: 'Mon', value: 14 },
|
||||
{ label: 'Tue', value: 22 },
|
||||
{ label: 'Wed', value: 18 },
|
||||
{ label: 'Thu', value: 27 },
|
||||
{ label: 'Fri', value: 31 },
|
||||
{ label: 'Sat', value: 9 },
|
||||
{ label: 'Sun', value: 5 },
|
||||
];
|
||||
|
||||
const SAMPLE_PIE = [
|
||||
{ label: 'Available', value: 42 },
|
||||
{ label: 'Under Offer', value: 12 },
|
||||
{ label: 'Sold', value: 38 },
|
||||
];
|
||||
|
||||
interface SampleRow {
|
||||
date: string;
|
||||
action: string;
|
||||
who: string;
|
||||
}
|
||||
|
||||
const SAMPLE_ROWS: SampleRow[] = [
|
||||
{ date: '2026-05-12', action: 'created client', who: 'Sarah K.' },
|
||||
{ date: '2026-05-12', action: 'sent EOI', who: 'Matt P.' },
|
||||
{ date: '2026-05-11', action: 'updated berth A12', who: 'Sarah K.' },
|
||||
{ date: '2026-05-11', action: 'archived interest', who: 'James R.' },
|
||||
];
|
||||
|
||||
/**
|
||||
* One-page sample PDF used by the admin Branding UI's "Test with sample PDF"
|
||||
* button. Exercises every brand-kit primitive a real report would touch so an
|
||||
* admin can sanity-check their logo placement, font/color rendering, header
|
||||
* letterboxing, and footer page numbering at a glance.
|
||||
*/
|
||||
export function BrandingSamplePdf({ portName, logoBuffer }: BrandingSamplePdfProps) {
|
||||
return (
|
||||
<DocumentShell
|
||||
portName={portName}
|
||||
docTitle="Branding Sample"
|
||||
docMeta="Generated to verify your port logo and design tokens"
|
||||
logoBuffer={logoBuffer}
|
||||
pdfTitle="Branding Sample"
|
||||
>
|
||||
<Section title="Summary" subtitle="A glance at how the brand kit renders in real reports.">
|
||||
<KeyValueGrid
|
||||
rows={[
|
||||
{ label: 'Port', value: portName },
|
||||
{ label: 'Logo source', value: logoBuffer ? 'Configured' : 'Fallback (text)' },
|
||||
{ label: 'Page size', value: 'A4' },
|
||||
{ label: 'Color scheme', value: 'Slate header · Blue accent' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
<Section title="Bar chart" subtitle="Sample weekly activity counts.">
|
||||
<BarChart data={SAMPLE_BARS} showValues />
|
||||
</Section>
|
||||
<Section title="Pie chart" subtitle="Sample berth status mix.">
|
||||
<PieChart data={SAMPLE_PIE} innerRadiusRatio={0.5} />
|
||||
</Section>
|
||||
<Section title="Table" subtitle="Recent sample events.">
|
||||
<DataTable<SampleRow>
|
||||
columns={[
|
||||
{ header: 'Date', flex: 1, render: (r) => r.date },
|
||||
{ header: 'Action', flex: 2, render: (r) => r.action },
|
||||
{ header: 'Who', flex: 1, render: (r) => r.who },
|
||||
]}
|
||||
rows={SAMPLE_ROWS}
|
||||
/>
|
||||
</Section>
|
||||
</DocumentShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user