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' ;
33import MapView , { FocusPoint } from './components/MapView' ;
44import LocationSearch from './components/LocationSearch' ;
55import { 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
250277export default App ;
0 commit comments