From 884b0e9d34d2057df3f4d37b2a4222998750e523 Mon Sep 17 00:00:00 2001 From: 2pk03 Date: Sun, 22 Mar 2026 16:19:29 +0100 Subject: [PATCH 1/9] Add dedicated mobile web app with touch-first UX Separate React app at /mobile served via /m/* on Caddy. Phone UAs auto-redirect; tablets stay on desktop. - 3-tab bottom nav: Alerts, Map, Search - Region picker sheet (replaces cycling) - Overlay picker on map with layer toggles - Alert cards with severity stripe and meta - Bottom sheet with swipe-to-dismiss for alert detail - Pull-to-refresh on alert list - Lazy-loaded Leaflet map (no Three.js in bundle) - PWA manifest for add-to-homescreen - Severity filter pills with counts - Category chip filters on search - Mobile bundle: 3.6 KB gzip (+ shared React) --- docker/Caddyfile | 7 + mobile/index.html | 20 ++ mobile/manifest.json | 16 ++ src/hooks/useAlerts.ts | 18 +- src/main.tsx | 10 + src/mobile/MobileAlertCard.tsx | 43 +++ src/mobile/MobileAlertList.tsx | 115 ++++++++ src/mobile/MobileAlertSheet.tsx | 49 ++++ src/mobile/MobileApp.tsx | 118 +++++++++ src/mobile/MobileBottomNav.tsx | 46 ++++ src/mobile/MobileHeader.tsx | 86 ++++++ src/mobile/MobileMapView.tsx | 221 ++++++++++++++++ src/mobile/MobileSearch.tsx | 89 +++++++ src/mobile/main.tsx | 13 + src/mobile/mobile.css | 451 ++++++++++++++++++++++++++++++++ src/mobile/useBottomSheet.ts | 63 +++++ src/mobile/usePullToRefresh.ts | 51 ++++ vite.config.ts | 7 +- 18 files changed, 1418 insertions(+), 5 deletions(-) create mode 100644 mobile/index.html create mode 100644 mobile/manifest.json create mode 100644 src/mobile/MobileAlertCard.tsx create mode 100644 src/mobile/MobileAlertList.tsx create mode 100644 src/mobile/MobileAlertSheet.tsx create mode 100644 src/mobile/MobileApp.tsx create mode 100644 src/mobile/MobileBottomNav.tsx create mode 100644 src/mobile/MobileHeader.tsx create mode 100644 src/mobile/MobileMapView.tsx create mode 100644 src/mobile/MobileSearch.tsx create mode 100644 src/mobile/main.tsx create mode 100644 src/mobile/mobile.css create mode 100644 src/mobile/useBottomSheet.ts create mode 100644 src/mobile/usePullToRefresh.ts diff --git a/docker/Caddyfile b/docker/Caddyfile index b9d693f..edc3bf4 100644 --- a/docker/Caddyfile +++ b/docker/Caddyfile @@ -143,6 +143,13 @@ } } + handle /m/* { + uri strip_prefix /m + root * /srv/mobile + 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..0c07913 --- /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/hooks/useAlerts.ts b/src/hooks/useAlerts.ts index dfcd3ff..7f66cd7 100644 --- a/src/hooks/useAlerts.ts +++ b/src/hooks/useAlerts.ts @@ -4,7 +4,7 @@ * See NOTICE for provenance and LICENSE for repository-local terms. */ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Alert } from "@/types/alert"; import { appURL } from "@/lib/app-url"; @@ -42,6 +42,8 @@ export function useAlerts() { const [alerts, setAlerts] = useState([]); const [isLive, setIsLive] = useState(false); const [isLoading, setIsLoading] = useState(true); + const intervalRef = useRef | null>(null); + const loadRef = useRef<() => Promise>(); useEffect(() => { let cancelled = false; @@ -74,8 +76,9 @@ export function useAlerts() { } } + loadRef.current = load; load(); - const interval = setInterval(load, POLL_MS); + intervalRef.current = setInterval(load, POLL_MS); const onFocus = () => load(); const onVisible = () => { if (document.visibilityState === "visible") load(); @@ -85,13 +88,20 @@ export function useAlerts() { return () => { cancelled = true; - clearInterval(interval); + if (intervalRef.current) clearInterval(intervalRef.current); window.removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onVisible); }; }, []); + const refetch = useCallback(() => { + // Trigger immediate fetch and reset the poll timer + if (intervalRef.current) clearInterval(intervalRef.current); + loadRef.current?.(); + intervalRef.current = setInterval(() => loadRef.current?.(), POLL_MS); + }, []); + const sourceCount = useMemo(() => new Set(alerts.map((a) => a.source_id)).size, [alerts]); - return { alerts, isLive, isLoading, sourceCount }; + return { alerts, isLive, isLoading, sourceCount, refetch }; } diff --git a/src/main.tsx b/src/main.tsx index a82653d..1e424c1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -10,6 +10,16 @@ import "./index.css"; import App from "./App.tsx"; import { ErrorBoundary } from "@/components/ErrorBoundary"; +// Redirect mobile devices to the dedicated mobile app +if ( + /Android|iPhone|iPod/.test(navigator.userAgent) && + window.innerWidth < 768 && + !new URLSearchParams(location.search).has("desktop") && + !document.cookie.includes("euosint_prefer_desktop") +) { + location.replace("/m/"); +} + createRoot(document.getElementById("root")!).render( diff --git a/src/mobile/MobileAlertCard.tsx b/src/mobile/MobileAlertCard.tsx new file mode 100644 index 0000000..f49470c --- /dev/null +++ b/src/mobile/MobileAlertCard.tsx @@ -0,0 +1,43 @@ +import type { Alert } from "@/types/alert"; +import { severityColor } from "@/lib/severity"; +import { freshnessLabel, categoryLabels } from "@/lib/severity"; + +interface Props { + alert: Alert; + onSelect: (alertId: string) => void; +} + +const severityBadgeColor: Record = { + critical: "#dc2626", + high: "#ef4444", + medium: "#f59e0b", + low: "#3b82f6", + info: "#64748b", +}; + +export function MobileAlertCard({ alert, onSelect }: Props) { + const sevColor = severityColor(alert.severity); + const badgeBg = severityBadgeColor[alert.severity] ?? "#64748b"; + + return ( +
onSelect(alert.alert_id)}> +
+
+
{alert.title}
+
+ + {alert.severity} + + {categoryLabels[alert.category] ?? alert.category} + · + {freshnessLabel(alert.freshness_hours)} + · + {alert.source.authority_name} +
+
+
+ ); +} diff --git a/src/mobile/MobileAlertList.tsx b/src/mobile/MobileAlertList.tsx new file mode 100644 index 0000000..c56027f --- /dev/null +++ b/src/mobile/MobileAlertList.tsx @@ -0,0 +1,115 @@ +import { useRef, useMemo } from "react"; +import type { Alert, Severity } from "@/types/alert"; +import { MobileAlertCard } from "./MobileAlertCard"; +import { usePullToRefresh } from "./usePullToRefresh"; + +const SEVERITY_FILTERS: Array<{ label: string; value: Severity | "all" }> = [ + { label: "All", value: "all" }, + { label: "Critical", value: "critical" }, + { label: "High", value: "high" }, + { label: "Medium", value: "medium" }, + { label: "Low", value: "low" }, +]; + +interface Props { + alerts: Alert[]; + isLoading: boolean; + severityFilter: Severity | "all"; + onSeverityChange: (s: Severity | "all") => void; + onSelectAlert: (alertId: string) => void; + onRefresh: () => void; +} + +export function MobileAlertList({ + alerts, + isLoading, + severityFilter, + onSeverityChange, + onSelectAlert, + onRefresh, +}: Props) { + const scrollRef = useRef(null); + const { isRefreshing, pullDistance, onTouchStart, onTouchMove, onTouchEnd } = + usePullToRefresh(scrollRef, onRefresh); + + const filtered = useMemo( + () => + severityFilter === "all" + ? alerts + : alerts.filter((a) => a.severity === severityFilter), + [alerts, severityFilter], + ); + + // Count per severity for pill badges + const counts = useMemo(() => { + const c: Record = { all: alerts.length }; + for (const a of alerts) c[a.severity] = (c[a.severity] ?? 0) + 1; + return c; + }, [alerts]); + + if (isLoading && alerts.length === 0) { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ ); + } + + return ( +
+ {/* Pull-to-refresh indicator */} + {(pullDistance > 0 || isRefreshing) && ( +
+
+
+ )} + + {/* Severity filter pills */} +
+ {SEVERITY_FILTERS.map(({ label, value }) => ( + + ))} +
+ + {/* 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..3cb3017 --- /dev/null +++ b/src/mobile/MobileApp.tsx @@ -0,0 +1,118 @@ +import { useState, useEffect, useMemo, lazy, Suspense, useCallback } from "react"; +import { useAlerts } from "@/hooks/useAlerts"; +import { alertMatchesRegionFilter } from "@/lib/regions"; +import type { Alert, Severity } 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 [selectedAlertId, setSelectedAlertId] = useState(null); + const clock = useUTCClock(); + + // Filter alerts by region + const regionAlerts = useMemo( + () => alerts.filter((a) => alertMatchesRegionFilter(a, regionFilter)), + [alerts, regionFilter], + ); + + // Sort: most recent first + const sorted = useMemo( + () => + [...regionAlerts].sort( + (a, b) => new Date(b.last_seen).getTime() - new Date(a.last_seen).getTime(), + ), + [regionAlerts], + ); + + // Count critical+high for badge + const urgentCount = useMemo( + () => regionAlerts.filter((a) => a.severity === "critical" || a.severity === "high").length, + [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..e08c7f0 --- /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..f31bcc1 --- /dev/null +++ b/src/mobile/MobileHeader.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; +import { Shield, Monitor, ChevronDown, Check } from "lucide-react"; + +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; + clock: string; +} + +export function MobileHeader({ regionFilter, onRegionChange, clock }: Props) { + const [pickerOpen, setPickerOpen] = useState(false); + + function selectRegion(value: string) { + onRegionChange(value); + setPickerOpen(false); + } + + function switchToDesktop() { + document.cookie = "euosint_prefer_desktop=1;path=/;max-age=31536000"; + location.href = "/"; + } + + const currentLabel = REGIONS.find((r) => r.value === regionFilter)?.label ?? "Global"; + + return ( + <> +
+ + EUOSINT + + + + {clock} + + +
+ + {pickerOpen && ( + <> +
setPickerOpen(false)} + /> +
+
+
Select Region
+ {REGIONS.map((r) => ( + + ))} +
+ + )} + + ); +} diff --git a/src/mobile/MobileMapView.tsx b/src/mobile/MobileMapView.tsx new file mode 100644 index 0000000..16b55a1 --- /dev/null +++ b/src/mobile/MobileMapView.tsx @@ -0,0 +1,221 @@ +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 } from "lucide-react"; +import type { Alert } from "@/types/alert"; +import { severityHex } from "@/lib/theme"; +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 }, +}; + +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 overlayLayers = useRef>(new Map()); + const [layerPickerOpen, setLayerPickerOpen] = useState(false); + const [activeOverlays, setActiveOverlays] = useState>(new Set()); + 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, + }); + + // Overlay pane above markers + 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: true, + 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), + }); + }, + }); + + map.addLayer(cluster); + mapRef.current = map; + clusterRef.current = cluster; + + const ro = new ResizeObserver(() => map.invalidateSize()); + ro.observe(containerRef.current); + + return () => { + ro.disconnect(); + map.remove(); + mapRef.current = null; + clusterRef.current = null; + overlayLayers.current.clear(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Fly to region on filter change + 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; + + // Remove deactivated overlays + for (const [id, layer] of overlayLayers.current.entries()) { + if (!activeOverlays.has(id)) { + map.removeLayer(layer); + overlayLayers.current.delete(id); + } + } + + // Add newly activated overlays + 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(); + + 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.bindTooltip( + `${alert.source.authority_name}
${alert.title.slice(0, 60)}`, + { className: "siem-tooltip", direction: "top" }, + ); + marker.on("click", () => onSelectAlert(alert.alert_id)); + cluster.addLayer(marker); + } + }, [alerts, onSelectAlert]); + + return ( +
+
+ + {/* Layers FAB */} + + + {/* Overlay picker sheet */} + {layerPickerOpen && ( + <> +
setLayerPickerOpen(false)} + /> +
+
+
Map Layers
+ {overlayDefs.map((def: OverlayDef) => ( + + ))} +
+ + )} +
+ ); +} diff --git a/src/mobile/MobileSearch.tsx b/src/mobile/MobileSearch.tsx new file mode 100644 index 0000000..5b80830 --- /dev/null +++ b/src/mobile/MobileSearch.tsx @@ -0,0 +1,89 @@ +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..eb2f63c --- /dev/null +++ b/src/mobile/mobile.css @@ -0,0 +1,451 @@ +@import "tailwindcss"; +@import "../index.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); +} + +/* ── 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: 6px 16px; + font-size: 10px; + color: #64748b; + transition: color 0.15s; + -webkit-tap-highlight-color: transparent; + user-select: none; +} + +.mobile-nav-tab.active { + color: #38bdf8; +} + +.mobile-nav-tab svg { + width: 22px; + height: 22px; +} + +/* ── Region pill ─────────────────────────────────────────────── */ +.mobile-region-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + 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; + transition: background 0.15s; +} + +.mobile-region-pill:active { + background: rgba(51, 65, 85, 0.6); +} + +/* ── 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; +} + +.mobile-severity-pill { + flex-shrink: 0; + padding: 4px 12px; + font-size: 11px; + 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; + 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: 12px; + border-bottom: 1px solid rgba(71, 85, 105, 0.15); + -webkit-tap-highlight-color: transparent; + 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: 13px; + 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: 11px; + color: #64748b; +} + +.mobile-alert-badge { + display: inline-block; + padding: 1px 6px; + font-size: 9px; + 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: 50; + -webkit-tap-highlight-color: transparent; +} + +.mobile-sheet { + position: fixed; + left: 0; + right: 0; + bottom: 0; + max-height: 90vh; + 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: 51; + 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: 10px 0 6px; + 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; + padding: 0 16px 16px; +} + +/* ── Search ──────────────────────────────────────────────────── */ +.mobile-search-input { + width: 100%; + padding: 10px 12px; + font-size: 15px; + 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: 6px; + padding: 8px 0; +} + +.mobile-search-chip { + padding: 4px 10px; + font-size: 11px; + border-radius: 999px; + border: 1px solid rgba(71, 85, 105, 0.4); + color: #94a3b8; + background: transparent; + -webkit-tap-highlight-color: transparent; + user-select: none; +} + +.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; +} + +/* ── 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; } +} + +/* ── Picker sheet (regions, layers) ──────────────────────────── */ +.mobile-picker-sheet { + position: fixed; + left: 0; + right: 0; + bottom: 0; + max-height: 70vh; + 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: 51; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + 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; + font-size: 15px; + color: #cbd5e1; + border-bottom: 1px solid rgba(71, 85, 105, 0.15); + -webkit-tap-highlight-color: transparent; + 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: 30; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 10px; + 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; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.mobile-layers-fab:active { + background: rgba(30, 41, 59, 0.9); +} + +.mobile-layers-badge { + position: absolute; + top: -4px; + right: -4px; + min-width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; + font-size: 9px; + 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/vite.config.ts b/vite.config.ts index 20cc850..f3fe031 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -29,10 +29,15 @@ export default defineConfig({ plugins: [react(), tailwindcss()], 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"; From 1efe30a680a3055fd37f16d0a8e1750bbc562a37 Mon Sep 17 00:00:00 2001 From: 2pk03 Date: Sun, 22 Mar 2026 16:21:36 +0100 Subject: [PATCH 2/9] Fix mobile sheet z-index to render above Leaflet map --- src/mobile/mobile.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mobile/mobile.css b/src/mobile/mobile.css index eb2f63c..e4d1fed 100644 --- a/src/mobile/mobile.css +++ b/src/mobile/mobile.css @@ -225,7 +225,7 @@ body { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.5); - z-index: 50; + z-index: 900; -webkit-tap-highlight-color: transparent; } @@ -238,7 +238,7 @@ body { 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: 51; + z-index: 901; overflow: hidden; display: flex; flex-direction: column; @@ -367,7 +367,7 @@ body { 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: 51; + z-index: 901; overflow-y: auto; -webkit-overflow-scrolling: touch; padding-bottom: calc(var(--sab) + 8px); From 3c4e99bba0b23844cc51b4474b7adceb399f8933 Mon Sep 17 00:00:00 2001 From: 2pk03 Date: Sun, 22 Mar 2026 16:23:43 +0100 Subject: [PATCH 3/9] Replace cluster zoom with alert list sheet on mobile map Tapping a cluster opens a scrollable bottom sheet listing all alerts sorted by severity. Each row shows severity badge, title, category, freshness, and source. Tap a row to open alert detail, tap the external link icon to open the source URL directly. Disabled spiderfy and zoom-on-click for touch-friendly UX. --- src/mobile/MobileMapView.tsx | 96 ++++++++++++++++++++++++++++++++---- src/mobile/mobile.css | 82 ++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 10 deletions(-) diff --git a/src/mobile/MobileMapView.tsx b/src/mobile/MobileMapView.tsx index 16b55a1..ea99a46 100644 --- a/src/mobile/MobileMapView.tsx +++ b/src/mobile/MobileMapView.tsx @@ -3,9 +3,10 @@ import L from "leaflet"; import "leaflet/dist/leaflet.css"; import "leaflet.markercluster"; import "leaflet.markercluster/dist/MarkerCluster.css"; -import { Layers, Check } from "lucide-react"; +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, @@ -24,6 +25,11 @@ const REGION_VIEWPORTS: 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; @@ -34,9 +40,11 @@ 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) => { @@ -60,7 +68,6 @@ export function MobileMapView({ alerts, regionFilter, onSelectAlert }: Props) { attributionControl: false, }); - // Overlay pane above markers const overlayPane = map.createPane("overlayPane"); overlayPane.style.zIndex = "650"; @@ -74,7 +81,7 @@ export function MobileMapView({ alerts, regionFilter, onSelectAlert }: Props) { const cluster = L.markerClusterGroup({ maxClusterRadius: 50, spiderfyOnMaxZoom: false, - zoomToBoundsOnClick: true, + zoomToBoundsOnClick: false, showCoverageOnHover: false, iconCreateFunction(c) { const count = c.getChildCount(); @@ -89,6 +96,23 @@ export function MobileMapView({ alerts, regionFilter, onSelectAlert }: Props) { }, }); + // 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; @@ -106,7 +130,7 @@ export function MobileMapView({ alerts, regionFilter, onSelectAlert }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Fly to region on filter change + // Fly to region useEffect(() => { const map = mapRef.current; if (!map) return; @@ -119,7 +143,6 @@ export function MobileMapView({ alerts, regionFilter, onSelectAlert }: Props) { const map = mapRef.current; if (!map) return; - // Remove deactivated overlays for (const [id, layer] of overlayLayers.current.entries()) { if (!activeOverlays.has(id)) { map.removeLayer(layer); @@ -127,7 +150,6 @@ export function MobileMapView({ alerts, regionFilter, onSelectAlert }: Props) { } } - // Add newly activated overlays for (const id of activeOverlays) { if (overlayLayers.current.has(id)) continue; const def = overlayDefs.find((o) => o.id === id); @@ -151,6 +173,7 @@ export function MobileMapView({ alerts, regionFilter, onSelectAlert }: Props) { if (!cluster) return; cluster.clearLayers(); + markerAlertMap.current.clear(); for (const alert of alerts) { if (alert.lat === 0 && alert.lng === 0) continue; @@ -161,11 +184,8 @@ export function MobileMapView({ alerts, regionFilter, onSelectAlert }: Props) { weight: 1, fillOpacity: 0.85, }); - marker.bindTooltip( - `${alert.source.authority_name}
${alert.title.slice(0, 60)}`, - { className: "siem-tooltip", direction: "top" }, - ); marker.on("click", () => onSelectAlert(alert.alert_id)); + markerAlertMap.current.set(L.Util.stamp(marker), alert); cluster.addLayer(marker); } }, [alerts, onSelectAlert]); @@ -216,6 +236,62 @@ export function MobileMapView({ alerts, regionFilter, onSelectAlert }: Props) {
)} + + {/* Cluster alert list sheet */} + {clusterAlerts.length > 0 && ( + <> +
setClusterAlerts([])} + /> +
+
+
+ Area Alerts ({clusterAlerts.length}) +
+
+ {clusterAlerts.map((alert) => ( +
+ + {alert.canonical_url && ( + + + + )} +
+ ))} +
+
+ + )}
); } diff --git a/src/mobile/mobile.css b/src/mobile/mobile.css index e4d1fed..5cb33db 100644 --- a/src/mobile/mobile.css +++ b/src/mobile/mobile.css @@ -433,6 +433,88 @@ body { background: rgba(30, 41, 59, 0.9); } +/* ── Cluster alert list ───────────────────────────────────────── */ +.mobile-cluster-list { + overflow-y: auto; + -webkit-overflow-scrolling: touch; + 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: 12px 0; + min-width: 0; + text-align: left; + -webkit-tap-highlight-color: transparent; +} + +.mobile-cluster-item-main:active { + opacity: 0.7; +} + +.mobile-cluster-sev { + flex-shrink: 0; + padding: 2px 5px; + font-size: 9px; + 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: 13px; + line-height: 1.35; + color: #e2e8f0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.mobile-cluster-meta { + font-size: 11px; + 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: 32px; + height: 32px; + color: #60a5fa; + border-radius: 6px; + -webkit-tap-highlight-color: transparent; +} + +.mobile-cluster-source:active { + background: rgba(96, 165, 250, 0.15); +} + +/* ── Layers FAB ──────────────────────────────────────────────── */ .mobile-layers-badge { position: absolute; top: -4px; From aeb2e37edd607ad6a2536a87d42173e31f0e3571 Mon Sep 17 00:00:00 2001 From: 2pk03 Date: Sun, 22 Mar 2026 16:27:06 +0100 Subject: [PATCH 4/9] Fix sheet z-index to 999999 and remove desktop switch button --- src/mobile/MobileHeader.tsx | 15 +-------------- src/mobile/mobile.css | 6 +++--- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/src/mobile/MobileHeader.tsx b/src/mobile/MobileHeader.tsx index f31bcc1..a18219e 100644 --- a/src/mobile/MobileHeader.tsx +++ b/src/mobile/MobileHeader.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Shield, Monitor, ChevronDown, Check } from "lucide-react"; +import { Shield, ChevronDown, Check } from "lucide-react"; const REGIONS = [ { value: "all", label: "Global" }, @@ -25,11 +25,6 @@ export function MobileHeader({ regionFilter, onRegionChange, clock }: Props) { setPickerOpen(false); } - function switchToDesktop() { - document.cookie = "euosint_prefer_desktop=1;path=/;max-age=31536000"; - location.href = "/"; - } - const currentLabel = REGIONS.find((r) => r.value === regionFilter)?.label ?? "Global"; return ( @@ -47,14 +42,6 @@ export function MobileHeader({ regionFilter, onRegionChange, clock }: Props) { {clock} - -
{pickerOpen && ( diff --git a/src/mobile/mobile.css b/src/mobile/mobile.css index 5cb33db..6796fb4 100644 --- a/src/mobile/mobile.css +++ b/src/mobile/mobile.css @@ -225,7 +225,7 @@ body { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.5); - z-index: 900; + z-index: 999998; -webkit-tap-highlight-color: transparent; } @@ -238,7 +238,7 @@ body { 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: 901; + z-index: 999999; overflow: hidden; display: flex; flex-direction: column; @@ -367,7 +367,7 @@ body { 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: 901; + z-index: 999999; overflow-y: auto; -webkit-overflow-scrolling: touch; padding-bottom: calc(var(--sab) + 8px); From 11ee278f642e921945fa1451ef4903c7d9804ca7 Mon Sep 17 00:00:00 2001 From: 2pk03 Date: Sun, 22 Mar 2026 16:28:37 +0100 Subject: [PATCH 5/9] Fix layers FAB z-index to render above Leaflet controls --- src/mobile/mobile.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mobile/mobile.css b/src/mobile/mobile.css index 6796fb4..9ae3bbe 100644 --- a/src/mobile/mobile.css +++ b/src/mobile/mobile.css @@ -414,7 +414,7 @@ body { position: absolute; top: 12px; right: 12px; - z-index: 30; + z-index: 10000; display: flex; align-items: center; justify-content: center; From 58603697b2cfd09e6e383f420591ca93ea41f57e Mon Sep 17 00:00:00 2001 From: 2pk03 Date: Sun, 22 Mar 2026 16:33:26 +0100 Subject: [PATCH 6/9] Fix react-hooks/exhaustive-deps warning in MobileMapView --- src/mobile/MobileMapView.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mobile/MobileMapView.tsx b/src/mobile/MobileMapView.tsx index ea99a46..6ff7e5b 100644 --- a/src/mobile/MobileMapView.tsx +++ b/src/mobile/MobileMapView.tsx @@ -119,13 +119,14 @@ export function MobileMapView({ alerts, regionFilter, onSelectAlert }: Props) { 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; - overlayLayers.current.clear(); + layers.clear(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); From 79cf842cd198d02c2fddd33a4cd3714ab3f03ef5 Mon Sep 17 00:00:00 2001 From: 2pk03 Date: Sun, 22 Mar 2026 16:35:29 +0100 Subject: [PATCH 7/9] Fix useRef missing initial value in useAlerts --- src/hooks/useAlerts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useAlerts.ts b/src/hooks/useAlerts.ts index 7f66cd7..b798043 100644 --- a/src/hooks/useAlerts.ts +++ b/src/hooks/useAlerts.ts @@ -43,7 +43,7 @@ export function useAlerts() { const [isLive, setIsLive] = useState(false); const [isLoading, setIsLoading] = useState(true); const intervalRef = useRef | null>(null); - const loadRef = useRef<() => Promise>(); + const loadRef = useRef<() => Promise>(undefined); useEffect(() => { let cancelled = false; From c385e81d47dd2e7f45726468c51c8b5de798ccfa Mon Sep 17 00:00:00 2001 From: 2pk03 Date: Sun, 22 Mar 2026 17:19:33 +0100 Subject: [PATCH 8/9] Mobile: category filter, Docker build, touch audit, conflict brief fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add category multi-select filter in mobile header (sheet picker) - Extract shared @theme variables into src/theme.css for both apps - Fix Docker build to bake mobile app (Caddyfile /m/* routing, manifest) - Add Vite dev server SPA fallback for /mobile/ path - Add Leaflet cluster styles to mobile CSS - Touch audit: bump all targets to ≥44px, fonts to ≥10px, vh→dvh, touch-action:manipulation, user-select:none, search input attrs, proper :active states replacing :hover on AlertDetail - Fix conflict brief violenceTypes showing alert categories instead of actual conflict data; hide empty violence/actors sections --- Dockerfile | 1 + docker/Caddyfile | 6 +- mobile/index.html | 2 +- src/components/AlertDetail.tsx | 8 +- src/components/AlertFeed.tsx | 20 ++-- src/index.css | 51 +---------- src/lib/conflict-briefs.ts | 2 +- src/mobile/MobileAlertList.tsx | 7 +- src/mobile/MobileApp.tsx | 33 ++++++- src/mobile/MobileBottomNav.tsx | 2 +- src/mobile/MobileHeader.tsx | 118 +++++++++++++++++++++--- src/mobile/MobileMapView.tsx | 2 +- src/mobile/MobileSearch.tsx | 8 +- src/mobile/mobile.css | 162 ++++++++++++++++++++++++++------- src/theme.css | 49 ++++++++++ vite.config.ts | 16 +++- 16 files changed, 360 insertions(+), 127 deletions(-) create mode 100644 src/theme.css 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 edc3bf4..244882d 100644 --- a/docker/Caddyfile +++ b/docker/Caddyfile @@ -145,11 +145,15 @@ handle /m/* { uri strip_prefix /m - root * /srv/mobile + root * /srv try_files {path} /mobile/index.html file_server } + handle /m { + redir /m/ permanent + } + handle { try_files {path} /index.html file_server diff --git a/mobile/index.html b/mobile/index.html index 0c07913..5e21388 100644 --- a/mobile/index.html +++ b/mobile/index.html @@ -10,7 +10,7 @@ - + EUOSINT 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 && ( ))} diff --git a/src/mobile/MobileApp.tsx b/src/mobile/MobileApp.tsx index 3cb3017..b878be9 100644 --- a/src/mobile/MobileApp.tsx +++ b/src/mobile/MobileApp.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useMemo, lazy, Suspense, useCallback } from "react"; import { useAlerts } from "@/hooks/useAlerts"; import { alertMatchesRegionFilter } from "@/lib/regions"; -import type { Alert, Severity } from "@/types/alert"; +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"; @@ -26,6 +27,7 @@ export function MobileApp() { 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(); @@ -35,21 +37,39 @@ export function MobileApp() { [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( () => - [...regionAlerts].sort( + [...categoryFiltered].sort( (a, b) => new Date(b.last_seen).getTime() - new Date(a.last_seen).getTime(), ), - [regionAlerts], + [categoryFiltered], ); // Count critical+high for badge const urgentCount = useMemo( - () => regionAlerts.filter((a) => a.severity === "critical" || a.severity === "high").length, - [regionAlerts], + () => 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, @@ -69,6 +89,9 @@ export function MobileApp() { diff --git a/src/mobile/MobileBottomNav.tsx b/src/mobile/MobileBottomNav.tsx index e08c7f0..7663a90 100644 --- a/src/mobile/MobileBottomNav.tsx +++ b/src/mobile/MobileBottomNav.tsx @@ -18,7 +18,7 @@ export function MobileBottomNav({ activeTab, onTabChange, alertCount }: Props) {
{alertCount > 0 && ( - + {alertCount > 99 ? "99+" : alertCount} )} diff --git a/src/mobile/MobileHeader.tsx b/src/mobile/MobileHeader.tsx index a18219e..a1134fa 100644 --- a/src/mobile/MobileHeader.tsx +++ b/src/mobile/MobileHeader.tsx @@ -1,5 +1,7 @@ import { useState } from "react"; -import { Shield, ChevronDown, Check } from "lucide-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" }, @@ -14,18 +16,52 @@ const REGIONS = [ 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, clock }: Props) { - const [pickerOpen, setPickerOpen] = useState(false); +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); - setPickerOpen(false); + setRegionPickerOpen(false); } - const currentLabel = REGIONS.find((r) => r.value === regionFilter)?.label ?? "Global"; + 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 ( <> @@ -33,22 +69,36 @@ export function MobileHeader({ regionFilter, onRegionChange, clock }: Props) { EUOSINT - +
+ {/* Category pill */} + + + {/* Region pill */} + +
{clock}
- {pickerOpen && ( + {/* Region picker sheet */} + {regionPickerOpen && ( <>
setPickerOpen(false)} + onClick={() => setRegionPickerOpen(false)} />
@@ -68,6 +118,46 @@ export function MobileHeader({ regionFilter, onRegionChange, clock }: Props) {
)} + + {/* 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 index 6ff7e5b..ee05e4e 100644 --- a/src/mobile/MobileMapView.tsx +++ b/src/mobile/MobileMapView.tsx @@ -245,7 +245,7 @@ export function MobileMapView({ alerts, regionFilter, onSelectAlert }: Props) { className="mobile-sheet-backdrop" onClick={() => setClusterAlerts([])} /> -
+
Area Alerts ({clusterAlerts.length}) diff --git a/src/mobile/MobileSearch.tsx b/src/mobile/MobileSearch.tsx index 5b80830..47d987b 100644 --- a/src/mobile/MobileSearch.tsx +++ b/src/mobile/MobileSearch.tsx @@ -39,7 +39,13 @@ export function MobileSearch({ onSelectAlert }: Props) {
setQuery(e.target.value)} diff --git a/src/mobile/mobile.css b/src/mobile/mobile.css index 9ae3bbe..2ab62cb 100644 --- a/src/mobile/mobile.css +++ b/src/mobile/mobile.css @@ -1,5 +1,6 @@ +@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 "../index.css"; +@import "../theme.css"; /* ── Safe area & viewport ────────────────────────────────────── */ :root { @@ -19,6 +20,7 @@ html, body, #root { body { background: var(--color-siem-bg, #030610); color: var(--color-siem-text, #e2e8f0); + font-family: "Montserrat", "Segoe UI", sans-serif; } /* ── Layout shell ────────────────────────────────────────────── */ @@ -65,12 +67,13 @@ body { flex-direction: column; align-items: center; gap: 2px; - padding: 6px 16px; + 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 { @@ -82,12 +85,12 @@ body { height: 22px; } -/* ── Region pill ─────────────────────────────────────────────── */ +/* ── Region / category pill ──────────────────────────────────── */ .mobile-region-pill { display: inline-flex; align-items: center; gap: 4px; - padding: 4px 10px; + padding: 6px 10px; /* ↑ 4→6px vert, min 32px tall */ font-size: 12px; font-weight: 600; color: #94a3b8; @@ -96,6 +99,7 @@ body { border-radius: 999px; -webkit-tap-highlight-color: transparent; user-select: none; + touch-action: manipulation; transition: background 0.15s; } @@ -103,6 +107,12 @@ body { 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%; @@ -117,12 +127,20 @@ body { 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; - padding: 4px 12px; - font-size: 11px; + 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); @@ -130,6 +148,7 @@ body { color: #94a3b8; -webkit-tap-highlight-color: transparent; user-select: none; + touch-action: manipulation; transition: all 0.15s; } @@ -143,9 +162,11 @@ body { .mobile-alert-card { display: flex; gap: 10px; - padding: 12px; + 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; } @@ -165,7 +186,7 @@ body { } .mobile-alert-title { - font-size: 13px; + font-size: 14px; /* ↑ 13→14px */ font-weight: 500; line-height: 1.35; display: -webkit-box; @@ -179,14 +200,14 @@ body { align-items: center; gap: 6px; margin-top: 4px; - font-size: 11px; + font-size: 12px; /* ↑ 11→12px */ color: #64748b; } .mobile-alert-badge { display: inline-block; - padding: 1px 6px; - font-size: 9px; + padding: 2px 6px; /* ↑ 1→2px vert */ + font-size: 10px; /* ↑ 9→10px */ font-weight: 700; text-transform: uppercase; letter-spacing: 0.3px; @@ -234,7 +255,7 @@ body { left: 0; right: 0; bottom: 0; - max-height: 90vh; + 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; @@ -251,7 +272,7 @@ body { flex: 0 0 auto; display: flex; justify-content: center; - padding: 10px 0 6px; + padding: 12px 0 8px; /* ↑ bigger drag target */ cursor: grab; -webkit-tap-highlight-color: transparent; } @@ -268,14 +289,15 @@ body { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; + overscroll-behavior-y: contain; padding: 0 16px 16px; } /* ── Search ──────────────────────────────────────────────────── */ .mobile-search-input { width: 100%; - padding: 10px 12px; - font-size: 15px; + 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; @@ -295,19 +317,21 @@ body { .mobile-search-chips { display: flex; flex-wrap: wrap; - gap: 6px; + gap: 8px; /* ↑ 6→8px */ padding: 8px 0; } .mobile-search-chip { - padding: 4px 10px; - font-size: 11px; + 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 { @@ -330,6 +354,38 @@ body { 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%; @@ -357,19 +413,46 @@ body { 100% { background-position: -200% 0; } } -/* ── Picker sheet (regions, layers) ──────────────────────────── */ +/* ── 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: 70vh; + 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); } @@ -394,10 +477,13 @@ body { 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; } @@ -418,14 +504,15 @@ body { display: flex; align-items: center; justify-content: center; - width: 40px; - height: 40px; - border-radius: 10px; + 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); } @@ -437,6 +524,7 @@ body { .mobile-cluster-list { overflow-y: auto; -webkit-overflow-scrolling: touch; + overscroll-behavior-y: contain; padding-bottom: 8px; } @@ -453,10 +541,13 @@ body { display: flex; align-items: flex-start; gap: 8px; - padding: 12px 0; + 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 { @@ -465,8 +556,8 @@ body { .mobile-cluster-sev { flex-shrink: 0; - padding: 2px 5px; - font-size: 9px; + padding: 2px 6px; /* ↑ 5→6px horiz */ + font-size: 10px; /* ↑ 9→10px */ font-weight: 700; letter-spacing: 0.3px; color: #fff; @@ -480,7 +571,7 @@ body { } .mobile-cluster-title { - font-size: 13px; + font-size: 14px; /* ↑ 13→14px */ line-height: 1.35; color: #e2e8f0; display: -webkit-box; @@ -490,7 +581,7 @@ body { } .mobile-cluster-meta { - font-size: 11px; + font-size: 12px; /* ↑ 11→12px */ color: #64748b; margin-top: 3px; white-space: nowrap; @@ -503,29 +594,30 @@ body { display: flex; align-items: center; justify-content: center; - width: 32px; - height: 32px; + width: 44px; /* ↑ 32→44px */ + height: 44px; /* ↑ 32→44px */ color: #60a5fa; - border-radius: 6px; + border-radius: 8px; -webkit-tap-highlight-color: transparent; + touch-action: manipulation; } .mobile-cluster-source:active { background: rgba(96, 165, 250, 0.15); } -/* ── Layers FAB ──────────────────────────────────────────────── */ +/* ── Layers badge ────────────────────────────────────────────── */ .mobile-layers-badge { position: absolute; top: -4px; right: -4px; - min-width: 16px; - height: 16px; + min-width: 18px; /* ↑ 16→18px */ + height: 18px; /* ↑ 16→18px */ display: flex; align-items: center; justify-content: center; padding: 0 4px; - font-size: 9px; + font-size: 10px; /* ↑ 9→10px */ font-weight: 700; color: #fff; background: #38bdf8; 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 f3fe031..d3ab352 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,7 +26,21 @@ 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: { From 7d9b0b18b930fcb6923bab99bb6381f0a258c75a Mon Sep 17 00:00:00 2001 From: 2pk03 Date: Sun, 22 Mar 2026 17:24:01 +0100 Subject: [PATCH 9/9] Fix /m redirect: use named matcher before handle blocks --- docker/Caddyfile | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docker/Caddyfile b/docker/Caddyfile index 244882d..f5ee2b1 100644 --- a/docker/Caddyfile +++ b/docker/Caddyfile @@ -143,6 +143,9 @@ } } + @mobile_bare path /m + redir @mobile_bare /m/ permanent + handle /m/* { uri strip_prefix /m root * /srv @@ -150,10 +153,6 @@ file_server } - handle /m { - redir /m/ permanent - } - handle { try_files {path} /index.html file_server