Skip to content

Commit e2fdc30

Browse files
committed
Add light/dark theming and layout updates
1 parent 0402979 commit e2fdc30

4 files changed

Lines changed: 240 additions & 171 deletions

File tree

src/App.tsx

Lines changed: 172 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { ReactNode, useEffect, useMemo, useState } from 'react';
2-
import { FiLayers, FiRefreshCcw, FiShield, FiSidebar, FiWifi, FiX } from 'react-icons/fi';
1+
import { useEffect, useMemo, useState } from 'react';
2+
import { FiMoon, FiRefreshCcw, FiSun } from 'react-icons/fi';
33
import MapView, { FocusPoint } from './components/MapView';
44
import LocationSearch from './components/LocationSearch';
55
import { useGateways } from './hooks/useGateways';
@@ -14,8 +14,14 @@ const App = () => {
1414
const [showTTN, setShowTTN] = useState(true);
1515
const [showPrivate, setShowPrivate] = useState(true);
1616
const [focusPoint, setFocusPoint] = useState<FocusPoint | null>(null);
17-
const [dashboardOpen, setDashboardOpen] = useState(true);
1817
const [selectedGateway, setSelectedGateway] = useState<Gateway | null>(null);
18+
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
19+
if (typeof window === 'undefined') {
20+
return 'dark';
21+
}
22+
const stored = window.localStorage.getItem('ttn-theme');
23+
return stored === 'light' ? 'light' : 'dark';
24+
});
1925

