UI Updates
This commit is contained in:
@@ -98,7 +98,7 @@ export default function AdminPage() {
|
|||||||
if (!isAdmin) return null;
|
if (!isAdmin) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-2xl mx-auto space-y-8">
|
<div className="max-w-2xl space-y-8">
|
||||||
<h1 className="text-white text-xl font-bold font-mono">Admin</h1>
|
<h1 className="text-white text-xl font-bold font-mono">Admin</h1>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ export default function AlertsPage() {
|
|||||||
const unacked = alerts.filter((a) => !a.acknowledged);
|
const unacked = alerts.filter((a) => !a.acknowledged);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-7xl mx-auto space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<h1 className="text-white text-xl font-bold font-mono">Alerts</h1>
|
<h1 className="text-white text-xl font-bold font-mono">Alerts</h1>
|
||||||
{unacked.length > 0 && (
|
{unacked.length > 0 && (
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function CallsPage() {
|
|||||||
<h2 className="text-sm font-semibold text-orange-400 uppercase tracking-wider mb-3">
|
<h2 className="text-sm font-semibold text-orange-400 uppercase tracking-wider mb-3">
|
||||||
Live ({active.length})
|
Live ({active.length})
|
||||||
</h2>
|
</h2>
|
||||||
<div className="overflow-x-auto">
|
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||||
@@ -61,7 +61,7 @@ export default function CallsPage() {
|
|||||||
<p className="text-gray-600 text-sm font-mono">No calls recorded yet.</p>
|
<p className="text-gray-600 text-sm font-mono">No calls recorded yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto">
|
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export default function DashboardPage() {
|
|||||||
{calls.length === 0 ? (
|
{calls.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">No calls recorded yet.</p>
|
<p className="text-gray-600 text-sm font-mono">No calls recorded yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||||
|
|||||||
@@ -4,6 +4,105 @@
|
|||||||
|
|
||||||
@import 'leaflet/dist/leaflet.css';
|
@import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
/* ── Base ─────────────────────────────────────────────────────────────────── */
|
||||||
html, body {
|
html, body {
|
||||||
@apply bg-gray-950 text-gray-100 font-mono;
|
@apply bg-gray-950 text-gray-100 font-mono;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Light mode overrides ─────────────────────────────────────────────────── */
|
||||||
|
/*
|
||||||
|
* The app's components use hardcoded dark-palette Tailwind classes (bg-gray-9xx,
|
||||||
|
* text-gray-xxx). Rather than adding dark: prefixes everywhere, we remap those
|
||||||
|
* classes here when the html element doesn't carry the .dark class.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Structural backgrounds */
|
||||||
|
html:not(.dark),
|
||||||
|
html:not(.dark) body { background-color: #f1f5f9; color: #0f172a; }
|
||||||
|
html:not(.dark) .bg-gray-950 { background-color: #f1f5f9 !important; }
|
||||||
|
html:not(.dark) .bg-gray-950\/95 { background-color: rgba(241,245,249,0.95) !important; }
|
||||||
|
html:not(.dark) .bg-gray-900 { background-color: #ffffff !important; }
|
||||||
|
html:not(.dark) .bg-gray-900\/60 { background-color: rgba(255,255,255,0.85) !important; }
|
||||||
|
html:not(.dark) .bg-gray-900\/50 { background-color: rgba(255,255,255,0.75) !important; }
|
||||||
|
html:not(.dark) .bg-gray-900\/30 { background-color: rgba(255,255,255,0.50) !important; }
|
||||||
|
html:not(.dark) .bg-gray-800 { background-color: #f1f5f9 !important; }
|
||||||
|
html:not(.dark) .bg-gray-800\/40 { background-color: rgba(241,245,249,0.60) !important; }
|
||||||
|
html:not(.dark) .bg-gray-800\/30 { background-color: rgba(241,245,249,0.50) !important; }
|
||||||
|
html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; }
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
html:not(.dark) .border-gray-800 { border-color: #e2e8f0 !important; }
|
||||||
|
html:not(.dark) .border-gray-700 { border-color: #cbd5e1 !important; }
|
||||||
|
html:not(.dark) .divide-gray-800 > * + * { border-color: #e2e8f0 !important; }
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
html:not(.dark) .text-white { color: #0f172a !important; }
|
||||||
|
html:not(.dark) .text-gray-100 { color: #1e293b !important; }
|
||||||
|
html:not(.dark) .text-gray-300 { color: #334155 !important; }
|
||||||
|
html:not(.dark) .text-gray-400 { color: #475569 !important; }
|
||||||
|
html:not(.dark) .text-gray-500 { color: #64748b !important; }
|
||||||
|
html:not(.dark) .text-gray-600 { color: #94a3b8 !important; }
|
||||||
|
|
||||||
|
/* Hover states */
|
||||||
|
html:not(.dark) .hover\:bg-gray-900:hover { background-color: #f8fafc !important; }
|
||||||
|
html:not(.dark) .hover\:bg-gray-900\/50:hover { background-color: rgba(255,255,255,0.75) !important; }
|
||||||
|
html:not(.dark) .hover\:bg-gray-800:hover { background-color: #f1f5f9 !important; }
|
||||||
|
html:not(.dark) .hover\:bg-gray-700:hover { background-color: #e2e8f0 !important; }
|
||||||
|
html:not(.dark) .active\:bg-gray-800:active { background-color: #f1f5f9 !important; }
|
||||||
|
|
||||||
|
/* Hover text */
|
||||||
|
html:not(.dark) .hover\:text-gray-300:hover { color: #334155 !important; }
|
||||||
|
html:not(.dark) .hover\:text-gray-200:hover { color: #1e293b !important; }
|
||||||
|
|
||||||
|
/* ── Accent badge palette (dark → light) ─────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Fire / Error */
|
||||||
|
html:not(.dark) .bg-red-900 { background-color: #fef2f2 !important; }
|
||||||
|
html:not(.dark) .bg-red-950 { background-color: #fff1f2 !important; }
|
||||||
|
html:not(.dark) .text-red-300 { color: #b91c1c !important; }
|
||||||
|
html:not(.dark) .text-red-400 { color: #dc2626 !important; }
|
||||||
|
html:not(.dark) .border-red-800 { border-color: #fca5a5 !important; }
|
||||||
|
|
||||||
|
/* Police */
|
||||||
|
html:not(.dark) .bg-blue-900 { background-color: #eff6ff !important; }
|
||||||
|
html:not(.dark) .bg-blue-950 { background-color: #eff6ff !important; }
|
||||||
|
html:not(.dark) .text-blue-300 { color: #1d4ed8 !important; }
|
||||||
|
html:not(.dark) .border-blue-800 { border-color: #93c5fd !important; }
|
||||||
|
|
||||||
|
/* EMS */
|
||||||
|
html:not(.dark) .bg-yellow-900 { background-color: #fefce8 !important; }
|
||||||
|
html:not(.dark) .bg-yellow-950 { background-color: #fefce8 !important; }
|
||||||
|
html:not(.dark) .text-yellow-300 { color: #a16207 !important; }
|
||||||
|
html:not(.dark) .text-yellow-400 { color: #ca8a04 !important; }
|
||||||
|
|
||||||
|
/* Accident / Recording */
|
||||||
|
html:not(.dark) .bg-orange-900 { background-color: #fff7ed !important; }
|
||||||
|
html:not(.dark) .bg-orange-950 { background-color: #fff7ed !important; }
|
||||||
|
html:not(.dark) .text-orange-300 { color: #c2410c !important; }
|
||||||
|
html:not(.dark) .text-orange-400 { color: #ea580c !important; }
|
||||||
|
html:not(.dark) .border-orange-800 { border-color: #fdba74 !important; }
|
||||||
|
|
||||||
|
/* Active / Online */
|
||||||
|
html:not(.dark) .bg-green-900 { background-color: #f0fdf4 !important; }
|
||||||
|
html:not(.dark) .bg-green-950 { background-color: #f0fdf4 !important; }
|
||||||
|
html:not(.dark) .text-green-300 { color: #15803d !important; }
|
||||||
|
html:not(.dark) .text-green-400 { color: #16a34a !important; }
|
||||||
|
html:not(.dark) .border-green-800 { border-color: #86efac !important; }
|
||||||
|
|
||||||
|
/* Unconfigured / Info */
|
||||||
|
html:not(.dark) .bg-indigo-950 { background-color: #eef2ff !important; }
|
||||||
|
html:not(.dark) .bg-indigo-900 { background-color: #eef2ff !important; }
|
||||||
|
html:not(.dark) .text-indigo-300 { color: #4338ca !important; }
|
||||||
|
html:not(.dark) .text-indigo-400 { color: #6366f1 !important; }
|
||||||
|
html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
||||||
|
|
||||||
|
/* ── Form inputs ─────────────────────────────────────────────────────────── */
|
||||||
|
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
|
||||||
|
html:not(.dark) select,
|
||||||
|
html:not(.dark) textarea {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
html:not(.dark) input::placeholder,
|
||||||
|
html:not(.dark) textarea::placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ export default function IncidentsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-7xl mx-auto space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-white text-xl font-bold font-mono">Incidents</h1>
|
<h1 className="text-white text-xl font-bold font-mono">Incidents</h1>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Nav } from "@/components/Nav";
|
import { Nav } from "@/components/Nav";
|
||||||
import { AuthProvider } from "@/components/AuthProvider";
|
import { AuthProvider } from "@/components/AuthProvider";
|
||||||
|
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -10,12 +11,18 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="dark">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
{/* Prevent flash of wrong theme before React hydrates */}
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: `(function(){try{var t=localStorage.getItem('drb-theme');if(t!=='light')document.documentElement.classList.add('dark');}catch(e){}})();` }} />
|
||||||
|
</head>
|
||||||
<body className="min-h-screen bg-gray-950">
|
<body className="min-h-screen bg-gray-950">
|
||||||
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Nav />
|
<Nav />
|
||||||
<main className="p-6">{children}</main>
|
<main className="max-w-screen-2xl mx-auto px-4 md:px-6 py-6">{children}</main>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,26 +50,14 @@ export default function MapPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-xl font-bold text-white font-mono">Map</h1>
|
<h1 className="text-xl font-bold text-white font-mono">Map</h1>
|
||||||
<div className="flex items-center gap-4 text-xs font-mono text-gray-400">
|
|
||||||
<span><span className="text-green-400">●</span> Online</span>
|
|
||||||
<span><span className="text-orange-400 animate-pulse">●</span> Recording</span>
|
|
||||||
<span><span className="text-indigo-400">●</span> Unconfigured</span>
|
|
||||||
<span><span className="text-gray-600">●</span> Offline</span>
|
|
||||||
<span className="border-l border-gray-700 pl-4"><span className="text-red-500">■</span> Fire</span>
|
|
||||||
<span><span className="text-blue-500">■</span> Police</span>
|
|
||||||
<span><span className="text-yellow-500">■</span> EMS</span>
|
|
||||||
<span><span className="text-orange-500">■</span> Accident</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-96 text-gray-600 font-mono text-sm">
|
<div className="flex items-center justify-center h-96 text-gray-600 font-mono text-sm">
|
||||||
Loading map…
|
Loading map…
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ height: "calc(100vh - 280px)", minHeight: "400px" }}>
|
<div className="h-[50vh] sm:h-[65vh] min-h-[400px]">
|
||||||
<MapView nodes={nodes} activeCalls={activeCalls} incidents={incidents} />
|
<MapView nodes={nodes} activeCalls={activeCalls} incidents={incidents} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -719,7 +719,7 @@ export default function SystemsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-3xl">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-bold text-white font-mono">Systems</h1>
|
<h1 className="text-xl font-bold text-white font-mono">Systems</h1>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default function TokensPage() {
|
|||||||
if (authLoading || !isAdmin) return null;
|
if (authLoading || !isAdmin) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-2xl">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-white font-mono">Bot Token Pool</h1>
|
<h1 className="text-xl font-bold text-white font-mono">Bot Token Pool</h1>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
|
import { MapContainer, TileLayer, Marker, Popup, LayersControl, FeatureGroup } from "react-leaflet";
|
||||||
import L from "leaflet";
|
import L from "leaflet";
|
||||||
import type { NodeRecord, CallRecord, IncidentRecord } from "@/lib/types";
|
import type { NodeRecord, CallRecord, IncidentRecord } from "@/lib/types";
|
||||||
|
|
||||||
@@ -59,7 +59,6 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
|||||||
activeCalls.map((c) => [c.node_id, c])
|
activeCalls.map((c) => [c.node_id, c])
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only show incidents that have been geocoded (location_coords set by the server).
|
|
||||||
const plottedIncidents = incidents.flatMap((inc) =>
|
const plottedIncidents = incidents.flatMap((inc) =>
|
||||||
inc.location_coords
|
inc.location_coords
|
||||||
? [{ inc, pos: [inc.location_coords.lat, inc.location_coords.lng] as [number, number] }]
|
? [{ inc, pos: [inc.location_coords.lat, inc.location_coords.lng] as [number, number] }]
|
||||||
@@ -81,18 +80,37 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
|||||||
: 4;
|
: 4;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="relative w-full h-full">
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={center}
|
center={center}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
className="w-full h-full rounded-lg"
|
className="w-full h-full rounded-lg"
|
||||||
style={{ background: "#111827" }}
|
style={{ background: "#111827" }}
|
||||||
>
|
>
|
||||||
|
<LayersControl position="topright">
|
||||||
|
{/* Base layers */}
|
||||||
|
<LayersControl.BaseLayer checked name="Dark">
|
||||||
<TileLayer
|
<TileLayer
|
||||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||||
/>
|
/>
|
||||||
|
</LayersControl.BaseLayer>
|
||||||
|
<LayersControl.BaseLayer name="Light">
|
||||||
|
<TileLayer
|
||||||
|
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||||
|
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||||
|
/>
|
||||||
|
</LayersControl.BaseLayer>
|
||||||
|
<LayersControl.BaseLayer name="Streets">
|
||||||
|
<TileLayer
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
/>
|
||||||
|
</LayersControl.BaseLayer>
|
||||||
|
|
||||||
{/* Node markers */}
|
{/* Overlay: Nodes */}
|
||||||
|
<LayersControl.Overlay checked name="Nodes">
|
||||||
|
<FeatureGroup>
|
||||||
{nodes.map((node) => (
|
{nodes.map((node) => (
|
||||||
<Marker
|
<Marker
|
||||||
key={node.node_id}
|
key={node.node_id}
|
||||||
@@ -114,8 +132,12 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
|||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
))}
|
))}
|
||||||
|
</FeatureGroup>
|
||||||
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
{/* Incident markers — positioned at the node covering the incident's system */}
|
{/* Overlay: Active Incidents */}
|
||||||
|
<LayersControl.Overlay checked name="Active Incidents">
|
||||||
|
<FeatureGroup>
|
||||||
{plottedIncidents.map(({ inc, pos }) => (
|
{plottedIncidents.map(({ inc, pos }) => (
|
||||||
<Marker
|
<Marker
|
||||||
key={inc.incident_id}
|
key={inc.incident_id}
|
||||||
@@ -139,6 +161,23 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
|||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
))}
|
))}
|
||||||
|
</FeatureGroup>
|
||||||
|
</LayersControl.Overlay>
|
||||||
|
</LayersControl>
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
|
|
||||||
|
{/* Legend overlay — inside the map wrapper, above tiles */}
|
||||||
|
<div className="absolute bottom-8 left-3 z-[1001] bg-gray-950/90 border border-gray-800 rounded-lg px-3 py-2 text-xs font-mono pointer-events-none space-y-1">
|
||||||
|
<div className="flex items-center gap-2"><span className="text-green-400">●</span> Online</div>
|
||||||
|
<div className="flex items-center gap-2"><span className="text-orange-400">●</span> Recording</div>
|
||||||
|
<div className="flex items-center gap-2"><span className="text-indigo-400">●</span> Unconfigured</div>
|
||||||
|
<div className="flex items-center gap-2"><span className="text-gray-500">●</span> Offline</div>
|
||||||
|
<div className="border-t border-gray-800 my-0.5" />
|
||||||
|
<div className="flex items-center gap-2"><span className="text-red-500">■</span> Fire</div>
|
||||||
|
<div className="flex items-center gap-2"><span className="text-blue-500">■</span> Police</div>
|
||||||
|
<div className="flex items-center gap-2"><span className="text-yellow-500">■</span> EMS</div>
|
||||||
|
<div className="flex items-center gap-2"><span className="text-orange-500">■</span> Accident</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+112
-13
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useUnconfiguredNodes } from "@/lib/useNodes";
|
import { useUnconfiguredNodes } from "@/lib/useNodes";
|
||||||
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
|
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
import { useTheme } from "@/components/ThemeProvider";
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ href: "/dashboard", label: "Dashboard" },
|
{ href: "/dashboard", label: "Dashboard" },
|
||||||
@@ -21,27 +23,58 @@ const adminLinks = [
|
|||||||
{ href: "/admin", label: "Admin" },
|
{ href: "/admin", label: "Admin" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function SunIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="5"/>
|
||||||
|
<line x1="12" y1="1" x2="12" y2="3"/>
|
||||||
|
<line x1="12" y1="21" x2="12" y2="23"/>
|
||||||
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
||||||
|
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||||
|
<line x1="1" y1="12" x2="3" y2="12"/>
|
||||||
|
<line x1="21" y1="12" x2="23" y2="12"/>
|
||||||
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
||||||
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MoonIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Nav() {
|
export function Nav() {
|
||||||
const { user, isAdmin, signOut } = useAuth();
|
const { user, isAdmin, signOut } = useAuth();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { nodes: pending } = useUnconfiguredNodes();
|
const { nodes: pending } = useUnconfiguredNodes();
|
||||||
const unackedAlerts = useUnacknowledgedAlerts();
|
const unackedAlerts = useUnacknowledgedAlerts();
|
||||||
|
const { theme, toggle } = useTheme();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
|
const allLinks = [...links, ...(isAdmin ? adminLinks : [])];
|
||||||
|
|
||||||
|
function navLinkClass(href: string) {
|
||||||
|
return `text-sm font-mono transition-colors shrink-0 ${
|
||||||
|
pathname.startsWith(href) ? "text-white" : "text-gray-500 hover:text-gray-300"
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="border-b border-gray-800 bg-gray-950 px-6 py-3 flex items-center gap-6 overflow-x-auto">
|
<nav className="sticky top-0 z-40 border-b border-gray-800 bg-gray-950/95 backdrop-blur">
|
||||||
<span className="font-mono font-bold text-white tracking-tight mr-4 shrink-0">DRB</span>
|
{/* Main bar */}
|
||||||
{[...links, ...(isAdmin ? adminLinks : [])].map(({ href, label }) => (
|
<div className="px-4 md:px-6 py-3 flex items-center gap-4 md:gap-6">
|
||||||
<Link
|
<span className="font-mono font-bold text-white tracking-tight shrink-0">DRB</span>
|
||||||
key={href}
|
|
||||||
href={href}
|
{/* Desktop links */}
|
||||||
className={`text-sm font-mono transition-colors shrink-0 ${
|
<div className="hidden md:flex items-center gap-6 overflow-x-auto">
|
||||||
pathname.startsWith(href)
|
{allLinks.map(({ href, label }) => (
|
||||||
? "text-white"
|
<Link key={href} href={href} className={navLinkClass(href)}>
|
||||||
: "text-gray-500 hover:text-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
{label === "Nodes" && pending.length > 0 && (
|
{label === "Nodes" && pending.length > 0 && (
|
||||||
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-yellow-500 text-gray-950 text-xs font-bold">
|
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-yellow-500 text-gray-950 text-xs font-bold">
|
||||||
@@ -55,7 +88,71 @@ export function Nav() {
|
|||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
<div className="ml-auto shrink-0">
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-3 shrink-0">
|
||||||
|
{/* Theme toggle */}
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
|
>
|
||||||
|
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Sign out (desktop) */}
|
||||||
|
<button
|
||||||
|
onClick={signOut}
|
||||||
|
className="hidden md:block text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Hamburger (mobile) */}
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileOpen((v) => !v)}
|
||||||
|
className="md:hidden text-gray-400 hover:text-gray-200 transition-colors p-1"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
{mobileOpen ? (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile drawer */}
|
||||||
|
{mobileOpen && (
|
||||||
|
<div className="md:hidden border-t border-gray-800 bg-gray-950 px-4 py-3 flex flex-col gap-1">
|
||||||
|
{allLinks.map(({ href, label }) => (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className={`py-2 text-sm font-mono transition-colors flex items-center gap-2 ${
|
||||||
|
pathname.startsWith(href) ? "text-white" : "text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{label === "Nodes" && pending.length > 0 && (
|
||||||
|
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-yellow-500 text-gray-950 text-xs font-bold">
|
||||||
|
{pending.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{label === "Alerts" && unackedAlerts.length > 0 && (
|
||||||
|
<span className="inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-red-600 text-white text-xs font-bold px-1">
|
||||||
|
{unackedAlerts.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<div className="border-t border-gray-800 pt-3 mt-1">
|
||||||
<button
|
<button
|
||||||
onClick={signOut}
|
onClick={signOut}
|
||||||
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
@@ -63,6 +160,8 @@ export function Nav() {
|
|||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type Theme = "dark" | "light";
|
||||||
|
|
||||||
|
const ThemeContext = createContext<{ theme: Theme; toggle: () => void }>({
|
||||||
|
theme: "dark",
|
||||||
|
toggle: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return useContext(ThemeContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [theme, setTheme] = useState<Theme>("dark");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem("drb-theme") as Theme | null;
|
||||||
|
if (saved === "light") setTheme("light");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||||
|
localStorage.setItem("drb-theme", theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, toggle: () => setTheme((t) => (t === "dark" ? "light" : "dark")) }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ const config: Config = {
|
|||||||
"./app/**/*.{ts,tsx}",
|
"./app/**/*.{ts,tsx}",
|
||||||
"./components/**/*.{ts,tsx}",
|
"./components/**/*.{ts,tsx}",
|
||||||
],
|
],
|
||||||
|
darkMode: ["class"],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
|
|||||||
Reference in New Issue
Block a user