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

@@ -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,