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.middleware.cors import CORSMiddleware
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
app = FastAPI(
@@ -25,6 +25,7 @@ app.include_router(maps.router)
app.include_router(items.router)
app.include_router(map_share.router)
app.include_router(websocket.router)
app.include_router(uploads.router)
@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 { 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();