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:
@@ -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: '',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 `<img src>` — the authenticated
|
||||
// `/api/v1/files/<id>/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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user