diff --git a/apps/web/src/app/dashboard/events/[id]/sign/design-controls.tsx b/apps/web/src/app/dashboard/events/[id]/sign/design-controls.tsx index f4c285c..127d829 100644 --- a/apps/web/src/app/dashboard/events/[id]/sign/design-controls.tsx +++ b/apps/web/src/app/dashboard/events/[id]/sign/design-controls.tsx @@ -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) => void; onBusy: (busy: boolean) => void }) { const [busy, setBusy] = useState(false); + const [cropFile, setCropFile] = useState(null); + const originalPhoto = useRef(null); + const backgroundInput = useRef(null); + const logoInput = useRef(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 ? setCropFile(null)} onConfirm={crop => { const file = cropFile; setCropFile(null); void upload(file, "backgroundImage", crop); }} /> : null} {design.paper === "custom" ? <> Units onChange({ accent: e.target.value })} />Text adapts for contrast against the background. {([{ key: "backgroundImage", label: "Background photo" }, { key: "logoImage", label: "Your logo" }] as const).map(field => - {field.label} { const file = e.target.files?.[0]; e.target.value = ""; if (file) void upload(file, field.key); }} /> - {design[field.key] ? : null} - {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. + {field.label} + { const file = e.target.files?.[0]; e.target.value = ""; if (file) void upload(file, field.key); }} /> + {design[field.key] ? {`${field.label} : null} +
+ + {field.key === "backgroundImage" && design.backgroundImage ? : null} + {design[field.key] ? : null} +
+ {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.
)} Manyangles logo ; diff --git a/apps/web/src/components/banner-crop-dialog.tsx b/apps/web/src/components/banner-crop-dialog.tsx index d01c589..4da7196 100644 --- a/apps/web/src/components/banner-crop-dialog.tsx +++ b/apps/web/src/components/banner-crop-dialog.tsx @@ -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(); const [crop, setCrop] = useState(); @@ -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 ( { if (!open) onCancel(); }}> - Crop your banner - Drag to reposition or resize the 8:3 frame. The shaded area will be cut off. Your original stays untouched. + {title} + {description}
- {src && !error ? setCrop(percent)} keepSelection ruleOfThirds> + {src && !error ? setCrop(percent)} keepSelection ruleOfThirds> Banner crop preview { 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.")} /> :

{error ?? "Preparing image…"}

}
-

The selected area becomes your banner everywhere. After uploading, use Save changes to publish it.

+

{hint}

- +