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:
|
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.',
|
'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',
|
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: '',
|
defaultValue: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -51,10 +51,13 @@ export const GET = withAuth(
|
|||||||
return NextResponse.json({ data: null });
|
return NextResponse.json({ data: null });
|
||||||
}
|
}
|
||||||
const baseUrl = env.APP_URL.replace(/\/+$/, '');
|
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({
|
return NextResponse.json({
|
||||||
data: {
|
data: {
|
||||||
fileId: file.id,
|
fileId: file.id,
|
||||||
previewUrl: `${baseUrl}/api/v1/files/${file.id}/preview`,
|
previewUrl: `${baseUrl}/api/public/files/${file.id}`,
|
||||||
sizeBytes: file.sizeBytes,
|
sizeBytes: file.sizeBytes,
|
||||||
mimeType: file.mimeType,
|
mimeType: file.mimeType,
|
||||||
},
|
},
|
||||||
@@ -96,7 +99,7 @@ export const POST = withAuth(
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
data: {
|
data: {
|
||||||
fileId: result.fileId,
|
fileId: result.fileId,
|
||||||
previewUrl: `${baseUrl}/api/v1/files/${result.fileId}/preview`,
|
previewUrl: `${baseUrl}/api/public/files/${result.fileId}`,
|
||||||
warnings: result.warnings,
|
warnings: result.warnings,
|
||||||
finalDimensions: processed.finalDimensions,
|
finalDimensions: processed.finalDimensions,
|
||||||
finalBytes: processed.finalBytes,
|
finalBytes: processed.finalBytes,
|
||||||
|
|||||||
@@ -40,17 +40,31 @@ export const POST = withAuth(
|
|||||||
if (!port) throw new ValidationError('No active port');
|
if (!port) throw new ValidationError('No active port');
|
||||||
|
|
||||||
const buffer = Buffer.from(await fileEntry.arrayBuffer());
|
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(
|
const record = await uploadFile(
|
||||||
port.id,
|
port.id,
|
||||||
port.slug,
|
port.slug,
|
||||||
{
|
{
|
||||||
buffer,
|
buffer,
|
||||||
originalName: fileEntry.name || 'branding.jpg',
|
originalName: fileEntry.name || `branding.${ext}`,
|
||||||
mimeType: fileEntry.type || 'image/jpeg',
|
mimeType,
|
||||||
size: fileEntry.size,
|
size: fileEntry.size,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filename: `branding-${Date.now()}.jpg`,
|
filename: `branding-${Date.now()}.${ext}`,
|
||||||
category: 'branding',
|
category: 'branding',
|
||||||
entityType: 'port',
|
entityType: 'port',
|
||||||
entityId: port.id,
|
entityId: port.id,
|
||||||
|
|||||||
@@ -45,17 +45,31 @@ export const POST = withAuth(async (req, ctx) => {
|
|||||||
if (!portId) throw new ValidationError('No active port');
|
if (!portId) throw new ValidationError('No active port');
|
||||||
|
|
||||||
const buffer = Buffer.from(await fileEntry.arrayBuffer());
|
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(
|
const record = await uploadFile(
|
||||||
portId,
|
portId,
|
||||||
portSlug,
|
portSlug,
|
||||||
{
|
{
|
||||||
buffer,
|
buffer,
|
||||||
originalName: fileEntry.name || 'avatar.jpg',
|
originalName: fileEntry.name || `avatar.${ext}`,
|
||||||
mimeType: fileEntry.type || 'image/jpeg',
|
mimeType,
|
||||||
size: fileEntry.size,
|
size: fileEntry.size,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filename: `avatar-${ctx.userId}.jpg`,
|
filename: `avatar-${ctx.userId}.${ext}`,
|
||||||
category: 'avatar',
|
category: 'avatar',
|
||||||
entityType: 'user',
|
entityType: 'user',
|
||||||
entityId: ctx.userId,
|
entityId: ctx.userId,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { toastError } from '@/lib/api/toast-error';
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useConfirmation } from '@/hooks/use-confirmation';
|
import { useConfirmation } from '@/hooks/use-confirmation';
|
||||||
|
|
||||||
@@ -40,6 +41,26 @@ interface UploadResult {
|
|||||||
originalFormat: string;
|
originalFormat: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw `fetch()` doesn't carry the `X-Port-Id` header that
|
||||||
|
* `apiFetch` injects, so the branding endpoints (which require port
|
||||||
|
* context) 400 with "No active port". We can't swap to `apiFetch`
|
||||||
|
* here because the upload payload is `FormData`, not JSON. So resolve
|
||||||
|
* the active port from the URL the same way `apiFetch` does and
|
||||||
|
* attach the header manually.
|
||||||
|
*/
|
||||||
|
async function brandingHeaders(): Promise<Headers> {
|
||||||
|
const headers = new Headers();
|
||||||
|
if (typeof window === 'undefined') return headers;
|
||||||
|
const slug = window.location.pathname.split('/').filter(Boolean)[0];
|
||||||
|
if (!slug || slug === 'login' || slug === 'portal' || slug === 'api' || slug === 'dashboard') {
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
const portId = await resolvePortIdFromSlug(slug);
|
||||||
|
if (portId) headers.set('X-Port-Id', portId);
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
const WARNING_LABELS: Record<string, string> = {
|
const WARNING_LABELS: Record<string, string> = {
|
||||||
trimmed: 'Auto-trimmed whitespace borders',
|
trimmed: 'Auto-trimmed whitespace borders',
|
||||||
resized: 'Downscaled for size',
|
resized: 'Downscaled for size',
|
||||||
@@ -82,7 +103,8 @@ export function PdfLogoUploader() {
|
|||||||
async function refresh() {
|
async function refresh() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/admin/branding/logo');
|
const headers = await brandingHeaders();
|
||||||
|
const res = await fetch('/api/v1/admin/branding/logo', { headers });
|
||||||
if (!res.ok) throw new Error(`GET ${res.status}`);
|
if (!res.ok) throw new Error(`GET ${res.status}`);
|
||||||
const body = (await res.json()) as { data: CurrentLogo | null };
|
const body = (await res.json()) as { data: CurrentLogo | null };
|
||||||
setCurrent(body.data ?? null);
|
setCurrent(body.data ?? null);
|
||||||
@@ -139,7 +161,12 @@ export function PdfLogoUploader() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const res = await fetch('/api/v1/admin/branding/logo', { method: 'POST', body: fd });
|
const headers = await brandingHeaders();
|
||||||
|
const res = await fetch('/api/v1/admin/branding/logo', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
const body = (await res.json()) as { data: UploadResult } | { error?: { message?: string } };
|
const body = (await res.json()) as { data: UploadResult } | { error?: { message?: string } };
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const message = ('error' in body && body.error?.message) || `Upload failed (${res.status})`;
|
const message = ('error' in body && body.error?.message) || `Upload failed (${res.status})`;
|
||||||
@@ -170,7 +197,8 @@ export function PdfLogoUploader() {
|
|||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
setWorking(true);
|
setWorking(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/admin/branding/logo', { method: 'DELETE' });
|
const headers = await brandingHeaders();
|
||||||
|
const res = await fetch('/api/v1/admin/branding/logo', { method: 'DELETE', headers });
|
||||||
if (!res.ok && res.status !== 204) throw new Error(`DELETE ${res.status}`);
|
if (!res.ok && res.status !== 204) throw new Error(`DELETE ${res.status}`);
|
||||||
toast.success('Logo removed');
|
toast.success('Logo removed');
|
||||||
setCurrent(null);
|
setCurrent(null);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch, resolvePortIdFromSlug } from '@/lib/api/client';
|
||||||
import { toastError } from '@/lib/api/toast-error';
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
|
||||||
export type SettingFieldType =
|
export type SettingFieldType =
|
||||||
@@ -49,6 +49,11 @@ export interface SettingFieldDef {
|
|||||||
defaultTemplate?: string;
|
defaultTemplate?: string;
|
||||||
/** For 'image-upload' fields: cropper aspect ratio. */
|
/** For 'image-upload' fields: cropper aspect ratio. */
|
||||||
imageAspect?: number;
|
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 {
|
interface SettingsRowResponse {
|
||||||
@@ -399,8 +404,27 @@ function ImageUploadField({
|
|||||||
|
|
||||||
async function uploadCropped(blob: Blob) {
|
async function uploadCropped(blob: Blob) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', new File([blob], 'image.jpg', { type: 'image/jpeg' }));
|
// Trust the blob's own MIME — the cropper auto-picks PNG when the
|
||||||
const res = await fetch('/api/v1/admin/settings/image', { method: 'POST', body: fd });
|
// 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) {
|
if (!res.ok) {
|
||||||
const err = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
|
const err = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
|
||||||
throw new Error(err.error?.message ?? 'Image upload failed');
|
throw new Error(err.error?.message ?? 'Image upload failed');
|
||||||
@@ -470,6 +494,7 @@ function ImageUploadField({
|
|||||||
file={pendingFile}
|
file={pendingFile}
|
||||||
aspect={field.imageAspect ?? 1}
|
aspect={field.imageAspect ?? 1}
|
||||||
outputWidth={field.imageAspect && field.imageAspect > 1 ? 1024 : 512}
|
outputWidth={field.imageAspect && field.imageAspect > 1 ? 1024 : 512}
|
||||||
|
outputFormat={field.imageFormat ?? 'auto'}
|
||||||
title={`Crop ${field.label.toLowerCase()}`}
|
title={`Crop ${field.label.toLowerCase()}`}
|
||||||
onUpload={uploadCropped}
|
onUpload={uploadCropped}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -118,7 +118,11 @@ export function UserSettings() {
|
|||||||
|
|
||||||
async function uploadAvatar(blob: Blob) {
|
async function uploadAvatar(blob: Blob) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', new File([blob], 'avatar.jpg', { type: 'image/jpeg' }));
|
// Preserve the cropper's chosen format — a transparent PNG uploaded
|
||||||
|
// as JPEG composites the alpha against black on export.
|
||||||
|
const mime = blob.type || 'image/jpeg';
|
||||||
|
const ext = mime === 'image/png' ? 'png' : 'jpg';
|
||||||
|
fd.append('file', new File([blob], `avatar.${ext}`, { type: mime }));
|
||||||
const res = await fetch('/api/v1/me/avatar', { method: 'POST', body: fd });
|
const res = await fetch('/api/v1/me/avatar', { method: 'POST', body: fd });
|
||||||
if (!res.ok) throw new Error('Avatar upload failed');
|
if (!res.ok) throw new Error('Avatar upload failed');
|
||||||
const json = (await res.json()) as { data: { avatarFileId: string } };
|
const json = (await res.json()) as { data: { avatarFileId: string } };
|
||||||
|
|||||||
@@ -23,10 +23,14 @@ export interface ImageCropperDialogProps {
|
|||||||
/** Aspect ratio for the crop frame. 1 = square (avatars), 4 = wide
|
/** Aspect ratio for the crop frame. 1 = square (avatars), 4 = wide
|
||||||
* banners. Defaults to 1. */
|
* banners. Defaults to 1. */
|
||||||
aspect?: number;
|
aspect?: number;
|
||||||
/** Output JPEG quality 0-1. Default 0.85. */
|
/** Output JPEG quality 0-1. Default 0.85. Ignored for PNG. */
|
||||||
outputQuality?: number;
|
outputQuality?: number;
|
||||||
/** Output width in pixels (height derived from aspect). Default 512. */
|
/** Output width in pixels (height derived from aspect). Default 512. */
|
||||||
outputWidth?: number;
|
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
|
/** Async upload handler — receives the cropped Blob. Cropper closes on
|
||||||
* success; toasts on error. */
|
* success; toasts on error. */
|
||||||
onUpload: (blob: Blob) => Promise<void>;
|
onUpload: (blob: Blob) => Promise<void>;
|
||||||
@@ -34,6 +38,14 @@ export interface ImageCropperDialogProps {
|
|||||||
title?: string;
|
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
|
* Reusable crop-then-upload modal. Renders a draggable/zoomable crop
|
||||||
* frame over the picked file, then writes the cropped pixels to a
|
* frame over the picked file, then writes the cropped pixels to a
|
||||||
@@ -49,6 +61,7 @@ export function ImageCropperDialog({
|
|||||||
aspect = 1,
|
aspect = 1,
|
||||||
outputQuality = 0.85,
|
outputQuality = 0.85,
|
||||||
outputWidth = 512,
|
outputWidth = 512,
|
||||||
|
outputFormat = 'auto',
|
||||||
onUpload,
|
onUpload,
|
||||||
title = 'Crop image',
|
title = 'Crop image',
|
||||||
}: ImageCropperDialogProps) {
|
}: ImageCropperDialogProps) {
|
||||||
@@ -64,10 +77,23 @@ export function ImageCropperDialog({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function handleUpload() {
|
async function handleUpload() {
|
||||||
if (!objectUrl || !pixels) return;
|
if (!objectUrl || !pixels || !file) return;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
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);
|
await onUpload(blob);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -146,8 +172,9 @@ export function ImageCropperDialog({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Crop the source image at `pixels`, resize to `outputWidth`×
|
* Crop the source image at `pixels`, resize to `outputWidth`×
|
||||||
* (outputWidth/aspect), and return as a JPEG Blob. Async because
|
* (outputWidth/aspect), and return as a JPEG or PNG Blob. Async because
|
||||||
* <img> needs an onload event before we can drawImage.
|
* <img> needs an onload event before we can drawImage. PNG preserves
|
||||||
|
* alpha for logos; JPEG is smaller for photos / backgrounds.
|
||||||
*/
|
*/
|
||||||
async function renderCrop(
|
async function renderCrop(
|
||||||
src: string,
|
src: string,
|
||||||
@@ -155,6 +182,7 @@ async function renderCrop(
|
|||||||
aspect: number,
|
aspect: number,
|
||||||
outputWidth: number,
|
outputWidth: number,
|
||||||
quality: number,
|
quality: number,
|
||||||
|
format: 'jpeg' | 'png',
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
const image = await loadImage(src);
|
const image = await loadImage(src);
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
@@ -174,11 +202,13 @@ async function renderCrop(
|
|||||||
outputWidth,
|
outputWidth,
|
||||||
outputHeight,
|
outputHeight,
|
||||||
);
|
);
|
||||||
|
const mime = format === 'png' ? 'image/png' : 'image/jpeg';
|
||||||
return await new Promise<Blob>((resolve, reject) => {
|
return await new Promise<Blob>((resolve, reject) => {
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
(b) => (b ? resolve(b) : reject(new Error('Canvas toBlob returned null'))),
|
(b) => (b ? resolve(b) : reject(new Error('Canvas toBlob returned null'))),
|
||||||
'image/jpeg',
|
mime,
|
||||||
quality,
|
// toBlob's quality param applies to JPEG/WebP only; PNG ignores it.
|
||||||
|
format === 'jpeg' ? quality : undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const slugToIdCache = new Map<string, string>();
|
|||||||
* trip instead of N. */
|
* trip instead of N. */
|
||||||
let inFlightPortsLookup: Promise<Array<{ id: string; slug: string }> | null> | null = null;
|
let inFlightPortsLookup: Promise<Array<{ id: string; slug: string }> | null> | null = null;
|
||||||
|
|
||||||
async function resolvePortIdFromSlug(slug: string): Promise<string | null> {
|
export async function resolvePortIdFromSlug(slug: string): Promise<string | null> {
|
||||||
const cached = slugToIdCache.get(slug);
|
const cached = slugToIdCache.get(slug);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
if (!inFlightPortsLookup) {
|
if (!inFlightPortsLookup) {
|
||||||
|
|||||||
Reference in New Issue
Block a user