Files
pn-new-crm/src/components/shared/image-cropper-dialog.tsx
Matt 83f75ef0f5 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>
2026-05-21 19:06:19 +02:00

225 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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;
});
}