update
This commit is contained in:
Binary file not shown.
@@ -595,11 +595,17 @@ async def delete_room_images(id: int, image_url: str=Query(..., description='Ima
|
|||||||
room = db.query(Room).filter(Room.id == id).first()
|
room = db.query(Room).filter(Room.id == id).first()
|
||||||
if not room:
|
if not room:
|
||||||
raise HTTPException(status_code=404, detail='Room not found')
|
raise HTTPException(status_code=404, detail='Room not found')
|
||||||
|
# For external URLs, keep the full URL for matching
|
||||||
|
# For local files, normalize to path
|
||||||
|
is_external_url = image_url.startswith('http://') or image_url.startswith('https://')
|
||||||
normalized_url = image_url
|
normalized_url = image_url
|
||||||
if image_url.startswith('http://') or image_url.startswith('https://'):
|
filename = None
|
||||||
from urllib.parse import urlparse
|
|
||||||
parsed = urlparse(image_url)
|
if is_external_url:
|
||||||
normalized_url = parsed.path
|
# For external URLs, use the full URL as-is for matching
|
||||||
|
normalized_url = image_url
|
||||||
|
else:
|
||||||
|
# For local files, normalize the path
|
||||||
if not normalized_url.startswith('/'):
|
if not normalized_url.startswith('/'):
|
||||||
normalized_url = f'/{normalized_url}'
|
normalized_url = f'/{normalized_url}'
|
||||||
filename = Path(normalized_url).name
|
filename = Path(normalized_url).name
|
||||||
@@ -620,10 +626,21 @@ async def delete_room_images(id: int, image_url: str=Query(..., description='Ima
|
|||||||
|
|
||||||
updated_images = []
|
updated_images = []
|
||||||
for img in existing_images:
|
for img in existing_images:
|
||||||
stored_path = img if img.startswith('/') else f'/{img}'
|
# For external URLs, match by full URL (keep images that don't match)
|
||||||
stored_filename = Path(stored_path).name
|
if is_external_url:
|
||||||
if img != normalized_url and stored_path != normalized_url and (stored_filename != filename):
|
# Keep the image if it doesn't match the URL we're deleting
|
||||||
|
if img != normalized_url:
|
||||||
updated_images.append(img)
|
updated_images.append(img)
|
||||||
|
else:
|
||||||
|
# For local files, match by path or filename (keep images that don't match)
|
||||||
|
stored_path = img if img.startswith('/') else f'/{img}'
|
||||||
|
stored_filename = Path(stored_path).name if '/' in str(stored_path) else stored_path
|
||||||
|
# Keep the image if it doesn't match any of the comparison criteria
|
||||||
|
if img != normalized_url and stored_path != normalized_url and (not filename or stored_filename != filename):
|
||||||
|
updated_images.append(img)
|
||||||
|
|
||||||
|
# Only try to delete the file if it's a local file (filename exists)
|
||||||
|
if filename:
|
||||||
file_path = Path(__file__).parent.parent.parent / 'uploads' / 'rooms' / filename
|
file_path = Path(__file__).parent.parent.parent / 'uploads' / 'rooms' / filename
|
||||||
if file_path.exists():
|
if file_path.exists():
|
||||||
file_path.unlink()
|
file_path.unlink()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const EditRoomPage: React.FC = () => {
|
|||||||
const [roomTypes, setRoomTypes] = useState<Array<{ id: number; name: string }>>([]);
|
const [roomTypes, setRoomTypes] = useState<Array<{ id: number; name: string }>>([]);
|
||||||
const [deletingImageUrl, setDeletingImageUrl] = useState<string | null>(null);
|
const [deletingImageUrl, setDeletingImageUrl] = useState<string | null>(null);
|
||||||
const [failedImageUrls, setFailedImageUrls] = useState<Set<string>>(new Set());
|
const [failedImageUrls, setFailedImageUrls] = useState<Set<string>>(new Set());
|
||||||
|
const [loadingImageUrls, setLoadingImageUrls] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
room_number: '',
|
room_number: '',
|
||||||
@@ -46,6 +47,44 @@ const EditRoomPage: React.FC = () => {
|
|||||||
fetchRoomTypes();
|
fetchRoomTypes();
|
||||||
}, [id]);
|
}, [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 () => {
|
const fetchRoomTypes = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await roomService.getRooms({ limit: 100, page: 1 });
|
const response = await roomService.getRooms({ limit: 100, page: 1 });
|
||||||
@@ -98,7 +137,7 @@ const EditRoomPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchRoomData = async () => {
|
const fetchRoomData = async () => {
|
||||||
if (!id) return;
|
if (!id) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -136,10 +175,13 @@ const EditRoomPage: React.FC = () => {
|
|||||||
view: room.view || '',
|
view: room.view || '',
|
||||||
amenities: amenitiesArray,
|
amenities: amenitiesArray,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return room;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to fetch room data', error);
|
logger.error('Failed to fetch room data', error);
|
||||||
toast.error(error.response?.data?.message || 'Failed to load room data');
|
toast.error(error.response?.data?.message || 'Failed to load room data');
|
||||||
navigate('/admin/advanced-rooms');
|
navigate('/admin/advanced-rooms');
|
||||||
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -299,7 +341,16 @@ const EditRoomPage: React.FC = () => {
|
|||||||
// Immediately mark as failed to prevent error handler from firing
|
// Immediately mark as failed to prevent error handler from firing
|
||||||
setFailedImageUrls(prev => new Set([...prev, imageUrl]));
|
setFailedImageUrls(prev => new Set([...prev, imageUrl]));
|
||||||
|
|
||||||
|
// For external URLs (like Unsplash), keep the full URL
|
||||||
|
// For local files, extract the path
|
||||||
let imagePath = imageUrl;
|
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://')) {
|
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(imageUrl);
|
const url = new URL(imageUrl);
|
||||||
@@ -309,14 +360,91 @@ const EditRoomPage: React.FC = () => {
|
|||||||
imagePath = match ? match[1] : imageUrl;
|
imagePath = match ? match[1] : imageUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await apiClient.delete(`/rooms/${editingRoom.id}/images`, {
|
// Also try the normalized format for comparison
|
||||||
params: { image_url: imagePath },
|
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');
|
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 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) {
|
} catch (error: any) {
|
||||||
logger.error('Error deleting image', error);
|
logger.error('Error deleting image', error);
|
||||||
toast.error(error.response?.data?.message || error.response?.data?.detail || 'Unable to delete image');
|
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) {
|
if (loading) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
}
|
}
|
||||||
@@ -379,8 +579,44 @@ const EditRoomPage: React.FC = () => {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, construct the full URL
|
// Handle local file paths (like room-*.png)
|
||||||
const cleanPath = img.startsWith('/') ? img : `/${img}`;
|
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}`;
|
return `${apiBaseUrl}${cleanPath}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -417,7 +653,9 @@ const EditRoomPage: React.FC = () => {
|
|||||||
roomTypeImages: roomTypeImages.length,
|
roomTypeImages: roomTypeImages.length,
|
||||||
allImages: allImages.length,
|
allImages: allImages.length,
|
||||||
sampleImage: allImages[0],
|
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) => {
|
const filteredImages = allImages.filter((img) => {
|
||||||
// Convert to string for comparison
|
// Convert to string for comparison
|
||||||
const imgStr = String(img);
|
const imgStr = String(img);
|
||||||
// Filter out failed and deleting images
|
// Only filter out deleting images (not failed ones - we'll show them with error state)
|
||||||
if (failedImageUrls.has(imgStr) || deletingImageUrl === imgStr) {
|
if (deletingImageUrl === imgStr) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Filter out empty or null images
|
// Filter out empty or null images
|
||||||
@@ -908,46 +1146,180 @@ const EditRoomPage: React.FC = () => {
|
|||||||
const isRoomImage = normalizedRoomImgs.includes(normalizedImg);
|
const isRoomImage = normalizedRoomImgs.includes(normalizedImg);
|
||||||
const isDeleting = deletingImageUrl === imgStr;
|
const isDeleting = deletingImageUrl === imgStr;
|
||||||
const hasFailed = failedImageUrls.has(imgStr);
|
const hasFailed = failedImageUrls.has(imgStr);
|
||||||
|
const isLoading = loadingImageUrls.has(imgStr);
|
||||||
|
|
||||||
// Safety check - should not happen due to filter, but double-check
|
// Safety check - should not happen due to filter, but double-check
|
||||||
if (hasFailed || isDeleting || !imageUrl) {
|
if (isDeleting || !imageUrl) {
|
||||||
return null;
|
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 (
|
return (
|
||||||
<div key={`${imgStr}-${index}`} className="relative group rounded-2xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 transform hover:scale-105">
|
<div key={`${imgStr}-${index}`} className="relative group rounded-2xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 transform hover:scale-105">
|
||||||
<div className="aspect-square overflow-hidden bg-gradient-to-br from-slate-100 to-slate-200 flex items-center justify-center">
|
<div className="aspect-square overflow-hidden bg-gradient-to-br from-slate-100 to-slate-200 flex items-center justify-center relative">
|
||||||
|
{/* Loading overlay - only show if loading and not failed */}
|
||||||
|
{isLoading && !hasFailed && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-slate-100/80 z-20 pointer-events-none">
|
||||||
|
<div className="w-8 h-8 border-2 border-amber-500 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{hasFailed && (
|
||||||
|
<div className="absolute inset-0 w-full h-full flex flex-col items-center justify-center p-4 text-center bg-slate-100 z-30">
|
||||||
|
<ImageIcon className="w-12 h-12 text-slate-400 mb-2" />
|
||||||
|
<p className="text-xs text-slate-500 font-medium mb-1">Failed to load</p>
|
||||||
|
{imageUrl.includes('/uploads/') && (
|
||||||
|
<p className="text-xs text-red-500 font-medium mb-2">File not found</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
// Retry loading the image by clearing error state
|
||||||
|
// The img element will be re-rendered when hasFailed becomes false
|
||||||
|
setFailedImageUrls(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(imgStr);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
setLoadingImageUrls(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.add(imgStr);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 text-xs bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
{isRoomImage && imageUrl.includes('/uploads/') && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveBrokenImage(imgStr)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="px-3 py-1.5 text-xs bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Image element - always render but conditionally show */}
|
||||||
<img
|
<img
|
||||||
|
key={`img-${imgStr}-${index}`}
|
||||||
|
data-image-id={`room-image-${index}-${imgStr}`}
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt={`Room Image ${index + 1}`}
|
alt={`Room Image ${index + 1}`}
|
||||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
className={`w-full h-full object-cover group-hover:scale-110 transition-all duration-500 ${
|
||||||
|
hasFailed ? 'opacity-0 absolute pointer-events-none' : 'opacity-100'
|
||||||
|
}`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
crossOrigin={useCrossOrigin ? "anonymous" : undefined}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const target = e.target as HTMLImageElement;
|
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) {
|
if (!target.dataset.errorHandled) {
|
||||||
target.dataset.errorHandled = 'true';
|
target.dataset.errorHandled = 'true';
|
||||||
// Clear the src to prevent further load attempts
|
|
||||||
target.src = '';
|
// Mark as failed
|
||||||
// Hide the image element
|
|
||||||
target.style.display = 'none';
|
|
||||||
// Immediately mark as failed to remove from display
|
|
||||||
setFailedImageUrls(prev => {
|
setFailedImageUrls(prev => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
newSet.add(imgStr);
|
newSet.add(imgStr);
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
|
setLoadingImageUrls(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(imgStr);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
|
||||||
// Stop event propagation to prevent further errors
|
// Stop event propagation to prevent further errors
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onLoad={() => {
|
onLoad={(e) => {
|
||||||
console.log('EditRoomPage - Image loaded successfully:', imageUrl);
|
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;
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isRoomImage && (
|
{isRoomImage && !hasFailed && (
|
||||||
<>
|
<>
|
||||||
<div className="absolute top-2 left-2 bg-gradient-to-r from-amber-500/90 to-amber-600/90 backdrop-blur-md text-white px-3 py-1.5 rounded-xl text-xs font-semibold opacity-0 group-hover:opacity-100 transition-opacity duration-300 shadow-lg">
|
<div className="absolute top-2 left-2 bg-gradient-to-r from-amber-500/90 to-amber-600/90 backdrop-blur-md text-white px-3 py-1.5 rounded-xl text-xs font-semibold opacity-0 group-hover:opacity-100 transition-opacity duration-300 shadow-lg">
|
||||||
Room Image
|
Room Image
|
||||||
|
|||||||
Reference in New Issue
Block a user