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 */} + Preview 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 ? (
Attached )} -

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 && (
Attachment { + 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 && (
Attachment { + 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 && (
Attachment { + 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();