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

@@ -1,7 +1,7 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.config import settings from app.config import settings
from app.routers import auth, maps, items, map_share, websocket from app.routers import auth, maps, items, map_share, websocket, uploads
# Create FastAPI application # Create FastAPI application
app = FastAPI( app = FastAPI(
@@ -25,6 +25,7 @@ app.include_router(maps.router)
app.include_router(items.router) app.include_router(items.router)
app.include_router(map_share.router) app.include_router(map_share.router)
app.include_router(websocket.router) app.include_router(websocket.router)
app.include_router(uploads.router)
@app.get("/") @app.get("/")

165
app/routers/uploads.py Normal file
View File

@@ -0,0 +1,165 @@
import os
import uuid
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, UploadFile, File, HTTPException, status, Depends
from fastapi.responses import FileResponse
from app.dependencies import get_current_user, get_optional_current_user
from app.models.user import User
router = APIRouter(prefix="/api/uploads", tags=["uploads"])
# Storage directory for uploaded images
STORAGE_DIR = Path("/home/shihaam/git/sarlink/mapmaker/storage/images")
STORAGE_DIR.mkdir(parents=True, exist_ok=True)
# Allowed image types
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
ALLOWED_MIME_TYPES = {
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/bmp"
}
# Maximum file size: 8MB
MAX_FILE_SIZE = 8 * 1024 * 1024
@router.post("/image")
async def upload_image(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user)
):
"""
Upload an image file.
Returns the file path that can be used to retrieve the image.
"""
# Validate file type by MIME type
if file.content_type not in ALLOWED_MIME_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid file type. Allowed types: {', '.join(ALLOWED_MIME_TYPES)}"
)
# Read file content
content = await file.read()
# Validate file size
if len(content) > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File too large. Maximum size is 8MB."
)
# Get file extension
original_extension = Path(file.filename).suffix.lower()
if original_extension not in ALLOWED_EXTENSIONS:
# Fallback to MIME type mapping
mime_to_ext = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"image/bmp": ".bmp"
}
original_extension = mime_to_ext.get(file.content_type, ".jpg")
# Generate random filename
random_filename = f"{uuid.uuid4()}{original_extension}"
file_path = STORAGE_DIR / random_filename
# Save file
try:
with open(file_path, "wb") as f:
f.write(content)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to save file: {str(e)}"
)
# Return the relative path
return {
"filename": random_filename,
"path": f"/api/uploads/image/{random_filename}",
"size": len(content)
}
@router.get("/image/{filename}")
async def get_image(
filename: str,
current_user: Optional[User] = Depends(get_optional_current_user)
):
"""
Retrieve an uploaded image.
Public endpoint - requires either authenticated user or valid share token.
"""
# Validate filename to prevent directory traversal
if ".." in filename or "/" in filename or "\\" in filename:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid filename"
)
file_path = STORAGE_DIR / filename
# Check if file exists
if not file_path.exists() or not file_path.is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Image not found"
)
# Determine media type based on extension
extension = file_path.suffix.lower()
media_type_map = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp"
}
media_type = media_type_map.get(extension, "application/octet-stream")
return FileResponse(file_path, media_type=media_type)
@router.delete("/image/{filename}")
async def delete_image(
filename: str,
current_user: User = Depends(get_current_user)
):
"""
Delete an uploaded image.
Requires authentication.
"""
# Validate filename to prevent directory traversal
if ".." in filename or "/" in filename or "\\" in filename:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid filename"
)
file_path = STORAGE_DIR / filename
# Check if file exists
if not file_path.exists() or not file_path.is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Image not found"
)
# Delete file
try:
os.remove(file_path)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete file: {str(e)}"
)
return {"message": "Image deleted successfully"}

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 { useState, useEffect, useRef } from 'react';
import { mapItemService } from '../../services/mapItemService'; import { mapItemService } from '../../services/mapItemService';
import { uploadService } from '../../services/uploadService';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
interface ItemContextMenuProps { interface ItemContextMenuProps {
@@ -18,8 +19,10 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
const [deleteConnectedCables, setDeleteConnectedCables] = useState(false); const [deleteConnectedCables, setDeleteConnectedCables] = useState(false);
const [newName, setNewName] = useState(item.properties.name || ''); const [newName, setNewName] = useState(item.properties.name || '');
const [notes, setNotes] = useState(item.properties.notes || ''); 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 [portCount, setPortCount] = useState(item.properties.port_count || 5);
const [isUploading, setIsUploading] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -105,9 +108,9 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
// Check file size (max 2MB) // Check file size (max 8MB)
if (file.size > 2 * 1024 * 1024) { if (file.size > 8 * 1024 * 1024) {
showToast('Image too large. Please use an image smaller than 2MB.', 'error'); showToast('Image too large. Please use an image smaller than 8MB.', 'error');
return; return;
} }
@@ -117,15 +120,21 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
return; return;
} }
const reader = new FileReader(); // Store the file for upload later
reader.onload = () => { setImageFile(file);
setImageData(reader.result as string);
}; // Create a preview URL
reader.readAsDataURL(file); const previewUrl = URL.createObjectURL(file);
setImagePath(previewUrl);
}; };
const handleRemoveImage = () => { 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) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
@@ -133,13 +142,31 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
const handleSaveNotes = async () => { const handleSaveNotes = async () => {
try { 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, { await mapItemService.updateMapItem(item.map_id, item.id, {
properties: { properties: {
...item.properties, ...item.properties,
notes: notes, notes: notes,
image: imageData, image: finalImagePath,
}, },
}); });
onUpdate(); onUpdate();
setShowNotesDialog(false); setShowNotesDialog(false);
onClose(); onClose();
@@ -147,6 +174,8 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
} catch (error) { } catch (error) {
console.error('Failed to save notes:', error); console.error('Failed to save notes:', error);
showToast('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"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Attach Image (optional) Attach Image (optional)
</label> </label>
{imageData ? ( {imagePath ? (
<div className="relative"> <div className="relative">
<img <img
src={imageData} src={imagePath.startsWith('blob:')
? imagePath
: uploadService.getImageUrl(imagePath)}
alt="Attached" alt="Attached"
className="w-full rounded border border-gray-300 dark:border-gray-600 mb-2 bg-gray-50 dark:bg-gray-900" 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' }} 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" 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>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={handleSaveNotes} 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>
<button <button
onClick={() => { setShowNotesDialog(false); onClose(); }} 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 Cancel
</button> </button>

View File

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

View File

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