diff --git a/package-lock.json b/package-lock.json index cbf5aba0..22bc0323 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "lucide-react": "^0.475.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-image-crop": "^11.1.2", "recharts": "^3.10.1", "tailwind-merge": "^3.0.1", "tailwindcss": "^4.0.0", @@ -7278,6 +7279,15 @@ "react": "^19.0.0" } }, + "node_modules/react-image-crop": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.1.2.tgz", + "integrity": "sha512-+0Pc2fxpwKL4u4oLmdKBw8XSwUceFbXbKEHvFOlsl/MGB1OVNic4uBlAPmEHGXYgoJIq+b63xHbc/aJMG0AVkA==", + "license": "ISC", + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index bcc5171b..9db893bd 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "lucide-react": "^0.475.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-image-crop": "^11.1.2", "recharts": "^3.10.1", "tailwind-merge": "^3.0.1", "tailwindcss": "^4.0.0", diff --git a/resources/js/components/branding/logo-crop-dialog.tsx b/resources/js/components/branding/logo-crop-dialog.tsx new file mode 100644 index 00000000..e305c9bc --- /dev/null +++ b/resources/js/components/branding/logo-crop-dialog.tsx @@ -0,0 +1,149 @@ +import { router } from '@inertiajs/react'; +import { useState } from 'react'; +import ReactCrop, { type PercentCrop } from 'react-image-crop'; +import 'react-image-crop/dist/ReactCrop.css'; + +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { useTranslation } from '@/hooks/use-translation'; + +export interface LogoCropBox { + x: number; + y: number; + width: number; + height: number; +} + +interface LogoCropDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The uploaded image, never a previous crop of it. */ + sourceUrl: string; + /** The last crop, in the upload's pixels, or null to start from the whole image. */ + savedCrop: LogoCropBox | null; +} + +const WHOLE: PercentCrop = { unit: '%', x: 0, y: 0, width: 100, height: 100 }; + +/** + * Draw a box on the uploaded logo and keep only that part of it. + * + * The box is held in percent while it is being drawn, so it survives the + * dialog resizing, and turned into the upload's own pixels only when it is + * saved. The server cuts the new file from the upload: nothing is cropped + * in the browser. + * + * `image-orientation: none` shows the stored pixels exactly as the server + * reads them. A browser would otherwise rotate a phone photo by its + * orientation tag, the server cannot, and the box would land on the wrong + * part of the picture. + */ +export default function LogoCropDialog({ open, onOpenChange, sourceUrl, savedCrop }: LogoCropDialogProps) { + const { t } = useTranslation(); + const [crop, setCrop] = useState(WHOLE); + const [natural, setNatural] = useState<{ width: number; height: number } | null>(null); + const [error, setError] = useState(); + const [saving, setSaving] = useState(false); + + const onImageLoad = (image: HTMLImageElement) => { + const width = image.naturalWidth; + const height = image.naturalHeight; + + setNatural({ width, height }); + setError(undefined); + setCrop( + savedCrop === null + ? WHOLE + : { + unit: '%', + x: (savedCrop.x / width) * 100, + y: (savedCrop.y / height) * 100, + width: (savedCrop.width / width) * 100, + height: (savedCrop.height / height) * 100, + }, + ); + }; + + const toPixels = (box: PercentCrop, size: { width: number; height: number }): LogoCropBox => { + const x = Math.max(0, Math.round((box.x / 100) * size.width)); + const y = Math.max(0, Math.round((box.y / 100) * size.height)); + + return { + x, + y, + width: Math.max(1, Math.min(size.width - x, Math.round((box.width / 100) * size.width))), + height: Math.max(1, Math.min(size.height - y, Math.round((box.height / 100) * size.height))), + }; + }; + + const save = () => { + if (natural === null || crop.width === 0 || crop.height === 0) { + return; + } + + setSaving(true); + + router.patch( + route('branding.logo.crop'), + { ...toPixels(crop, natural) }, + { + preserveScroll: true, + onSuccess: () => onOpenChange(false), + onError: (errors) => setError(errors.logo ?? errors.width ?? errors.x ?? Object.values(errors)[0]), + onFinish: () => setSaving(false), + }, + ); + }; + + return ( + + + + {t('Crop logo')} + + {t( + 'Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.', + )} + + + +
+ setCrop(percent)} keepSelection minWidth={8} minHeight={8}> + onImageLoad(e.currentTarget)} + style={{ imageOrientation: 'none' }} + className="max-h-[56vh] max-w-full" + /> + +
+ + {natural !== null && ( +

+ {(() => { + const box = toPixels(crop, natural); + + return t(':width × :height pixels', { width: String(box.width), height: String(box.height) }); + })()} +

+ )} + + + + + + + + +
+
+ ); +} diff --git a/resources/js/pages/branding/edit.tsx b/resources/js/pages/branding/edit.tsx index 27f381ae..e2928dcc 100644 --- a/resources/js/pages/branding/edit.tsx +++ b/resources/js/pages/branding/edit.tsx @@ -2,6 +2,7 @@ import { type BreadcrumbItem, type SharedData } from '@/types'; import { Head, useForm, usePage } from '@inertiajs/react'; import { FormEventHandler, useEffect, useRef, useState } from 'react'; +import LogoCropDialog, { type LogoCropBox } from '@/components/branding/logo-crop-dialog'; import Heading from '@/components/heading'; import InputError from '@/components/input-error'; import { Button } from '@/components/ui/button'; @@ -22,6 +23,9 @@ interface Watermark { interface BrandingEditProps { logo_url: string | null; + logo_source_url: string | null; + logo_crop: LogoCropBox | null; + logo_cropped: boolean; hide_attribution: boolean; show_site_name: boolean; watermark: Watermark; @@ -30,7 +34,7 @@ interface BrandingEditProps { type Tab = 'logo' | 'watermark' | 'attribution'; -export default function BrandingEdit({ logo_url, hide_attribution, show_site_name, watermark, watermark_positions }: BrandingEditProps) { +export default function BrandingEdit({ logo_url, logo_source_url, logo_crop, logo_cropped, hide_attribution, show_site_name, watermark, watermark_positions }: BrandingEditProps) { const { t } = useTranslation(); const { capabilities } = usePage().props; const fileInputRef = useRef(null); @@ -135,6 +139,14 @@ export default function BrandingEdit({ logo_url, hide_attribution, show_site_nam removeForm.delete(route('branding.destroy'), { preserveScroll: true, preserveState: true }); }; + // Cropping is optional: an upload is used whole until somebody crops it. + const [cropping, setCropping] = useState(false); + const restoreForm = useForm({}); + + const restore = () => { + restoreForm.delete(route('branding.logo.restore'), { preserveScroll: true, preserveState: true }); + }; + const submitWatermark: FormEventHandler = (e) => { e.preventDefault(); watermarkForm.post(route('branding.watermark.update'), { @@ -212,10 +224,31 @@ export default function BrandingEdit({ logo_url, hide_attribution, show_site_nam - {logo_url && ( - + {logo_url && logo_source_url && ( +
+ + {logo_cropped && ( + + )} + +
+ )} + + {logo_source_url && ( + // Keyed by the upload so a new logo opens on a fresh box. + )}