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:
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user