diff --git a/frontend/components/ManualCropUI.tsx b/frontend/components/ManualCropUI.tsx new file mode 100644 index 00000000..80b2193b --- /dev/null +++ b/frontend/components/ManualCropUI.tsx @@ -0,0 +1,316 @@ +import React, { useRef, useEffect, useState } from 'react'; +import { X } from 'lucide-react'; +import { useCropHandles, type CropBounds, type HandleType } from '@/hooks/useCropHandles'; + +interface ManualCropUIProps { + imageUrl: string; + onCropChange: (bounds: CropBounds | null) => void; + imageDimensions?: { width: number; height: number }; + initialCrop?: CropBounds; +} + +const HANDLE_SIZE = 12; +const HANDLE_TYPES: HandleType[] = [ + 'top-left', + 'top', + 'top-right', + 'right', + 'bottom-right', + 'bottom', + 'bottom-left', + 'left', +]; + +export default function ManualCropUI({ + imageUrl, + onCropChange, + imageDimensions, + initialCrop, +}: ManualCropUIProps) { + const imageRef = useRef(null); + const containerRef = useRef(null); + const [displayDimensions, setDisplayDimensions] = useState<{ width: number; height: number } | null>(null); + const [error, setError] = useState(null); + + const actualDimensions = imageDimensions || displayDimensions; + + const { crop, setCrop, startDrag, moveDrag, endDrag, resetCrop, isDragging } = useCropHandles( + actualDimensions + ? { + imageDimensions: actualDimensions, + initialCrop, + minSize: 100, + } + : { imageDimensions: { width: 1, height: 1 }, initialCrop, minSize: 100 } + ); + + // Measure image dimensions on load + const handleImageLoad = (e: React.SyntheticEvent) => { + const img = e.currentTarget; + const width = img.naturalWidth || img.width; + const height = img.naturalHeight || img.height; + + if (width && height) { + setDisplayDimensions({ width, height }); + setError(null); + } + }; + + const handleImageError = () => { + setError('Failed to load image'); + }; + + // Emit crop changes to parent + useEffect(() => { + onCropChange(crop); + }, [crop, onCropChange]); + + if (error) { + return ( +
+
+

{error}

+
+
+ ); + } + + // If no dimensions yet, show minimal loading state with hidden image + if (!actualDimensions) { + return ( +
+
+ Photo preview +
+
Loading image...
+
+ ); + } + + const containerWidth = containerRef.current?.clientWidth || 400; + const scale = containerWidth / actualDimensions.width; + + const displayWidth = actualDimensions.width * scale; + const displayHeight = actualDimensions.height * scale; + + const getHandlePosition = ( + handleType: HandleType + ): { left: string; top: string; transform: string } => { + if (!crop) { + return { left: '0', top: '0', transform: 'translate(0, 0)' }; + } + + const x = crop.x * scale; + const y = crop.y * scale; + const w = crop.width * scale; + const h = crop.height * scale; + + const offsets = { + 'top-left': { left: x, top: y }, + top: { left: x + w / 2, top: y }, + 'top-right': { left: x + w, top: y }, + right: { left: x + w, top: y + h / 2 }, + 'bottom-right': { left: x + w, top: y + h }, + bottom: { left: x + w / 2, top: y + h }, + 'bottom-left': { left: x, top: y + h }, + left: { left: x, top: y + h / 2 }, + }; + + const pos = offsets[handleType]; + return { + left: `${pos.left}px`, + top: `${pos.top}px`, + transform: 'translate(-50%, -50%)', + }; + }; + + const handleMouseDown = (handleType: HandleType) => (e: React.MouseEvent) => { + e.preventDefault(); + if (!containerRef.current || !crop) return; + + const rect = containerRef.current.getBoundingClientRect(); + const startX = (e.clientX - rect.left) / scale; + const startY = (e.clientY - rect.top) / scale; + + startDrag(handleType, startX, startY); + }; + + const handleTouchStart = (handleType: HandleType) => (e: React.TouchEvent) => { + e.preventDefault(); + if (!containerRef.current || !crop || e.touches.length === 0) return; + + const rect = containerRef.current.getBoundingClientRect(); + const touch = e.touches[0]; + const startX = (touch.clientX - rect.left) / scale; + const startY = (touch.clientY - rect.top) / scale; + + startDrag(handleType, startX, startY); + }; + + // Global mouse/touch move and end listeners + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + const currentX = (e.clientX - rect.left) / scale; + const currentY = (e.clientY - rect.top) / scale; + moveDrag(currentX, currentY); + }; + + const handleTouchMove = (e: TouchEvent) => { + if (!containerRef.current || e.touches.length === 0) return; + const rect = containerRef.current.getBoundingClientRect(); + const touch = e.touches[0]; + const currentX = (touch.clientX - rect.left) / scale; + const currentY = (touch.clientY - rect.top) / scale; + moveDrag(currentX, currentY); + }; + + const handleEnd = () => { + endDrag(); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleEnd); + document.addEventListener('touchmove', handleTouchMove, { passive: false }); + document.addEventListener('touchend', handleEnd); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleEnd); + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleEnd); + }; + }, [isDragging, scale, moveDrag, endDrag]); + + return ( +
+ {/* Image container with crop preview */} +
+ {/* Image */} + Photo preview + + {/* Semi-transparent overlay outside crop box */} + {crop && ( +
+ {/* Top overlay */} +
+ {/* Bottom overlay */} +
+ {/* Left overlay */} +
+ {/* Right overlay */} +
+ + {/* Crop bounding box */} +
+ + {/* Handles */} + {HANDLE_TYPES.map((handleType) => ( +
+ )} +
+ + {/* Controls */} +
+ {crop && ( + + )} + {!crop && ( +
+ Drag handles to adjust crop area +
+ )} +
+ + {/* Crop bounds display (debug) */} + {crop && ( +
+ Crop: {Math.round(crop.x)}, {Math.round(crop.y)} | Size: {Math.round(crop.width)} x{' '} + {Math.round(crop.height)} +
+ )} +
+ ); +} diff --git a/frontend/hooks/useCropHandles.ts b/frontend/hooks/useCropHandles.ts new file mode 100644 index 00000000..80d0d559 --- /dev/null +++ b/frontend/hooks/useCropHandles.ts @@ -0,0 +1,222 @@ +import { useState, useCallback } from 'react'; + +export interface CropBounds { + x: number; + y: number; + width: number; + height: number; +} + +export type HandleType = + | 'top-left' + | 'top' + | 'top-right' + | 'right' + | 'bottom-right' + | 'bottom' + | 'bottom-left' + | 'left'; + +interface UseCropHandlesOptions { + imageDimensions: { width: number; height: number }; + initialCrop?: CropBounds; + minSize?: number; +} + +interface UseCropHandlesReturn { + crop: CropBounds | null; + setCrop: (bounds: CropBounds | null) => void; + startDrag: (handleType: HandleType, startX: number, startY: number) => void; + moveDrag: (currentX: number, currentY: number) => void; + endDrag: () => void; + resetCrop: () => void; + isDragging: boolean; +} + +const MIN_SIZE_DEFAULT = 100; + +export function useCropHandles({ + imageDimensions, + initialCrop, + minSize = MIN_SIZE_DEFAULT, +}: UseCropHandlesOptions): UseCropHandlesReturn { + // Constrain initial crop if provided + const constrainInitialCrop = (bounds: CropBounds | undefined): CropBounds | null => { + if (!bounds) return null; + + const { width: imgW, height: imgH } = imageDimensions; + let { x, y, width, height } = bounds; + + width = Math.max(minSize, width); + height = Math.max(minSize, height); + x = Math.max(0, Math.min(x, imgW - width)); + y = Math.max(0, Math.min(y, imgH - height)); + width = Math.min(width, imgW - x); + height = Math.min(height, imgH - y); + + return { x, y, width, height }; + }; + + const [crop, setCropState] = useState( + constrainInitialCrop(initialCrop) + ); + const [isDragging, setIsDragging] = useState(false); + const [dragState, setDragState] = useState<{ + handleType: HandleType; + startX: number; + startY: number; + startCrop: CropBounds; + } | null>(null); + + const constrainBounds = useCallback( + (bounds: CropBounds): CropBounds => { + const { width: imgW, height: imgH } = imageDimensions; + + // Enforce minimum size + let { x, y, width, height } = bounds; + width = Math.max(minSize, width); + height = Math.max(minSize, height); + + // Constrain within image bounds + x = Math.max(0, Math.min(x, imgW - width)); + y = Math.max(0, Math.min(y, imgH - height)); + + // Ensure bounds don't exceed image dimensions + width = Math.min(width, imgW - x); + height = Math.min(height, imgH - y); + + return { x, y, width, height }; + }, + [imageDimensions, minSize] + ); + + const startDrag = useCallback( + (handleType: HandleType, startX: number, startY: number) => { + if (!crop) return; + + setIsDragging(true); + setDragState({ + handleType, + startX, + startY, + startCrop: { ...crop }, + }); + }, + [crop] + ); + + const moveDrag = useCallback( + (currentX: number, currentY: number) => { + if (!dragState || !crop) return; + + const deltaX = currentX - dragState.startX; + const deltaY = currentY - dragState.startY; + const { x, y, width, height } = dragState.startCrop; + const { width: imgW, height: imgH } = imageDimensions; + + let newBounds: CropBounds = { x, y, width, height }; + + switch (dragState.handleType) { + case 'top-left': + newBounds = { + x: x + deltaX, + y: y + deltaY, + width: width - deltaX, + height: height - deltaY, + }; + break; + case 'top': + newBounds = { + x, + y: y + deltaY, + width, + height: height - deltaY, + }; + break; + case 'top-right': + newBounds = { + x, + y: y + deltaY, + width: width + deltaX, + height: height - deltaY, + }; + break; + case 'right': + newBounds = { + x, + y, + width: width + deltaX, + height, + }; + break; + case 'bottom-right': + newBounds = { + x, + y, + width: width + deltaX, + height: height + deltaY, + }; + break; + case 'bottom': + newBounds = { + x, + y, + width, + height: height + deltaY, + }; + break; + case 'bottom-left': + newBounds = { + x: x + deltaX, + y, + width: width - deltaX, + height: height + deltaY, + }; + break; + case 'left': + newBounds = { + x: x + deltaX, + y, + width: width - deltaX, + height, + }; + break; + } + + newBounds = constrainBounds(newBounds); + setCropState(newBounds); + }, + [dragState, crop, constrainBounds, imageDimensions] + ); + + const endDrag = useCallback(() => { + setIsDragging(false); + setDragState(null); + }, []); + + const resetCrop = useCallback(() => { + setCropState(null); + }, []); + + const setCrop = useCallback( + (bounds: CropBounds | null) => { + if (!bounds) { + setCropState(null); + return; + } + const constrained = constrainBounds(bounds); + setCropState(constrained); + }, + [constrainBounds] + ); + + return { + crop, + setCrop, + startDrag, + moveDrag, + endDrag, + resetCrop, + isDragging, + }; +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 961add59..13b04347 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1 +1 @@ -if(!self.define){let e,s={};const n=(n,t)=>(n=new URL(n+".js",t).href,s[n]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=s,document.head.appendChild(e)}else e=n,importScripts(n),s()}).then(()=>{let e=s[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(t,a)=>{const c=e||("document"in self?document.currentScript.src:"")||location.href;if(s[c])return;let i={};const r=e=>n(e,c),o={module:{uri:c},exports:i,require:r};s[c]=Promise.all(t.map(e=>o[e]||r(e))).then(e=>(a(...e),i))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/app-build-manifest.json",revision:"ea4f10cd1b0fcdffee504618ef9c2d19"},{url:"/_next/static/BrepwvDoHttl-w0NAva_c/_buildManifest.js",revision:"31eba5c94d685fe15b2d866310094bce"},{url:"/_next/static/BrepwvDoHttl-w0NAva_c/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/chunks/13.ae2fd9645430ed90.js",revision:"ae2fd9645430ed90"},{url:"/_next/static/chunks/158-6524d0eb64e9974b.js",revision:"6524d0eb64e9974b"},{url:"/_next/static/chunks/255-4f212684648fcab9.js",revision:"4f212684648fcab9"},{url:"/_next/static/chunks/374-a4ed2a0d861d9cab.js",revision:"a4ed2a0d861d9cab"},{url:"/_next/static/chunks/4bd1b696-c023c6e3521b1417.js",revision:"c023c6e3521b1417"},{url:"/_next/static/chunks/512-ce90bee899e5ddb3.js",revision:"ce90bee899e5ddb3"},{url:"/_next/static/chunks/7cb1fa1f-32fc9056ac653916.js",revision:"32fc9056ac653916"},{url:"/_next/static/chunks/802-838213ff73429b14.js",revision:"838213ff73429b14"},{url:"/_next/static/chunks/814-d4585cd22c8d1c30.js",revision:"d4585cd22c8d1c30"},{url:"/_next/static/chunks/app/_not-found/page-e122f8f409e58055.js",revision:"e122f8f409e58055"},{url:"/_next/static/chunks/app/admin/page-d60f4fa33408df66.js",revision:"d60f4fa33408df66"},{url:"/_next/static/chunks/app/inventory/page-f4fc5e180c7a3fe6.js",revision:"f4fc5e180c7a3fe6"},{url:"/_next/static/chunks/app/layout-eb0f519b25043024.js",revision:"eb0f519b25043024"},{url:"/_next/static/chunks/app/login/page-0b0585ba736fd477.js",revision:"0b0585ba736fd477"},{url:"/_next/static/chunks/app/logs/page-0715321816cc583d.js",revision:"0715321816cc583d"},{url:"/_next/static/chunks/app/page-b23d2b549257681c.js",revision:"b23d2b549257681c"},{url:"/_next/static/chunks/framework-050c1f32293f7182.js",revision:"050c1f32293f7182"},{url:"/_next/static/chunks/main-aa573100fdde0547.js",revision:"aa573100fdde0547"},{url:"/_next/static/chunks/main-app-613127bff4bcda66.js",revision:"613127bff4bcda66"},{url:"/_next/static/chunks/pages/_app-7d307437aca18ad4.js",revision:"7d307437aca18ad4"},{url:"/_next/static/chunks/pages/_error-cb2a52f75f2162e2.js",revision:"cb2a52f75f2162e2"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-7a9cf6b863b8c67d.js",revision:"7a9cf6b863b8c67d"},{url:"/_next/static/css/9e0a1c9c068e43ca.css",revision:"9e0a1c9c068e43ca"},{url:"/custom-logo.png",revision:"d41d8cd98f00b204e9800998ecf8427e"},{url:"/logo.png",revision:"084f394cfe19b7d85abec9878a797795"},{url:"/logo_TFM.png",revision:"084f394cfe19b7d85abec9878a797795"},{url:"/manifest.json",revision:"7b6e42d577201b657b72b8698d44d0b2"},{url:"/network.json",revision:"b00477650779657bbd360477fa7e4fc7"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:n,state:t})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")}); +if(!self.define){let e,s={};const n=(n,t)=>(n=new URL(n+".js",t).href,s[n]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=s,document.head.appendChild(e)}else e=n,importScripts(n),s()}).then(()=>{let e=s[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(t,a)=>{const c=e||("document"in self?document.currentScript.src:"")||location.href;if(s[c])return;let i={};const r=e=>n(e,c),o={module:{uri:c},exports:i,require:r};s[c]=Promise.all(t.map(e=>o[e]||r(e))).then(e=>(a(...e),i))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/app-build-manifest.json",revision:"33d4b2bcb6ee641b2f2dee1a2b476ec6"},{url:"/_next/static/UEJyF8wdUNnSt70NFcxA_/_buildManifest.js",revision:"31eba5c94d685fe15b2d866310094bce"},{url:"/_next/static/UEJyF8wdUNnSt70NFcxA_/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/chunks/13.ae2fd9645430ed90.js",revision:"ae2fd9645430ed90"},{url:"/_next/static/chunks/158-dba1f3c61bdd6a36.js",revision:"dba1f3c61bdd6a36"},{url:"/_next/static/chunks/255-4f212684648fcab9.js",revision:"4f212684648fcab9"},{url:"/_next/static/chunks/374-a4ed2a0d861d9cab.js",revision:"a4ed2a0d861d9cab"},{url:"/_next/static/chunks/4bd1b696-c023c6e3521b1417.js",revision:"c023c6e3521b1417"},{url:"/_next/static/chunks/512-ce90bee899e5ddb3.js",revision:"ce90bee899e5ddb3"},{url:"/_next/static/chunks/7cb1fa1f-32fc9056ac653916.js",revision:"32fc9056ac653916"},{url:"/_next/static/chunks/802-838213ff73429b14.js",revision:"838213ff73429b14"},{url:"/_next/static/chunks/814-d4585cd22c8d1c30.js",revision:"d4585cd22c8d1c30"},{url:"/_next/static/chunks/app/_not-found/page-e122f8f409e58055.js",revision:"e122f8f409e58055"},{url:"/_next/static/chunks/app/admin/page-b32529b7a6d4e675.js",revision:"b32529b7a6d4e675"},{url:"/_next/static/chunks/app/inventory/page-f4fc5e180c7a3fe6.js",revision:"f4fc5e180c7a3fe6"},{url:"/_next/static/chunks/app/layout-eb0f519b25043024.js",revision:"eb0f519b25043024"},{url:"/_next/static/chunks/app/login/page-24c8c1eecd6671f9.js",revision:"24c8c1eecd6671f9"},{url:"/_next/static/chunks/app/logs/page-937f3663cbbc2ebb.js",revision:"937f3663cbbc2ebb"},{url:"/_next/static/chunks/app/page-985258059cabf681.js",revision:"985258059cabf681"},{url:"/_next/static/chunks/framework-050c1f32293f7182.js",revision:"050c1f32293f7182"},{url:"/_next/static/chunks/main-aa573100fdde0547.js",revision:"aa573100fdde0547"},{url:"/_next/static/chunks/main-app-613127bff4bcda66.js",revision:"613127bff4bcda66"},{url:"/_next/static/chunks/pages/_app-7d307437aca18ad4.js",revision:"7d307437aca18ad4"},{url:"/_next/static/chunks/pages/_error-cb2a52f75f2162e2.js",revision:"cb2a52f75f2162e2"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-7a9cf6b863b8c67d.js",revision:"7a9cf6b863b8c67d"},{url:"/_next/static/css/8fc043d41b3393ed.css",revision:"8fc043d41b3393ed"},{url:"/custom-logo.png",revision:"d41d8cd98f00b204e9800998ecf8427e"},{url:"/logo.png",revision:"084f394cfe19b7d85abec9878a797795"},{url:"/logo_TFM.png",revision:"084f394cfe19b7d85abec9878a797795"},{url:"/manifest.json",revision:"7b6e42d577201b657b72b8698d44d0b2"},{url:"/network.json",revision:"b00477650779657bbd360477fa7e4fc7"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:n,state:t})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")}); diff --git a/frontend/tests/components/ManualCropUI.test.tsx b/frontend/tests/components/ManualCropUI.test.tsx new file mode 100644 index 00000000..a974f958 --- /dev/null +++ b/frontend/tests/components/ManualCropUI.test.tsx @@ -0,0 +1,450 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ManualCropUI from '@/components/ManualCropUI'; +import type { CropBounds } from '@/hooks/useCropHandles'; + +describe('ManualCropUI Component', () => { + const mockOnCropChange = vi.fn(); + const testImageUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + const imageDimensions = { width: 800, height: 600 }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Rendering', () => { + it('should render component with image element', () => { + render( + + ); + + const image = screen.getByAltText('Photo preview'); + expect(image).toBeInTheDocument(); + }); + + it('should render container with proper structure', () => { + const { container } = render( + + ); + + const cropContainer = container.querySelector('div'); + expect(cropContainer).toBeInTheDocument(); + }); + + it('should show loading state when dimensions not provided', () => { + render( + + ); + + // Component should render image element even without dimensions + const image = screen.getByAltText('Photo preview'); + expect(image).toBeInTheDocument(); + // Should show loading message + expect(screen.getByText('Loading image...')).toBeInTheDocument(); + }); + + it('should render "Use Full Photo" button when crop is active', () => { + const initialCrop: CropBounds = { x: 100, y: 100, width: 200, height: 200 }; + + render( + + ); + + const button = screen.getByRole('button', { name: /use full photo/i }); + expect(button).toBeInTheDocument(); + }); + + it('should not render "Use Full Photo" button when no crop is set', () => { + render( + + ); + + const button = screen.queryByRole('button', { name: /use full photo/i }); + expect(button).not.toBeInTheDocument(); + }); + }); + + describe('Crop Handle Rendering', () => { + it('should render 8 drag handles when crop is active', () => { + const initialCrop: CropBounds = { x: 100, y: 100, width: 200, height: 200 }; + + const { container } = render( + + ); + + const handles = container.querySelectorAll('button[aria-label*="Drag"]'); + expect(handles.length).toBe(8); // 4 corners + 4 edges + }); + + it('should render corner handles', () => { + const initialCrop: CropBounds = { x: 100, y: 100, width: 200, height: 200 }; + + render( + + ); + + expect(screen.getByLabelText('Drag top-left handle')).toBeInTheDocument(); + expect(screen.getByLabelText('Drag top-right handle')).toBeInTheDocument(); + expect(screen.getByLabelText('Drag bottom-left handle')).toBeInTheDocument(); + expect(screen.getByLabelText('Drag bottom-right handle')).toBeInTheDocument(); + }); + + it('should render edge handles', () => { + const initialCrop: CropBounds = { x: 100, y: 100, width: 200, height: 200 }; + + render( + + ); + + expect(screen.getByLabelText('Drag top handle')).toBeInTheDocument(); + expect(screen.getByLabelText('Drag right handle')).toBeInTheDocument(); + expect(screen.getByLabelText('Drag bottom handle')).toBeInTheDocument(); + expect(screen.getByLabelText('Drag left handle')).toBeInTheDocument(); + }); + + it('should not render handles when no crop is active', () => { + const { container } = render( + + ); + + const handles = container.querySelectorAll('button[aria-label*="Drag"]'); + expect(handles.length).toBe(0); + }); + }); + + describe('Semi-transparent Overlay', () => { + it('should render overlay elements when crop is active', () => { + const initialCrop: CropBounds = { x: 100, y: 100, width: 200, height: 200 }; + + const { container } = render( + + ); + + const overlays = container.querySelectorAll('div[class*="bg-black"]'); + expect(overlays.length).toBeGreaterThan(0); + }); + + it('should render bounding box with cyan border', () => { + const initialCrop: CropBounds = { x: 100, y: 100, width: 200, height: 200 }; + + const { container } = render( + + ); + + const bbox = container.querySelector('div[class*="border-cyan"]'); + expect(bbox).toBeInTheDocument(); + expect(bbox).toHaveClass('border-cyan-400'); + }); + + it('should not render overlay when no crop is active', () => { + const { container } = render( + + ); + + const overlays = container.querySelectorAll('div[class*="bg-black/40"]'); + expect(overlays.length).toBe(0); + }); + }); + + describe('onCropChange Callback', () => { + it('should call onCropChange with initial crop', () => { + const initialCrop: CropBounds = { x: 100, y: 100, width: 200, height: 200 }; + + render( + + ); + + expect(mockOnCropChange).toHaveBeenCalledWith(initialCrop); + }); + + it('should call onCropChange with null when no crop', () => { + render( + + ); + + expect(mockOnCropChange).toHaveBeenCalledWith(null); + }); + }); + + describe('Use Full Photo Button', () => { + it('should clear crop when "Use Full Photo" is clicked', async () => { + const initialCrop: CropBounds = { x: 100, y: 100, width: 200, height: 200 }; + + render( + + ); + + const user = userEvent.setup(); + const button = screen.getByRole('button', { name: /use full photo/i }); + + await user.click(button); + + await waitFor(() => { + expect(mockOnCropChange).toHaveBeenCalledWith(null); + }); + }); + + it('should hide handles after clearing crop', async () => { + const initialCrop: CropBounds = { x: 100, y: 100, width: 200, height: 200 }; + + const { container } = render( + + ); + + const user = userEvent.setup(); + const button = screen.getByRole('button', { name: /use full photo/i }); + + await user.click(button); + + await waitFor(() => { + const handles = container.querySelectorAll('button[aria-label*="Drag"]'); + expect(handles.length).toBe(0); + }); + }); + }); + + describe('Error Handling', () => { + it('should have error handling for image load failures', () => { + const { container } = render( + + ); + + // Component should render without crashing + const image = screen.getByAltText('Photo preview'); + expect(image).toBeInTheDocument(); + }); + }); + + describe('Image Dimension Handling', () => { + it('should use provided imageDimensions prop', () => { + const { container } = render( + + ); + + // Component should render successfully with imageDimensions + const image = screen.getByAltText('Photo preview'); + expect(image).toBeInTheDocument(); + }); + }); + + describe('Touch Support', () => { + it('should render handles with touch support', () => { + const initialCrop: CropBounds = { x: 100, y: 100, width: 200, height: 200 }; + + render( + + ); + + const handle = screen.getByLabelText('Drag top-left handle'); + // Check that ontouchstart is defined (event handler exists) + expect(handle).toBeDefined(); + }); + }); + + describe('Mouse Support', () => { + it('should render handles with mouse support', () => { + const initialCrop: CropBounds = { x: 100, y: 100, width: 200, height: 200 }; + + render( + + ); + + const handle = screen.getByLabelText('Drag top-left handle'); + // Check that onmousedown is defined (event handler exists) + expect(handle).toBeDefined(); + }); + }); + + describe('Responsive Behavior', () => { + it('should render handles with consistent sizing', () => { + const initialCrop: CropBounds = { x: 100, y: 100, width: 200, height: 200 }; + + const { container } = render( + + ); + + const handles = container.querySelectorAll('button[aria-label*="Drag"]'); + handles.forEach((handle) => { + // Each handle should have width and height of 12px + expect(handle).toHaveStyle('width: 12px'); + expect(handle).toHaveStyle('height: 12px'); + }); + }); + }); + + describe('Minimum Crop Size Enforcement', () => { + it('should enforce minimum crop size', () => { + const tinyBounds: CropBounds = { x: 100, y: 100, width: 10, height: 10 }; + + render( + + ); + + // onCropChange should be called with constrained bounds + const calls = mockOnCropChange.mock.calls; + const constrainedCrop = calls[calls.length - 1]?.[0]; + if (constrainedCrop) { + expect(constrainedCrop.width).toBeGreaterThanOrEqual(100); + expect(constrainedCrop.height).toBeGreaterThanOrEqual(100); + } + }); + }); + + describe('Crop Bounds Display', () => { + it('should display crop bounds debug information', () => { + const initialCrop: CropBounds = { x: 150, y: 200, width: 250, height: 300 }; + + render( + + ); + + const debugText = screen.getByText(/crop:/i); + expect(debugText).toBeInTheDocument(); + }); + + it('should not display bounds when no crop is set', () => { + render( + + ); + + const debugText = screen.queryByText(/crop:/i); + expect(debugText).not.toBeInTheDocument(); + }); + }); + + describe('TypeScript Strict Mode Compliance', () => { + it('should accept required and optional props', () => { + const { container } = render( + + ); + + expect(container).toBeInTheDocument(); + }); + + it('should work with minimal required props', () => { + const { container } = render( + + ); + + expect(container).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/tests/hooks/useCropHandles.test.ts b/frontend/tests/hooks/useCropHandles.test.ts new file mode 100644 index 00000000..6c32bc95 --- /dev/null +++ b/frontend/tests/hooks/useCropHandles.test.ts @@ -0,0 +1,451 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useCropHandles, type CropBounds } from '@/hooks/useCropHandles'; + +describe('useCropHandles Hook', () => { + const imageDimensions = { width: 800, height: 600 }; + const initialCrop: CropBounds = { x: 100, y: 100, width: 200, height: 200 }; + + describe('Initialization', () => { + it('should initialize with null crop when no initialCrop provided', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions }) + ); + + expect(result.current.crop).toBeNull(); + expect(result.current.isDragging).toBe(false); + }); + + it('should initialize with provided initialCrop', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions, initialCrop }) + ); + + expect(result.current.crop).toEqual(initialCrop); + }); + + it('should enforce minimum size on initialCrop', () => { + const tinyBounds: CropBounds = { x: 50, y: 50, width: 10, height: 10 }; + const { result } = renderHook(() => + useCropHandles({ imageDimensions, initialCrop: tinyBounds, minSize: 100 }) + ); + + expect(result.current.crop).toEqual({ + x: 50, + y: 50, + width: 100, + height: 100, + }); + }); + }); + + describe('setCrop', () => { + it('should set crop bounds', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions }) + ); + + const newBounds: CropBounds = { x: 50, y: 50, width: 300, height: 300 }; + + act(() => { + result.current.setCrop(newBounds); + }); + + expect(result.current.crop).toEqual(newBounds); + }); + + it('should clear crop with null', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions, initialCrop }) + ); + + act(() => { + result.current.setCrop(null); + }); + + expect(result.current.crop).toBeNull(); + }); + + it('should constrain bounds within image', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions }) + ); + + const outOfBounds: CropBounds = { x: 600, y: 400, width: 400, height: 400 }; + + act(() => { + result.current.setCrop(outOfBounds); + }); + + expect(result.current.crop!.x).toBeLessThanOrEqual(imageDimensions.width - result.current.crop!.width); + expect(result.current.crop!.y).toBeLessThanOrEqual(imageDimensions.height - result.current.crop!.height); + }); + + it('should enforce minimum size', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions, minSize: 100 }) + ); + + const smallBounds: CropBounds = { x: 50, y: 50, width: 20, height: 20 }; + + act(() => { + result.current.setCrop(smallBounds); + }); + + expect(result.current.crop!.width).toBeGreaterThanOrEqual(100); + expect(result.current.crop!.height).toBeGreaterThanOrEqual(100); + }); + }); + + describe('resetCrop', () => { + it('should clear crop bounds', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions, initialCrop }) + ); + + expect(result.current.crop).not.toBeNull(); + + act(() => { + result.current.resetCrop(); + }); + + expect(result.current.crop).toBeNull(); + }); + }); + + describe('Drag Operations - Corner Handles', () => { + it('should drag top-left corner inward', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions, initialCrop }) + ); + + act(() => { + result.current.startDrag('top-left', 100, 100); + }); + + expect(result.current.isDragging).toBe(true); + + act(() => { + result.current.moveDrag(120, 120); + }); + + expect(result.current.crop!.x).toBe(120); + expect(result.current.crop!.y).toBe(120); + expect(result.current.crop!.width).toBe(180); + expect(result.current.crop!.height).toBe(180); + }); + + it('should drag top-right corner', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions, initialCrop }) + ); + + act(() => { + result.current.startDrag('top-right', 300, 100); + }); + + act(() => { + result.current.moveDrag(350, 130); + }); + + expect(result.current.crop!.x).toBe(100); + expect(result.current.crop!.y).toBe(130); + expect(result.current.crop!.width).toBe(250); + expect(result.current.crop!.height).toBe(170); + }); + + it('should drag bottom-right corner', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions, initialCrop }) + ); + + act(() => { + result.current.startDrag('bottom-right', 300, 300); + }); + + act(() => { + result.current.moveDrag(350, 380); + }); + + expect(result.current.crop!.x).toBe(100); + expect(result.current.crop!.y).toBe(100); + expect(result.current.crop!.width).toBe(250); + expect(result.current.crop!.height).toBe(280); + }); + + it('should drag bottom-left corner', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions, initialCrop }) + ); + + act(() => { + result.current.startDrag('bottom-left', 100, 300); + }); + + act(() => { + result.current.moveDrag(140, 380); + }); + + expect(result.current.crop!.x).toBe(140); + expect(result.current.crop!.y).toBe(100); + expect(result.current.crop!.width).toBe(160); + expect(result.current.crop!.height).toBe(280); + }); + }); + + describe('Drag Operations - Edge Handles', () => { + it('should drag top edge', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions, initialCrop }) + ); + + act(() => { + result.current.startDrag('top', 200, 100); + }); + + act(() => { + result.current.moveDrag(200, 130); + }); + + expect(result.current.crop!.x).toBe(100); + expect(result.current.crop!.y).toBe(130); + expect(result.current.crop!.width).toBe(200); + expect(result.current.crop!.height).toBe(170); + }); + + it('should drag right edge', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions, initialCrop }) + ); + + act(() => { + result.current.startDrag('right', 300, 200); + }); + + act(() => { + result.current.moveDrag(380, 200); + }); + + expect(result.current.crop!.x).toBe(100); + expect(result.current.crop!.y).toBe(100); + expect(result.current.crop!.width).toBe(280); + expect(result.current.crop!.height).toBe(200); + }); + + it('should drag bottom edge', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions, initialCrop }) + ); + + act(() => { + result.current.startDrag('bottom', 200, 300); + }); + + act(() => { + result.current.moveDrag(200, 380); + }); + + expect(result.current.crop!.x).toBe(100); + expect(result.current.crop!.y).toBe(100); + expect(result.current.crop!.width).toBe(200); + expect(result.current.crop!.height).toBe(280); + }); + + it('should drag left edge', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions, initialCrop }) + ); + + act(() => { + result.current.startDrag('left', 100, 200); + }); + + act(() => { + result.current.moveDrag(140, 200); + }); + + expect(result.current.crop!.x).toBe(140); + expect(result.current.crop!.y).toBe(100); + expect(result.current.crop!.width).toBe(160); + expect(result.current.crop!.height).toBe(200); + }); + }); + + describe('Drag Constraints', () => { + it('should not allow dragging outside left boundary', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions, initialCrop }) + ); + + act(() => { + result.current.startDrag('left', 100, 200); + }); + + act(() => { + result.current.moveDrag(-100, 200); // Try to drag far left + }); + + expect(result.current.crop!.x).toBeGreaterThanOrEqual(0); + }); + + it('should not allow dragging outside right boundary', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions, initialCrop }) + ); + + act(() => { + result.current.startDrag('right', 300, 200); + }); + + act(() => { + result.current.moveDrag(900, 200); // Try to drag far right + }); + + const { crop } = result.current; + expect(crop!.x + crop!.width).toBeLessThanOrEqual(imageDimensions.width); + }); + + it('should not allow dragging outside top boundary', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions, initialCrop }) + ); + + act(() => { + result.current.startDrag('top', 200, 100); + }); + + act(() => { + result.current.moveDrag(200, -100); // Try to drag far up + }); + + expect(result.current.crop!.y).toBeGreaterThanOrEqual(0); + }); + + it('should not allow dragging outside bottom boundary', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions, initialCrop }) + ); + + act(() => { + result.current.startDrag('bottom', 200, 300); + }); + + act(() => { + result.current.moveDrag(200, 800); // Try to drag far down + }); + + const { crop } = result.current; + expect(crop!.y + crop!.height).toBeLessThanOrEqual(imageDimensions.height); + }); + + it('should not allow crop smaller than minSize', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions, initialCrop, minSize: 100 }) + ); + + act(() => { + result.current.startDrag('bottom-right', 300, 300); + }); + + act(() => { + result.current.moveDrag(180, 180); // Try to make tiny crop + }); + + expect(result.current.crop!.width).toBeGreaterThanOrEqual(100); + expect(result.current.crop!.height).toBeGreaterThanOrEqual(100); + }); + }); + + describe('Drag End', () => { + it('should end drag and set isDragging to false', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions, initialCrop }) + ); + + act(() => { + result.current.startDrag('top-left', 100, 100); + }); + + expect(result.current.isDragging).toBe(true); + + act(() => { + result.current.moveDrag(120, 120); + }); + + act(() => { + result.current.endDrag(); + }); + + expect(result.current.isDragging).toBe(false); + }); + + it('should preserve crop bounds after endDrag', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions, initialCrop }) + ); + + act(() => { + result.current.startDrag('right', 300, 200); + }); + + act(() => { + result.current.moveDrag(350, 200); + }); + + const boundsBeforeEnd = { ...result.current.crop! }; + + act(() => { + result.current.endDrag(); + }); + + expect(result.current.crop).toEqual(boundsBeforeEnd); + }); + }); + + describe('Edge Cases', () => { + it('should not crash when dragging without starting', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions, initialCrop }) + ); + + expect(() => { + act(() => { + result.current.moveDrag(150, 150); + }); + }).not.toThrow(); + }); + + it('should handle very large image dimensions', () => { + const largeDimensions = { width: 10000, height: 8000 }; + const { result } = renderHook(() => + useCropHandles({ imageDimensions: largeDimensions, initialCrop }) + ); + + act(() => { + result.current.startDrag('bottom-right', 300, 300); + }); + + act(() => { + result.current.moveDrag(5000, 4000); + }); + + expect(result.current.crop).toBeDefined(); + expect(result.current.crop!.x + result.current.crop!.width).toBeLessThanOrEqual(largeDimensions.width); + }); + + it('should handle custom minimum size', () => { + const { result } = renderHook(() => + useCropHandles({ imageDimensions, minSize: 50 }) + ); + + const smallBounds: CropBounds = { x: 10, y: 10, width: 20, height: 20 }; + + act(() => { + result.current.setCrop(smallBounds); + }); + + expect(result.current.crop!.width).toBeGreaterThanOrEqual(50); + expect(result.current.crop!.height).toBeGreaterThanOrEqual(50); + }); + }); +}); diff --git a/images/networking/testitem_d8328c2d_thumb.jpg b/images/networking/testitem_d8328c2d_thumb.jpg new file mode 100644 index 00000000..d2349a6d Binary files /dev/null and b/images/networking/testitem_d8328c2d_thumb.jpg differ diff --git a/images/networking/testitem_e1787985_thumb.jpg b/images/networking/testitem_e1787985_thumb.jpg new file mode 100644 index 00000000..802fdf20 Binary files /dev/null and b/images/networking/testitem_e1787985_thumb.jpg differ diff --git a/images/networking/testitem_original.jpg b/images/networking/testitem_original.jpg new file mode 100644 index 00000000..89e38f48 Binary files /dev/null and b/images/networking/testitem_original.jpg differ diff --git a/images/networking/testitem_thumb.jpg b/images/networking/testitem_thumb.jpg new file mode 100644 index 00000000..d2349a6d Binary files /dev/null and b/images/networking/testitem_thumb.jpg differ