diff --git a/app/main.py b/app/main.py
index 72fba0f..3ba2fd5 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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("/")
diff --git a/app/routers/uploads.py b/app/routers/uploads.py
new file mode 100644
index 0000000..eb1d390
--- /dev/null
+++ b/app/routers/uploads.py
@@ -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"}
diff --git a/public/src/components/common/ImagePreview.tsx b/public/src/components/common/ImagePreview.tsx
new file mode 100644
index 0000000..d6b84e9
--- /dev/null
+++ b/public/src/components/common/ImagePreview.tsx
@@ -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(
+
+
+ {/* Close button */}
+
+
+ {/* Image */}
+

e.stopPropagation()}
+ />
+
+
,
+ document.body
+ );
+}
diff --git a/public/src/components/map/ItemContextMenu.tsx b/public/src/components/map/ItemContextMenu.tsx
index a7ea198..23a8934 100644
--- a/public/src/components/map/ItemContextMenu.tsx
+++ b/public/src/components/map/ItemContextMenu.tsx
@@ -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(item.properties.image || null);
+ const [imagePath, setImagePath] = useState(item.properties.image || null);
+ const [imageFile, setImageFile] = useState(null);
const [portCount, setPortCount] = useState(item.properties.port_count || 5);
+ const [isUploading, setIsUploading] = useState(false);
const menuRef = useRef(null);
const fileInputRef = useRef(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
- {imageData ? (
+ {imagePath ? (

)}
-
Max size: 2MB
+
Max size: 8MB
diff --git a/public/src/components/map/MapItemsLayer.tsx b/public/src/components/map/MapItemsLayer.tsx
index c253647..e4832af 100644
--- a/public/src/components/map/MapItemsLayer.tsx
+++ b/public/src/components/map/MapItemsLayer.tsx
@@ -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
(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 && (

{
+ e.preventDefault();
+ e.stopPropagation();
+ setImagePreview(uploadService.getImageUrl(item.properties.image));
+ }}
+ onMouseDown={(e) => {
+ e.stopPropagation();
+ }}
/>
)}
@@ -344,10 +355,18 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
{item.properties.image && (

{
+ e.preventDefault();
+ e.stopPropagation();
+ setImagePreview(uploadService.getImageUrl(item.properties.image));
+ }}
+ onMouseDown={(e) => {
+ e.stopPropagation();
+ }}
/>
)}
@@ -479,10 +498,18 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
{item.properties.image && (

{
+ e.preventDefault();
+ e.stopPropagation();
+ setImagePreview(uploadService.getImageUrl(item.properties.image));
+ }}
+ onMouseDown={(e) => {
+ e.stopPropagation();
+ }}
/>
)}
@@ -506,6 +533,14 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
/>,
document.body
)}
+
+ {/* Image preview modal */}
+ {imagePreview && (
+ setImagePreview(null)}
+ />
+ )}
>
);
}
diff --git a/public/src/index.css b/public/src/index.css
index 1ab8506..cc0f4c2 100644
--- a/public/src/index.css
+++ b/public/src/index.css
@@ -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 */
diff --git a/public/src/services/uploadService.ts b/public/src/services/uploadService.ts
new file mode 100644
index 0000000..3f3efe3
--- /dev/null
+++ b/public/src/services/uploadService.ts
@@ -0,0 +1,40 @@
+import { apiClient } from './api';
+
+interface UploadImageResponse {
+ filename: string;
+ path: string;
+ size: number;
+}
+
+class UploadService {
+ async uploadImage(file: File): Promise {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const response = await apiClient.post('/api/uploads/image', formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ });
+
+ return response.data;
+ }
+
+ async deleteImage(filename: string): Promise {
+ 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();