Improve sign image uploads with crop preview and repositioning

This commit is contained in:
2026-09-10 13:25:03 -04:00
parent 9d50aeaeab
commit 3ff4208c1b
2 changed files with 49 additions and 15 deletions
@@ -8,6 +8,10 @@ import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { SignDesign } from "@/lib/guest-sign";
import { signPaper } from "@/lib/guest-sign";
import type { BannerCrop } from "@album/contracts";
import { BannerCropDialog } from "@/components/banner-crop-dialog";
import { Crop, Upload, Trash2 } from "lucide-react";
const presets = {
linenCard: { layout: "compact", paper: "card6x4", background: brandPalette.background, accent: brandPalette.ink, decoration: "minimal", font: "funnel" },
@@ -19,12 +23,18 @@ const presets = {
export function SignDesignControls({ design, onChange, onBusy }: { design: SignDesign; onChange: (patch: Partial<SignDesign>) => void; onBusy: (busy: boolean) => void }) {
const [busy, setBusy] = useState(false);
const [cropFile, setCropFile] = useState<File | null>(null);
const originalPhoto = useRef<File | null>(null);
const backgroundInput = useRef<HTMLInputElement>(null);
const logoInput = useRef<HTMLInputElement>(null);
const cropAspect = signPaper(design).viewHeight < 850 ? 580 / 800 : 850 / 530;
const preset = Object.entries(presets).find(([, values]) => Object.entries(values).every(([key, value]) => key === "paper" || design[key as keyof SignDesign] === value))?.[0] ?? "";
const uploadId = useRef(0);
async function upload(file: File, field: "logoImage" | "backgroundImage") {
async function upload(file: File, field: "logoImage" | "backgroundImage", crop?: BannerCrop) {
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) {
toast.error("Choose a JPEG, PNG, or WebP up to 10 MB."); return;
}
if (field === "backgroundImage" && !crop) { setCropFile(file); return; }
const id = ++uploadId.current;
setBusy(true); onBusy(true);
const url = URL.createObjectURL(file);
@@ -33,17 +43,27 @@ export function SignDesignControls({ design, onChange, onBusy }: { design: SignD
image.src = url;
await image.decode();
const max = field === "logoImage" ? 1000 : 2200;
const scale = Math.min(1, max / Math.max(image.naturalWidth, image.naturalHeight));
const sx = crop ? image.naturalWidth * crop.x / 100 : 0;
const sy = crop ? image.naturalHeight * crop.y / 100 : 0;
const sw = crop ? image.naturalWidth * crop.width / 100 : image.naturalWidth;
const sh = crop ? image.naturalHeight * crop.height / 100 : image.naturalHeight;
const scale = Math.min(1, max / Math.max(sw, sh));
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(image.naturalWidth * scale)); canvas.height = Math.max(1, Math.round(image.naturalHeight * scale));
canvas.width = Math.max(1, Math.round(sw * scale)); canvas.height = Math.max(1, Math.round(sh * scale));
const context = canvas.getContext("2d");
if (!context) throw new Error("Image tools unavailable");
context.drawImage(image, 0, 0, canvas.width, canvas.height);
if (id === uploadId.current) onChange({ [field]: canvas.toDataURL("image/png") });
context.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
if (id === uploadId.current) {
const data = canvas.toDataURL(field === "backgroundImage" ? "image/jpeg" : "image/png", 0.95);
if (data.length > 12 * 1024 * 1024) throw new Error("Image too large");
onChange({ [field]: data, ...(field === "backgroundImage" ? { layout: "photo" as const } : {}) });
if (field === "backgroundImage") originalPhoto.current = file;
}
} catch { toast.error("Couldn't open this image. Try another file."); }
finally { URL.revokeObjectURL(url); setBusy(false); onBusy(false); }
}
return <>
{cropFile ? <BannerCropDialog key={`${cropFile.name}-${cropAspect}`} file={cropFile} aspect={cropAspect} title="Frame your background photo" description="Drag the crop frame to reposition it, or resize it to zoom in. The shaded area will be cut off." hint="Matches the photo area in your current sign orientation. Apply to preview it on the sign, then save your design. Changing orientation may require another crop." confirmLabel="Apply crop" onCancel={() => setCropFile(null)} onConfirm={crop => { const file = cropFile; setCropFile(null); void upload(file, "backgroundImage", crop); }} /> : null}
{design.paper === "custom" ? <>
<Field><FieldLabel htmlFor="sign-unit">Units</FieldLabel><Select value={design.unit ?? "in"} onValueChange={value => {
const unit = value as "in" | "mm";
@@ -61,9 +81,18 @@ export function SignDesignControls({ design, onChange, onBusy }: { design: SignD
<Field><FieldLabel htmlFor="sign-accent">Accent</FieldLabel><Input id="sign-accent" type="color" value={design.accent ?? brandPalette.primary} onChange={e => onChange({ accent: e.target.value })} /><FieldDescription>Text adapts for contrast against the background.</FieldDescription></Field>
</div>
{([{ key: "backgroundImage", label: "Background photo" }, { key: "logoImage", label: "Your logo" }] as const).map(field => <Field key={field.key}>
<FieldLabel htmlFor={`sign-${field.key}`}>{field.label}</FieldLabel><Input id={`sign-${field.key}`} type="file" accept="image/jpeg,image/png,image/webp" disabled={busy} onChange={e => { const file = e.target.files?.[0]; e.target.value = ""; if (file) void upload(file, field.key); }} />
{design[field.key] ? <Button type="button" variant="outline" disabled={busy} onClick={() => onChange({ [field.key]: undefined })}>Remove {field.label.toLowerCase()}</Button> : null}
<FieldDescription>{field.key === "logoImage" ? "Transparent PNG works best. Replaces the Manyangles logo." : "Used by photo presets. Cropped to fill the photo area; switch presets without losing your upload."} Embedded in your export; never added to the event gallery.</FieldDescription>
<FieldLabel>{field.label}</FieldLabel>
<input ref={field.key === "backgroundImage" ? backgroundInput : logoInput} className="hidden" type="file" accept="image/jpeg,image/png,image/webp" disabled={busy} onChange={e => { const file = e.target.files?.[0]; e.target.value = ""; if (file) void upload(file, field.key); }} />
{design[field.key] ? <img src={design[field.key]} alt={`${field.label} preview`} className="max-h-36 w-full rounded-lg border object-contain" /> : null}
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" disabled={busy} onClick={() => (field.key === "backgroundImage" ? backgroundInput : logoInput).current?.click()}><Upload data-icon="inline-start" />{design[field.key] ? "Replace" : "Choose"} {field.label.toLowerCase()}</Button>
{field.key === "backgroundImage" && design.backgroundImage ? <Button type="button" variant="outline" disabled={busy} onClick={async () => {
if (originalPhoto.current) { setCropFile(originalPhoto.current); return; }
try { const blob = await (await fetch(design.backgroundImage!)).blob(); setCropFile(new File([blob], "saved-background.jpg", { type: blob.type })); } catch { toast.error("Could not reopen the image. Choose it again."); }
}}><Crop data-icon="inline-start" />Adjust crop</Button> : null}
{design[field.key] ? <Button type="button" variant="outline" disabled={busy} onClick={() => { onChange({ [field.key]: undefined }); if (field.key === "backgroundImage") originalPhoto.current = null; }}><Trash2 data-icon="inline-start" />Remove</Button> : null}
</div>
<FieldDescription>{field.key === "logoImage" ? "Transparent PNG works best. Replaces the Manyangles logo." : "Choose a photo to preview and adjust its crop. After reloading, upload the original again to recover areas outside the saved crop."} JPEG, PNG, or WebP, up to 10 MB. Embedded in your export; never added to the gallery.</FieldDescription>
</Field>)}
<Field><FieldLabel htmlFor="sign-brand">Manyangles logo</FieldLabel><Select value={design.showBrand === false ? "hide" : "show"} onValueChange={value => onChange({ showBrand: value === "show" })}><SelectTrigger id="sign-brand" className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="show">Show when no custom logo</SelectItem><SelectItem value="hide">Hide</SelectItem></SelectGroup></SelectContent></Select></Field>
</>;
+12 -7
View File
@@ -7,10 +7,15 @@ import { BANNER_ASPECT_RATIO, type BannerCrop } from "@album/contracts";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
export function BannerCropDialog({ file, onCancel, onConfirm }: {
export function BannerCropDialog({ file, onCancel, onConfirm, aspect = BANNER_ASPECT_RATIO, title = "Crop your banner", description = "Drag to reposition or resize the 8:3 frame. The shaded area will be cut off. Your original stays untouched.", hint = "The selected area becomes your banner everywhere. After uploading, use Save changes to publish it.", confirmLabel = "Use crop & upload" }: {
file: File;
onCancel: () => void;
onConfirm: (crop: BannerCrop) => void;
aspect?: number;
title?: string;
description?: string;
hint?: string;
confirmLabel?: string;
}) {
const [src, setSrc] = useState<string>();
const [crop, setCrop] = useState<PercentCrop>();
@@ -37,18 +42,18 @@ export function BannerCropDialog({ file, onCancel, onConfirm }: {
return () => { cancelled = true; if (url) URL.revokeObjectURL(url); };
}, [file]);
function reset(width = dimensions.width, height = dimensions.height) {
setCrop(centerCrop(makeAspectCrop({ unit: "%", width: 100 }, BANNER_ASPECT_RATIO, width, height), width, height));
setCrop(centerCrop(makeAspectCrop({ unit: "%", width: 100 }, aspect, width, height), width, height));
}
const valid = crop && crop.width * dimensions.width / 100 >= 8 && crop.height * dimensions.height / 100 >= 3 && !error;
return (
<Dialog open onOpenChange={(open) => { if (!open) onCancel(); }}>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Crop your banner</DialogTitle>
<DialogDescription>Drag to reposition or resize the 8:3 frame. The shaded area will be cut off. Your original stays untouched.</DialogDescription>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex min-h-32 justify-center overflow-hidden rounded-lg bg-muted">
{src && !error ? <ReactCrop crop={crop} aspect={BANNER_ASPECT_RATIO} onChange={(_, percent) => setCrop(percent)} keepSelection ruleOfThirds>
{src && !error ? <ReactCrop crop={crop} aspect={aspect} onChange={(_, percent) => setCrop(percent)} keepSelection ruleOfThirds>
<img src={src} alt="Banner crop preview" className="block max-h-[50dvh] max-w-full object-contain"
onLoad={(event) => {
const { naturalWidth: width, naturalHeight: height } = event.currentTarget;
@@ -57,11 +62,11 @@ export function BannerCropDialog({ file, onCancel, onConfirm }: {
}} onError={() => setError("This image couldn't be opened. Choose another image.")} />
</ReactCrop> : <p role={error ? "alert" : "status"} className="self-center p-4 text-sm text-muted-foreground">{error ?? "Preparing image…"}</p>}
</div>
<p className="text-xs text-muted-foreground">The selected area becomes your banner everywhere. After uploading, use Save changes to publish it.</p>
<p className="text-xs text-muted-foreground">{hint}</p>
<DialogFooter>
<Button type="button" variant="ghost" disabled={!crop || Boolean(error)} onClick={() => reset()}>Reset crop</Button>
<Button type="button" variant="outline" onClick={onCancel}>Cancel</Button>
<Button type="button" disabled={!valid} onClick={() => { if (crop && valid) onConfirm({ x: crop.x, y: crop.y, width: crop.width, height: crop.height }); }}>Use crop & upload</Button>
<Button type="button" disabled={!valid} onClick={() => { if (crop && valid) onConfirm({ x: crop.x, y: crop.y, width: crop.width, height: crop.height }); }}>{confirmLabel}</Button>
</DialogFooter>
</DialogContent>
</Dialog>