From de5645609b491335a4101d1d57e8bf191d3ecb5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Wed, 15 Jul 2026 11:57:40 +0200 Subject: [PATCH 01/19] Add profile picture editor --- .../profile-picture/profile-picture-schema.ts | 23 ++ .../app/components/custom/image-cropper.tsx | 326 ++++++++++++++++++ fdm-app/app/components/ui/slider.tsx | 26 ++ .../routes/user.settings.profile.picture.tsx | 206 +++++++++++ fdm-app/app/routes/user.settings.profile.tsx | 3 +- fdm-app/package.json | 2 + pnpm-lock.yaml | 313 ++++++++++++++++- 7 files changed, 896 insertions(+), 3 deletions(-) create mode 100644 fdm-app/app/components/blocks/profile-picture/profile-picture-schema.ts create mode 100644 fdm-app/app/components/custom/image-cropper.tsx create mode 100644 fdm-app/app/components/ui/slider.tsx create mode 100644 fdm-app/app/routes/user.settings.profile.picture.tsx diff --git a/fdm-app/app/components/blocks/profile-picture/profile-picture-schema.ts b/fdm-app/app/components/blocks/profile-picture/profile-picture-schema.ts new file mode 100644 index 000000000..277b1ddd3 --- /dev/null +++ b/fdm-app/app/components/blocks/profile-picture/profile-picture-schema.ts @@ -0,0 +1,23 @@ +import z from "zod" + +const PositionSchema = z.preprocess( + (val) => (typeof val === "string" ? Number(val) : val), + z + .number() + .gt(-0.001) + .transform((x) => Math.max(0, Math.floor(x))), +) + +const SizeSchema = z.preprocess( + (val) => (typeof val === "string" ? Number(val) : val), + z + .number() + .gt(-0.001) + .transform((x) => Math.max(1, Math.floor(x))), +) +export const ProfilePictureSchema = z.object({ + cropRectX: PositionSchema, + cropRectY: PositionSchema, + cropRectWidth: SizeSchema, + cropRectHeight: SizeSchema, +}) diff --git a/fdm-app/app/components/custom/image-cropper.tsx b/fdm-app/app/components/custom/image-cropper.tsx new file mode 100644 index 000000000..f08aaa713 --- /dev/null +++ b/fdm-app/app/components/custom/image-cropper.tsx @@ -0,0 +1,326 @@ +import { PointerEventHandler, useEffect, useRef, useState, WheelEventHandler } from "react" +import { Slider } from "~/components/ui/slider" +interface Rectangle { + x: number + y: number + width: number + height: number +} + +export interface ImageData { + src: string + imageWidth: number + imageHeight: number +} + +interface ImageCropperAppProps { + aspectRatio?: number + appAspectRatio?: number + frameShape?: "ellipse" | "rectangle" + frameRelativeSize?: number + imageData: ImageData + onClear: () => void +} + +/** + * Computes the largest rectangle with the given aspect ratio that will fit into the outer rectangle. + * It aligns the center of the new rectangle to the center of the outer rectangle. + * + * @param outerRect rectangle to fit the new rectangle into. + * @param innerAspectRatio aspect ratio (width / height) for the new rectangle. + * @returns the computed rectangle. + */ +function fitRectangleIn(outerRect: Rectangle, innerAspectRatio: number): Rectangle { + if (outerRect.width / outerRect.height > innerAspectRatio) { + // Fit height + const finalWidth = outerRect.height * innerAspectRatio + + return { + x: outerRect.x + (outerRect.width - finalWidth) / 2, + y: outerRect.y, + width: finalWidth, + height: outerRect.height, + } + } + + // Fit width + const finalHeight = outerRect.width / innerAspectRatio + + return { + x: outerRect.x, + y: outerRect.y + (outerRect.height - finalHeight) / 2, + width: outerRect.width, + height: finalHeight, + } +} + +/** + * Computes the smallest rectangle with the given aspect ratio that will contain the inner rectangle. + * It aligns the center of the new rectangle to the center of the outer rectangle. + * + * @param innerRect rectangle to contain in the new rectangle. + * @param outerAspectRatio aspect ratio (width / height) for the new rectangle. + * @returns the computed rectangle. + */ +function fitRectangleOut(innerRect: Rectangle, outerAspectRatio: number): Rectangle { + if (innerRect.width / innerRect.height > outerAspectRatio) { + // Fit width + const finalHeight = innerRect.width / outerAspectRatio + + return { + x: innerRect.x, + y: innerRect.y + (innerRect.height - finalHeight) / 2, + width: innerRect.width, + height: finalHeight, + } + } + + // Fit height + const finalWidth = innerRect.height * outerAspectRatio + + return { + x: innerRect.x + (innerRect.width - finalWidth) / 2, + y: innerRect.y, + width: finalWidth, + height: innerRect.height, + } +} + +function scaleAroundCenter(rect: Rectangle, centerX: number, centerY: number, scale: number) { + return { + x: (rect.x - centerX) * scale + centerX, + y: (rect.y - centerY) * scale + centerY, + width: rect.width * scale, + height: rect.height * scale, + } +} + +function getRectangleCenter(rect: Rectangle): [number, number] { + return [rect.x + rect.width / 2, rect.y + rect.height / 2] +} + +function getRectangleSvgCommands(rect: Rectangle) { + return [ + `M ${rect.x},${rect.y}`, + `L ${rect.x + rect.width}, ${rect.y}`, + `L ${rect.x + rect.width}, ${rect.y + rect.height}`, + `L ${rect.x}, ${rect.y + rect.height}`, + `L ${rect.x},${rect.y}`, + ] +} + +function getEllipseSvgCommands(rect: Rectangle) { + const cx = rect.x + rect.width / 2 + const cy = rect.y + rect.height / 2 + const rx = rect.width / 2 + const ry = rect.height / 2 + + return [ + `M ${cx - rx}, ${cy}`, + `A ${rx},${ry} 0 1,0 ${cx + rx},${cy}`, + `A ${rx},${ry} 0 1,0 ${cx - rx},${cy}`, + ] +} + +function getResultFrameRect( + imageData: ImageData, + aspectRatio: number, + x: number, + y: number, + scale: number, +) { + const resultImageRect = { x: 0, y: 0, width: imageData.imageWidth, height: imageData.imageHeight } + const resultFrameRect = fitRectangleIn(resultImageRect, aspectRatio) + resultFrameRect.x += x + resultFrameRect.y += y + return scaleAroundCenter(resultFrameRect, ...getRectangleCenter(resultFrameRect), scale) +} + +const SVG_VIEWBOX_HEIGHT = 500 +const MAX_SCALE = 1 +const MIN_SCALE = 1 / 10 + +export function ImageCropperApp({ + aspectRatio = 1 / 1, + imageData, + frameRelativeSize = 0.6, + appAspectRatio = 1 / 1, + frameShape = "ellipse", +}: ImageCropperAppProps) { + // x and y are relative to the center of the image, in image pixel units. + const [x, setX] = useState(0) + const [y, setY] = useState(0) + + // Scaling happens around the center of the frame rectangle / ellipse. + // Tracking it as an array is more convenient in order to be able to use with the shadcn slider. + const [scaleSliderValue, setScaleSliderValue] = useState([1]) + const scale = scaleSliderValue[0] + + const dragState = useRef({ + dragging: false, + lastX: 0, + lastY: 0, + }) + + useEffect(() => { + setX(0) + setY(0) + setScaleSliderValue([1]) + dragState.current.dragging = false + }, [imageData]) + + // all other rectangles are fit onto this + const appRect = { + x: 0, + y: 0, + width: appAspectRatio * SVG_VIEWBOX_HEIGHT, + height: SVG_VIEWBOX_HEIGHT, + } + + // First fit the frame + const frameRect = fitRectangleIn(appRect, aspectRatio) + + // Fit the image around the frame + const imageRect = fitRectangleOut(frameRect, imageData.imageWidth / imageData.imageHeight) + + // Translate the image before scaling everything + imageRect.x -= (x * imageRect.width) / imageData.imageWidth + imageRect.y -= (y * imageRect.height) / imageData.imageHeight + + const imageRectScaled = scaleAroundCenter( + imageRect, + ...getRectangleCenter(appRect), + frameRelativeSize / scale, + ) + const frameRectScaled = scaleAroundCenter( + frameRect, + ...getRectangleCenter(appRect), + frameRelativeSize, + ) + + const appSvgCommands = getRectangleSvgCommands(appRect).join(" ") + const frameSvgCommands = + frameShape === "ellipse" + ? getEllipseSvgCommands(frameRectScaled).join(" ") + : getRectangleSvgCommands(frameRectScaled).join(" ") + + const resultFrameRectScaled = getResultFrameRect(imageData, aspectRatio, x, y, scale) + + /** + * Adjusts the new state values so that the frame rectangle stays inside the image rectangle, + * then actually sets the state. + * + * @param nextX value that would be passed to `setX` + * @param nextY value that would be passed to `setY` + * @param nextScale element of the array that would be passed to `setScaleSliderValue` + */ + function moveIntoRectAndSet(nextX: number, nextY: number, nextScale: number) { + const newRect = getResultFrameRect(imageData, aspectRatio, nextX, nextY, nextScale) + + if (newRect.x < 0) nextX = (newRect.width - imageData.imageWidth) / 2 + if (newRect.x + newRect.width > imageData.imageWidth) + nextX = (imageData.imageWidth - newRect.width) / 2 + + if (newRect.y < 0) nextY = (newRect.height - imageData.imageHeight) / 2 + if (newRect.y + newRect.height > imageData.imageHeight) + nextY = (imageData.imageHeight - newRect.height) / 2 + + setX(nextX) + setY(nextY) + setScaleSliderValue([nextScale]) + } + + const handlePointerDown: PointerEventHandler = (e) => { + e.preventDefault() + e.currentTarget.setPointerCapture(e.pointerId) + const bcr = e.currentTarget.getBoundingClientRect() + dragState.current.dragging = true + dragState.current.lastX = ((e.clientX - bcr.left) / bcr.height) * SVG_VIEWBOX_HEIGHT + dragState.current.lastY = ((e.clientY - bcr.top) / bcr.height) * SVG_VIEWBOX_HEIGHT + } + + const handlePointerMove: PointerEventHandler = (e) => { + if (!dragState.current.dragging) { + return + } + e.preventDefault() + const bcr = e.currentTarget.getBoundingClientRect() + + // svg might appear smaller than SVG_VIEWBOX_HEIGHT due to CSS + const currentX = ((e.clientX - bcr.left) / bcr.width) * appAspectRatio * SVG_VIEWBOX_HEIGHT + const currentY = ((e.clientY - bcr.top) / bcr.height) * SVG_VIEWBOX_HEIGHT + + // Movement amount is scaled by how large the image actually is vs how large it appears + const speed = imageData.imageWidth / imageRectScaled.width + + let nextX = x - (currentX - dragState.current.lastX) * speed + let nextY = y - (currentY - dragState.current.lastY) * speed + + moveIntoRectAndSet(nextX, nextY, scale) + dragState.current.lastX = currentX + dragState.current.lastY = currentY + } + + const handlePointerUp: PointerEventHandler = () => { + dragState.current.dragging = false + } + + function handleZoomInput(value: number[]) { + const nextScale = value[0] > MAX_SCALE ? MAX_SCALE : value[0] < MIN_SCALE ? MIN_SCALE : value[0] + + moveIntoRectAndSet(x, y, nextScale) + } + + const handleWheel: WheelEventHandler = (e) => { + if (e.deltaY > 0) { + handleZoomInput([scale * 1.05]) + } else { + handleZoomInput([scale * 0.95]) + } + } + + return ( +
+ + + + +
+ + + + + +
+ +
+ ) +} diff --git a/fdm-app/app/components/ui/slider.tsx b/fdm-app/app/components/ui/slider.tsx new file mode 100644 index 000000000..6784a8ca7 --- /dev/null +++ b/fdm-app/app/components/ui/slider.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import * as SliderPrimitive from "@radix-ui/react-slider" + +import { cn } from "~/lib/utils" + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)) +Slider.displayName = SliderPrimitive.Root.displayName + +export { Slider } diff --git a/fdm-app/app/routes/user.settings.profile.picture.tsx b/fdm-app/app/routes/user.settings.profile.picture.tsx new file mode 100644 index 000000000..c503d6032 --- /dev/null +++ b/fdm-app/app/routes/user.settings.profile.picture.tsx @@ -0,0 +1,206 @@ +import { FileUpload } from "@remix-run/form-data-parser" +import { parseFormData } from "@remix-run/form-data-parser" +import { LucideImage } from "lucide-react" +import fs from "node:fs/promises" +import { useEffect, useState } from "react" +import { Form, useNavigate } from "react-router" +import { redirectWithSuccess } from "remix-toast" +import sharp from "sharp" +import { ProfilePictureSchema } from "~/components/blocks/profile-picture/profile-picture-schema" +import { ImageCropperApp, ImageData } from "~/components/custom/image-cropper" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog" +import { buildObjectKey, uploadObject } from "~/integrations/gcs.server" +import { getSession } from "~/lib/auth.server" +import { handleActionError } from "~/lib/error" +import { readAndValidateFileUpload } from "~/lib/upload-utils.server" +import { Dropzone } from "../components/custom/dropzone" +import { Button } from "../components/ui/button" +import { Spinner } from "../components/ui/spinner" +import { cn } from "../lib/utils" +import { Route } from "./+types/user.settings.profile.picture" + +const MAX_SIZE_BYTES = 5 * 1024 * 1024 + +const ALLOWED_MIME_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/webp", + "image/heic", + "image/heif", +]) + +const MIME_TO_EXT: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/heic": "heic", + "image/heif": "heif", +} + +export default function UserProfilePictureDialog() { + const maxFileSize = MAX_SIZE_BYTES + const navigate = useNavigate() + + const [imageData, setImageData] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [files, setFiles] = useState([]) + + useEffect(() => { + let active = true + if (files.length > 0) { + const fileReader = new FileReader() + fileReader.addEventListener("load", () => { + if (!active) return + if (typeof fileReader.result !== "string") return + const dataUrl = fileReader.result + const img = new Image() + img.src = dataUrl + img.addEventListener("load", () => { + if (!active) return + setIsLoading(false) + setImageData({ src: dataUrl, imageWidth: img.width, imageHeight: img.height }) + }) + }) + fileReader.readAsDataURL(files[0]) + } else { + setImageData(null) + } + return () => { + active = false + setIsLoading(false) + } + }, [files]) + + return ( + navigate("..")}> + +
+ + Upload nieuwe profielfoto + Hier kun je een nieuwe profielfoto uploaden. + +
+ `.${ext}`)} + maxSize={maxFileSize} + multiple={false} + required={true} + value={files} + className="aspect-square" + onFilesChange={setFiles} + > + {isLoading ? ( + + ) : ( + + )} +
+ {maxFileSize + ? `Beeld tot ${ + maxFileSize > 1024 * 1024 + ? `${maxFileSize / 1024 / 1024}MB` + : maxFileSize > 1024 + ? `${maxFileSize / 1024}KB` + : `${maxFileSize}B` + }` + : "Een beeld toevoegen"} +
+
+
+ {imageData && ( + <> + setFiles([])} + /> +
+ + +
+ + )} + +
+
+ ) +} + +export async function action({ request }: Route.ActionArgs) { + try { + const session = await getSession(request) + if (!session) { + return Response.json({ error: "Unauthorized" }, { status: 401 }) + } + + let fileBuffer: Buffer | null = null + let detectedMime: string | null = null + + const uploadHandler = async (fileUpload: FileUpload) => { + if (fileUpload.fieldName !== "file") return undefined + const result = await readAndValidateFileUpload(fileUpload, ALLOWED_MIME_TYPES) + fileBuffer = result.buffer + detectedMime = result.mime + + return new File([new Uint8Array(fileBuffer)], fileUpload.name, { + type: detectedMime, + }) + } + + let formData: FormData + try { + formData = await parseFormData(request, { maxFileSize: MAX_SIZE_BYTES }, uploadHandler) + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid upload" + return Response.json({ error: message }, { status: 400 }) + } + + const cropRectResult = ProfilePictureSchema.safeParse(Object.fromEntries(formData.entries())) + + if (cropRectResult.error) { + return Response.json({ errors: cropRectResult.error }, { status: 400 }) + } + + const cropRect = cropRectResult.data + + if (!fileBuffer || !detectedMime) { + return Response.json({ error: "No valid image file provided" }, { status: 400 }) + } + + const cropped = await sharp(fileBuffer) + .extract({ + left: cropRect.cropRectX, + top: cropRect.cropRectY, + width: cropRect.cropRectWidth, + height: cropRect.cropRectHeight, + }) + .resize(200, 200, { fit: "cover" }) + .webp() + .toUint8Array() + + await fs.writeFile("profile-pic.webp", cropped.data) + + const objectKey = buildObjectKey("profile_pictures/users", session.principal_id, "webp") + + await uploadObject(objectKey, cropped.data, "image/webp") + + // TODO: Set the user's profile picture to a permanent GCS URL + + return redirectWithSuccess("/user/settings/profile", { + message: "Profielfoto is succesvol geüpload.", + }) + } catch (err) { + throw handleActionError(err) + } +} diff --git a/fdm-app/app/routes/user.settings.profile.tsx b/fdm-app/app/routes/user.settings.profile.tsx index 41ad7487f..5ad6a9fed 100644 --- a/fdm-app/app/routes/user.settings.profile.tsx +++ b/fdm-app/app/routes/user.settings.profile.tsx @@ -1,4 +1,4 @@ -import { type LoaderFunctionArgs, type MetaFunction, useLoaderData } from "react-router" +import { type LoaderFunctionArgs, type MetaFunction, Outlet, useLoaderData } from "react-router" import { FarmTitle } from "~/components/blocks/farm/farm-title" import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card" @@ -78,6 +78,7 @@ export default function UserSettingsProfile() { + ) } diff --git a/fdm-app/package.json b/fdm-app/package.json index 258594344..53f15172c 100644 --- a/fdm-app/package.json +++ b/fdm-app/package.json @@ -30,6 +30,7 @@ "@nmi-agro/fdm-data": "workspace:*", "@nmi-agro/fdm-helpdesk": "workspace:*", "@nmi-agro/fdm-rvo": "workspace:^", + "@radix-ui/react-slider": "^1.4.3", "@react-email/ui": "^6.7.0", "@react-pdf/renderer": "^4.5.1", "@react-router/node": "^8.2.0", @@ -86,6 +87,7 @@ "remix-hook-form": "7.2.0", "remix-toast": "^4.0.0", "remix-utils": "^10.0.0", + "sharp": "^0.35.3", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tailwindcss-animate": "^1.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf8d6e727..328890f47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -235,6 +235,9 @@ importers: '@nmi-agro/fdm-rvo': specifier: workspace:^ version: link:../fdm-rvo + '@radix-ui/react-slider': + specifier: ^1.4.3 + version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@react-email/ui': specifier: ^6.7.0 version: 6.7.0(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -403,6 +406,9 @@ importers: remix-utils: specifier: ^10.0.0 version: 10.0.0(@standard-schema/spec@1.1.0)(react-router@8.2.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + sharp: + specifier: ^0.35.3 + version: 0.35.3(@types/node@26.1.1) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -2905,70 +2911,145 @@ packages: cpu: [arm64] os: [darwin] + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-x64@0.34.5': resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + '@img/sharp-libvips-darwin-arm64@1.2.4': resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.2.4': resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} cpu: [x64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-linux-arm64@1.2.4': resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2976,6 +3057,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2983,6 +3071,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2990,6 +3085,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2997,6 +3099,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3004,6 +3113,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3011,6 +3127,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3018,6 +3141,13 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3025,29 +3155,63 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + '@img/sharp-win32-arm64@0.34.5': resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + '@img/sharp-win32-ia32@0.34.5': resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-x64@0.34.5': resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -11296,6 +11460,15 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -15383,103 +15556,206 @@ snapshots: dependencies: react: 19.2.7 - '@img/colour@1.1.0': - optional: true + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true + '@img/sharp-darwin-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.2 + optional: true + '@img/sharp-darwin-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.2.4 optional: true + '@img/sharp-darwin-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.2 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + '@img/sharp-libvips-darwin-arm64@1.2.4': optional: true + '@img/sharp-libvips-darwin-arm64@1.3.2': + optional: true + '@img/sharp-libvips-darwin-x64@1.2.4': optional: true + '@img/sharp-libvips-darwin-x64@1.3.2': + optional: true + '@img/sharp-libvips-linux-arm64@1.2.4': optional: true + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + '@img/sharp-libvips-linux-arm@1.2.4': optional: true + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + '@img/sharp-libvips-linux-riscv64@1.2.4': optional: true + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + '@img/sharp-libvips-linux-s390x@1.2.4': optional: true + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + '@img/sharp-libvips-linux-x64@1.2.4': optional: true + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + '@img/sharp-linux-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.2.4 optional: true + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + '@img/sharp-linux-arm@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.2.4 optional: true + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + '@img/sharp-linux-ppc64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 + optional: true + '@img/sharp-linux-riscv64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true + '@img/sharp-linux-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + '@img/sharp-linux-s390x@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + '@img/sharp-linux-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.2.4 optional: true + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + '@img/sharp-linuxmusl-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 optional: true + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + optional: true + '@img/sharp-linuxmusl-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.2.4 optional: true + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + '@img/sharp-wasm32@0.34.5': dependencies: '@emnapi/runtime': 1.11.1 optional: true + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.1 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + '@img/sharp-win32-arm64@0.34.5': optional: true + '@img/sharp-win32-arm64@0.35.3': + optional: true + '@img/sharp-win32-ia32@0.34.5': optional: true + '@img/sharp-win32-ia32@0.35.3': + optional: true + '@img/sharp-win32-x64@0.34.5': optional: true + '@img/sharp-win32-x64@0.35.3': + optional: true + '@inquirer/external-editor@1.0.3(@types/node@26.1.1)': dependencies: chardet: 2.2.0 @@ -24498,6 +24774,39 @@ snapshots: '@img/sharp-win32-x64': 0.34.5 optional: true + sharp@0.35.3(@types/node@26.1.1): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 26.1.1 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 From 972e3eca492b72bea6a72a3385b24fbb4f884421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 16 Jul 2026 13:12:37 +0200 Subject: [PATCH 02/19] Add user profile info management --- .../blocks/mijnpercelen/form-upload.tsx | 1 + .../blocks/profile/profile-info-form.tsx | 82 +++++++ .../blocks/profile/profile-info-schema.ts | 20 ++ .../profile/profile-picture-manager.tsx | 198 +++++++++++++++++ .../profile-picture-schema.ts | 0 .../components/blocks/soil/form-upload.tsx | 1 + fdm-app/app/components/custom/dropzone.tsx | 7 +- .../app/components/custom/image-cropper.tsx | 5 +- ...pi.profile-picture.$principal_id[.]webp.ts | 40 ++++ ...e.tsx => user.settings.profile._index.tsx} | 15 +- .../app/routes/user.settings.profile.edit.tsx | 198 +++++++++++++++++ .../routes/user.settings.profile.picture.tsx | 206 ------------------ fdm-app/app/routes/welcome.tsx | 28 +-- 13 files changed, 562 insertions(+), 239 deletions(-) create mode 100644 fdm-app/app/components/blocks/profile/profile-info-form.tsx create mode 100644 fdm-app/app/components/blocks/profile/profile-info-schema.ts create mode 100644 fdm-app/app/components/blocks/profile/profile-picture-manager.tsx rename fdm-app/app/components/blocks/{profile-picture => profile}/profile-picture-schema.ts (100%) create mode 100644 fdm-app/app/routes/api.profile-picture.$principal_id[.]webp.ts rename fdm-app/app/routes/{user.settings.profile.tsx => user.settings.profile._index.tsx} (82%) create mode 100644 fdm-app/app/routes/user.settings.profile.edit.tsx delete mode 100644 fdm-app/app/routes/user.settings.profile.picture.tsx diff --git a/fdm-app/app/components/blocks/mijnpercelen/form-upload.tsx b/fdm-app/app/components/blocks/mijnpercelen/form-upload.tsx index 058ccdd25..f5f1ec6fa 100644 --- a/fdm-app/app/components/blocks/mijnpercelen/form-upload.tsx +++ b/fdm-app/app/components/blocks/mijnpercelen/form-upload.tsx @@ -238,6 +238,7 @@ export function MijnPercelenUploadForm({ onBlur={onBlur} onFilesChange={handleFilesSet} className={cn( + "h-32", hasAllRequiredFiles && "border-green-500 bg-green-50", uploadState === "error" && "border-red-500 bg-red-50", uploadState === "success" && "border-green-500 bg-green-50", diff --git a/fdm-app/app/components/blocks/profile/profile-info-form.tsx b/fdm-app/app/components/blocks/profile/profile-info-form.tsx new file mode 100644 index 000000000..e5e95d110 --- /dev/null +++ b/fdm-app/app/components/blocks/profile/profile-info-form.tsx @@ -0,0 +1,82 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { useEffect } from "react" +import { Controller } from "react-hook-form" +import { Form, useFetcher } from "react-router" +import { RemixFormProvider, useRemixForm } from "remix-hook-form" +import z from "zod" +import { Button } from "~/components/ui/button" +import { Field, FieldError, FieldLabel } from "~/components/ui/field" +import { Input } from "~/components/ui/input" +import { Spinner } from "~/components/ui/spinner" +import { ProfileInfoSchema } from "./profile-info-schema" + +const InfoFormSchema = ProfileInfoSchema.extend({ intent: z.literal("update_profile_info") }) +export function ProfileInfoForm({ + user, +}: { + user: { firstname?: string | null | undefined; surname?: string | null | undefined } +}) { + const fetcher = useFetcher() + + const isSubmitting = fetcher.state !== "idle" + + const form = useRemixForm({ + mode: "onTouched", + resolver: zodResolver(InfoFormSchema), + fetcher: fetcher, + stringifyAllValues: false, + defaultValues: { + intent: "update_profile_info" as const, + firstname: user.firstname ?? "", + surname: user.surname ?? "", + }, + }) + + console.log(form.formState.errors) + + useEffect(() => { + form.reset({ + intent: "update_profile_info" as const, + firstname: user.firstname ?? "", + surname: user.surname ?? "", + }) + }, [user, form.reset]) + + return ( + +
+
+ + ( + + Voornaam + + {fieldState.error && } + + )} + /> + ( + + Achternaam + + {fieldState.error && } + + )} + /> +
+
+ + +
+
+
+ ) +} diff --git a/fdm-app/app/components/blocks/profile/profile-info-schema.ts b/fdm-app/app/components/blocks/profile/profile-info-schema.ts new file mode 100644 index 000000000..f8cc015a7 --- /dev/null +++ b/fdm-app/app/components/blocks/profile/profile-info-schema.ts @@ -0,0 +1,20 @@ +import z from "zod" + +export const ProfileInfoSchema = z.object({ + firstname: z + .string({ + error: (issue) => (issue.input === undefined ? "Vul je voornaam in" : undefined), + }) + .trim() + .min(1, { + error: "Vul je voornaam in", + }), + surname: z + .string({ + error: (issue) => (issue.input === undefined ? "Vul je achternaam in" : undefined), + }) + .trim() + .min(1, { + error: "Vul je achternaam in", + }), +}) diff --git a/fdm-app/app/components/blocks/profile/profile-picture-manager.tsx b/fdm-app/app/components/blocks/profile/profile-picture-manager.tsx new file mode 100644 index 000000000..63404e00a --- /dev/null +++ b/fdm-app/app/components/blocks/profile/profile-picture-manager.tsx @@ -0,0 +1,198 @@ +import { LucideImage } from "lucide-react" +import { useEffect, useRef, useState } from "react" +import { useFetcher } from "react-router" +import { Dropzone } from "~/components/custom/dropzone" +import { ImageCropperApp, type ImageData } from "~/components/custom/image-cropper" +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogTrigger, +} from "~/components/ui/alert-dialog" +import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar" +import { Button } from "~/components/ui/button" +import { Spinner } from "~/components/ui/spinner" +import { cn } from "~/lib/utils" + +export const MAX_SIZE_BYTES = 5 * 1024 * 1024 + +export const ALLOWED_MIME_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/webp", + "image/heic", + "image/heif", +]) + +export const MIME_TO_EXT: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/heic": "heic", + "image/heif": "heif", +} + +export default function ProfilePictureManager({ + currentPicture, + currentAlt, + initials, +}: { initials: string } & ( + | { currentPicture: string; currentAlt: string } + | { currentPicture?: null | undefined; currentAlt?: unknown } +)) { + const uploadFetcher = useFetcher() + const deleteFetcher = useFetcher() + const maxFileSize = MAX_SIZE_BYTES + const appAspectRatio = 3 / 2 + + const [imageData, setImageData] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [files, setFiles] = useState([]) + + const fileInputRef = useRef(null) + + // Take the input file, first convert it into a data URL, then read its width and height using an Image object, making up two asynchronous passes. + useEffect(() => { + let active = true + if (files.length > 0) { + const fileReader = new FileReader() + fileReader.addEventListener("load", () => { + if (!active) return + if (typeof fileReader.result !== "string") return + const dataUrl = fileReader.result + const img = new Image() + img.src = dataUrl + img.addEventListener("load", () => { + if (!active) return + setIsLoading(false) + setImageData({ src: dataUrl, imageWidth: img.width, imageHeight: img.height }) + }) + }) + fileReader.readAsDataURL(files[0]) + } else { + setImageData(null) + } + return () => { + active = false + setIsLoading(false) + } + }, [files]) + + const isUploading = uploadFetcher.state !== "idle" + const isDeleting = deleteFetcher.state !== "idle" + + return ( + + +
+ `.${ext}`)} + maxSize={maxFileSize} + multiple={false} + required={true} + value={files} + className="relative h-auto" + style={{ aspectRatio: appAspectRatio }} + onFilesChange={setFiles} + > + {isLoading ? ( + + ) : currentPicture && currentAlt ? ( +
+ Huidige profielfoto. + + + {initials} + +
+ ) : ( + <> + +
+ {maxFileSize + ? `Kies een afbeelding tot ${ + maxFileSize > 1024 * 1024 + ? `${maxFileSize / 1024 / 1024}MB` + : maxFileSize > 1024 + ? `${maxFileSize / 1024}KB` + : `${maxFileSize}B` + }` + : "Hier een beeld toevoegen"} +
+ + )} +
+
+ {imageData && ( + setFiles([])} + /> + )} +
+ {imageData ? ( + <> + + + + ) : currentPicture ? ( + <> + + + + + + Weet je het zeker? + + Deze actie kan niet ongedaan worden gemaakt. + + + + + + + + + + + + + + ) : ( + + )} +
+
+ ) +} diff --git a/fdm-app/app/components/blocks/profile-picture/profile-picture-schema.ts b/fdm-app/app/components/blocks/profile/profile-picture-schema.ts similarity index 100% rename from fdm-app/app/components/blocks/profile-picture/profile-picture-schema.ts rename to fdm-app/app/components/blocks/profile/profile-picture-schema.ts diff --git a/fdm-app/app/components/blocks/soil/form-upload.tsx b/fdm-app/app/components/blocks/soil/form-upload.tsx index 60f6fcea0..258d8f4da 100644 --- a/fdm-app/app/components/blocks/soil/form-upload.tsx +++ b/fdm-app/app/components/blocks/soil/form-upload.tsx @@ -103,6 +103,7 @@ export function SoilAnalysisUploadForm() { maxSize={fileSizeLimit} required className={cn( + "h-32", uploadStatus === "success" && "border-green-500 bg-green-50", uploadStatus === "error" && "border-red-500 bg-red-50", )} diff --git a/fdm-app/app/components/custom/dropzone.tsx b/fdm-app/app/components/custom/dropzone.tsx index 56f71127e..f7d748d18 100644 --- a/fdm-app/app/components/custom/dropzone.tsx +++ b/fdm-app/app/components/custom/dropzone.tsx @@ -1,6 +1,6 @@ "use client" -import type { InputHTMLAttributes, ReactNode } from "react" +import type { CSSProperties, InputHTMLAttributes, ReactNode } from "react" import { X } from "lucide-react" import { createContext, useContext, useEffect, useId, useRef } from "react" import { toast as notify } from "sonner" @@ -27,6 +27,7 @@ export type DropzoneProps = { value?: File[] accept?: string | string[] name: string + style?: CSSProperties className?: string allowReset?: boolean minSize?: number @@ -50,6 +51,7 @@ export const Dropzone = ({ multiple, required, disabled, + style, className, children, allowReset = true, @@ -216,8 +218,9 @@ export const Dropzone = ({ disabled={disabled} />