'use client'; import React, { useState, useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { X, Radar, Plus, Trash2, MapPin, Crosshair } from 'lucide-react'; import { API_BASE } from '@/lib/api'; import type { SarAoi } from '@/types/dashboard'; interface SarAoiEditorModalProps { onClose: () => void; /** Enter map drop mode — modal hides, user clicks map to place AOI center. */ onRequestMapPick: () => void; /** Coordinates picked from the map (set by parent after drop-mode click). */ pickedCoords: { lat: number; lng: number } | null; /** Called after the modal consumes pickedCoords so the parent can clear them. */ onPickConsumed: () => void; /** Called after an AOI is created or deleted so MaplibreViewer can refresh. */ onAoiListChanged?: () => void; /** Whether map drop mode is currently active. */ dropModeActive?: boolean; } const AOI_CATEGORIES = [ { value: 'watchlist', label: 'Watchlist' }, { value: 'conflict', label: 'Conflict Zone' }, { value: 'infrastructure', label: 'Infrastructure' }, { value: 'natural_hazard', label: 'Natural Hazard' }, { value: 'border', label: 'Border Area' }, { value: 'maritime', label: 'Maritime' }, ]; function slugify(s: string): string { return s .toLowerCase() .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 64); } const SarAoiEditorModal = React.memo(function SarAoiEditorModal({ onClose, onRequestMapPick, pickedCoords, onPickConsumed, onAoiListChanged, dropModeActive, }: SarAoiEditorModalProps) { const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); // ----- AOI list ----- const [aois, setAois] = useState([]); const [listLoading, setListLoading] = useState(true); const fetchAois = useCallback(async () => { try { const res = await fetch(`${API_BASE}/api/sar/aois`, { credentials: 'include' }); if (!res.ok) return; const body = await res.json(); if (Array.isArray(body?.aois)) setAois(body.aois); } catch { /* silent */ } setListLoading(false); }, []); useEffect(() => { fetchAois(); }, [fetchAois]); // ----- Form state ----- const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [centerLat, setCenterLat] = useState(''); const [centerLon, setCenterLon] = useState(''); const [radiusKm, setRadiusKm] = useState('25'); const [category, setCategory] = useState('watchlist'); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); const [showForm, setShowForm] = useState(false); // Consume picked coords from map useEffect(() => { if (pickedCoords) { setCenterLat(pickedCoords.lat.toFixed(5)); setCenterLon(pickedCoords.lng.toFixed(5)); setShowForm(true); onPickConsumed(); } }, [pickedCoords, onPickConsumed]); const resetForm = () => { setName(''); setDescription(''); setCenterLat(''); setCenterLon(''); setRadiusKm('25'); setCategory('watchlist'); setError(''); }; const handleSubmit = async () => { const trimName = name.trim(); if (!trimName) { setError('Name is required'); return; } const lat = parseFloat(centerLat); const lon = parseFloat(centerLon); if (!Number.isFinite(lat) || lat < -90 || lat > 90) { setError('Latitude must be between -90 and 90'); return; } if (!Number.isFinite(lon) || lon < -180 || lon > 180) { setError('Longitude must be between -180 and 180'); return; } const rad = parseFloat(radiusKm); if (!Number.isFinite(rad) || rad < 1 || rad > 500) { setError('Radius must be 1-500 km'); return; } setSubmitting(true); setError(''); try { const payload = { id: slugify(trimName) || `aoi_${Date.now()}`, name: trimName, description: description.trim(), center_lat: lat, center_lon: lon, radius_km: rad, category, }; const res = await fetch(`${API_BASE}/api/sar/aois`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(payload), }); if (!res.ok) { const body = await res.json().catch(() => ({})); const d = body?.detail; let msg = `HTTP ${res.status}`; if (typeof d === 'string') msg = d; else if (Array.isArray(d) && d.length > 0) { msg = d.map((item: Record) => { if (typeof item === 'string') return item; const loc = Array.isArray(item?.loc) ? (item.loc as string[]).slice(1).join('.') : ''; return loc ? `${loc}: ${item?.msg || 'invalid'}` : (item?.msg as string) || JSON.stringify(item); }).join('; '); } else if (d && typeof d === 'object') msg = JSON.stringify(d); throw new Error(msg); } resetForm(); setShowForm(false); await fetchAois(); onAoiListChanged?.(); } catch (e) { setError(e instanceof Error ? e.message : 'Failed to create AOI'); } finally { setSubmitting(false); } }; const handleDelete = async (aoiId: string) => { try { const res = await fetch(`${API_BASE}/api/sar/aois/${encodeURIComponent(aoiId)}`, { method: 'DELETE', credentials: 'include', }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(typeof body?.detail === 'string' ? body.detail : `HTTP ${res.status}`); } await fetchAois(); onAoiListChanged?.(); } catch (e) { setError(e instanceof Error ? e.message : 'Failed to delete AOI'); } }; // If drop mode is active, show a small floating pill instead of full modal if (dropModeActive) { if (!mounted) return null; return createPortal( CLICK THE MAP TO PLACE AOI CENTER , document.body, ); } if (!mounted) return null; return createPortal( { if (e.target === e.currentTarget) { (e.currentTarget as HTMLElement).dataset.downOnBackdrop = '1'; } else { (e.currentTarget as HTMLElement).dataset.downOnBackdrop = ''; } }} onMouseUp={(e) => { const el = e.currentTarget as HTMLElement; const wasDown = el.dataset.downOnBackdrop === '1'; el.dataset.downOnBackdrop = ''; if (wasDown && e.target === e.currentTarget) onClose(); }} style={{ direction: 'ltr' }} className="fixed inset-0 z-[9999] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4" > e.stopPropagation()} className="relative w-full max-w-lg max-h-[85vh] overflow-y-auto rounded-lg border border-cyan-500/40 bg-zinc-950/95 text-cyan-100 shadow-[0_0_40px_rgba(0,200,255,0.25)]" > {/* Header */}
SAR AREAS OF INTEREST
{/* Error bar */} {error && (
{error}
)} {/* AOI List */}
{listLoading ? 'LOADING...' : `${aois.length} AOI${aois.length !== 1 ? 'S' : ''} DEFINED`} {!showForm && ( )}
{aois.length > 0 && (
{aois.map((aoi) => (
{aoi.name} {aoi.category}
{aoi.center[0].toFixed(3)}, {aoi.center[1].toFixed(3)} · {aoi.radius_km} km
))}
)} {!listLoading && aois.length === 0 && !showForm && (
No AOIs defined yet. Click "Add AOI" to create one.
)}
{/* Add AOI Form */} {showForm && (
NEW AOI
{/* Name */}
setName(e.target.value)} placeholder="e.g. Crimea Bridge" className="w-full bg-zinc-900 border border-cyan-500/30 rounded px-3 py-1.5 text-xs text-cyan-100 placeholder:text-cyan-500/30 focus:outline-none focus:border-cyan-400/60" autoComplete="off" />
{/* Description */}
setDescription(e.target.value)} placeholder="Brief description" className="w-full bg-zinc-900 border border-cyan-500/30 rounded px-3 py-1.5 text-xs text-cyan-100 placeholder:text-cyan-500/30 focus:outline-none focus:border-cyan-400/60" autoComplete="off" />
{/* Center coordinates + pick button */}
setCenterLat(e.target.value)} placeholder="45.2606" className="w-full bg-zinc-900 border border-cyan-500/30 rounded px-3 py-1.5 text-xs text-cyan-100 placeholder:text-cyan-500/30 focus:outline-none focus:border-cyan-400/60" autoComplete="off" />
setCenterLon(e.target.value)} placeholder="36.5106" className="w-full bg-zinc-900 border border-cyan-500/30 rounded px-3 py-1.5 text-xs text-cyan-100 placeholder:text-cyan-500/30 focus:outline-none focus:border-cyan-400/60" autoComplete="off" />
{/* Radius + Category */}
setRadiusKm(e.target.value)} placeholder="25" className="w-full bg-zinc-900 border border-cyan-500/30 rounded px-3 py-1.5 text-xs text-cyan-100 placeholder:text-cyan-500/30 focus:outline-none focus:border-cyan-400/60" autoComplete="off" />
{/* Submit */}
)}
, document.body, ); }); export default SarAoiEditorModal;