2026
const onlineGateways = useMemo(
2127
() => gateways.filter((gateway) => gateway.online && gateway.location),
@@ -50,142 +56,95 @@ const App = () => {
5056
setSelectedGateway(gateway);
5157
};
5258

59+
useEffect(() => {
60+
document.documentElement.dataset.theme = theme;
61+
if (typeof window !== 'undefined') {
62+
window.localStorage.setItem('ttn-theme', theme);
63+
}
64+
}, [theme]);
65+
66+
const toggleTheme = () => setTheme((value) => (value === 'dark' ? 'light' : 'dark'));
67+
const isDark = theme === 'dark';
68+
5369
const lastUpdatedLabel = lastUpdated
5470
? lastUpdated.toLocaleString(undefined, { hour: '2-digit', minute: '2-digit', day: 'numeric', month: 'short' })
5571
: '—';
5672

57-
const heroSubtitle = loading
58-
? 'Fetching live gateway data from Packet Broker…'
59-
: `${formatNumber(visibleGateways.length)} gateways are visible with the current filters.`;
60-
6173
return (
62-
<div className="relative h-screen w-screen overflow-hidden">
63-
<MapView
64-
gateways={toggleAllOff ? [] : (visibleGateways as Gateway[])}
65-
focusPoint={focusPoint}
66-
onFocusConsumed={() => setFocusPoint(null)}
67-
onGatewaySelect={handleGatewaySelect}
68-
/>
69-
{dashboardOpen && (
70-
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 flex justify-center px-6 pt-6">
71-
<div className="flex w-full max-w-6xl flex-col gap-4">
72-
<div className="pointer-events-auto glass-panel rounded-3xl p-6 text-white">
73-
<div className="flex flex-wrap items-start justify-between gap-6">
74-
<div>
75-
<p className="text-xs uppercase tracking-[0.4em] text-slate-400">The Things Network</p>
76-
<h1 className="font-display text-4xl font-semibold text-white">Gateway Atlas</h1>
77-
<p className="mt-2 max-w-2xl text-sm text-slate-300">{heroSubtitle}</p>
78-
</div>
79-
<div className="flex w-full flex-col items-end gap-3 sm:w-auto">
80-
<button
81-
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/10 px-3 py-1.5 text-xs uppercase tracking-[0.3em] text-slate-200 transition hover:border-white/30"
82-
onClick={() => setDashboardOpen(false)}
83-
>
84-
Close <FiX />
85-
</button>
86-
<LocationSearch onSelect={handleSearchSelect} />
87-
</div>
88-
</div>
89-
<div className="mt-5 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
90-
<StatCard
91-
label="Online gateways"
92-
value={formatNumber(onlineGateways.length)}
93-
icon={<FiWifi />}
94-
/>
95-
<StatCard
96-
label="Things Stack"
97-
value={formatNumber(ttnCount)}
98-
sublabel="The Things Network + TTNv2"
99-
icon={<FiLayers />}
100-
active={showTTN}
101-
onToggle={() => setShowTTN((value) => !value)}
102-
/>
103-
<StatCard
104-
label="Private networks"
105-
value={formatNumber(privateCount)}
106-
sublabel="Other Packet Broker participants"
107-
icon={<FiShield />}
108-
active={showPrivate}
109-
onToggle={() => setShowPrivate((value) => !value)}
110-
/>
111-
<StatCard
112-
label="Last updated"
113-
value={lastUpdatedLabel}
114-
sublabel="Packet Broker telemetry"
115-
icon={<FiRefreshCcw />}
116-
/>
117-
</div>
118-
</div>
119-
<div className="pointer-events-auto">
120-
<div className="glass-panel rounded-3xl p-6">
121-
<h2 className="font-display text-lg text-white">Gateway details</h2>
122-
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Click a gateway on the map to inspect it</p>
123-
{selectedGateway ? (
124-
<GatewayDetails gateway={selectedGateway} />
125-
) : (
126-
<p className="mt-4 text-sm text-slate-400">
127-
{loading ? 'Loading live data…' : 'Select any gateway pinpoint to see its IDs, hardware placement, and coordinates.'}
128-
</p>
129-
)}
130-
</div>
131-
</div>
74+
<div className={`relative h-screen w-screen overflow-hidden ${isDark ? 'bg-[#050914] text-white' : 'bg-[#f5f6fb] text-slate-900'}`}>
75+
<div
76+
className={`pointer-events-auto absolute left-6 top-6 z-20 flex max-w-md flex-col gap-3 rounded-3xl border p-5 ${
77+
isDark ? 'border-white/10 bg-[#050914]/85 text-white' : 'border-slate-200 bg-white/95 text-slate-900 shadow-xl'
78+
}`}
79+
>
80+
<div className="flex items-start justify-between gap-3">
81+
<p className={`text-sm ${isDark ? 'text-slate-300' : 'text-slate-600'}`}>
82+
{loading ? 'Fetching live gateway data from Packet Broker…' : 'Explore live Packet Broker gateways around the world.'}
83+
</p>
84+
<button
85+
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs uppercase tracking-[0.3em] transition ${
86+
isDark ? 'border-white/20 text-slate-200 hover:border-white/40' : 'border-slate-300 text-slate-600 hover:border-slate-500'
87+
}`}
88+
onClick={toggleTheme}
89+
>
90+
{isDark ? (
91+
<>
92+
<FiSun /> Light
93+
</>
94+
) : (
95+
<>
96+
<FiMoon /> Dark
97+
</>
98+
)}
99+
</button>
132100
</div>
133-
</div>
134-
)}
135-
{!dashboardOpen && (
136-
<button
137-
className="pointer-events-auto absolute top-6 right-6 z-10 inline-flex items-center gap-2 rounded-2xl border border-white/30 bg-[#050914]/80 px-4 py-2 text-sm text-white shadow-lg transition hover:border-white"
138-
onClick={() => setDashboardOpen(true)}
101+
<LocationSearch onSelect={handleSearchSelect} theme={theme} />
102+
</div>
103+
<div className="absolute inset-0">
104+
<MapView
105+
gateways={toggleAllOff ? [] : (visibleGateways as Gateway[])}
106+
focusPoint={focusPoint}
107+
onFocusConsumed={() => setFocusPoint(null)}
108+
onGatewaySelect={handleGatewaySelect}
109+
theme={theme}
110+
/>
111+
<div
112+
className={`pointer-events-auto absolute bottom-6 left-6 flex max-w-4xl flex-nowrap items-center gap-3 overflow-x-auto rounded-full border px-4 py-3 text-xs ${
113+
isDark ? 'border-white/10 bg-[#050914]/85 text-slate-300' : 'border-slate-200 bg-white/95 text-slate-600 shadow-lg'
114+
}`}
139115
>
140-
<FiSidebar /> Open dashboard
141-
</button>
142-
)}
143-
<div className="pointer-events-auto absolute bottom-6 left-6 flex flex-wrap items-center gap-4 rounded-full bg-[#050914]/70 px-4 py-2 text-xs text-slate-400">
144-
<LegendSwatch color="#47e7ff" label="The Things Stack" />
145-
<LegendSwatch color="#ff9db9" label="Private network" />
146-
{error && <span className="text-red-300">{error}</span>}
116+
<LegendSummary
117+
color="#47e7ff"
118+
label="The Things Stack"
119+
count={formatNumber(ttnCount)}
120+
description="TTN + TTNv2"
121+
isDark={isDark}
122+
/>
123+
<LegendSummary
124+
color="#ff9db9"
125+
label="Private network"
126+
count={formatNumber(privateCount)}
127+
description="Other Packet Broker participants"
128+
isDark={isDark}
129+
/>
130+
<InfoPill label="Online gateways" value={formatNumber(onlineGateways.length)} isDark={isDark} />
131+
{error && (
132+
<span className={`rounded-full px-3 py-1 ${isDark ? 'bg-red-500/20 text-red-200' : 'bg-red-100 text-red-500'}`}>
133+
{error}
134+
</span>
135+
)}
136+
</div>
147137
</div>
138+
{loading && <LoadingOverlay isDark={isDark} />}
139+
{selectedGateway && (
140+
<GatewayModal gateway={selectedGateway} onClose={() => setSelectedGateway(null)} theme={theme} />
141+
)}
148142
</div>
149143
);
150144
};
151145

152-
interface StatCardProps {
153-
label: string;
154-
value: string;
155-
sublabel?: string;
156-
icon: ReactNode;
157-
active?: boolean;
158-
onToggle?: () => void;
159-
}
160-
161-
const StatCard = ({ label, value, sublabel, icon, active = true, onToggle }: StatCardProps) => {
162-
const isInteractive = Boolean(onToggle);
163-
const className = [
164-
'flex flex-col gap-2 rounded-2xl border border-white/5 bg-white/5 px-4 py-3 shadow-inner shadow-white/5',
165-
isInteractive ? 'cursor-pointer transition hover:border-white/20' : '',
166-
isInteractive && !active ? 'opacity-40' : '',
167-
]
168-
.filter(Boolean)
169-
.join(' ');
170-
171-
const handleClick = () => {
172-
if (!isInteractive) return;
173-
onToggle?.();
174-
};
175-
176-
return (
177-
<button className={className} onClick={handleClick} type={isInteractive ? 'button' : undefined}>
178-
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.3em] text-slate-400">
179-
<span>{icon}</span>
180-
{label}
181-
</div>
182-
<div className="font-display text-3xl text-white">{value}</div>
183-
{sublabel && <div className="text-sm text-slate-400">{sublabel}</div>}
184-
</button>
185-
);
186-
};
187-
188-
const GatewayDetails = ({ gateway }: { gateway: Gateway }) => {
146+
const GatewayDetails = ({ gateway, theme }: { gateway: Gateway; theme: 'dark' | 'light' }) => {
147+
const isDark = theme === 'dark';
189148
const coordinateText = gateway.location
190149
? `${gateway.location.latitude.toFixed(4)}, ${gateway.location.longitude.toFixed(4)}`
191150
: 'Location not provided';
@@ -211,40 +170,108 @@ const GatewayDetails = ({ gateway }: { gateway: Gateway }) => {
211170
];
212171

213172
return (
214-
<div className="mt-5 space-y-5 text-sm text-slate-200">
215-
<SectionGrid title="Identity" items={detailItems} />
216-
<SectionGrid title="Location" items={locationItems} />
173+
<div className="mt-5 space-y-5 text-sm">
174+
<SectionGrid title="Identity" items={detailItems} theme={theme} />
175+
<SectionGrid title="Location" items={locationItems} theme={theme} />
217176
<SectionGrid
218177
title="Status"
219178
items={[
220179
{ label: 'Online state', value: gateway.online ? 'Online in Packet Broker' : 'Offline' },
221180
{ label: 'Last updated', value: new Date(gateway.updatedAt).toLocaleString() },
222181
{ label: 'Antenna placement', value: antenna },
223182
]}
183+
theme={theme}
224184
/>
225185
</div>
226186
);
227187
};
228188

229-
const SectionGrid = ({ title, items }: { title: string; items: { label: string; value: string }[] }) => (
230-
<div>
231-
<p className="text-xs uppercase tracking-[0.4em] text-slate-500">{title}</p>
232-
<div className="mt-3 grid gap-3 md:grid-cols-3">
233-
{items.map((item) => (
234-
<div key={item.label} className="rounded-2xl bg-white/5 px-5 py-4">
235-
<p className="text-[0.65rem] uppercase tracking-[0.3em] text-slate-500">{item.label}</p>
236-
<p className="mt-2 text-base text-white">{item.value}</p>
237-
</div>
238-
))}
189+
const SectionGrid = ({ title, items, theme }: { title: string; items: { label: string; value: string }[]; theme: 'dark' | 'light' }) => {
190+
const isDark = theme === 'dark';
191+
return (
192+
<div>
193+
<p className={`text-xs uppercase tracking-[0.4em] ${isDark ? 'text-slate-500' : 'text-slate-600'}`}>{title}</p>
194+
<div className="mt-3 grid gap-3 md:grid-cols-3">
195+
{items.map((item) => (
196+
<div
197+
key={item.label}
198+
className={`rounded-2xl px-5 py-4 ${isDark ? 'bg-white/5 text-white' : 'bg-slate-100 text-slate-900'}`}
199+
>
200+
<p className={`text-[0.65rem] uppercase tracking-[0.3em] ${isDark ? 'text-slate-500' : 'text-slate-600'}`}>
201+
{item.label}
202+
</p>
203+
<p className="mt-2 break-words text-base">{item.value}</p>
204+
</div>
205+
))}
206+
</div>
207+
</div>
208+
);
209+
};
210+
211+
const LegendSummary = ({
212+
color,
213+
label,
214+
count,
215+
description,
216+
isDark,
217+
}: {
218+
color: string;
219+
label: string;
220+
count: string;
221+
description: string;
222+
isDark: boolean;
223+
}) => (
224+
<div className={`flex flex-shrink-0 items-center gap-3 rounded-full px-3 py-2 ${isDark ? 'bg-white/5 text-white' : 'bg-slate-200/80 text-slate-900'}`}>
225+
<div className="flex items-center gap-2">
226+
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: color }} />
227+
<span className="text-sm font-medium">{label}</span>
228+
</div>
229+
<div>
230+
<p className="font-display text-sm">{count}</p>
231+
<p className={`text-[0.6rem] uppercase tracking-[0.3em] ${isDark ? 'text-slate-500' : 'text-slate-600'}`}>{description}</p>
239232
</div>
240233
</div>
241234
);
242235

243-
const LegendSwatch = ({ color, label }: { color: string; label: string }) => (
244-
<span className="flex items-center gap-2 rounded-full bg-white/5 px-3 py-1 text-[0.7rem]">
245-
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: color }} />
246-
{label}
247-
</span>
236+
const InfoPill = ({ label, value, isDark }: { label: string; value: string; isDark: boolean }) => (
237+
<div className={`flex-shrink-0 rounded-full px-3 py-2 ${isDark ? 'bg-white/5 text-white' : 'bg-slate-200/80 text-slate-900'}`}>
238+
<p className={`text-[0.6rem] uppercase tracking-[0.3em] ${isDark ? 'text-slate-500' : 'text-slate-600'}`}>{label}</p>
239+
<p className="text-sm font-medium">{value}</p>
240+
</div>
241+
);
242+
243+
const LoadingOverlay = ({ isDark }: { isDark: boolean }) => (
244+
<div
245+
className={`pointer-events-auto absolute inset-0 z-30 flex flex-col items-center justify-center gap-3 text-center ${
246+
isDark ? 'bg-[#050914]/90 text-white' : 'bg-white/80 text-slate-900'
247+
}`}
248+
>
249+
<FiRefreshCcw className={`text-3xl animate-spin ${isDark ? 'text-white' : 'text-slate-700'}`} />
250+
<p className="font-display text-xl">Fetching live gateway data…</p>
251+
<p className={`max-w-sm text-sm ${isDark ? 'text-slate-400' : 'text-slate-600'}`}>
252+
Hang tight while we synchronize with Packet Broker.
253+
</p>
254+
</div>
255+
);
256+
257+
const GatewayModal = ({ gateway, onClose, theme }: { gateway: Gateway; onClose: () => void; theme: 'dark' | 'light' }) => (
258+
<div className={`pointer-events-auto fixed inset-0 z-40 flex items-center justify-center p-6 ${theme === 'dark' ? 'bg-black/70' : 'bg-black/20'}`}>
259+
<div className={`glass-panel relative w-full max-w-3xl rounded-3xl p-8 ${theme === 'dark' ? 'text-white' : 'text-slate-900'}`}>
260+
<button
261+
className={`absolute right-6 top-6 inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs uppercase tracking-[0.3em] transition ${
262+
theme === 'dark' ? 'border-white/20 text-slate-200 hover:border-white/40' : 'border-slate-300 text-slate-600 hover:border-slate-500'
263+
}`}
264+
onClick={onClose}
265+
>
266+
Close
267+
</button>
268+
<h2 className="font-display text-2xl">Gateway details</h2>
269+
<p className={`${theme === 'dark' ? 'text-slate-400' : 'text-slate-600'} text-sm`}>
270+
Full telemetry as reported by Packet Broker
271+
</p>
272+
<GatewayDetails gateway={gateway} theme={theme} />
273+
</div>
274+
</div>
248275
);
249276

250277
export default App;

0 commit comments

Comments
 (0)