Files
pn-new-crm/src/components/admin/branding/pdf-logo-uploader.tsx

349 lines
12 KiB
TypeScript
Raw Normal View History

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>
2026-05-12 20:51:49 +02:00
'use client';
import 'react-image-crop/dist/ReactCrop.css';
import { useEffect, useRef, useState } from 'react';
import ReactCrop, { centerCrop, makeAspectCrop, type Crop, type PixelCrop } from 'react-image-crop';
import { Loader2, RefreshCw, Trash2, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { toastError } from '@/lib/api/toast-error';
import { toast } from 'sonner';
import { useConfirmation } from '@/hooks/use-confirmation';
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>
2026-05-12 20:51:49 +02:00
const ACCEPT = 'image/png,image/jpeg,image/webp,image/svg+xml,image/heic,image/heif,image/avif';
type AspectMode = 'wide' | 'square' | 'freeform';
const ASPECT_RATIOS: Record<AspectMode, number | undefined> = {
wide: 3,
square: 1,
freeform: undefined,
};
interface CurrentLogo {
fileId: string;
previewUrl: string;
sizeBytes: string | null;
mimeType: string | null;
}
interface UploadResult {
fileId: string;
previewUrl: string;
warnings: string[];
finalDimensions: { width: number; height: number };
finalBytes: number;
originalDimensions: { width: number; height: number };
originalFormat: string;
}
const WARNING_LABELS: Record<string, string> = {
trimmed: 'Auto-trimmed whitespace borders',
resized: 'Downscaled for size',
'no-alpha': 'No transparent background — will show a white box on dark headers',
'jpeg-source': 'JPEG source — prefer PNG with alpha for crisp rendering',
'svg-rasterized': 'SVG rasterized to PNG at 300 DPI',
'heic-converted': 'HEIC/HEIF converted to PNG',
'webp-converted': 'WebP converted to PNG',
};
function centeredCrop(width: number, height: number, aspect: number): Crop {
return centerCrop(makeAspectCrop({ unit: '%', width: 90 }, aspect, width, height), width, height);
}
export function PdfLogoUploader() {
const { confirm, dialog: confirmDialog } = useConfirmation();
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>
2026-05-12 20:51:49 +02:00
const [current, setCurrent] = useState<CurrentLogo | null>(null);
const [loading, setLoading] = useState(true);
const [working, setWorking] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [crop, setCrop] = useState<Crop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop | null>(null);
const [aspectMode, setAspectMode] = useState<AspectMode>('wide');
const [warnings, setWarnings] = useState<string[]>([]);
const [uploadInfo, setUploadInfo] = useState<UploadResult | null>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
void refresh();
}, []);
useEffect(() => {
return () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [objectUrl]);
async function refresh() {
setLoading(true);
try {
const res = await fetch('/api/v1/admin/branding/logo');
if (!res.ok) throw new Error(`GET ${res.status}`);
const body = (await res.json()) as { data: CurrentLogo | null };
setCurrent(body.data ?? null);
} catch (err) {
toastError(err);
} finally {
setLoading(false);
}
}
function pick(f: File | null) {
if (!f) return;
if (objectUrl) URL.revokeObjectURL(objectUrl);
const url = URL.createObjectURL(f);
setFile(f);
setObjectUrl(url);
setCrop(undefined);
setCompletedCrop(null);
setWarnings([]);
setUploadInfo(null);
}
function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) {
const { naturalWidth: w, naturalHeight: h } = e.currentTarget;
const aspect = ASPECT_RATIOS[aspectMode];
if (aspect) setCrop(centeredCrop(w, h, aspect));
}
function changeAspect(mode: AspectMode) {
setAspectMode(mode);
const img = imgRef.current;
const aspect = ASPECT_RATIOS[mode];
if (img && aspect) {
setCrop(centeredCrop(img.naturalWidth, img.naturalHeight, aspect));
} else {
setCrop(undefined);
}
}
async function upload() {
if (!file) return;
setWorking(true);
try {
const fd = new FormData();
fd.append('file', file);
if (completedCrop && completedCrop.width > 0 && completedCrop.height > 0) {
fd.append(
'crop',
JSON.stringify({
x: completedCrop.x,
y: completedCrop.y,
width: completedCrop.width,
height: completedCrop.height,
}),
);
}
const res = await fetch('/api/v1/admin/branding/logo', { method: 'POST', body: fd });
const body = (await res.json()) as { data: UploadResult } | { error?: { message?: string } };
if (!res.ok) {
const message = ('error' in body && body.error?.message) || `Upload failed (${res.status})`;
throw new Error(message);
}
const data = (body as { data: UploadResult }).data;
setUploadInfo(data);
setWarnings(data.warnings ?? []);
toast.success('Logo saved');
await refresh();
// Clear the local crop staging so the preview now shows the stored logo.
setFile(null);
if (objectUrl) URL.revokeObjectURL(objectUrl);
setObjectUrl(null);
} catch (err) {
toastError(err);
} finally {
setWorking(false);
}
}
async function clear() {
const ok = await confirm({
title: 'Remove PDF logo',
description: 'Remove the PDF logo? Future reports will fall back to the port name.',
confirmLabel: 'Remove',
});
if (!ok) return;
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>
2026-05-12 20:51:49 +02:00
setWorking(true);
try {
const res = await fetch('/api/v1/admin/branding/logo', { method: 'DELETE' });
if (!res.ok && res.status !== 204) throw new Error(`DELETE ${res.status}`);
toast.success('Logo removed');
setCurrent(null);
setUploadInfo(null);
setWarnings([]);
} catch (err) {
toastError(err);
} finally {
setWorking(false);
}
}
function openSamplePdf() {
window.open('/api/v1/admin/branding/logo/sample-pdf', '_blank');
}
return (
<Card>
<CardHeader>
<CardTitle>PDF logo</CardTitle>
<CardDescription>
Used as the header logo on every internal PDF the CRM generates (reports, expense sheets,
record exports). Separate from the email/UI logo so PDFs can have a higher-resolution,
alpha-channel version optimized for print.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="rounded-md bg-muted/40 p-4 text-sm text-muted-foreground space-y-1">
<p>Use PNG or SVG with a transparent background.</p>
<p>Minimum 200×200px; recommended 600×200px (wide) or 400×400px (square).</p>
<p>Max 5MB; we&apos;ll auto-trim, downscale, and convert to PNG.</p>
<p>Avoid JPEGs unless the background is solid white.</p>
</div>
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading current logo
</div>
) : current ? (
<div className="flex items-start gap-4 rounded-md border p-4">
<div className="flex h-20 w-44 items-center justify-center rounded bg-[#0f172a] p-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={current.previewUrl}
alt="Current PDF logo on dark header"
className="max-h-full max-w-full object-contain"
/>
</div>
<div className="flex-1 space-y-1">
<div className="text-sm font-medium">Current logo</div>
<div className="text-xs text-muted-foreground">
{current.sizeBytes
? `${(Number(current.sizeBytes) / 1024).toFixed(1)} KB`
: 'size unknown'}{' '}
· {current.mimeType ?? 'image'}
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" onClick={openSamplePdf}>
Test with sample PDF
</Button>
<Button variant="outline" size="sm" onClick={clear} disabled={working}>
<Trash2 className="mr-1 h-3 w-3" /> Remove
</Button>
</div>
</div>
</div>
) : (
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
No PDF logo configured yet. Reports use the port name as a text header.
</div>
)}
<div className="space-y-2">
<Label>Upload a new logo</Label>
<input
ref={fileInputRef}
type="file"
accept={ACCEPT}
onChange={(e) => pick(e.target.files?.[0] ?? null)}
className="block w-full text-sm file:mr-3 file:rounded file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-foreground"
/>
</div>
{objectUrl ? (
<div className="space-y-4 rounded-md border p-4">
<div className="flex items-center gap-2 text-sm">
<span>Crop:</span>
{(['wide', 'square', 'freeform'] as const).map((m) => (
<button
type="button"
key={m}
onClick={() => changeAspect(m)}
className={`rounded border px-2 py-0.5 text-xs capitalize ${
aspectMode === m
? 'border-primary bg-primary text-primary-foreground'
: 'border-input hover:bg-accent'
}`}
>
{m === 'wide' ? 'Wide 3:1' : m === 'square' ? 'Square 1:1' : 'Freeform'}
</button>
))}
</div>
<ReactCrop
crop={crop}
onChange={(_c, percent) => setCrop(percent)}
onComplete={(c) => setCompletedCrop(c)}
aspect={ASPECT_RATIOS[aspectMode]}
className="max-h-[400px]"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={imgRef}
src={objectUrl}
alt="Logo to crop"
onLoad={onImageLoad}
className="max-h-[400px]"
/>
</ReactCrop>
<div className="flex gap-2">
<Button onClick={upload} disabled={working}>
{working ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Upload className="mr-1 h-3 w-3" />
)}
Save logo
</Button>
<Button
variant="outline"
onClick={() => {
setFile(null);
if (objectUrl) URL.revokeObjectURL(objectUrl);
setObjectUrl(null);
setCrop(undefined);
setCompletedCrop(null);
}}
disabled={working}
>
Cancel
</Button>
<Button
variant="ghost"
onClick={() => fileInputRef.current?.click()}
disabled={working}
>
<RefreshCw className="mr-1 h-3 w-3" /> Choose a different file
</Button>
</div>
</div>
) : null}
{uploadInfo ? (
<div className="rounded-md border p-4 text-sm space-y-2">
<div className="font-medium">Processed</div>
<div className="text-xs text-muted-foreground">
From {uploadInfo.originalFormat.toUpperCase()} {uploadInfo.originalDimensions.width}×
{uploadInfo.originalDimensions.height}
{' → '}
PNG {uploadInfo.finalDimensions.width}×{uploadInfo.finalDimensions.height} (
{(uploadInfo.finalBytes / 1024).toFixed(1)} KB)
</div>
{warnings.length > 0 ? (
<ul className="list-disc space-y-0.5 pl-5 text-xs">
{warnings.map((w) => (
<li key={w}>{WARNING_LABELS[w] ?? w}</li>
))}
</ul>
) : null}
</div>
) : null}
{confirmDialog}
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>
2026-05-12 20:51:49 +02:00
</CardContent>
</Card>
);
}