v0.4.0 - merge from claude/dev-015sto5mf2MpPCE57TbNKtaF #1
@@ -1,14 +1,152 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
from PIL import Image
|
||||||
|
from PIL.ExifTags import TAGS, GPSTAGS
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import RosterUnit
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["photos"])
|
router = APIRouter(prefix="/api", tags=["photos"])
|
||||||
|
|
||||||
PHOTOS_BASE_DIR = Path("data/photos")
|
PHOTOS_BASE_DIR = Path("data/photos")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_exif_data(image_path: Path) -> dict:
|
||||||
|
"""
|
||||||
|
Extract EXIF metadata from an image file.
|
||||||
|
Returns dict with timestamp, GPS coordinates, and other metadata.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
image = Image.open(image_path)
|
||||||
|
exif_data = image._getexif()
|
||||||
|
|
||||||
|
if not exif_data:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
metadata = {}
|
||||||
|
|
||||||
|
# Extract standard EXIF tags
|
||||||
|
for tag_id, value in exif_data.items():
|
||||||
|
tag = TAGS.get(tag_id, tag_id)
|
||||||
|
|
||||||
|
# Extract datetime
|
||||||
|
if tag == "DateTime" or tag == "DateTimeOriginal":
|
||||||
|
try:
|
||||||
|
metadata["timestamp"] = datetime.strptime(str(value), "%Y:%m:%d %H:%M:%S")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Extract GPS data
|
||||||
|
if tag == "GPSInfo":
|
||||||
|
gps_data = {}
|
||||||
|
for gps_tag_id in value:
|
||||||
|
gps_tag = GPSTAGS.get(gps_tag_id, gps_tag_id)
|
||||||
|
gps_data[gps_tag] = value[gps_tag_id]
|
||||||
|
|
||||||
|
# Convert GPS data to decimal degrees
|
||||||
|
lat = gps_data.get("GPSLatitude")
|
||||||
|
lat_ref = gps_data.get("GPSLatitudeRef")
|
||||||
|
lon = gps_data.get("GPSLongitude")
|
||||||
|
lon_ref = gps_data.get("GPSLongitudeRef")
|
||||||
|
|
||||||
|
if lat and lon and lat_ref and lon_ref:
|
||||||
|
# Convert to decimal degrees
|
||||||
|
lat_decimal = convert_to_degrees(lat)
|
||||||
|
if lat_ref == "S":
|
||||||
|
lat_decimal = -lat_decimal
|
||||||
|
|
||||||
|
lon_decimal = convert_to_degrees(lon)
|
||||||
|
if lon_ref == "W":
|
||||||
|
lon_decimal = -lon_decimal
|
||||||
|
|
||||||
|
metadata["latitude"] = lat_decimal
|
||||||
|
metadata["longitude"] = lon_decimal
|
||||||
|
metadata["coordinates"] = f"{lat_decimal},{lon_decimal}"
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error extracting EXIF data: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_degrees(value):
|
||||||
|
"""
|
||||||
|
Convert GPS coordinates from degrees/minutes/seconds to decimal degrees.
|
||||||
|
"""
|
||||||
|
d, m, s = value
|
||||||
|
return float(d) + (float(m) / 60.0) + (float(s) / 3600.0)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/unit/{unit_id}/upload-photo")
|
||||||
|
async def upload_photo(
|
||||||
|
unit_id: str,
|
||||||
|
photo: UploadFile = File(...),
|
||||||
|
auto_populate_coords: bool = True,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Upload a photo for a unit and extract EXIF metadata.
|
||||||
|
If GPS data exists and auto_populate_coords is True, update the unit's coordinates.
|
||||||
|
"""
|
||||||
|
# Validate file type
|
||||||
|
allowed_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||||
|
file_ext = Path(photo.filename).suffix.lower()
|
||||||
|
|
||||||
|
if file_ext not in allowed_extensions:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid file type. Allowed: {', '.join(allowed_extensions)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create photos directory for this unit
|
||||||
|
unit_photo_dir = PHOTOS_BASE_DIR / unit_id
|
||||||
|
unit_photo_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate filename with timestamp to avoid collisions
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"{timestamp}_{photo.filename}"
|
||||||
|
file_path = unit_photo_dir / filename
|
||||||
|
|
||||||
|
# Save the file
|
||||||
|
try:
|
||||||
|
with open(file_path, "wb") as buffer:
|
||||||
|
shutil.copyfileobj(photo.file, buffer)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to save photo: {str(e)}")
|
||||||
|
|
||||||
|
# Extract EXIF metadata
|
||||||
|
metadata = extract_exif_data(file_path)
|
||||||
|
|
||||||
|
# Update unit coordinates if GPS data exists and auto_populate_coords is True
|
||||||
|
coordinates_updated = False
|
||||||
|
if auto_populate_coords and "coordinates" in metadata:
|
||||||
|
roster_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
|
||||||
|
if roster_unit:
|
||||||
|
roster_unit.coordinates = metadata["coordinates"]
|
||||||
|
roster_unit.last_updated = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
coordinates_updated = True
|
||||||
|
|
||||||
|
return JSONResponse(content={
|
||||||
|
"success": True,
|
||||||
|
"filename": filename,
|
||||||
|
"file_path": f"/api/unit/{unit_id}/photo/{filename}",
|
||||||
|
"metadata": {
|
||||||
|
"timestamp": metadata.get("timestamp").isoformat() if metadata.get("timestamp") else None,
|
||||||
|
"latitude": metadata.get("latitude"),
|
||||||
|
"longitude": metadata.get("longitude"),
|
||||||
|
"coordinates": metadata.get("coordinates")
|
||||||
|
},
|
||||||
|
"coordinates_updated": coordinates_updated
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/unit/{unit_id}/photos")
|
@router.get("/unit/{unit_id}/photos")
|
||||||
def get_unit_photos(unit_id: str):
|
def get_unit_photos(unit_id: str):
|
||||||
"""
|
"""
|
||||||
@@ -51,6 +189,46 @@ def get_unit_photos(unit_id: str):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recent-photos")
|
||||||
|
def get_recent_photos(limit: int = 12):
|
||||||
|
"""
|
||||||
|
Get the most recently uploaded photos across all units.
|
||||||
|
Returns photos sorted by modification time (newest first).
|
||||||
|
"""
|
||||||
|
if not PHOTOS_BASE_DIR.exists():
|
||||||
|
return {"photos": []}
|
||||||
|
|
||||||
|
all_photos = []
|
||||||
|
image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||||
|
|
||||||
|
# Scan all unit directories
|
||||||
|
for unit_dir in PHOTOS_BASE_DIR.iterdir():
|
||||||
|
if not unit_dir.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
unit_id = unit_dir.name
|
||||||
|
|
||||||
|
# Get all photos in this unit's directory
|
||||||
|
for file_path in unit_dir.iterdir():
|
||||||
|
if file_path.is_file() and file_path.suffix.lower() in image_extensions:
|
||||||
|
all_photos.append({
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"filename": file_path.name,
|
||||||
|
"path": f"/api/unit/{unit_id}/photo/{file_path.name}",
|
||||||
|
"modified": file_path.stat().st_mtime,
|
||||||
|
"modified_iso": datetime.fromtimestamp(file_path.stat().st_mtime).isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by modification time (most recent first) and limit
|
||||||
|
all_photos.sort(key=lambda x: x["modified"], reverse=True)
|
||||||
|
recent_photos = all_photos[:limit]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"photos": recent_photos,
|
||||||
|
"total": len(all_photos)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/unit/{unit_id}/photo/{filename}")
|
@router.get("/unit/{unit_id}/photo/{filename}")
|
||||||
def get_photo(unit_id: str, filename: str):
|
def get_photo(unit_id: str, filename: str):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ pydantic==2.5.0
|
|||||||
python-multipart==0.0.6
|
python-multipart==0.0.6
|
||||||
jinja2==3.1.2
|
jinja2==3.1.2
|
||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
|
Pillow==10.1.0
|
||||||
|
|||||||
@@ -159,6 +159,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Photos Section -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8" id="recent-photos-card">
|
||||||
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Photos</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-photos-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-content" id="recent-photos-content">
|
||||||
|
<div id="recentPhotosGallery" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">Loading recent photos...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Fleet Status Section with Tabs -->
|
<!-- Fleet Status Section with Tabs -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="fleet-status-card">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="fleet-status-card">
|
||||||
|
|
||||||
@@ -471,6 +491,46 @@ function parseLocation(location) {
|
|||||||
// TODO: Add geocoding support for address strings
|
// TODO: Add geocoding support for address strings
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load and display recent photos
|
||||||
|
async function loadRecentPhotos() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/recent-photos?limit=12');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load recent photos');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const gallery = document.getElementById('recentPhotosGallery');
|
||||||
|
|
||||||
|
if (data.photos && data.photos.length > 0) {
|
||||||
|
gallery.innerHTML = '';
|
||||||
|
data.photos.forEach(photo => {
|
||||||
|
const photoDiv = document.createElement('div');
|
||||||
|
photoDiv.className = 'relative group';
|
||||||
|
photoDiv.innerHTML = `
|
||||||
|
<a href="/unit/${photo.unit_id}" class="block">
|
||||||
|
<img src="${photo.path}" alt="${photo.unit_id}"
|
||||||
|
class="w-full h-32 object-cover rounded-lg shadow hover:shadow-lg transition-shadow">
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2 rounded-b-lg">
|
||||||
|
<p class="text-white text-xs font-semibold">${photo.unit_id}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
gallery.appendChild(photoDiv);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
gallery.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">No photos uploaded yet. Upload photos from unit detail pages.</p>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading recent photos:', error);
|
||||||
|
document.getElementById('recentPhotosGallery').innerHTML = '<p class="text-sm text-red-500 col-span-full">Failed to load recent photos</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load recent photos on page load and refresh every 30 seconds
|
||||||
|
loadRecentPhotos();
|
||||||
|
setInterval(loadRecentPhotos, 30000);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -177,6 +177,38 @@
|
|||||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
|
||||||
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
|
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Photos -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Photos</h3>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-2">
|
||||||
|
<!-- Take Photo Button (Camera) -->
|
||||||
|
<label for="photoCameraUpload" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors cursor-pointer flex items-center gap-2 text-sm sm:text-base">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">Take Photo</span>
|
||||||
|
<span class="sm:hidden">Camera</span>
|
||||||
|
</label>
|
||||||
|
<!-- Choose from Library Button -->
|
||||||
|
<label for="photoLibraryUpload" class="px-4 py-2 bg-seismo-navy hover:bg-blue-800 text-white rounded-lg transition-colors cursor-pointer flex items-center gap-2 text-sm sm:text-base">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">Choose Photo</span>
|
||||||
|
<span class="sm:hidden">Library</span>
|
||||||
|
</label>
|
||||||
|
<input type="file" id="photoCameraUpload" accept="image/*" capture="environment" class="hidden" onchange="uploadPhoto(this.files[0])">
|
||||||
|
<input type="file" id="photoLibraryUpload" accept="image/*" class="hidden" onchange="uploadPhoto(this.files[0])">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="photoGallery" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">Loading photos...</p>
|
||||||
|
</div>
|
||||||
|
<div id="uploadStatus" class="hidden mt-4 p-4 rounded-lg"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -632,7 +664,107 @@ function parseLocation(location) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load and display photos
|
||||||
|
async function loadPhotos() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/unit/${unitId}/photos`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load photos');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const gallery = document.getElementById('photoGallery');
|
||||||
|
|
||||||
|
if (data.photos && data.photos.length > 0) {
|
||||||
|
gallery.innerHTML = '';
|
||||||
|
data.photo_urls.forEach((url, index) => {
|
||||||
|
const photoDiv = document.createElement('div');
|
||||||
|
photoDiv.className = 'relative group';
|
||||||
|
photoDiv.innerHTML = `
|
||||||
|
<img src="${url}" alt="Unit photo ${index + 1}"
|
||||||
|
class="w-full h-48 object-cover rounded-lg shadow cursor-pointer hover:shadow-lg transition-shadow"
|
||||||
|
onclick="window.open('${url}', '_blank')">
|
||||||
|
${index === 0 ? '<span class="absolute top-2 left-2 bg-seismo-orange text-white text-xs px-2 py-1 rounded">Primary</span>' : ''}
|
||||||
|
`;
|
||||||
|
gallery.appendChild(photoDiv);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
gallery.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">No photos yet. Add a photo to get started.</p>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading photos:', error);
|
||||||
|
document.getElementById('photoGallery').innerHTML = '<p class="text-sm text-red-500 col-span-full">Failed to load photos</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload photo with EXIF metadata extraction
|
||||||
|
async function uploadPhoto(file) {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const statusDiv = document.getElementById('uploadStatus');
|
||||||
|
statusDiv.className = 'mt-4 p-4 rounded-lg bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200';
|
||||||
|
statusDiv.textContent = 'Uploading photo and extracting metadata...';
|
||||||
|
statusDiv.classList.remove('hidden');
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('photo', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/unit/${unitId}/upload-photo`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Show success message with metadata info
|
||||||
|
let message = 'Photo uploaded successfully!';
|
||||||
|
if (result.metadata && result.metadata.coordinates) {
|
||||||
|
message += ` GPS location detected: ${result.metadata.coordinates}`;
|
||||||
|
if (result.coordinates_updated) {
|
||||||
|
message += ' (Unit coordinates updated automatically)';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message += ' No GPS data found in photo.';
|
||||||
|
}
|
||||||
|
|
||||||
|
statusDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
|
||||||
|
statusDiv.textContent = message;
|
||||||
|
|
||||||
|
// Reload photos and unit data
|
||||||
|
await loadPhotos();
|
||||||
|
if (result.coordinates_updated) {
|
||||||
|
await loadUnitData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide status after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
statusDiv.classList.add('hidden');
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Reset both file inputs
|
||||||
|
document.getElementById('photoCameraUpload').value = '';
|
||||||
|
document.getElementById('photoLibraryUpload').value = '';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading photo:', error);
|
||||||
|
statusDiv.className = 'mt-4 p-4 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200';
|
||||||
|
statusDiv.textContent = `Error uploading photo: ${error.message}`;
|
||||||
|
|
||||||
|
// Hide error after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
statusDiv.classList.add('hidden');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load data when page loads
|
// Load data when page loads
|
||||||
loadUnitData();
|
loadUnitData().then(() => {
|
||||||
|
loadPhotos();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user