upload images to storage instead of DB and add image high quaiilty view

This commit is contained in:
2025-12-13 14:48:10 +05:00
parent 62a13a9f45
commit 3cfb46ced0
7 changed files with 377 additions and 24 deletions

View File

@@ -0,0 +1,66 @@
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
interface ImagePreviewProps {
imageUrl: string;
onClose: () => void;
}
export function ImagePreview({ imageUrl, onClose }: ImagePreviewProps) {
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
// Prevent body scroll when modal is open
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = 'unset';
};
}, [onClose]);
return createPortal(
<div
className="fixed inset-0 bg-black/80 flex items-center justify-center p-4 animate-fadeIn"
style={{ zIndex: 10001 }}
onClick={onClose}
>
<div className="relative max-w-7xl max-h-[90vh] w-full h-full flex items-center justify-center">
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 bg-white/10 hover:bg-white/20 text-white rounded-full w-10 h-10 flex items-center justify-center transition-colors backdrop-blur-sm"
aria-label="Close"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/* Image */}
<img
src={imageUrl}
alt="Preview"
className="max-w-full max-h-full object-contain rounded-lg shadow-2xl"
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>,
document.body
);
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { mapItemService } from '../../services/mapItemService';
import { uploadService } from '../../services/uploadService';
import { useUIStore } from '../../stores/uiStore';
interface ItemContextMenuProps {
@@ -18,8 +19,10 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
const [deleteConnectedCables, setDeleteConnectedCables] = useState(false);
const [newName, setNewName] = useState(item.properties.name || '');
const [notes, setNotes] = useState(item.properties.notes || '');
const [imageData, setImageData] = useState<string | null>(item.properties.image || null);
const [imagePath, setImagePath] = useState<string | null>(item.properties.image || null);
const [imageFile, setImageFile] = useState<File | null>(null);
const [portCount, setPortCount] = useState(item.properties.port_count || 5);
const [isUploading, setIsUploading] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -105,9 +108,9 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
const file = e.target.files?.[0];
if (!file) return;
// Check file size (max 2MB)
if (file.size > 2 * 1024 * 1024) {
showToast('Image too large. Please use an image smaller than 2MB.', 'error');
// Check file size (max 8MB)
if (file.size > 8 * 1024 * 1024) {
showToast('Image too large. Please use an image smaller than 8MB.', 'error');
return;
}
@@ -117,15 +120,21 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
return;
}
const reader = new FileReader();
reader.onload = () => {
setImageData(reader.result as string);
};
reader.readAsDataURL(file);
// Store the file for upload later
setImageFile(file);
// Create a preview URL
const previewUrl = URL.createObjectURL(file);
setImagePath(previewUrl);
};
const handleRemoveImage = () => {
setImageData(null);
// Revoke the preview URL if it exists
if (imagePath && imagePath.startsWith('blob:')) {
URL.revokeObjectURL(imagePath);
}
setImagePath(null);
setImageFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
@@ -133,13 +142,31 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
const handleSaveNotes = async () => {
try {
setIsUploading(true);
let finalImagePath = imagePath;
// If there's a new file to upload
if (imageFile) {
try {
const uploadResponse = await uploadService.uploadImage(imageFile);
finalImagePath = uploadResponse.path;
} catch (error) {
console.error('Failed to upload image:', error);
showToast('Failed to upload image', 'error');
setIsUploading(false);
return;
}
}
// Save the notes and image path
await mapItemService.updateMapItem(item.map_id, item.id, {
properties: {
...item.properties,
notes: notes,
image: imageData,
image: finalImagePath,
},
});
onUpdate();
setShowNotesDialog(false);
onClose();
@@ -147,6 +174,8 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
} catch (error) {
console.error('Failed to save notes:', error);
showToast('Failed to save notes', 'error');
} finally {
setIsUploading(false);
}
};
@@ -324,10 +353,12 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Attach Image (optional)
</label>
{imageData ? (
{imagePath ? (
<div className="relative">
<img
src={imageData}
src={imagePath.startsWith('blob:')
? imagePath
: uploadService.getImageUrl(imagePath)}
alt="Attached"
className="w-full rounded border border-gray-300 dark:border-gray-600 mb-2 bg-gray-50 dark:bg-gray-900"
style={{ maxHeight: '200px', objectFit: 'contain' }}
@@ -348,19 +379,21 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-blue-50 dark:file:bg-blue-900/30 file:text-blue-700 dark:file:text-blue-300 hover:file:bg-blue-100 dark:hover:file:bg-blue-900/50 transition-colors"
/>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Max size: 2MB</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Max size: 8MB</p>
</div>
<div className="flex gap-2">
<button
onClick={handleSaveNotes}
className="flex-1 px-3 py-1.5 bg-blue-600 dark:bg-blue-700 text-white rounded hover:bg-blue-700 dark:hover:bg-blue-600 transition-colors shadow-md"
disabled={isUploading}
className="flex-1 px-3 py-1.5 bg-blue-600 dark:bg-blue-700 text-white rounded hover:bg-blue-700 dark:hover:bg-blue-600 transition-colors shadow-md disabled:opacity-50 disabled:cursor-not-allowed"
>
Save
{isUploading ? 'Uploading...' : 'Save'}
</button>
<button
onClick={() => { setShowNotesDialog(false); onClose(); }}
className="flex-1 px-3 py-1.5 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors"
disabled={isUploading}
className="flex-1 px-3 py-1.5 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>

View File

@@ -3,8 +3,10 @@ import { createPortal } from 'react-dom';
import { Polyline, Marker, Popup, Circle, Tooltip, useMapEvents } from 'react-leaflet';
import L from 'leaflet';
import { mapItemService } from '../../services/mapItemService';
import { uploadService } from '../../services/uploadService';
import { CABLE_COLORS, type MapItem, type CableType } from '../../types/mapItem';
import { ItemContextMenu } from './ItemContextMenu';
import { ImagePreview } from '../common/ImagePreview';
import { useDrawingStore } from '../../stores/drawingStore';
// Calculate distance between two lat/lng points in meters using Haversine formula
@@ -106,6 +108,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
item: MapItem;
position: { x: number; y: number };
} | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const { activeTool } = useDrawingStore();
// Check if we're in drawing mode (should suppress popups)
@@ -247,10 +250,18 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
{item.properties.image && (
<div className="mt-2">
<img
src={item.properties.image}
src={uploadService.getImageUrl(item.properties.image)}
alt="Attachment"
className="w-full rounded border border-gray-200 dark:border-gray-700"
className="w-full rounded border border-gray-200 dark:border-gray-700 cursor-pointer hover:opacity-80 transition-opacity"
style={{ maxHeight: '150px', objectFit: 'contain' }}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setImagePreview(uploadService.getImageUrl(item.properties.image));
}}
onMouseDown={(e) => {
e.stopPropagation();
}}
/>
</div>
)}
@@ -344,10 +355,18 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
{item.properties.image && (
<div className="mt-2">
<img
src={item.properties.image}
src={uploadService.getImageUrl(item.properties.image)}
alt="Attachment"
className="w-full rounded border border-gray-200 dark:border-gray-700"
className="w-full rounded border border-gray-200 dark:border-gray-700 cursor-pointer hover:opacity-80 transition-opacity"
style={{ maxHeight: '150px', objectFit: 'contain' }}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setImagePreview(uploadService.getImageUrl(item.properties.image));
}}
onMouseDown={(e) => {
e.stopPropagation();
}}
/>
</div>
)}
@@ -479,10 +498,18 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
{item.properties.image && (
<div className="mt-2">
<img
src={item.properties.image}
src={uploadService.getImageUrl(item.properties.image)}
alt="Attachment"
className="w-full rounded border border-gray-200 dark:border-gray-700"
className="w-full rounded border border-gray-200 dark:border-gray-700 cursor-pointer hover:opacity-80 transition-opacity"
style={{ maxHeight: '150px', objectFit: 'contain' }}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setImagePreview(uploadService.getImageUrl(item.properties.image));
}}
onMouseDown={(e) => {
e.stopPropagation();
}}
/>
</div>
)}
@@ -506,6 +533,14 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
/>,
document.body
)}
{/* Image preview modal */}
{imagePreview && (
<ImagePreview
imageUrl={imagePreview}
onClose={() => setImagePreview(null)}
/>
)}
</>
);
}

View File

@@ -25,6 +25,10 @@
animation: scaleIn 0.2s ease-out;
}
.animate-fadeIn {
animation: fadeIn 0.2s ease-out;
}
@keyframes slideIn {
0% {
transform: translateX(100%);
@@ -46,6 +50,15 @@
opacity: 1;
}
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
}
/* Leaflet CSS will be imported in main.tsx */

View File

@@ -0,0 +1,40 @@
import { apiClient } from './api';
interface UploadImageResponse {
filename: string;
path: string;
size: number;
}
class UploadService {
async uploadImage(file: File): Promise<UploadImageResponse> {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post<UploadImageResponse>('/api/uploads/image', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
}
async deleteImage(filename: string): Promise<void> {
await apiClient.delete(`/api/uploads/image/${filename}`);
}
getImageUrl(path: string): string {
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// If it's a path like /api/uploads/image/xxx.jpg
if (path.startsWith('/api/uploads/image/')) {
return `${apiUrl}${path}`;
}
// If it's just a filename
return `${apiUrl}/api/uploads/image/${path}`;
}
}
export const uploadService = new UploadService();