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 <img> 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 19:06:19 +02:00
parent b7533fee3e
commit 83f75ef0f5
9 changed files with 145 additions and 23 deletions

View File

@@ -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<void>;
@@ -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
* <img> needs an onload event before we can drawImage.
* (outputWidth/aspect), and return as a JPEG or PNG Blob. Async because
* <img> 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<Blob> {
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<Blob>((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,
);
});
}