From 83f75ef0f50b2f2b7531e46bf2a181c09afb5606 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 19:06:19 +0200 Subject: [PATCH] feat(uploads): preserve PNG alpha + X-Port-Id headers on admin image uploads Logo / avatar / branding-image uploads were silently flattening alpha channels because the cropper hardcoded JPEG output and the upload routes hardcoded the `.jpg` extension. Transparent PNGs landed in storage as opaque JPEGs with black-composited fringes around logo edges. - ImageCropperDialog gains an `outputFormat: 'auto' | 'jpeg' | 'png'` prop. `auto` (the new default) preserves alpha: PNG output when the source MIME is PNG / GIF / WebP / AVIF, JPEG otherwise. - SettingsFormCard's image-upload field forwards the cropper's chosen MIME and extension into the FormData payload and adds an `imageFormat` field-def hook for fields that should override the auto-detection. - Admin settings + avatar routes pick the storage-filename extension from the upload MIME so PNG sources stay PNG end-to-end. - Branding-routes refactor: the X-Port-Id header that apiFetch injects is missing on raw FormData uploads, so the routes 400'd with "No active port". Resolve port id from the URL slug via the now-exported `resolvePortIdFromSlug` and attach the header manually. - Logo previewUrl points at /api/public/files/{id} (returns image bytes) instead of /api/v1/files/{id}/preview (returns JSON), so the preview actually renders. - Email-background field declares 16:9 aspect so the cropper doesn't fall back to a 1:1 circular mask for a viewport-cover image. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[portSlug]/admin/branding/page.tsx | 4 ++ src/app/api/v1/admin/branding/logo/route.ts | 7 ++- src/app/api/v1/admin/settings/image/route.ts | 20 +++++++-- src/app/api/v1/me/avatar/route.ts | 20 +++++++-- .../admin/branding/pdf-logo-uploader.tsx | 34 ++++++++++++-- .../admin/shared/settings-form-card.tsx | 31 +++++++++++-- src/components/settings/user-settings.tsx | 6 ++- .../shared/image-cropper-dialog.tsx | 44 ++++++++++++++++--- src/lib/api/client.ts | 2 +- 9 files changed, 145 insertions(+), 23 deletions(-) diff --git a/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx b/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx index 2aedd4cb..3af67355 100644 --- a/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx @@ -52,6 +52,10 @@ const FIELDS: SettingFieldDef[] = [ description: 'Blurred photo shown behind the white email card and the auth-shell (login / reset password) pages. Leave blank to render a plain off-white backdrop. Recommended: 1920x1080 JPG, pre-blurred to ~20px gaussian so it reads as a soft background even on small clients.', type: 'image-upload', + // 16:9 — landscape. Without an explicit aspect, the cropper falls + // back to 1:1 and renders a circular mask (intended for avatars), + // which is the wrong UX for a viewport-cover background. + imageAspect: 16 / 9, defaultValue: '', }, { diff --git a/src/app/api/v1/admin/branding/logo/route.ts b/src/app/api/v1/admin/branding/logo/route.ts index 320fd4f1..5a56d0f6 100644 --- a/src/app/api/v1/admin/branding/logo/route.ts +++ b/src/app/api/v1/admin/branding/logo/route.ts @@ -51,10 +51,13 @@ export const GET = withAuth( return NextResponse.json({ data: null }); } const baseUrl = env.APP_URL.replace(/\/+$/, ''); + // Stream from the public-by-id surface (gated on `category='branding'`) + // so the URL works as a direct `` — the authenticated + // `/api/v1/files//preview` returns JSON, not image bytes. return NextResponse.json({ data: { fileId: file.id, - previewUrl: `${baseUrl}/api/v1/files/${file.id}/preview`, + previewUrl: `${baseUrl}/api/public/files/${file.id}`, sizeBytes: file.sizeBytes, mimeType: file.mimeType, }, @@ -96,7 +99,7 @@ export const POST = withAuth( return NextResponse.json({ data: { fileId: result.fileId, - previewUrl: `${baseUrl}/api/v1/files/${result.fileId}/preview`, + previewUrl: `${baseUrl}/api/public/files/${result.fileId}`, warnings: result.warnings, finalDimensions: processed.finalDimensions, finalBytes: processed.finalBytes, diff --git a/src/app/api/v1/admin/settings/image/route.ts b/src/app/api/v1/admin/settings/image/route.ts index 55fafe65..283dc94d 100644 --- a/src/app/api/v1/admin/settings/image/route.ts +++ b/src/app/api/v1/admin/settings/image/route.ts @@ -40,17 +40,31 @@ export const POST = withAuth( if (!port) throw new ValidationError('No active port'); const buffer = Buffer.from(await fileEntry.arrayBuffer()); + // Pick the storage filename's extension from the upload's MIME so + // PNG uploads aren't silently relabelled `.jpg` (the previous + // hardcoded extension flattened alpha-transparent logos). + const mimeType = fileEntry.type || 'image/jpeg'; + const ext = + mimeType === 'image/png' + ? 'png' + : mimeType === 'image/webp' + ? 'webp' + : mimeType === 'image/gif' + ? 'gif' + : mimeType === 'image/avif' + ? 'avif' + : 'jpg'; const record = await uploadFile( port.id, port.slug, { buffer, - originalName: fileEntry.name || 'branding.jpg', - mimeType: fileEntry.type || 'image/jpeg', + originalName: fileEntry.name || `branding.${ext}`, + mimeType, size: fileEntry.size, }, { - filename: `branding-${Date.now()}.jpg`, + filename: `branding-${Date.now()}.${ext}`, category: 'branding', entityType: 'port', entityId: port.id, diff --git a/src/app/api/v1/me/avatar/route.ts b/src/app/api/v1/me/avatar/route.ts index 7fa8a513..a323e7a4 100644 --- a/src/app/api/v1/me/avatar/route.ts +++ b/src/app/api/v1/me/avatar/route.ts @@ -45,17 +45,31 @@ export const POST = withAuth(async (req, ctx) => { if (!portId) throw new ValidationError('No active port'); const buffer = Buffer.from(await fileEntry.arrayBuffer()); + // Pick the storage filename's extension from the upload's MIME so + // PNG uploads aren't silently relabelled `.jpg` (which would strip + // the alpha-channel signal from the storage layer). + const mimeType = fileEntry.type || 'image/jpeg'; + const ext = + mimeType === 'image/png' + ? 'png' + : mimeType === 'image/webp' + ? 'webp' + : mimeType === 'image/gif' + ? 'gif' + : mimeType === 'image/avif' + ? 'avif' + : 'jpg'; const record = await uploadFile( portId, portSlug, { buffer, - originalName: fileEntry.name || 'avatar.jpg', - mimeType: fileEntry.type || 'image/jpeg', + originalName: fileEntry.name || `avatar.${ext}`, + mimeType, size: fileEntry.size, }, { - filename: `avatar-${ctx.userId}.jpg`, + filename: `avatar-${ctx.userId}.${ext}`, category: 'avatar', entityType: 'user', entityId: ctx.userId, diff --git a/src/components/admin/branding/pdf-logo-uploader.tsx b/src/components/admin/branding/pdf-logo-uploader.tsx index 99b4f7b3..57db78c4 100644 --- a/src/components/admin/branding/pdf-logo-uploader.tsx +++ b/src/components/admin/branding/pdf-logo-uploader.tsx @@ -10,6 +10,7 @@ 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 { resolvePortIdFromSlug } from '@/lib/api/client'; import { toast } from 'sonner'; import { useConfirmation } from '@/hooks/use-confirmation'; @@ -40,6 +41,26 @@ interface UploadResult { originalFormat: string; } +/** + * Raw `fetch()` doesn't carry the `X-Port-Id` header that + * `apiFetch` injects, so the branding endpoints (which require port + * context) 400 with "No active port". We can't swap to `apiFetch` + * here because the upload payload is `FormData`, not JSON. So resolve + * the active port from the URL the same way `apiFetch` does and + * attach the header manually. + */ +async function brandingHeaders(): Promise { + const headers = new Headers(); + if (typeof window === 'undefined') return headers; + const slug = window.location.pathname.split('/').filter(Boolean)[0]; + if (!slug || slug === 'login' || slug === 'portal' || slug === 'api' || slug === 'dashboard') { + return headers; + } + const portId = await resolvePortIdFromSlug(slug); + if (portId) headers.set('X-Port-Id', portId); + return headers; +} + const WARNING_LABELS: Record = { trimmed: 'Auto-trimmed whitespace borders', resized: 'Downscaled for size', @@ -82,7 +103,8 @@ export function PdfLogoUploader() { async function refresh() { setLoading(true); try { - const res = await fetch('/api/v1/admin/branding/logo'); + const headers = await brandingHeaders(); + const res = await fetch('/api/v1/admin/branding/logo', { headers }); if (!res.ok) throw new Error(`GET ${res.status}`); const body = (await res.json()) as { data: CurrentLogo | null }; setCurrent(body.data ?? null); @@ -139,7 +161,12 @@ export function PdfLogoUploader() { }), ); } - const res = await fetch('/api/v1/admin/branding/logo', { method: 'POST', body: fd }); + const headers = await brandingHeaders(); + const res = await fetch('/api/v1/admin/branding/logo', { + method: 'POST', + headers, + 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})`; @@ -170,7 +197,8 @@ export function PdfLogoUploader() { if (!ok) return; setWorking(true); try { - const res = await fetch('/api/v1/admin/branding/logo', { method: 'DELETE' }); + const headers = await brandingHeaders(); + const res = await fetch('/api/v1/admin/branding/logo', { method: 'DELETE', headers }); if (!res.ok && res.status !== 204) throw new Error(`DELETE ${res.status}`); toast.success('Logo removed'); setCurrent(null); diff --git a/src/components/admin/shared/settings-form-card.tsx b/src/components/admin/shared/settings-form-card.tsx index 14d5ca4a..b00fcb5c 100644 --- a/src/components/admin/shared/settings-form-card.tsx +++ b/src/components/admin/shared/settings-form-card.tsx @@ -19,7 +19,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { apiFetch } from '@/lib/api/client'; +import { apiFetch, resolvePortIdFromSlug } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; export type SettingFieldType = @@ -49,6 +49,11 @@ export interface SettingFieldDef { defaultTemplate?: string; /** For 'image-upload' fields: cropper aspect ratio. */ imageAspect?: number; + /** For 'image-upload' fields: output format. Default 'jpeg' (smaller + * files, good for photos / backgrounds). Use 'png' for logos with + * transparency — JPEG has no alpha channel, so transparent pixels + * composite against black on export. */ + imageFormat?: 'jpeg' | 'png'; } interface SettingsRowResponse { @@ -399,8 +404,27 @@ function ImageUploadField({ async function uploadCropped(blob: Blob) { const fd = new FormData(); - fd.append('file', new File([blob], 'image.jpg', { type: 'image/jpeg' })); - const res = await fetch('/api/v1/admin/settings/image', { method: 'POST', body: fd }); + // Trust the blob's own MIME — the cropper auto-picks PNG when the + // source had alpha, JPEG otherwise. Hardcoding to JPEG here threw + // away the alpha channel on transparent logos. + const mime = blob.type || 'image/jpeg'; + const ext = mime === 'image/png' ? 'png' : 'jpg'; + fd.append('file', new File([blob], `image.${ext}`, { type: mime })); + // Raw fetch (not apiFetch — FormData body) → manually attach the + // X-Port-Id header that the admin settings route requires. + const headers = new Headers(); + if (typeof window !== 'undefined') { + const slug = window.location.pathname.split('/').filter(Boolean)[0]; + if (slug && slug !== 'login' && slug !== 'portal' && slug !== 'api' && slug !== 'dashboard') { + const portId = await resolvePortIdFromSlug(slug); + if (portId) headers.set('X-Port-Id', portId); + } + } + const res = await fetch('/api/v1/admin/settings/image', { + method: 'POST', + headers, + body: fd, + }); if (!res.ok) { const err = (await res.json().catch(() => ({}))) as { error?: { message?: string } }; throw new Error(err.error?.message ?? 'Image upload failed'); @@ -470,6 +494,7 @@ function ImageUploadField({ file={pendingFile} aspect={field.imageAspect ?? 1} outputWidth={field.imageAspect && field.imageAspect > 1 ? 1024 : 512} + outputFormat={field.imageFormat ?? 'auto'} title={`Crop ${field.label.toLowerCase()}`} onUpload={uploadCropped} /> diff --git a/src/components/settings/user-settings.tsx b/src/components/settings/user-settings.tsx index b64d6496..8514be26 100644 --- a/src/components/settings/user-settings.tsx +++ b/src/components/settings/user-settings.tsx @@ -118,7 +118,11 @@ export function UserSettings() { async function uploadAvatar(blob: Blob) { const fd = new FormData(); - fd.append('file', new File([blob], 'avatar.jpg', { type: 'image/jpeg' })); + // Preserve the cropper's chosen format — a transparent PNG uploaded + // as JPEG composites the alpha against black on export. + const mime = blob.type || 'image/jpeg'; + const ext = mime === 'image/png' ? 'png' : 'jpg'; + fd.append('file', new File([blob], `avatar.${ext}`, { type: mime })); const res = await fetch('/api/v1/me/avatar', { method: 'POST', body: fd }); if (!res.ok) throw new Error('Avatar upload failed'); const json = (await res.json()) as { data: { avatarFileId: string } }; diff --git a/src/components/shared/image-cropper-dialog.tsx b/src/components/shared/image-cropper-dialog.tsx index 78eb9797..8d7fa10d 100644 --- a/src/components/shared/image-cropper-dialog.tsx +++ b/src/components/shared/image-cropper-dialog.tsx @@ -23,10 +23,14 @@ export interface ImageCropperDialogProps { /** Aspect ratio for the crop frame. 1 = square (avatars), 4 = wide * banners. Defaults to 1. */ aspect?: number; - /** Output JPEG quality 0-1. Default 0.85. */ + /** Output JPEG quality 0-1. Default 0.85. Ignored for PNG. */ outputQuality?: number; /** Output width in pixels (height derived from aspect). Default 512. */ outputWidth?: number; + /** Output format. 'auto' (default) preserves alpha: PNG output when + * the source is PNG/GIF/WebP/AVIF, JPEG otherwise. Override to force + * a specific format. */ + outputFormat?: 'auto' | 'jpeg' | 'png'; /** Async upload handler — receives the cropped Blob. Cropper closes on * success; toasts on error. */ onUpload: (blob: Blob) => Promise; @@ -34,6 +38,14 @@ export interface ImageCropperDialogProps { title?: string; } +/** + * MIME types that carry an alpha channel. A PNG source dropped through a + * JPEG-output canvas composites transparent pixels against black on + * export — destroying the design intent of any logo with transparency. + * Default to PNG output whenever the source could have had alpha. + */ +const ALPHA_CAPABLE_MIME = new Set(['image/png', 'image/gif', 'image/webp', 'image/avif']); + /** * Reusable crop-then-upload modal. Renders a draggable/zoomable crop * frame over the picked file, then writes the cropped pixels to a @@ -49,6 +61,7 @@ export function ImageCropperDialog({ aspect = 1, outputQuality = 0.85, outputWidth = 512, + outputFormat = 'auto', onUpload, title = 'Crop image', }: ImageCropperDialogProps) { @@ -64,10 +77,23 @@ export function ImageCropperDialog({ }, []); async function handleUpload() { - if (!objectUrl || !pixels) return; + if (!objectUrl || !pixels || !file) return; setUploading(true); try { - const blob = await renderCrop(objectUrl, pixels, aspect, outputWidth, outputQuality); + const resolvedFormat: 'jpeg' | 'png' = + outputFormat === 'auto' + ? ALPHA_CAPABLE_MIME.has(file.type) + ? 'png' + : 'jpeg' + : outputFormat; + const blob = await renderCrop( + objectUrl, + pixels, + aspect, + outputWidth, + outputQuality, + resolvedFormat, + ); await onUpload(blob); onOpenChange(false); } catch (err) { @@ -146,8 +172,9 @@ export function ImageCropperDialog({ /** * Crop the source image at `pixels`, resize to `outputWidth`× - * (outputWidth/aspect), and return as a JPEG Blob. Async because - * needs an onload event before we can drawImage. + * (outputWidth/aspect), and return as a JPEG or PNG Blob. Async because + * needs an onload event before we can drawImage. PNG preserves + * alpha for logos; JPEG is smaller for photos / backgrounds. */ async function renderCrop( src: string, @@ -155,6 +182,7 @@ async function renderCrop( aspect: number, outputWidth: number, quality: number, + format: 'jpeg' | 'png', ): Promise { const image = await loadImage(src); const canvas = document.createElement('canvas'); @@ -174,11 +202,13 @@ async function renderCrop( outputWidth, outputHeight, ); + const mime = format === 'png' ? 'image/png' : 'image/jpeg'; return await new Promise((resolve, reject) => { canvas.toBlob( (b) => (b ? resolve(b) : reject(new Error('Canvas toBlob returned null'))), - 'image/jpeg', - quality, + mime, + // toBlob's quality param applies to JPEG/WebP only; PNG ignores it. + format === 'jpeg' ? quality : undefined, ); }); } diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index b67b8ae6..55894b9b 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -15,7 +15,7 @@ const slugToIdCache = new Map(); * trip instead of N. */ let inFlightPortsLookup: Promise | null> | null = null; -async function resolvePortIdFromSlug(slug: string): Promise { +export async function resolvePortIdFromSlug(slug: string): Promise { const cached = slugToIdCache.get(slug); if (cached) return cached; if (!inFlightPortsLookup) {