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>
225 lines
6.7 KiB
TypeScript
225 lines
6.7 KiB
TypeScript
'use client';
|
||
|
||
import { useCallback, useState } from 'react';
|
||
import Cropper, { type Area } from 'react-easy-crop';
|
||
import { Loader2 } from 'lucide-react';
|
||
|
||
import { Button } from '@/components/ui/button';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog';
|
||
import { Label } from '@/components/ui/label';
|
||
import { toastError } from '@/lib/api/toast-error';
|
||
|
||
export interface ImageCropperDialogProps {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
/** The picked file (raw, pre-crop). Cropper renders an object URL. */
|
||
file: File | null;
|
||
/** 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. 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>;
|
||
/** Dialog title. Default "Crop image". */
|
||
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
|
||
* Canvas, exports as JPEG, and hands the Blob to the caller.
|
||
*
|
||
* Used for: profile avatars, port logos, brochure covers — anywhere a
|
||
* square or fixed-aspect image needs to land in storage.
|
||
*/
|
||
export function ImageCropperDialog({
|
||
open,
|
||
onOpenChange,
|
||
file,
|
||
aspect = 1,
|
||
outputQuality = 0.85,
|
||
outputWidth = 512,
|
||
outputFormat = 'auto',
|
||
onUpload,
|
||
title = 'Crop image',
|
||
}: ImageCropperDialogProps) {
|
||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||
const [zoom, setZoom] = useState(1);
|
||
const [pixels, setPixels] = useState<Area | null>(null);
|
||
const [uploading, setUploading] = useState(false);
|
||
|
||
const objectUrl = file ? URL.createObjectURL(file) : null;
|
||
|
||
const onCropComplete = useCallback((_area: Area, areaPixels: Area) => {
|
||
setPixels(areaPixels);
|
||
}, []);
|
||
|
||
async function handleUpload() {
|
||
if (!objectUrl || !pixels || !file) return;
|
||
setUploading(true);
|
||
try {
|
||
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) {
|
||
toastError(err);
|
||
} finally {
|
||
setUploading(false);
|
||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||
}
|
||
}
|
||
|
||
function handleClose(o: boolean) {
|
||
if (!o && objectUrl) URL.revokeObjectURL(objectUrl);
|
||
onOpenChange(o);
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={handleClose}>
|
||
<DialogContent className="sm:max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle>{title}</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
{objectUrl && (
|
||
<>
|
||
<div className="relative h-72 w-full bg-muted rounded-md overflow-hidden">
|
||
<Cropper
|
||
image={objectUrl}
|
||
crop={crop}
|
||
zoom={zoom}
|
||
aspect={aspect}
|
||
onCropChange={setCrop}
|
||
onZoomChange={setZoom}
|
||
onCropComplete={onCropComplete}
|
||
cropShape={aspect === 1 ? 'round' : 'rect'}
|
||
showGrid={false}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2 pt-2">
|
||
<Label htmlFor="cropper-zoom" className="text-xs text-muted-foreground">
|
||
Zoom
|
||
</Label>
|
||
<input
|
||
id="cropper-zoom"
|
||
type="range"
|
||
min={1}
|
||
max={3}
|
||
step={0.05}
|
||
value={zoom}
|
||
onChange={(e) => setZoom(Number(e.target.value))}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => handleClose(false)} disabled={uploading}>
|
||
Cancel
|
||
</Button>
|
||
<Button onClick={handleUpload} disabled={uploading || !pixels}>
|
||
{uploading ? (
|
||
<>
|
||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
|
||
Uploading…
|
||
</>
|
||
) : (
|
||
'Save'
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Crop the source image at `pixels`, resize to `outputWidth`×
|
||
* (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,
|
||
pixels: Area,
|
||
aspect: number,
|
||
outputWidth: number,
|
||
quality: number,
|
||
format: 'jpeg' | 'png',
|
||
): Promise<Blob> {
|
||
const image = await loadImage(src);
|
||
const canvas = document.createElement('canvas');
|
||
const outputHeight = Math.round(outputWidth / aspect);
|
||
canvas.width = outputWidth;
|
||
canvas.height = outputHeight;
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) throw new Error('Canvas 2d context unavailable');
|
||
ctx.drawImage(
|
||
image,
|
||
pixels.x,
|
||
pixels.y,
|
||
pixels.width,
|
||
pixels.height,
|
||
0,
|
||
0,
|
||
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'))),
|
||
mime,
|
||
// toBlob's quality param applies to JPEG/WebP only; PNG ignores it.
|
||
format === 'jpeg' ? quality : undefined,
|
||
);
|
||
});
|
||
}
|
||
|
||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||
return new Promise((resolve, reject) => {
|
||
const img = new Image();
|
||
img.crossOrigin = 'anonymous';
|
||
img.onload = () => resolve(img);
|
||
img.onerror = (e) => reject(e);
|
||
img.src = src;
|
||
});
|
||
}
|