From e32527ae8c82853fa150b4338bf9a9a8ab787683 Mon Sep 17 00:00:00 2001 From: Iliyan Angelov Date: Tue, 2 Dec 2025 22:16:57 +0200 Subject: [PATCH] update --- .../__pycache__/room_routes.cpython-312.pyc | Bin 53176 -> 53256 bytes Backend/src/rooms/routes/room_routes.py | 45 +- Frontend/src/pages/admin/EditRoomPage.tsx | 430 ++++++++++++++++-- 3 files changed, 432 insertions(+), 43 deletions(-) diff --git a/Backend/src/rooms/routes/__pycache__/room_routes.cpython-312.pyc b/Backend/src/rooms/routes/__pycache__/room_routes.cpython-312.pyc index 9d26b61e43931a87ae18fd912e2dd665481f4b94..586b0efb9d4d0d63be548fe250ec780e2cf197ec 100644 GIT binary patch delta 1410 zcmZ8fZA@EL7(S=Bz5Teiy@gxoZJ~UW4ycUp4;h1)4Gpj#I0l)be2jPzL15;#4un&1 zMq`cqAUjbpn-SDR7PbLmBE-Z1o5G^WdMT!jnGt@N7=LxB(L{fE4y+s5P0sT^=e*~A z-uFHCUH+H}PBWJ8&1MrI-``we?~O}~mV0yXlS|hYapsMr1nnH8E&zt1F98As=_%%$ zjL88Vf&o?r0X@rk}T_G(9Z{qMu3ccDR^dDY%;{_dL%mI(P*Eh2LudcnULX0 z&>jFFz>$I_#3A~a3SMbK<=G=DDC&elg~l? zO@$LrU9iz5$%YXQ0NF?p=_v**$kOM%a-a-i(h?F;{gf_0e#X|FbOAe@GtW&{PkD7E-xS;1~<*8 z;s4&_%$7qBBw*ngyC{qaLvRQTGp%3}z4?u`Or&a%-ffNv&xLooNf# zvqKcZIYaF?p&WEapaulFh5yyt7$9+6strw)gXn znU00p?IV>RzOV{cvsROqdndVQ^%J*BS4YFO&1!Y?XP)WNu)8$kuH150hFaxGAuc!j z{BmPB|CHL&7CLu63T5=HghDAE zS*wKQcxmm7u8JI7T{5jFYGDO-efJkf2TP%%N)>i(IAP0D<;Ji2k{Vb&Eye&b3kN1N iGPb{s0q}}uNV7*>e=tDv9E@oQjg8@_?{sUMiO~OHl!Z?K delta 1281 zcmZ8fe@q)y9DnbwM|-_%uh1)u{y55@KnrUD`7tMR8R#HR8BX2irefV8D+1zCH$(22 zGfS+IM6(wSi!+ozj3i*BKw>e*>_-%$F>tn~b>biXVd7t=>P$3d>U-sfKQ4Kn@8^Ag ze(t>&i_^&|+WNW0Vg~r@dnXvFdM#o7X%?QjG?$<>2E9M$rA7;}DM9>$tXFZ$1+sC_ zIAqcaAfSL+pu$;5Y5~xUsu6|X6Ac=pGAJ?UKpWHMQbsnZ%v<1X+$`$YOawq>W+57H zaZ;KIS)VILJujQ;H8$$niL+7nHlL+7nNo~LgF!(vvp_Wul%wgm{Q$c&{J+&z|F0gU z`hmvc#i{H);PInaF0b-iDp^UPRDRfirSf^HmVZgLY)TDiR+U$oXIcRmg%d5RWrw$^ zR@JoSZ&$eiKT+o6x0OLK@vs5>(=Zy?(FV~+XPxNBvm#l$WOj@LKsFOpO$0ST6$V1+ zT9vavv0>VPzVo{+DyJHH`K>1Cf>JKZoXpGEAAz#<3jGkMzz2l9AB*m2<@T(nsY;+Q z5Rg-N6iobn4xlfNx$I5baU8AHY$`p_if4Gx?KjL8A?n$Vsx~AXFQIMYHZ+;^q7UZ9 zB62H!c+>Q}GB^@eM*EZy(&z7kZgk;INm6M1CT{XLHW+y-xEXg5mtiCvJ%bJ;!mtoc zC!B8U@UzNLcrY^7C->rPC7)D~FObnUiFT$CkMW}Ng@D6{>wECjQ4@wAz;7>toS_yy zxzHBQmhO&Ovz(aW%5<(Q&ZfD*Oe7O**Msd>_oai~-|~CDc6cTuDMwB0P}Wm1b?Wk| zSa(+NWP}=BsF|^+g+19aZ(LgU)~|aSV%;0|l1XdIUY)Vm>Gryq3H`Zn_`HFwjdf(p zE8mr-rFlAC-gLPlc`$i8`AE_qJG>$7j^CA%LK&$^mzrWnvO>}8!zrP1#+9_D{D)SL ztoQb(?(fgqq>Rm{+kElUU)cgV7Wcem29DAzyHIyV^y{KOBi8F;eMW56#nvQtbX@J% z#Rt}VPIz?jM2yY*#@ z!}tJSf?bX9Vnni_%2c3xs`1AVr{+_?ii{pwF)Dg?kZw{V1~^J0=%TUN17hJJHamzjEXWD5R;f z9}TTI;qk@T%J;^`I@lJs=KwbIn`V7%JipBWFhZT9ekP$`Xh5BWISSL { const [roomTypes, setRoomTypes] = useState>([]); const [deletingImageUrl, setDeletingImageUrl] = useState(null); const [failedImageUrls, setFailedImageUrls] = useState>(new Set()); + const [loadingImageUrls, setLoadingImageUrls] = useState>(new Set()); const [formData, setFormData] = useState({ room_number: '', @@ -46,6 +47,44 @@ const EditRoomPage: React.FC = () => { fetchRoomTypes(); }, [id]); + // Reset image loading states when room data changes + useEffect(() => { + if (editingRoom) { + // Clear failed images when room data is loaded/refreshed + setFailedImageUrls(new Set()); + setLoadingImageUrls(new Set()); + + // Check for cached images after a brief delay to allow DOM to render + setTimeout(() => { + const allImageElements = document.querySelectorAll('img[data-image-id^="room-image-"]'); + allImageElements.forEach((img) => { + const imgElement = img as HTMLImageElement; + if (imgElement.complete && imgElement.naturalWidth > 0) { + const imageId = imgElement.getAttribute('data-image-id'); + if (imageId) { + // Extract the original path from the image ID + const match = imageId.match(/room-image-\d+-(.+)/); + if (match) { + const originalPath = match[1]; + console.log('EditRoomPage - Found cached image on mount:', originalPath); + setFailedImageUrls(prev => { + const newSet = new Set(prev); + newSet.delete(originalPath); + return newSet; + }); + setLoadingImageUrls(prev => { + const newSet = new Set(prev); + newSet.delete(originalPath); + return newSet; + }); + } + } + } + }); + }, 100); + } + }, [editingRoom?.id]); + const fetchRoomTypes = async () => { try { const response = await roomService.getRooms({ limit: 100, page: 1 }); @@ -98,7 +137,7 @@ const EditRoomPage: React.FC = () => { }; const fetchRoomData = async () => { - if (!id) return; + if (!id) return null; try { setLoading(true); @@ -136,10 +175,13 @@ const EditRoomPage: React.FC = () => { view: room.view || '', amenities: amenitiesArray, }); + + return room; } catch (error: any) { logger.error('Failed to fetch room data', error); toast.error(error.response?.data?.message || 'Failed to load room data'); navigate('/admin/advanced-rooms'); + return null; } finally { setLoading(false); } @@ -299,24 +341,110 @@ const EditRoomPage: React.FC = () => { // Immediately mark as failed to prevent error handler from firing setFailedImageUrls(prev => new Set([...prev, imageUrl])); + // For external URLs (like Unsplash), keep the full URL + // For local files, extract the path let imagePath = imageUrl; - if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) { - try { - const url = new URL(imageUrl); - imagePath = url.pathname; - } catch (e) { - const match = imageUrl.match(/(\/uploads\/.*)/); - imagePath = match ? match[1] : imageUrl; + const isExternalUrl = imageUrl.startsWith('http://') || imageUrl.startsWith('https://'); + + if (isExternalUrl) { + // For external URLs, use the full URL as stored in database + imagePath = imageUrl; + } else { + // For local files, extract the path + if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) { + try { + const url = new URL(imageUrl); + imagePath = url.pathname; + } catch (e) { + const match = imageUrl.match(/(\/uploads\/.*)/); + imagePath = match ? match[1] : imageUrl; + } } } - - await apiClient.delete(`/rooms/${editingRoom.id}/images`, { - params: { image_url: imagePath }, + + // Also try the normalized format for comparison + const normalizedForDb = normalizeForComparison(imageUrl); + + // Log what we're trying to delete + console.log('EditRoomPage - Deleting image:', { + originalUrl: imageUrl, + imagePath, + normalizedForDb, + isExternalUrl, + roomImages: editingRoom.images }); + // Try deleting with the full URL/path first (most likely to match database) + let deleteSuccess = false; + try { + const response = await apiClient.delete(`/rooms/${editingRoom.id}/images`, { + params: { image_url: imagePath }, + }); + console.log('EditRoomPage - Delete successful with imagePath:', response.data); + deleteSuccess = true; + } catch (firstError: any) { + console.warn('EditRoomPage - Delete failed with imagePath, trying normalizedForDb:', firstError.response?.data); + // If that fails and formats are different, try with the normalized format + if (normalizedForDb !== imagePath && !isExternalUrl) { + try { + const response = await apiClient.delete(`/rooms/${editingRoom.id}/images`, { + params: { image_url: normalizedForDb }, + }); + console.log('EditRoomPage - Delete successful with normalizedForDb:', response.data); + deleteSuccess = true; + } catch (secondError: any) { + console.error('EditRoomPage - Delete failed with both formats:', secondError.response?.data); + throw secondError; + } + } else { + throw firstError; + } + } + + if (!deleteSuccess) { + throw new Error('Failed to delete image'); + } + toast.success('Image deleted successfully'); + + // Clear the image from state immediately + setFailedImageUrls(prev => { + const newSet = new Set(prev); + newSet.delete(imageUrl); + newSet.delete(imagePath); + newSet.delete(normalizedForDb); + return newSet; + }); + setLoadingImageUrls(prev => { + const newSet = new Set(prev); + newSet.delete(imageUrl); + newSet.delete(imagePath); + newSet.delete(normalizedForDb); + return newSet; + }); + + // Refetch room data to get updated image list await refreshRooms(); - await fetchRoomData(); + const updatedRoom = await fetchRoomData(); + + // Verify the image was actually removed + if (updatedRoom) { + const stillExists = (updatedRoom.images || []).some((img: any) => { + const imgStr = String(img); + return imgStr === imageUrl || + imgStr === imagePath || + imgStr === normalizedForDb || + normalizeForComparison(imgStr) === normalizedForDb; + }); + + if (stillExists) { + console.warn('EditRoomPage - Image still exists after deletion:', { + imageUrl, + updatedImages: updatedRoom.images + }); + toast.warning('Image may still be in the list. Please refresh the page.'); + } + } } catch (error: any) { logger.error('Error deleting image', error); toast.error(error.response?.data?.message || error.response?.data?.detail || 'Unable to delete image'); @@ -331,6 +459,78 @@ const EditRoomPage: React.FC = () => { } }; + const handleRemoveBrokenImage = async (imageUrl: string) => { + if (!editingRoom) return; + if (!window.confirm('This image file is missing. Remove it from the room?')) return; + + try { + setDeletingImageUrl(imageUrl); + + // For external URLs (like Unsplash), keep the full URL + // For local files, extract the path + let imagePath = imageUrl; + const isExternalUrl = imageUrl.startsWith('http://') || imageUrl.startsWith('https://'); + + if (isExternalUrl) { + // For external URLs, use the full URL as stored in database + imagePath = imageUrl; + } else { + // For local files, extract the path + if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) { + try { + const url = new URL(imageUrl); + imagePath = url.pathname; + } catch (e) { + const match = imageUrl.match(/(\/uploads\/.*)/); + imagePath = match ? match[1] : imageUrl; + } + } + } + + // Also try the normalized format for comparison + const normalizedForDb = normalizeForComparison(imageUrl); + + // Try deleting with the normalized path first + try { + await apiClient.delete(`/rooms/${editingRoom.id}/images`, { + params: { image_url: imagePath }, + }); + } catch (firstError: any) { + // If that fails, try with the database format + if (normalizedForDb !== imagePath) { + await apiClient.delete(`/rooms/${editingRoom.id}/images`, { + params: { image_url: normalizedForDb }, + }); + } else { + throw firstError; + } + } + + toast.success('Broken image reference removed'); + + // Clear the image from state immediately before refetching + setFailedImageUrls(prev => { + const newSet = new Set(prev); + newSet.delete(imageUrl); + return newSet; + }); + setLoadingImageUrls(prev => { + const newSet = new Set(prev); + newSet.delete(imageUrl); + return newSet; + }); + + // Refetch room data to get updated image list + await refreshRooms(); + await fetchRoomData(); + } catch (error: any) { + logger.error('Error removing broken image', error); + toast.error(error.response?.data?.message || error.response?.data?.detail || 'Unable to remove image reference'); + } finally { + setDeletingImageUrl(null); + } + }; + if (loading) { return ; } @@ -379,8 +579,44 @@ const EditRoomPage: React.FC = () => { return ''; } - // Otherwise, construct the full URL - const cleanPath = img.startsWith('/') ? img : `/${img}`; + // Handle local file paths (like room-*.png) + let cleanPath = img.trim(); + + // If it's just a filename (like "room-xxx.png"), add the uploads/rooms path + if (cleanPath.match(/^room-[\w-]+\.(png|jpg|jpeg|gif|webp|webm)$/i)) { + cleanPath = `/uploads/rooms/${cleanPath}`; + } + // If it starts with / but not /uploads, check if it's a room filename + else if (cleanPath.startsWith('/') && !cleanPath.startsWith('/uploads/')) { + if (cleanPath.match(/^\/room-[\w-]+\.(png|jpg|jpeg|gif|webp|webm)$/i)) { + cleanPath = `/uploads/rooms${cleanPath}`; + } else { + // Assume it should be in uploads + cleanPath = `/uploads${cleanPath}`; + } + } + // If it doesn't start with /, add it + else if (!cleanPath.startsWith('/')) { + // Check if it looks like a room filename + if (cleanPath.match(/^room-[\w-]+\.(png|jpg|jpeg|gif|webp|webm)$/i)) { + cleanPath = `/uploads/rooms/${cleanPath}`; + } else { + cleanPath = `/uploads/${cleanPath}`; + } + } + // If it already has /uploads/rooms/, use it as is + else if (cleanPath.startsWith('/uploads/rooms/')) { + // Already correct + } + // If it has /uploads/ but not /uploads/rooms/, check if it's a room file + else if (cleanPath.startsWith('/uploads/') && !cleanPath.startsWith('/uploads/rooms/')) { + const filename = cleanPath.split('/').pop() || ''; + if (filename.match(/^room-[\w-]+\.(png|jpg|jpeg|gif|webp|webm)$/i)) { + cleanPath = `/uploads/rooms/${filename}`; + } + } + + // Construct the full URL return `${apiBaseUrl}${cleanPath}`; }; @@ -417,7 +653,9 @@ const EditRoomPage: React.FC = () => { roomTypeImages: roomTypeImages.length, allImages: allImages.length, sampleImage: allImages[0], - normalizedUrl: allImages[0] ? normalizeImageUrl(String(allImages[0])) : 'none' + normalizedUrl: allImages[0] ? normalizeImageUrl(String(allImages[0])) : 'none', + allImagePaths: allImages.map((img: any) => String(img)), + normalizedUrls: allImages.map((img: any) => normalizeImageUrl(String(img))) }); } @@ -880,8 +1118,8 @@ const EditRoomPage: React.FC = () => { const filteredImages = allImages.filter((img) => { // Convert to string for comparison const imgStr = String(img); - // Filter out failed and deleting images - if (failedImageUrls.has(imgStr) || deletingImageUrl === imgStr) { + // Only filter out deleting images (not failed ones - we'll show them with error state) + if (deletingImageUrl === imgStr) { return false; } // Filter out empty or null images @@ -908,46 +1146,180 @@ const EditRoomPage: React.FC = () => { const isRoomImage = normalizedRoomImgs.includes(normalizedImg); const isDeleting = deletingImageUrl === imgStr; const hasFailed = failedImageUrls.has(imgStr); + const isLoading = loadingImageUrls.has(imgStr); // Safety check - should not happen due to filter, but double-check - if (hasFailed || isDeleting || !imageUrl) { + if (isDeleting || !imageUrl) { return null; } + // Determine if we should use crossOrigin for CORS + // Only use crossOrigin for external URLs (different origin) + // Same-origin requests don't need crossOrigin and it can cause OpaqueResponseBlocking errors + const isExternalUrl = imageUrl.startsWith('http://') || imageUrl.startsWith('https://'); + const isSameOrigin = isExternalUrl && ( + imageUrl.startsWith(apiBaseUrl) || + imageUrl.startsWith(window.location.origin) + ); + const useCrossOrigin = isExternalUrl && !isSameOrigin; + return (
-
+
+ {/* Loading overlay - only show if loading and not failed */} + {isLoading && !hasFailed && ( +
+
+
+ )} + + {/* Error state */} + {hasFailed && ( +
+ +

Failed to load

+ {imageUrl.includes('/uploads/') && ( +

File not found

+ )} +
+ + {isRoomImage && imageUrl.includes('/uploads/') && ( + + )} +
+
+ )} + + {/* Image element - always render but conditionally show */} {`Room { const target = e.target as HTMLImageElement; - // Only handle error once to prevent infinite loop + const retryCount = parseInt(target.dataset.retryCount || '0'); + + // Check if it's a 404 (file not found) + const is404 = target.src && (target.src.includes('404') || target.complete === false); + console.warn('EditRoomPage - Image load error:', { + imageUrl, + originalPath: imgStr, + retryCount, + hasCrossOrigin: !!target.crossOrigin, + is404: is404 || 'unknown', + note: is404 ? 'File may not exist on server. Check if file was deleted or path is incorrect.' : 'CORS or network error' + }); + + // Try retrying without crossOrigin if it was set and we haven't retried yet + if (useCrossOrigin && target.crossOrigin && retryCount === 0) { + target.dataset.retryCount = '1'; + target.removeAttribute('crossorigin'); + target.src = ''; + // Force reload by setting src again + setTimeout(() => { + target.src = imageUrl; + }, 100); + return; + } + + // Only mark as failed after retries are exhausted if (!target.dataset.errorHandled) { target.dataset.errorHandled = 'true'; - // Clear the src to prevent further load attempts - target.src = ''; - // Hide the image element - target.style.display = 'none'; - // Immediately mark as failed to remove from display + + // Mark as failed setFailedImageUrls(prev => { const newSet = new Set(prev); newSet.add(imgStr); return newSet; }); + setLoadingImageUrls(prev => { + const newSet = new Set(prev); + newSet.delete(imgStr); + return newSet; + }); + // Stop event propagation to prevent further errors e.stopPropagation(); e.preventDefault(); } }} - onLoad={() => { - console.log('EditRoomPage - Image loaded successfully:', imageUrl); + onLoad={(e) => { + const target = e.target as HTMLImageElement; + console.log('EditRoomPage - Image loaded successfully:', { + imageUrl, + originalPath: imgStr, + naturalWidth: target.naturalWidth, + naturalHeight: target.naturalHeight, + complete: target.complete, + cached: target.complete && target.naturalWidth > 0 + }); + // Clear failed state if image loads successfully + setFailedImageUrls(prev => { + if (prev.has(imgStr)) { + console.log('EditRoomPage - Clearing failed state for:', imgStr); + } + const newSet = new Set(prev); + newSet.delete(imgStr); + return newSet; + }); + setLoadingImageUrls(prev => { + const newSet = new Set(prev); + newSet.delete(imgStr); + return newSet; + }); + // Ensure image is visible + target.style.opacity = '1'; + target.style.display = 'block'; + }} + onLoadStart={() => { + // Mark as loading when image starts loading + setLoadingImageUrls(prev => { + const newSet = new Set(prev); + newSet.add(imgStr); + return newSet; + }); + // Clear failed state when starting to load + setFailedImageUrls(prev => { + const newSet = new Set(prev); + newSet.delete(imgStr); + return newSet; + }); }} />
- {isRoomImage && ( + {isRoomImage && !hasFailed && ( <>
Room Image