@@ -96,6 +96,275 @@ document.getElementById('diagnosticsModal').addEventListener('click', (e) => {
9696document . addEventListener ( 'keydown' , ( e ) => {
9797 if ( e . key === 'Escape' ) {
9898 closeDiagnostics ( ) ;
99+ closeMap ( ) ;
100+ }
101+ } ) ;
102+
103+ // Map Logic
104+ let map = null ;
105+ let mapInitialized = false ;
106+ let peerMarkers = { } ; // id -> marker
107+ let ipCache = { } ; // ip -> { lat, lon }
108+ let lastPeerData = [ ] ;
109+ let myLocation = null ;
110+
111+ const fetchMyLocation = async ( ) => {
112+ if ( myLocation ) return ;
113+ try {
114+ const res = await fetch ( 'https://ipwho.is/' ) ;
115+ const data = await res . json ( ) ;
116+ if ( data . success ) {
117+ myLocation = { lat : data . latitude , lon : data . longitude , city : data . city , country : data . country } ;
118+ updateMap ( lastPeerData ) ;
119+ }
120+ } catch ( e ) {
121+ console . error ( 'My location fetch failed' , e ) ;
122+ }
123+ }
124+
125+ const openMap = ( ) => {
126+ document . getElementById ( 'mapModal' ) . classList . add ( 'active' ) ;
127+ if ( ! mapInitialized ) {
128+ initMap ( ) ;
129+ } else {
130+ setTimeout ( ( ) => {
131+ map . invalidateSize ( ) ;
132+ } , 100 ) ;
133+ }
134+
135+ fetchMyLocation ( ) ;
136+
137+ if ( lastPeerData . length > 0 ) {
138+ updateMap ( lastPeerData ) ;
139+ }
140+ }
141+
142+ const closeMap = ( ) => {
143+ document . getElementById ( 'mapModal' ) . classList . remove ( 'active' ) ;
144+ }
145+
146+ document . getElementById ( 'mapModal' ) . addEventListener ( 'click' , ( e ) => {
147+ if ( e . target . id === 'mapModal' ) {
148+ closeMap ( ) ;
149+ }
150+ } ) ;
151+
152+ const initMap = ( ) => {
153+ if ( mapInitialized ) return ;
154+
155+ map = L . map ( 'map' ) . setView ( [ 20 , 0 ] , 2 ) ;
156+
157+ L . tileLayer ( 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' , {
158+ attribution : '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>' ,
159+ subdomains : 'abcd' ,
160+ maxZoom : 19
161+ } ) . addTo ( map ) ;
162+
163+ mapInitialized = true ;
164+
165+ setTimeout ( ( ) => {
166+ map . invalidateSize ( ) ;
167+ } , 100 ) ;
168+ }
169+
170+ const fetchLocation = async ( ip ) => {
171+ if ( ipCache [ ip ] ) return ipCache [ ip ] ;
172+
173+ // Skip local IPs
174+ if ( ip === '127.0.0.1' || ip === '::1' || ip . startsWith ( '192.168.' ) || ip . startsWith ( '10.' ) || ip . startsWith ( '172.16.' ) ) {
175+ return null ;
176+ }
177+
178+ try {
179+ const res = await fetch ( `https://ipwho.is/${ ip } ` ) ;
180+ const data = await res . json ( ) ;
181+ if ( data . success ) {
182+ const loc = { lat : data . latitude , lon : data . longitude , city : data . city , country : data . country } ;
183+ ipCache [ ip ] = loc ;
184+ return loc ;
185+ }
186+ } catch ( e ) {
187+ console . error ( 'Geo fetch failed' , e ) ;
188+ }
189+ return null ;
190+ }
191+
192+ const updateMap = async ( peers ) => {
193+ if ( ! mapInitialized ) return ;
194+ if ( ! peers ) peers = [ ] ;
195+
196+ const currentIds = new Set ( peers . map ( p => p . id ) ) ;
197+
198+ // Remove old markers
199+ for ( const id in peerMarkers ) {
200+ if ( id !== 'me' && ! currentIds . has ( id ) ) {
201+ map . removeLayer ( peerMarkers [ id ] ) ;
202+ delete peerMarkers [ id ] ;
203+ }
204+ }
205+
206+ // Add/Update markers
207+ for ( const peer of peers ) {
208+ if ( ! peer . ip ) continue ;
209+
210+ if ( ! peerMarkers [ peer . id ] ) {
211+ const loc = await fetchLocation ( peer . ip ) ;
212+ if ( loc ) {
213+ const marker = L . circleMarker ( [ loc . lat , loc . lon ] , {
214+ radius : 10 ,
215+ fillColor : "#4ade80" ,
216+ color : "transparent" ,
217+ weight : 0 ,
218+ opacity : 0 ,
219+ fillOpacity : 0.15
220+ } ) . addTo ( map ) ;
221+
222+ marker . bindPopup ( `<b>Node</b> ${ peer . id . slice ( - 8 ) } <br>${ loc . city } , ${ loc . country } ` ) ;
223+ peerMarkers [ peer . id ] = marker ;
224+ }
225+ }
226+ }
227+
228+ // Add My Location
229+ if ( myLocation && ! peerMarkers [ 'me' ] ) {
230+ const marker = L . circleMarker ( [ myLocation . lat , myLocation . lon ] , {
231+ radius : 6 ,
232+ fillColor : "#ffffff" ,
233+ color : "#4ade80" ,
234+ weight : 2 ,
235+ opacity : 1 ,
236+ fillOpacity : 1
237+ } ) . addTo ( map ) ;
238+
239+ marker . bindPopup ( `<b>This Node</b><br>${ myLocation . city } , ${ myLocation . country } ` ) ;
240+ peerMarkers [ 'me' ] = marker ;
241+ }
242+ }
243+
244+ const terminal = document . getElementById ( 'terminal' ) ;
245+ const terminalOutput = document . getElementById ( 'terminal-output' ) ;
246+ const terminalInput = document . getElementById ( 'terminal-input' ) ;
247+ const terminalToggle = document . getElementById ( 'terminal-toggle' ) ;
248+ const promptEl = document . querySelector ( '.prompt' ) ;
249+ let myId = null ;
250+ let myChatHistory = [ ] ;
251+
252+ terminalToggle . addEventListener ( 'click' , ( e ) => {
253+ e . stopPropagation ( ) ;
254+ toggleChat ( ) ;
255+ } ) ;
256+
257+ // Initialize chat state from localStorage
258+ const initChatState = ( ) => {
259+ const isCollapsed = localStorage . getItem ( 'chatCollapsed' ) === 'true' ;
260+ if ( isCollapsed ) {
261+ terminal . classList . add ( 'collapsed' ) ;
262+ terminalToggle . innerText = '▲' ;
263+ document . body . classList . remove ( 'chat-active' ) ;
264+ document . body . classList . add ( 'chat-collapsed' ) ;
265+ } else {
266+ terminal . classList . remove ( 'collapsed' ) ;
267+ terminalToggle . innerText = '▼' ;
268+ document . body . classList . add ( 'chat-active' ) ;
269+ document . body . classList . remove ( 'chat-collapsed' ) ;
270+ }
271+ } ;
272+
273+ const toggleChat = ( ) => {
274+ terminal . classList . toggle ( 'collapsed' ) ;
275+ const isCollapsed = terminal . classList . contains ( 'collapsed' ) ;
276+ terminalToggle . innerText = isCollapsed ? '▲' : '▼' ;
277+
278+ localStorage . setItem ( 'chatCollapsed' , isCollapsed ) ;
279+
280+ if ( isCollapsed ) {
281+ document . body . classList . remove ( 'chat-active' ) ;
282+ document . body . classList . add ( 'chat-collapsed' ) ;
283+ } else {
284+ document . body . classList . add ( 'chat-active' ) ;
285+ document . body . classList . remove ( 'chat-collapsed' ) ;
286+ terminalOutput . scrollTop = terminalOutput . scrollHeight ;
287+ }
288+ }
289+
290+ const updatePromptStatus = ( ) => {
291+ const now = Date . now ( ) ;
292+ myChatHistory = myChatHistory . filter ( t => now - t < 10000 ) ;
293+
294+ if ( myChatHistory . length >= 5 ) {
295+ promptEl . style . color = 'orange' ;
296+ } else {
297+ promptEl . style . color = '#4ade80' ;
298+ }
299+ } ;
300+
301+ setInterval ( updatePromptStatus , 500 ) ;
302+
303+ const getColorFromId = ( id ) => {
304+ if ( ! id ) return '#666' ;
305+ let hash = 0 ;
306+ for ( let i = 0 ; i < id . length ; i ++ ) {
307+ hash = id . charCodeAt ( i ) + ( ( hash << 5 ) - hash ) ;
308+ }
309+ const c = ( hash & 0x00FFFFFF ) . toString ( 16 ) . toUpperCase ( ) ;
310+ return '#' + "00000" . substring ( 0 , 6 - c . length ) + c ;
311+ }
312+
313+ const appendMessage = ( msg ) => {
314+ const div = document . createElement ( 'div' ) ;
315+
316+ if ( msg . type === 'SYSTEM' ) {
317+ div . className = 'msg-system' ;
318+ div . innerText = `[SYSTEM] ${ msg . content } ` ;
319+ } else if ( msg . type === 'CHAT' ) {
320+ const senderColor = getColorFromId ( msg . sender ) ;
321+ const senderName = msg . sender === myId ? 'You' : msg . sender . slice ( - 4 ) ;
322+
323+ const senderSpan = document . createElement ( 'span' ) ;
324+ senderSpan . className = 'msg-sender' ;
325+ senderSpan . style . color = senderColor ;
326+ senderSpan . innerText = `[${ senderName } ]` ;
327+
328+ const contentSpan = document . createElement ( 'span' ) ;
329+ contentSpan . className = 'msg-content' ;
330+ contentSpan . innerText = ` > ${ msg . content } ` ;
331+
332+ div . appendChild ( senderSpan ) ;
333+ div . appendChild ( contentSpan ) ;
334+ }
335+
336+ terminalOutput . appendChild ( div ) ;
337+ terminalOutput . scrollTop = terminalOutput . scrollHeight ;
338+ }
339+
340+ terminalInput . addEventListener ( 'keypress' , async ( e ) => {
341+ if ( e . key === 'Enter' ) {
342+ const content = terminalInput . value . trim ( ) ;
343+ if ( ! content ) return ;
344+
345+ terminalInput . value = '' ;
346+
347+ try {
348+ const res = await fetch ( '/api/chat' , {
349+ method : 'POST' ,
350+ headers : { 'Content-Type' : 'application/json' } ,
351+ body : JSON . stringify ( { content } )
352+ } ) ;
353+
354+ if ( res . ok ) {
355+ myChatHistory . push ( Date . now ( ) ) ;
356+ updatePromptStatus ( ) ;
357+ } else if ( res . status === 429 ) {
358+ // Force update if we hit the limit unexpectedly
359+ // Add a dummy timestamp to force the limit state if not already there
360+ if ( myChatHistory . length < 5 ) {
361+ myChatHistory . push ( Date . now ( ) ) ;
362+ }
363+ updatePromptStatus ( ) ;
364+ }
365+ } catch ( err ) {
366+ console . error ( 'Failed to send message' , err ) ;
367+ }
99368 }
100369} ) ;
101370
@@ -104,6 +373,34 @@ const evtSource = new EventSource("/events");
104373evtSource . onmessage = ( event ) => {
105374 const data = JSON . parse ( event . data ) ;
106375
376+ if ( data . type === 'CHAT' || data . type === 'SYSTEM' ) {
377+ appendMessage ( data ) ;
378+ return ;
379+ }
380+
381+ if ( data . chatEnabled ) {
382+ terminal . classList . remove ( 'hidden' ) ;
383+
384+ // Only initialize state once when chat becomes enabled
385+ if ( ! terminal . dataset . initialized ) {
386+ initChatState ( ) ;
387+ terminal . dataset . initialized = 'true' ;
388+ }
389+ } else {
390+ terminal . classList . add ( 'hidden' ) ;
391+ document . body . classList . remove ( 'chat-active' ) ;
392+ document . body . classList . remove ( 'chat-collapsed' ) ;
393+ }
394+
395+ if ( data . id ) myId = data . id ;
396+
397+ if ( data . peers ) {
398+ lastPeerData = data . peers ;
399+ if ( mapInitialized && document . getElementById ( 'mapModal' ) . classList . contains ( 'active' ) ) {
400+ updateMap ( data . peers ) ;
401+ }
402+ }
403+
107404 updateParticles ( data . count ) ;
108405
109406 if ( countEl . innerText != data . count ) {
0 commit comments