upload images to storage instead of DB and add image high quaiilty view
This commit is contained in:
@@ -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
165
app/routers/uploads.py
Normal 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"}
|
||||||
66
public/src/components/common/ImagePreview.tsx
Normal file
66
public/src/components/common/ImagePreview.tsx
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
40
public/src/services/uploadService.ts
Normal file
40
public/src/services/uploadService.ts
Normal 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();
|
||||||
Reference in New Issue
Block a user