diff --git a/Dockerfile b/Dockerfile index f57aae1..ac18352 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ FROM caddy:2.10-alpine COPY docker/Caddyfile /etc/caddy/Caddyfile COPY --from=build /app/dist /srv +COPY --from=build /app/mobile/manifest.json /srv/mobile/manifest.json EXPOSE 80 443 diff --git a/docker/Caddyfile b/docker/Caddyfile index b9d693f..f5ee2b1 100644 --- a/docker/Caddyfile +++ b/docker/Caddyfile @@ -143,6 +143,16 @@ } } + @mobile_bare path /m + redir @mobile_bare /m/ permanent + + handle /m/* { + uri strip_prefix /m + root * /srv + try_files {path} /mobile/index.html + file_server + } + handle { try_files {path} /index.html file_server diff --git a/mobile/index.html b/mobile/index.html new file mode 100644 index 0000000..5e21388 --- /dev/null +++ b/mobile/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + + EUOSINT + + +
+ + + diff --git a/mobile/manifest.json b/mobile/manifest.json new file mode 100644 index 0000000..576adcd --- /dev/null +++ b/mobile/manifest.json @@ -0,0 +1,16 @@ +{ + "name": "EUOSINT", + "short_name": "EUOSINT", + "description": "Open-Source Intelligence Monitor", + "start_url": "/m/", + "display": "standalone", + "background_color": "#030610", + "theme_color": "#030610", + "icons": [ + { + "src": "/favicon-shield.svg", + "sizes": "any", + "type": "image/svg+xml" + } + ] +} diff --git a/src/components/AlertDetail.tsx b/src/components/AlertDetail.tsx index e0ad22f..61100dd 100644 --- a/src/components/AlertDetail.tsx +++ b/src/components/AlertDetail.tsx @@ -79,9 +79,10 @@ export function AlertDetail({ alert, onClose }: Props) {
@@ -239,7 +240,8 @@ export function AlertDetail({ alert, onClose }: Props) { href={alert.canonical_url} target="_blank" rel="noopener noreferrer" - className="flex items-center justify-center gap-2 w-full py-3 px-4 bg-siem-accent hover:bg-siem-accent/80 text-white font-bold text-sm rounded-lg transition-colors" + className="flex items-center justify-center gap-2 w-full py-3.5 px-4 bg-siem-accent active:bg-siem-accent/80 text-white font-bold text-sm rounded-lg transition-colors" + style={{ WebkitTapHighlightColor: "transparent", minHeight: 48, touchAction: "manipulation" }} > GO TO OFFICIAL ALERT diff --git a/src/components/AlertFeed.tsx b/src/components/AlertFeed.tsx index ea5254e..a709a89 100644 --- a/src/components/AlertFeed.tsx +++ b/src/components/AlertFeed.tsx @@ -829,16 +829,18 @@ export function AlertFeed({
)} -
-
Violence / focus
-
- {activeConflictBrief.violenceTypes.map((item) => ( - - {item} - - ))} + {activeConflictBrief.violenceTypes.length > 0 && ( +
+
Violence / focus
+
+ {activeConflictBrief.violenceTypes.map((item) => ( + + {item} + + ))} +
-
+ )} {activeConflictBrief.latestAlert && ( + ))} +
+ + {/* Alert cards */} + {filtered.length === 0 ? ( +
+ No alerts + + {severityFilter !== "all" + ? `No ${severityFilter} alerts in this region` + : "Pull down to refresh"} + +
+ ) : ( + filtered.map((alert) => ( + + )) + )} + + ); +} diff --git a/src/mobile/MobileAlertSheet.tsx b/src/mobile/MobileAlertSheet.tsx new file mode 100644 index 0000000..ed05a87 --- /dev/null +++ b/src/mobile/MobileAlertSheet.tsx @@ -0,0 +1,49 @@ +import type { Alert } from "@/types/alert"; +import { AlertDetail } from "@/components/AlertDetail"; +import { useBottomSheet } from "./useBottomSheet"; +import { useEffect } from "react"; + +interface Props { + alert: Alert | null; + onClose: () => void; +} + +export function MobileAlertSheet({ alert, onClose }: Props) { + const { isOpen, open, close, sheetRef, onDragStart, onDragMove, onDragEnd } = + useBottomSheet(); + + useEffect(() => { + if (alert) { + open(); + } + }, [alert, open]); + + function handleClose() { + close(); + // Delay onClose so the animation plays + setTimeout(onClose, 300); + } + + if (!isOpen || !alert) return null; + + return ( + <> +
+
+
+
+ +
+
+ + ); +} diff --git a/src/mobile/MobileApp.tsx b/src/mobile/MobileApp.tsx new file mode 100644 index 0000000..b878be9 --- /dev/null +++ b/src/mobile/MobileApp.tsx @@ -0,0 +1,141 @@ +import { useState, useEffect, useMemo, lazy, Suspense, useCallback } from "react"; +import { useAlerts } from "@/hooks/useAlerts"; +import { alertMatchesRegionFilter } from "@/lib/regions"; +import { categoryLabels, categoryOrder } from "@/lib/severity"; +import type { Alert, Severity, AlertCategory } from "@/types/alert"; +import { MobileHeader } from "./MobileHeader"; +import { MobileBottomNav, type MobileTab } from "./MobileBottomNav"; +import { MobileAlertList } from "./MobileAlertList"; +import { MobileAlertSheet } from "./MobileAlertSheet"; +import { MobileSearch } from "./MobileSearch"; + +const MobileMapView = lazy(() => + import("./MobileMapView").then((m) => ({ default: m.MobileMapView })), +); + +function useUTCClock() { + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => setNow(new Date()), 10_000); + return () => clearInterval(id); + }, []); + return `${String(now.getUTCHours()).padStart(2, "0")}:${String(now.getUTCMinutes()).padStart(2, "0")}Z`; +} + +export function MobileApp() { + const { alerts, isLoading, refetch } = useAlerts(); + const [regionFilter, setRegionFilter] = useState("Europe"); + const [activeTab, setActiveTab] = useState("alerts"); + const [severityFilter, setSeverityFilter] = useState("all"); + const [categoryFilter, setCategoryFilter] = useState>(new Set()); + const [selectedAlertId, setSelectedAlertId] = useState(null); + const clock = useUTCClock(); + + // Filter alerts by region + const regionAlerts = useMemo( + () => alerts.filter((a) => alertMatchesRegionFilter(a, regionFilter)), + [alerts, regionFilter], + ); + + // Apply category filter + const categoryFiltered = useMemo( + () => + categoryFilter.size === 0 + ? regionAlerts + : regionAlerts.filter((a) => categoryFilter.has(a.category)), + [regionAlerts, categoryFilter], + ); + + // Sort: most recent first + const sorted = useMemo( + () => + [...categoryFiltered].sort( + (a, b) => new Date(b.last_seen).getTime() - new Date(a.last_seen).getTime(), + ), + [categoryFiltered], + ); + + // Count critical+high for badge + const urgentCount = useMemo( + () => categoryFiltered.filter((a) => a.severity === "critical" || a.severity === "high").length, + [categoryFiltered], + ); + + // Categories with counts for the picker (based on region alerts, before category filter) + const categoriesWithCounts = useMemo(() => { + const countMap: Record = {}; + for (const a of regionAlerts) countMap[a.category] = (countMap[a.category] ?? 0) + 1; + return categoryOrder + .filter((c) => countMap[c]) + .map((c) => ({ value: c, label: categoryLabels[c] ?? c, count: countMap[c] })); + }, [regionAlerts]); + + // Find selected alert across all alerts (search results may not be in regionAlerts) + const selectedAlert: Alert | null = useMemo( + () => alerts.find((a) => a.alert_id === selectedAlertId) ?? null, + [alerts, selectedAlertId], + ); + + const handleSelectAlert = useCallback((alertId: string) => { + setSelectedAlertId(alertId); + }, []); + + const handleCloseSheet = useCallback(() => { + setSelectedAlertId(null); + }, []); + + return ( +
+ + +
+ {activeTab === "alerts" && ( + + )} + + {activeTab === "map" && ( + +
+ Loading map... +
+ } + > + +
+ )} + + {activeTab === "search" && ( + + )} +
+ + + + +
+ ); +} diff --git a/src/mobile/MobileBottomNav.tsx b/src/mobile/MobileBottomNav.tsx new file mode 100644 index 0000000..7663a90 --- /dev/null +++ b/src/mobile/MobileBottomNav.tsx @@ -0,0 +1,46 @@ +import { Bell, Map, Search } from "lucide-react"; + +export type MobileTab = "alerts" | "map" | "search"; + +interface Props { + activeTab: MobileTab; + onTabChange: (tab: MobileTab) => void; + alertCount: number; +} + +export function MobileBottomNav({ activeTab, onTabChange, alertCount }: Props) { + return ( + + ); +} diff --git a/src/mobile/MobileHeader.tsx b/src/mobile/MobileHeader.tsx new file mode 100644 index 0000000..a1134fa --- /dev/null +++ b/src/mobile/MobileHeader.tsx @@ -0,0 +1,163 @@ +import { useState } from "react"; +import { Shield, ChevronDown, Check, Filter } from "lucide-react"; +import type { AlertCategory } from "@/types/alert"; +import { categoryLabels } from "@/lib/severity"; + +const REGIONS = [ + { value: "all", label: "Global" }, + { value: "Europe", label: "Europe" }, + { value: "Middle East", label: "Middle East" }, + { value: "Africa", label: "Africa" }, + { value: "North America", label: "North America" }, + { value: "Asia-Pacific", label: "Asia-Pacific" }, + { value: "Caribbean", label: "Caribbean" }, +]; + +interface Props { + regionFilter: string; + onRegionChange: (region: string) => void; + categoryFilter: Set; + onCategoryChange: (c: Set) => void; + categoriesWithCounts: Array<{ value: AlertCategory; label: string; count: number }>; + clock: string; +} + +export function MobileHeader({ + regionFilter, + onRegionChange, + categoryFilter, + onCategoryChange, + categoriesWithCounts, + clock, +}: Props) { + const [regionPickerOpen, setRegionPickerOpen] = useState(false); + const [categoryPickerOpen, setCategoryPickerOpen] = useState(false); + + function selectRegion(value: string) { + onRegionChange(value); + setRegionPickerOpen(false); + } + + function toggleCategory(cat: AlertCategory) { + const next = new Set(categoryFilter); + if (next.has(cat)) next.delete(cat); + else next.add(cat); + onCategoryChange(next); + } + + function clearCategories() { + onCategoryChange(new Set()); + } + + const currentRegionLabel = REGIONS.find((r) => r.value === regionFilter)?.label ?? "Global"; + const hasFilter = categoryFilter.size > 0; + + // Short label for category pill + let categoryPillLabel: string; + if (!hasFilter) { + categoryPillLabel = "Category"; + } else if (categoryFilter.size === 1) { + const cat = [...categoryFilter][0]; + categoryPillLabel = categoryLabels[cat] ?? cat; + } else { + categoryPillLabel = `${categoryFilter.size} categories`; + } + + return ( + <> +
+ + EUOSINT + +
+ {/* Category pill */} + + + {/* Region pill */} + +
+ + {clock} +
+ + {/* Region picker sheet */} + {regionPickerOpen && ( + <> +
setRegionPickerOpen(false)} + /> +
+
+
Select Region
+ {REGIONS.map((r) => ( + + ))} +
+ + )} + + {/* Category picker sheet */} + {categoryPickerOpen && ( + <> +
setCategoryPickerOpen(false)} + /> +
+
+
+ Filter by Category + {hasFilter && ( + + )} +
+ {categoriesWithCounts.map(({ value, label, count }) => ( + + ))} +
+ + )} + + ); +} diff --git a/src/mobile/MobileMapView.tsx b/src/mobile/MobileMapView.tsx new file mode 100644 index 0000000..ee05e4e --- /dev/null +++ b/src/mobile/MobileMapView.tsx @@ -0,0 +1,298 @@ +import { useEffect, useRef, useState, useCallback } from "react"; +import L from "leaflet"; +import "leaflet/dist/leaflet.css"; +import "leaflet.markercluster"; +import "leaflet.markercluster/dist/MarkerCluster.css"; +import { Layers, Check, ExternalLink } from "lucide-react"; +import type { Alert } from "@/types/alert"; +import { severityHex } from "@/lib/theme"; +import { categoryLabels, freshnessLabel } from "@/lib/severity"; +import { + DEFAULT_OVERLAYS, + loadOverlay, + type OverlayDef, + type OverlayId, +} from "@/lib/map-overlays"; + +const REGION_VIEWPORTS: Record = { + Europe: { center: [50, 10], zoom: 4 }, + "Middle East": { center: [28, 46], zoom: 5 }, + "North America": { center: [42, -100], zoom: 4 }, + "South America": { center: [-15, -60], zoom: 3 }, + Africa: { center: [4, 20], zoom: 4 }, + "Asia-Pacific": { center: [15, 105], zoom: 4 }, + Caribbean: { center: [18, -75], zoom: 5 }, + all: { center: [20, 0], zoom: 3 }, +}; + +const SEV_ORDER: Record = { critical: 4, high: 3, medium: 2, low: 1, info: 0 }; +const SEV_BADGE_COLOR: Record = { + critical: "#dc2626", high: "#ef4444", medium: "#f59e0b", low: "#3b82f6", info: "#64748b", +}; + +interface Props { + alerts: Alert[]; + regionFilter: string; + onSelectAlert: (alertId: string) => void; +} + +export function MobileMapView({ alerts, regionFilter, onSelectAlert }: Props) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const clusterRef = useRef(null); + const markerAlertMap = useRef>(new Map()); + const overlayLayers = useRef>(new Map()); + const [layerPickerOpen, setLayerPickerOpen] = useState(false); + const [activeOverlays, setActiveOverlays] = useState>(new Set()); + const [clusterAlerts, setClusterAlerts] = useState([]); + const overlayDefs = DEFAULT_OVERLAYS; + + const toggleOverlay = useCallback((id: OverlayId) => { + setActiveOverlays((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + // Initialize map once + useEffect(() => { + if (!containerRef.current || mapRef.current) return; + + const vp = REGION_VIEWPORTS[regionFilter] ?? REGION_VIEWPORTS.all; + const map = L.map(containerRef.current, { + center: vp.center, + zoom: vp.zoom, + zoomControl: false, + attributionControl: false, + }); + + const overlayPane = map.createPane("overlayPane"); + overlayPane.style.zIndex = "650"; + + L.tileLayer( + "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", + { maxZoom: 18, subdomains: "abcd" }, + ).addTo(map); + + L.control.zoom({ position: "bottomright" }).addTo(map); + + const cluster = L.markerClusterGroup({ + maxClusterRadius: 50, + spiderfyOnMaxZoom: false, + zoomToBoundsOnClick: false, + showCoverageOnHover: false, + iconCreateFunction(c) { + const count = c.getChildCount(); + let size: "small" | "medium" | "large" = "small"; + if (count >= 100) size = "large"; + else if (count >= 30) size = "medium"; + return L.divIcon({ + html: `${count}`, + className: `siem-cluster siem-cluster-${size}`, + iconSize: L.point(36, 36), + }); + }, + }); + + // Cluster tap → show alert list sheet + cluster.on("clusterclick", (e: L.LeafletEvent & { layer?: L.MarkerCluster }) => { + const cl = e.layer; + if (!cl) return; + const childAlerts = (cl.getAllChildMarkers() as L.Layer[]) + .map((m) => markerAlertMap.current.get(L.Util.stamp(m))) + .filter((a): a is Alert => Boolean(a)) + .sort((a, b) => { + const sd = (SEV_ORDER[b.severity] ?? 0) - (SEV_ORDER[a.severity] ?? 0); + if (sd !== 0) return sd; + return new Date(b.last_seen).getTime() - new Date(a.last_seen).getTime(); + }); + if (childAlerts.length > 0) { + setClusterAlerts(childAlerts); + } + }); + + map.addLayer(cluster); + mapRef.current = map; + clusterRef.current = cluster; + + const ro = new ResizeObserver(() => map.invalidateSize()); + ro.observe(containerRef.current); + const layers = overlayLayers.current; + + return () => { + ro.disconnect(); + map.remove(); + mapRef.current = null; + clusterRef.current = null; + layers.clear(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Fly to region + useEffect(() => { + const map = mapRef.current; + if (!map) return; + const vp = REGION_VIEWPORTS[regionFilter] ?? REGION_VIEWPORTS.all; + map.flyTo(vp.center, vp.zoom, { duration: 0.6 }); + }, [regionFilter]); + + // Manage overlay layers + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + for (const [id, layer] of overlayLayers.current.entries()) { + if (!activeOverlays.has(id)) { + map.removeLayer(layer); + overlayLayers.current.delete(id); + } + } + + for (const id of activeOverlays) { + if (overlayLayers.current.has(id)) continue; + const def = overlayDefs.find((o) => o.id === id); + if (!def) continue; + loadOverlay(map, def, { regionFilter }).then((layer) => { + if (!mapRef.current || !activeOverlays.has(id)) { + map.removeLayer(layer); + return; + } + const prev = overlayLayers.current.get(id); + if (prev) map.removeLayer(prev); + overlayLayers.current.set(id, layer); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeOverlays, regionFilter]); + + // Update alert markers + useEffect(() => { + const cluster = clusterRef.current; + if (!cluster) return; + + cluster.clearLayers(); + markerAlertMap.current.clear(); + + for (const alert of alerts) { + if (alert.lat === 0 && alert.lng === 0) continue; + const marker = L.circleMarker([alert.lat, alert.lng], { + radius: 6, + fillColor: severityHex(alert.severity), + color: "#ffffff40", + weight: 1, + fillOpacity: 0.85, + }); + marker.on("click", () => onSelectAlert(alert.alert_id)); + markerAlertMap.current.set(L.Util.stamp(marker), alert); + cluster.addLayer(marker); + } + }, [alerts, onSelectAlert]); + + return ( +
+
+ + {/* Layers FAB */} + + + {/* Overlay picker sheet */} + {layerPickerOpen && ( + <> +
setLayerPickerOpen(false)} + /> +
+
+
Map Layers
+ {overlayDefs.map((def: OverlayDef) => ( + + ))} +
+ + )} + + {/* Cluster alert list sheet */} + {clusterAlerts.length > 0 && ( + <> +
setClusterAlerts([])} + /> +
+
+
+ Area Alerts ({clusterAlerts.length}) +
+
+ {clusterAlerts.map((alert) => ( +
+ + {alert.canonical_url && ( + + + + )} +
+ ))} +
+
+ + )} +
+ ); +} diff --git a/src/mobile/MobileSearch.tsx b/src/mobile/MobileSearch.tsx new file mode 100644 index 0000000..47d987b --- /dev/null +++ b/src/mobile/MobileSearch.tsx @@ -0,0 +1,95 @@ +import { useSearch } from "@/hooks/useSearch"; +import { MobileAlertCard } from "./MobileAlertCard"; +import type { AlertCategory } from "@/types/alert"; +import { categoryLabels, categoryOrder } from "@/lib/severity"; +import { useState, useMemo } from "react"; +import { SearchX, WifiOff } from "lucide-react"; + +interface Props { + onSelectAlert: (alertId: string) => void; +} + +// Show a useful subset of categories as quick-filter chips +const CHIP_CATEGORIES: AlertCategory[] = categoryOrder.slice(0, 10); + +export function MobileSearch({ onSelectAlert }: Props) { + const { query, setQuery, results, isSearching, isApiAvailable } = useSearch(); + const [categoryFilter, setCategoryFilter] = useState(null); + + const filtered = useMemo( + () => + categoryFilter + ? results.filter((a) => a.category === categoryFilter) + : results, + [results, categoryFilter], + ); + + if (isApiAvailable === false) { + return ( +
+ + Search API unavailable + The collector API is not reachable +
+ ); + } + + return ( +
+
+ setQuery(e.target.value)} + autoFocus + /> + +
+ {CHIP_CATEGORIES.map((cat) => ( + + ))} +
+
+ +
+ {isSearching && ( +
+
+
+ )} + + {!isSearching && query.trim() && filtered.length === 0 && ( +
+ + No results for "{query}" +
+ )} + + {!isSearching && + filtered.map((alert) => ( + + ))} +
+
+ ); +} diff --git a/src/mobile/main.tsx b/src/mobile/main.tsx new file mode 100644 index 0000000..a2f4c8d --- /dev/null +++ b/src/mobile/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./mobile.css"; +import { MobileApp } from "./MobileApp"; +import { ErrorBoundary } from "@/components/ErrorBoundary"; + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/src/mobile/mobile.css b/src/mobile/mobile.css new file mode 100644 index 0000000..2ab62cb --- /dev/null +++ b/src/mobile/mobile.css @@ -0,0 +1,625 @@ +@import url("https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800&family=Roboto+Mono:wght@400;500;600&display=swap"); +@import "tailwindcss"; +@import "../theme.css"; + +/* ── Safe area & viewport ────────────────────────────────────── */ +:root { + --sat: env(safe-area-inset-top, 0px); + --sab: env(safe-area-inset-bottom, 0px); + --header-h: 48px; + --nav-h: 56px; +} + +html, body, #root { + height: 100%; + overflow: hidden; + overscroll-behavior: none; + -webkit-overflow-scrolling: touch; +} + +body { + background: var(--color-siem-bg, #030610); + color: var(--color-siem-text, #e2e8f0); + font-family: "Montserrat", "Segoe UI", sans-serif; +} + +/* ── Layout shell ────────────────────────────────────────────── */ +.mobile-shell { + display: flex; + flex-direction: column; + height: 100%; + padding-top: var(--sat); + padding-bottom: var(--sab); +} + +.mobile-header { + flex: 0 0 var(--header-h); + display: flex; + align-items: center; + padding: 0 12px; + gap: 8px; + border-bottom: 1px solid rgba(71, 85, 105, 0.3); + background: rgba(3, 6, 10, 0.92); + backdrop-filter: blur(8px); + z-index: 20; +} + +.mobile-content { + flex: 1; + overflow: hidden; + position: relative; +} + +.mobile-nav { + flex: 0 0 var(--nav-h); + display: flex; + align-items: center; + justify-content: space-around; + border-top: 1px solid rgba(71, 85, 105, 0.3); + background: rgba(3, 6, 10, 0.95); + backdrop-filter: blur(8px); + z-index: 20; +} + +/* ── Bottom nav tabs ─────────────────────────────────────────── */ +.mobile-nav-tab { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 8px 20px; /* ↑ 6→8px vert, 16→20px horiz */ + font-size: 10px; + color: #64748b; + transition: color 0.15s; + -webkit-tap-highlight-color: transparent; + user-select: none; + touch-action: manipulation; +} + +.mobile-nav-tab.active { + color: #38bdf8; +} + +.mobile-nav-tab svg { + width: 22px; + height: 22px; +} + +/* ── Region / category pill ──────────────────────────────────── */ +.mobile-region-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 10px; /* ↑ 4→6px vert, min 32px tall */ + font-size: 12px; + font-weight: 600; + color: #94a3b8; + background: rgba(51, 65, 85, 0.35); + border: 1px solid rgba(71, 85, 105, 0.4); + border-radius: 999px; + -webkit-tap-highlight-color: transparent; + user-select: none; + touch-action: manipulation; + transition: background 0.15s; +} + +.mobile-region-pill:active { + background: rgba(51, 65, 85, 0.6); +} + +.mobile-region-pill--active { + background: rgba(56, 189, 248, 0.15); + border-color: rgba(56, 189, 248, 0.4); + color: #38bdf8; +} + +/* ── Alert list ──────────────────────────────────────────────── */ +.mobile-alert-list { + height: 100%; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior-y: none; +} + +.mobile-severity-pills { + display: flex; + gap: 6px; + padding: 10px 12px 6px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; /* hide scrollbar Firefox */ +} +.mobile-severity-pills::-webkit-scrollbar { + display: none; /* hide scrollbar WebKit */ +} + +.mobile-severity-pill { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 14px; /* ↑ 4→6px vert, 12→14px horiz */ + min-height: 32px; /* ensure tappable height */ + font-size: 12px; /* ↑ 11→12px */ + font-weight: 600; + border-radius: 999px; + border: 1px solid rgba(71, 85, 105, 0.4); + background: transparent; + color: #94a3b8; + -webkit-tap-highlight-color: transparent; + user-select: none; + touch-action: manipulation; + transition: all 0.15s; +} + +.mobile-severity-pill.active { + background: rgba(56, 189, 248, 0.15); + border-color: rgba(56, 189, 248, 0.4); + color: #38bdf8; +} + +/* ── Alert card ──────────────────────────────────────────────── */ +.mobile-alert-card { + display: flex; + gap: 10px; + padding: 14px 12px; /* ↑ 12→14px vert */ + border-bottom: 1px solid rgba(71, 85, 105, 0.15); + -webkit-tap-highlight-color: transparent; + user-select: none; + touch-action: manipulation; + transition: background 0.1s; +} + +.mobile-alert-card:active { + background: rgba(51, 65, 85, 0.25); +} + +.mobile-alert-sev { + flex: 0 0 4px; + border-radius: 2px; + align-self: stretch; +} + +.mobile-alert-body { + flex: 1; + min-width: 0; +} + +.mobile-alert-title { + font-size: 14px; /* ↑ 13→14px */ + font-weight: 500; + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.mobile-alert-meta { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; + font-size: 12px; /* ↑ 11→12px */ + color: #64748b; +} + +.mobile-alert-badge { + display: inline-block; + padding: 2px 6px; /* ↑ 1→2px vert */ + font-size: 10px; /* ↑ 9→10px */ + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; + border-radius: 3px; + color: #fff; +} + +/* ── Pull-to-refresh ─────────────────────────────────────────── */ +.mobile-ptr-indicator { + position: absolute; + top: 0; + left: 0; + right: 0; + display: flex; + justify-content: center; + padding: 12px 0; + z-index: 10; + pointer-events: none; +} + +.mobile-ptr-spinner { + width: 20px; + height: 20px; + border: 2px solid rgba(56, 189, 248, 0.3); + border-top-color: #38bdf8; + border-radius: 50%; + animation: mobile-spin 0.6s linear infinite; +} + +@keyframes mobile-spin { + to { transform: rotate(360deg); } +} + +/* ── Bottom sheet ────────────────────────────────────────────── */ +.mobile-sheet-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 999998; + -webkit-tap-highlight-color: transparent; +} + +.mobile-sheet { + position: fixed; + left: 0; + right: 0; + bottom: 0; + max-height: 90dvh; /* ↑ vh→dvh */ + background: rgba(15, 23, 42, 0.98); + border-top: 1px solid rgba(71, 85, 105, 0.4); + border-radius: 16px 16px 0 0; + z-index: 999999; + overflow: hidden; + display: flex; + flex-direction: column; + transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); + will-change: transform; + padding-bottom: var(--sab); +} + +.mobile-sheet-handle { + flex: 0 0 auto; + display: flex; + justify-content: center; + padding: 12px 0 8px; /* ↑ bigger drag target */ + cursor: grab; + -webkit-tap-highlight-color: transparent; +} + +.mobile-sheet-handle::after { + content: ""; + width: 36px; + height: 4px; + border-radius: 2px; + background: #475569; +} + +.mobile-sheet-content { + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior-y: contain; + padding: 0 16px 16px; +} + +/* ── Search ──────────────────────────────────────────────────── */ +.mobile-search-input { + width: 100%; + padding: 12px 14px; /* ↑ 10/12 → 12/14 */ + font-size: 16px; /* ↑ 15→16px prevents iOS zoom */ + background: rgba(30, 41, 59, 0.6); + border: 1px solid rgba(71, 85, 105, 0.4); + border-radius: 10px; + color: #e2e8f0; + outline: none; + -webkit-appearance: none; +} + +.mobile-search-input::placeholder { + color: #475569; +} + +.mobile-search-input:focus { + border-color: rgba(56, 189, 248, 0.5); +} + +.mobile-search-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; /* ↑ 6→8px */ + padding: 8px 0; +} + +.mobile-search-chip { + padding: 6px 12px; /* ↑ 4/10 → 6/12 */ + min-height: 32px; /* ensure tappable */ + font-size: 12px; /* ↑ 11→12px */ + border-radius: 999px; + border: 1px solid rgba(71, 85, 105, 0.4); + color: #94a3b8; + background: transparent; + -webkit-tap-highlight-color: transparent; + user-select: none; + touch-action: manipulation; +} + +.mobile-search-chip.active { + background: rgba(56, 189, 248, 0.15); + border-color: rgba(56, 189, 248, 0.4); + color: #38bdf8; +} + +/* ── Empty states ────────────────────────────────────────────── */ +.mobile-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: #475569; + font-size: 14px; + gap: 8px; + padding: 32px; + text-align: center; +} + +/* ── Leaflet cluster overrides ──────────────────────────────── */ +.siem-cluster { + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-family: "Roboto Mono", monospace; + font-weight: 600; + color: var(--color-siem-text, #e6edf5); + border: 2px solid color-mix(in srgb, var(--color-siem-accent, #E8630A) 45%, transparent); +} + +.siem-cluster span { + line-height: 1; +} + +.siem-cluster-small { + font-size: 11px; + background: color-mix(in srgb, var(--color-siem-panel, #0b1120) 88%, transparent); +} + +.siem-cluster-medium { + font-size: 12px; + background: color-mix(in srgb, var(--color-siem-accent, #E8630A) 22%, transparent); +} + +.siem-cluster-large { + font-size: 13px; + background: color-mix(in srgb, var(--color-siem-high, #f29d4b) 28%, transparent); + border-color: color-mix(in srgb, var(--color-siem-high, #f29d4b) 55%, transparent); +} + +/* ── Map ─────────────────────────────────────────────────────── */ +.mobile-map { + height: 100%; + width: 100%; +} + +.mobile-map .leaflet-container { + height: 100%; + width: 100%; + background: #030610; +} + +/* ── Loading skeleton ────────────────────────────────────────── */ +.mobile-skeleton { + height: 64px; + margin: 0 12px; + border-radius: 8px; + background: linear-gradient(90deg, rgba(30,41,59,0.3) 25%, rgba(30,41,59,0.5) 50%, rgba(30,41,59,0.3) 75%); + background-size: 200% 100%; + animation: mobile-shimmer 1.5s infinite; +} + +@keyframes mobile-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* ── Clear / action buttons in sheets ────────────────────────── */ +.mobile-clear-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; + min-width: 72px; + padding: 8px 14px; + font-size: 13px; + font-weight: 600; + color: #38bdf8; + background: rgba(56, 189, 248, 0.1); + border: 1px solid rgba(56, 189, 248, 0.25); + border-radius: 8px; + text-transform: none; + letter-spacing: 0; + -webkit-tap-highlight-color: transparent; + user-select: none; + touch-action: manipulation; + transition: background 0.15s; +} + +.mobile-clear-btn:active { + background: rgba(56, 189, 248, 0.25); +} + +/* ── Picker sheet (regions, layers, categories) ──────────────── */ +.mobile-picker-sheet { + position: fixed; + left: 0; + right: 0; + bottom: 0; + max-height: 70dvh; /* ↑ vh→dvh */ + background: rgba(15, 23, 42, 0.98); + border-top: 1px solid rgba(71, 85, 105, 0.4); + border-radius: 16px 16px 0 0; + z-index: 999999; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior-y: contain; + padding-bottom: calc(var(--sab) + 8px); + animation: mobile-slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1); +} + +@keyframes mobile-slide-up { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +.mobile-picker-title { + font-size: 13px; + font-weight: 700; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 4px 16px 8px; +} + +.mobile-picker-item { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 14px 16px; + min-height: 48px; /* ensure 48px touch target */ + font-size: 15px; + color: #cbd5e1; + border-bottom: 1px solid rgba(71, 85, 105, 0.15); + -webkit-tap-highlight-color: transparent; + user-select: none; + touch-action: manipulation; + transition: background 0.1s; +} + +.mobile-picker-item:active { + background: rgba(51, 65, 85, 0.3); +} + +.mobile-picker-item.active { + color: #38bdf8; +} + +/* ── Layers FAB ──────────────────────────────────────────────── */ +.mobile-layers-fab { + position: absolute; + top: 12px; + right: 12px; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + width: 44px; /* ↑ 40→44px */ + height: 44px; /* ↑ 40→44px */ + border-radius: 12px; + background: rgba(15, 23, 42, 0.85); + border: 1px solid rgba(71, 85, 105, 0.5); + color: #cbd5e1; + backdrop-filter: blur(8px); + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.mobile-layers-fab:active { + background: rgba(30, 41, 59, 0.9); +} + +/* ── Cluster alert list ───────────────────────────────────────── */ +.mobile-cluster-list { + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior-y: contain; + padding-bottom: 8px; +} + +.mobile-cluster-item { + display: flex; + align-items: center; + gap: 8px; + padding: 0 16px; + border-bottom: 1px solid rgba(71, 85, 105, 0.15); +} + +.mobile-cluster-item-main { + flex: 1; + display: flex; + align-items: flex-start; + gap: 8px; + padding: 14px 0; /* ↑ 12→14px vert */ + min-height: 48px; /* ensure touch target */ + min-width: 0; + text-align: left; + -webkit-tap-highlight-color: transparent; + user-select: none; + touch-action: manipulation; +} + +.mobile-cluster-item-main:active { + opacity: 0.7; +} + +.mobile-cluster-sev { + flex-shrink: 0; + padding: 2px 6px; /* ↑ 5→6px horiz */ + font-size: 10px; /* ↑ 9→10px */ + font-weight: 700; + letter-spacing: 0.3px; + color: #fff; + border-radius: 3px; + margin-top: 2px; +} + +.mobile-cluster-text { + flex: 1; + min-width: 0; +} + +.mobile-cluster-title { + font-size: 14px; /* ↑ 13→14px */ + line-height: 1.35; + color: #e2e8f0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.mobile-cluster-meta { + font-size: 12px; /* ↑ 11→12px */ + color: #64748b; + margin-top: 3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mobile-cluster-source { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 44px; /* ↑ 32→44px */ + height: 44px; /* ↑ 32→44px */ + color: #60a5fa; + border-radius: 8px; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; +} + +.mobile-cluster-source:active { + background: rgba(96, 165, 250, 0.15); +} + +/* ── Layers badge ────────────────────────────────────────────── */ +.mobile-layers-badge { + position: absolute; + top: -4px; + right: -4px; + min-width: 18px; /* ↑ 16→18px */ + height: 18px; /* ↑ 16→18px */ + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; + font-size: 10px; /* ↑ 9→10px */ + font-weight: 700; + color: #fff; + background: #38bdf8; + border-radius: 999px; +} diff --git a/src/mobile/useBottomSheet.ts b/src/mobile/useBottomSheet.ts new file mode 100644 index 0000000..7be6e38 --- /dev/null +++ b/src/mobile/useBottomSheet.ts @@ -0,0 +1,63 @@ +import { useRef, useState, useCallback } from "react"; + +export function useBottomSheet() { + const [isOpen, setIsOpen] = useState(false); + const sheetRef = useRef(null); + const startY = useRef(0); + const currentTranslate = useRef(0); + const dragging = useRef(false); + + const open = useCallback(() => { + setIsOpen(true); + currentTranslate.current = 0; + }, []); + + const close = useCallback(() => { + const el = sheetRef.current; + if (el) { + el.style.transform = "translateY(100%)"; + } + setTimeout(() => { + setIsOpen(false); + currentTranslate.current = 0; + }, 300); + }, []); + + const onDragStart = useCallback((e: React.TouchEvent) => { + startY.current = e.touches[0].clientY; + dragging.current = true; + const el = sheetRef.current; + if (el) el.style.transition = "none"; + }, []); + + const onDragMove = useCallback((e: React.TouchEvent) => { + if (!dragging.current) return; + const dy = e.touches[0].clientY - startY.current; + if (dy < 0) return; // Only allow drag down + currentTranslate.current = dy; + const el = sheetRef.current; + if (el) el.style.transform = `translateY(${dy}px)`; + }, []); + + const onDragEnd = useCallback(() => { + dragging.current = false; + const el = sheetRef.current; + if (el) el.style.transition = "transform 0.3s cubic-bezier(0.32, 0.72, 0, 1)"; + if (currentTranslate.current > 120) { + close(); + } else { + currentTranslate.current = 0; + if (el) el.style.transform = "translateY(0)"; + } + }, [close]); + + return { + isOpen, + open, + close, + sheetRef, + onDragStart, + onDragMove, + onDragEnd, + }; +} diff --git a/src/mobile/usePullToRefresh.ts b/src/mobile/usePullToRefresh.ts new file mode 100644 index 0000000..fb7031c --- /dev/null +++ b/src/mobile/usePullToRefresh.ts @@ -0,0 +1,51 @@ +import { useRef, useCallback, useState, type RefObject } from "react"; + +const THRESHOLD = 60; + +export function usePullToRefresh( + scrollRef: RefObject, + onRefresh: () => void, +) { + const [isRefreshing, setIsRefreshing] = useState(false); + const [pullDistance, setPullDistance] = useState(0); + const startY = useRef(0); + const pulling = useRef(false); + + const onTouchStart = useCallback((e: React.TouchEvent) => { + const el = scrollRef.current; + if (!el || el.scrollTop > 0) return; + startY.current = e.touches[0].clientY; + pulling.current = true; + }, [scrollRef]); + + const onTouchMove = useCallback((e: React.TouchEvent) => { + if (!pulling.current || isRefreshing) return; + const dy = e.touches[0].clientY - startY.current; + if (dy > 0) { + setPullDistance(Math.min(dy * 0.5, THRESHOLD * 1.5)); + } + }, [isRefreshing]); + + const onTouchEnd = useCallback(() => { + if (!pulling.current) return; + pulling.current = false; + if (pullDistance >= THRESHOLD && !isRefreshing) { + setIsRefreshing(true); + onRefresh(); + setTimeout(() => { + setIsRefreshing(false); + setPullDistance(0); + }, 1000); + } else { + setPullDistance(0); + } + }, [pullDistance, isRefreshing, onRefresh]); + + return { + isRefreshing, + pullDistance, + onTouchStart, + onTouchMove, + onTouchEnd, + }; +} diff --git a/src/theme.css b/src/theme.css new file mode 100644 index 0000000..0b668ad --- /dev/null +++ b/src/theme.css @@ -0,0 +1,49 @@ +@theme { + /* ── Relative type scale (scales with html font-size) ──────────── */ + --font-size-4xs: 0.615rem; /* ~8px at 13px root */ + --font-size-3xs: 0.692rem; /* ~9px at 13px root */ + --font-size-2xs: 0.77rem; /* ~10px at 13px root */ + --font-size-xxs: 0.846rem; /* ~11px at 13px root */ + + /* ── Brand ──────────────────────────────────────────────────────── */ + --color-siem-bg: #070E1A; + --color-siem-panel: #0b1120; + --color-siem-panel-strong: #131d2e; + --color-siem-border: #1e293b; + --color-siem-text: #e6edf5; + --color-siem-muted: #5A7B95; + --color-siem-accent: #E8630A; + --color-siem-accent-strong: #FF8533; + --color-siem-neutral: #9ca3af; + + /* ── Severity ───────────────────────────────────────────────────── */ + --color-siem-critical: #ff5d5d; + --color-siem-high: #f29d4b; + --color-siem-medium: #e3c867; + --color-siem-low: #4ccb8d; + --color-siem-info: #60a5fa; + + /* ── Category ───────────────────────────────────────────────────── */ + --color-cat-informational: #60a5fa; + --color-cat-cyber: #3b82f6; + --color-cat-education: #4f95a4; + --color-cat-humanitarian: #2f8c8c; + --color-cat-conflict: #725f95; + --color-cat-humsec: #3a7395; + --color-cat-wanted: #a14a5b; + --color-cat-missing: #aa8b43; + --color-cat-appeal: #5577a4; + --color-cat-fraud: #338c66; + --color-cat-safety: #5b6887; + --color-cat-terrorism: #a34c4c; + --color-cat-private: #8f6a46; + --color-cat-travel: #c27a3a; + --color-cat-health: #4ca38c; + --color-cat-intel: #7a6eab; + --color-cat-emergency: #b85c4a; + --color-cat-environment: #4a8b6e; + --color-cat-disease: #c45e8a; + --color-cat-maritime: #2a7a9b; + --color-cat-logistics: #8b6e4a; + --color-cat-legislative: #6b7f45; +} diff --git a/vite.config.ts b/vite.config.ts index 20cc850..d3ab352 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,13 +26,32 @@ const appVersion = export default defineConfig({ base: process.env.BASE_PATH ?? "/", - plugins: [react(), tailwindcss()], + plugins: [ + react(), + tailwindcss(), + { + name: "mobile-spa-fallback", + configureServer(server) { + server.middlewares.use((req, _res, next) => { + if (req.url && /^\/mobile(\/(?!index\.html).*)?\/?(\?.*)?$/.test(req.url)) { + req.url = "/mobile/index.html"; + } + next(); + }); + }, + }, + ], build: { rollupOptions: { + input: { + main: path.resolve(__dirname, "index.html"), + mobile: path.resolve(__dirname, "mobile/index.html"), + }, output: { manualChunks(id) { if (!id.includes("node_modules")) return undefined; - if (id.includes("leaflet") || id.includes("three")) return "vendor-map"; + if (id.includes("three")) return "vendor-three"; + if (id.includes("leaflet")) return "vendor-leaflet"; if (id.includes("react") || id.includes("scheduler")) return "vendor-react"; if (id.includes("lucide-react")) return "vendor-icons"; return "vendor";