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