document.addEventListener('DOMContentLoaded', function() { const sitesData = [ { name: 'YouTube Live', icon: 'youtube.png', description: 'The world\'s largest video platform with live streaming capabilities.', type: 'popout', instructions: ` `, notes: 'YouTube Live chat loads automatically when using the popout chat. For best results, use the "Popout Chat" option from YouTube\'s menu.' }, { name: 'YouTube Static Comments', icon: 'youtube.png', description: 'Regular non-live YouTube video comments.', type: 'manual', instructions: ` ` }, { name: 'Twitch', icon: 'twitch.png', description: 'Popular gaming and creative content live streaming platform.', type: 'popout', instructions: ` `, notes: 'Twitch chat popout automatically connects when opened. Channel points redemptions are also supported.' }, { name: 'Facebook Live', icon: 'facebook.png', description: 'Live streaming on Facebook\'s social media platform.', type: 'standard', instructions: ` `, notes: 'Facebook Live chat integration works on the main page. Also works with Workplace.com (using the same setup as Facebook). A creator-owned API bridge is also available at sources/websocket/facebook.html.' }, { name: 'Instagram Live', icon: 'instagram.png', description: 'Live streaming on Instagram\'s social media platform.', type: 'standard', instructions: ` `, notes: 'Payload type: instagramlive. CSS note for styling: [data.type = "instagramlive"]' }, { name: 'Instagram Post Comments', icon: 'instagram.png', description: 'Non-live Instagram post comments.', type: 'toggle', instructions: ` `, notes: 'Payload type: instagram. CSS note for styling: [data.type = "instagram"]' }, { name: 'X Live (Twitter)', icon: 'x.png', description: 'Live video streaming on X (formerly Twitter).', type: 'popout', instructions: ` ` }, { name: 'X Static Posts', icon: 'x.png', description: 'Static posts on X (formerly Twitter).', type: 'manual', instructions: ` ` }, { name: 'Threads.net', icon: 'threads.png', description: 'Meta\'s text-based social platform.', type: 'manual', instructions: ` ` }, { name: 'TikTok Live', icon: 'tiktok.png', description: 'Live streaming on the popular short-form video platform.', type: 'standard', instructions: ` ` }, { name: 'Discord', icon: 'discord.png', description: 'Popular communication platform for communities.', type: 'toggle', instructions: ` `, notes: 'See this video for help with toggled integrations: https://www.youtube.com/watch?v=L3l0_8V1t0Q' }, { name: 'Zoom', icon: 'zoom.png', description: 'Video conferencing platform with chat functionality.', type: 'standard', instructions: ` ` }, { name: 'Google Meet', icon: 'meet.png', description: 'Google\'s video conferencing platform.', type: 'toggle', instructions: ` ` }, { name: 'WhatsApp Web', icon: 'whatsapp.png', description: 'Web version of the popular messaging platform.', type: 'toggle', instructions: ` `, notes: 'No avatar support available for WhatsApp integration.' }, { name: 'Telegram', icon: 'telegram.png', description: 'Cloud-based messaging service.', type: 'toggle', instructions: ` `, notes: 'See this video for help with toggled integrations: https://www.youtube.com/watch?v=L3l0_8V1t0Q' }, { name: 'VPZone.tv', icon: 'vpzone.png', description: 'Live streaming platform with on-page chat capture and an authenticated API page option. Supports Pixels tips as donations, dock delete/block sync, and a stream title/category editor for channel owners.', type: 'standard', instructions: ` `, notes: 'An API-backed source is also available at sources/websocket/vpzone.html?channel=USERNAME.' }, { name: 'Slack', icon: 'slack.png', description: 'Business communication platform.', type: 'toggle', instructions: ` `, notes: 'See this video for help with toggled integrations: https://www.youtube.com/watch?v=L3l0_8V1t0Q' }, { name: 'LinkedIn Events', icon: 'linkedin.png', description: 'Professional network\'s live events platform.', type: 'standard', instructions: ` ` }, { name: 'VDO.Ninja', icon: 'vdoninja.png', description: 'Free, peer-to-peer, broadcasting tool.', type: 'popout', instructions: ` ` }, { name: 'Microsoft Teams', icon: 'teams.png', description: 'Microsoft\'s collaboration platform.', type: 'standard', instructions: ` ` }, { name: 'Restream.io Chat', icon: 'restream.png', description: 'Multi-platform streaming service\'s chat.', type: 'standard', instructions: ` ` }, { name: 'Owncast', icon: 'owncast.png', description: 'Self-hosted streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Twitch IRC WebSocket', icon: 'twitch.png', description: 'WebSocket connection to Twitch chat.', type: 'websocket', instructions: ` ` }, { name: 'Joystick Bot WebSocket', icon: 'joystick.png', description: 'WebSocket connection using your own Joystick bot credentials.', type: 'websocket', instructions: ` ` }, { name: 'IRC WebSocket', icon: 'irc.png', description: 'WebSocket connection to IRC networks.', type: 'websocket', instructions: ` ` }, { name: 'Kick.com', icon: 'kick.png', description: 'Emerging live streaming platform.', type: 'popout', instructions: ` ` }, { name: 'GoodGame.ru', icon: 'goodgame.png', description: 'Live streaming platform with a standalone popout chat page.', type: 'popout', instructions: ` ` }, { name: 'Rumble', icon: 'rumble.png', description: 'Video sharing and streaming platform.', type: 'popout', instructions: ` `, notes: 'A creator-owned API-backed source is also available at sources/websocket/rumble.html?apiUrl=....' }, { name: 'Rumble API URL', icon: 'rumble.png', description: 'Read-only creator API bridge for Rumble chat and stream events.', type: 'websocket', instructions: ` `, notes: 'Read-only mode. The API URL is secret and includes your live stream key; Rumble currently documents no separate OAuth requirement for reading it. The page can also resolve the normal https://rumble.com/chat/popup/<stream-id> URL so you can open the injected popup directly without first scraping the /live page.' }, { name: 'Odysee', icon: 'odysee.png', description: 'Blockchain-based media platform.', type: 'popout', instructions: ` ` }, { name: 'Amazon Live', icon: 'amazon.png', description: 'Amazon\'s live shopping platform.', type: 'standard', instructions: ` ` }, { name: 'Vimeo', icon: 'vimeo.png', description: 'High-quality video platform with live streaming.', type: 'standard', instructions: ` ` }, { name: 'Picarto.tv', icon: 'picarto.png', description: 'Art streaming platform.', type: 'popout', instructions: ` ` }, { name: 'Crowdcast.io', icon: 'crowdcast.png', description: 'Interactive webinar platform.', type: 'standard', instructions: ` ` }, { name: 'Mixcloud Live', icon: 'mixcloud.png', description: 'Audio streaming platform.', type: 'popout', instructions: ` ` }, { name: 'Bilibili.tv', icon: 'bilibili.png', description: 'Chinese video sharing and streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Whop', icon: 'whop.png', description: 'Pay to view crypto videos.', type: 'standard', instructions: ` ` }, { name: 'Bilibili.com', icon: 'bilibili.png', description: 'Chinese video sharing website (main domain).', type: 'standard', instructions: ` ` }, { name: 'VK Play Live', icon: 'vkplay.png', description: 'Russian streaming platform (formerly vkplay.live).', type: 'popout', instructions: ` ` }, { name: 'VK Live', icon: 'vk.png', description: 'Live streaming on VK.com.', type: 'standard', instructions: ` ` }, { name: 'Piczel.tv', icon: 'piczel.png', description: 'Art streaming platform.', type: 'popout', instructions: ` ` }, { name: 'Locals.com', icon: 'locals.png', description: 'Subscription-based community platform.', type: 'standard', instructions: ` ` }, { name: 'Nimo.TV', icon: 'nimo.png', description: 'Global gaming live streaming platform.', type: 'popout', instructions: ` ` }, { name: 'Amazon Chime', icon: 'chime.png', description: 'Amazon\'s communication service.', type: 'standard', instructions: ` ` }, { name: 'NonOLive', icon: 'nonolive.png', description: 'Live streaming platform popular in Asia.', type: 'standard', instructions: ` ` }, { name: 'StageTEN.tv', icon: 'generic.png', description: 'Interactive live streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Blaze.stream', icon: 'blaze.png', description: 'Live streaming platform.', type: 'standard', instructions: ` ` }, { name: 'BandLab', icon: 'bandlab.png', description: 'Music creation and sharing platform.', type: 'standard', instructions: ` ` }, { name: 'FloatPlane', icon: 'floatplane.png', description: 'Subscription-based video platform.', type: 'popout', instructions: ` ` }, { name: 'ChatGPT', icon: 'openai.png', description: 'Capture new User and ChatGPT turns from an open browser conversation, or use the tab as an experimental relay companion.', type: 'toggle', instructions: ` `, notes: 'No OpenAI API key is used for browser capture. This is separate from the ChatGPT API provider. Regular text chat is the most reliable target; ChatGPT Voice mode and page controls can change. See the ChatGPT browser companion guide.' }, { name: 'Livestorm.io', icon: 'livestorm.png', description: 'Professional video communication platform.', type: 'standard', instructions: ` ` }, { name: 'Cozy.tv', icon: 'cozy.png', description: 'Alternative streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Steam Broadcasts', icon: 'steam.png', description: 'Steam\'s built-in broadcasting feature.', type: 'standard', instructions: ` ` }, { name: 'Whatnot', icon: 'whatnot.png', description: 'Live shopping and auction platform.', type: 'standard', instructions: ` ` }, { name: 'eBay Live', icon: 'ebay.png', description: 'Live shopping and auction streaming on eBay.', type: 'standard', instructions: ` ` }, { name: 'Sessions.us', icon: 'sessions.png', description: 'Video meeting platform.', type: 'standard', instructions: ` ` }, { name: 'Chzzk.naver.com', icon: 'chzzk.png', description: 'Korean streaming platform by Naver.', type: 'popout', instructions: ` ` }, { name: 'IRC Quakenet', icon: 'irc.png', description: 'QuakeNet IRC web client.', type: 'standard', instructions: ` ` }, { name: 'IRC KiwiIRC', icon: 'kiwiirc.png', description: 'KiwiIRC web client.', type: 'standard', instructions: ` ` }, { name: 'Webex', icon: 'webex.png', description: 'Cisco\'s video conferencing platform.', type: 'standard', instructions: ` ` }, { name: 'Riverside.fm', icon: 'riverside.png', description: 'Professional recording platform.', type: 'standard', instructions: ` ` }, { name: 'Fansly', icon: 'fansly.png', description: 'Creator subscription platform.', type: 'popout', instructions: ` ` }, { name: 'Camsoda', icon: 'camsoda.png', description: 'Adult live streaming platform.', type: 'standard', instructions: ` ` }, { name: 'MyFreeCams', icon: 'myfreecams.png', description: 'Adult live streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Bongacams', icon: 'bongacams.png', description: 'Adult live streaming platform.', type: 'standard', instructions: ` ` }, { name: 'CAM4', icon: 'cam4.png', description: 'Adult live streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Stripchat', icon: 'stripchat.png', description: 'Adult live streaming platform.', type: 'standard', instructions: ` ` }, { name: 'TwitCasting', icon: 'twitcasting.png', description: 'Japanese live streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Bigo.tv', icon: 'bigo.png', description: 'Global live streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Substack', icon: 'substack.png', description: 'Newsletter and publishing platform.', type: 'standard', instructions: ` ` }, { name: 'Roll20', icon: 'roll20.png', description: 'Virtual tabletop for roleplaying games.', type: 'standard', instructions: ` ` }, { name: 'On24', icon: 'on24.png', description: 'Webinar and virtual event platform.', type: 'standard', instructions: ` ` }, { name: 'Chaturbate', icon: 'chaturbate.png', description: 'Adult live streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Cherry TV', icon: 'cherrytv.png', description: 'Live streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Truffle.vip', icon: 'truffle.png', description: 'Chat platform for creators.', type: 'standard', instructions: ` ` }, { name: 'Favorited', icon: 'favorited.png', description: 'Streaming platform for creators.', type: 'popout', instructions: ` ` }, { name: 'Simps', icon: 'simps.png', description: 'Streaming platform with app-based chat.', type: 'standard', instructions: ` ` }, { name: 'Pilled.net', icon: 'pilled.png', description: 'Alternative social media platform.', type: 'standard', instructions: ` ` }, { name: 'Portal', icon: 'portal.png', description: 'Streaming portal platform.', type: 'standard', instructions: ` ` }, { name: 'Pump.fun', icon: 'pumpfun.png', description: 'Live trading and chat for Pump.fun coins.', type: 'standard', instructions: ` `, notes: 'Keep the coin page open with the live chat visible.' }, { name: 'NicoVideo', icon: 'nicovideo.png', description: 'Japanese video streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Rutube', icon: 'rutube.png', description: 'Russian video streaming platform.', type: 'popout', instructions: ` ` }, { name: 'FC2', icon: 'fc2.png', description: 'Japanese content platform with streaming.', type: 'standard', instructions: ` ` }, { name: 'Vertical Pixel Zone', icon: 'generic.png', description: 'Streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Mixlr', icon: 'mixlr.png', description: 'Audio streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Jaco.live', icon: 'jaco.png', description: 'Live streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Gala Music', icon: 'gala.png', description: 'Music streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Circle.so', icon: 'generic.png', description: 'Community platform.', type: 'standard', instructions: ` ` }, { name: 'Estrim', icon: 'estrim.png', description: 'Live streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Online Church', icon: 'onlinechurch.png', description: 'Church streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Parti', icon: 'parti.png', description: 'Chat platform.', type: 'popout', instructions: ` ` }, { name: 'Wave Video', icon: 'wavevideo.png', description: 'Video creation and streaming platform.', type: 'standard', instructions: ` ` }, { name: 'WorldsWave', icon: 'worldswave.png', description: 'Social media and live streaming platform.', type: 'popout', instructions: ` `, notes: 'Captures new rendered chat messages. Viewer counts require Show viewer count or Hype Mode.' }, { name: 'WebinarGeek', icon: 'webinargeek.png', description: 'Webinar platform.', type: 'standard', instructions: ` ` }, { name: 'uScreen', icon: 'uscreen.png', description: 'Video monetization platform.', type: 'standard', instructions: ` ` }, { name: 'Zap.stream', icon: 'zapstream.png', description: 'Live streaming platform.', type: 'standard', instructions: ` ` }, { name: 'MeetMe', icon: 'meetme.png', description: 'Social discovery app.', type: 'standard', instructions: ` ` }, { name: 'SoopLive', icon: 'sooplive.png', description: 'Streaming platform.', type: 'popout', instructions: ` ` }, { name: 'FLEX TV', icon: 'flextv.png', description: 'Korean live streaming platform.', type: 'standard', instructions: ` `, notes: 'Chat capture is based on the rendered chat panel and starts with new messages after Social Stream attaches.' }, { name: 'Beamstream', icon: 'beamstream.png', description: 'Live streaming platform.', type: 'popout', instructions: ` ` }, { name: 'Seal Team Sloth', icon: 'sealteamsloth.png', description: 'Live streaming platform.', type: 'popout', instructions: ` `, notes: 'Captures new chat messages, emotes, avatars, name colors, user/message IDs, and optional viewer-count updates.' }, { name: 'Castyr', icon: 'castyr.png', description: 'Live streaming platform with standalone pop-out chat.', type: 'popout', instructions: ` `, notes: 'Captures new rendered chat messages, name colors, inline emotes, and optional viewer-count updates.' }, { name: 'CI.ME', icon: 'cime.png', description: 'Korean live streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Castr', icon: 'castr.png', description: 'Live streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Chatroll', icon: 'chatroll.png', description: 'Embeddable chat service.', type: 'standard', instructions: ` ` }, { name: 'Tellonym', icon: 'generic.png', description: 'Anonymous messaging platform.', type: 'standard', instructions: ` ` }, { name: 'LivePush', icon: 'livepush.png', description: 'Multi-platform chat solution.', type: 'standard', instructions: ` ` }, { name: 'MegaphoneTV', icon: 'megaphonetv.png', description: 'Interactive streaming platform.', type: 'standard', instructions: ` ` }, { name: 'NextCloud', icon: 'nextcloud.png', description: 'Self-hosted productivity platform with chat.', type: 'standard', instructions: ` ` }, { name: 'PeerTube', icon: 'peertube.png', description: 'Decentralized video platform.', type: 'standard', instructions: ` ` }, { name: 'Bitchute', icon: 'bitchute.png', description: 'Video hosting platform.', type: 'standard', instructions: ` ` }, { name: 'Buzzit', icon: 'buzzit.png', description: 'Community platform.', type: 'standard', instructions: ` ` }, { name: 'Joystick.tv', icon: 'joystick.png', description: 'Adult streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Rooter', icon: 'rooter.png', description: 'Game streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Loco.gg', icon: 'loco.png', description: 'Game streaming platform.', type: 'standard', instructions: ` `, notes: 'Also works with loco.com domains' }, { name: 'Arena Social', icon: 'arenasocial.png', description: 'Social streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Versus.cam', icon: 'generic.png', description: 'Testing platform.', type: 'standard', instructions: ` ` }, { name: 'Vercel Demo', icon: 'generic.png', description: 'Social Stream Ninja demo.', type: 'standard', instructions: ` ` }, { name: 'CBOX', icon: 'cbox.png', description: 'Embeddable chat service.', type: 'standard', instructions: ` ` }, { name: 'Wix Live', icon: 'wix.png', description: 'Live streaming on Wix websites.', type: 'standard', instructions: ` `, notes: 'Also works with: https://manage.wix.com/dashboard/*/live-video/* and embedded widgets at https://editor.wixapps.net/render/prod/modals/wix-vod-widget/*' }, { name: 'Retake.tv', icon: 'retake.png', description: 'Web3 live streaming platform.', type: 'standard', instructions: ` ` }, { name: 'BoltPlus.tv', icon: 'boltplus.png', description: 'Live streaming platform.', type: 'popout', instructions: ` ` }, { name: 'Velora.tv', icon: 'velora.png', description: 'Live streaming platform.', type: 'standard', instructions: ` ` }, { name: 'RokFin', icon: 'rokfin.png', description: 'Creator-focused content platform.', type: 'popout', instructions: ` ` }, { name: 'Stream.place', icon: 'streamplace.png', description: 'Live streaming platform.', type: 'standard', instructions: ` ` }, { name: 'TradingView Streams', icon: 'tradingview.png', description: 'Financial charting platform with live streams.', type: 'standard', instructions: ` ` }, { name: 'SharePlay.tv', icon: 'shareplay.png', description: 'Live streaming platform.', type: 'standard', instructions: ` ` }, { name: 'CloutHub', icon: 'cloudhub.png', description: 'Social media and community platform.', type: 'standard', instructions: ` ` }, { name: 'Slido', icon: 'slido.png', description: 'Audience interaction and Q&A platform for webinars.', type: 'standard', instructions: ` ` }, { name: 'YouNow', icon: 'younow.png', description: 'Live broadcasting platform.', type: 'standard', instructions: ` ` }, { name: 'QuickChannel', icon: 'quickchannel.png', description: 'Live streaming and video platform.', type: 'standard', instructions: ` ` }, { name: 'Instafeed', icon: 'generic.png', description: 'Instagram feed aggregation service.', type: 'standard', instructions: ` ` }, { name: 'Patreon', icon: 'patreon.png', description: 'Creator subscription and membership platform.', type: 'toggle', instructions: ` ` }, { name: 'Minnit Chat', icon: 'minnit.png', description: 'Embeddable chat service.', type: 'standard', instructions: ` ` }, { name: 'LFG.tv', icon: 'lfg.png', description: 'Gaming and esports streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Kwai Studio', icon: 'kwai.png', description: 'Kwai live-streaming studio chat and activity feed.', type: 'standard', instructions: ` ` }, { name: 'Livestream.com', icon: 'livestream.png', description: 'Live video and event streaming platform.', type: 'standard', instructions: ` ` }, { name: 'Open Streaming Platform', icon: 'openstreamingplatform.png', description: 'Open-source video streaming platform with chat-only views.', type: 'popout', instructions: ` ` }, { name: 'StreamElements', icon: 'streamelements.png', description: 'Overlay relay for tips, subscriptions, follows, and other alerts.', type: 'standard', instructions: ` ` }, { name: 'TikFinity Activity Feed', icon: 'tikfinity.png', description: 'Read-only TikTok chat and event ingestion through TikFinity.', type: 'standard', instructions: ` `, notes: 'Events are normalized as TikTok events; chat send-back is not supported through this feed.' }, { name: 'XP Sync', icon: 'xpsync.png', description: 'Live chat platform with replies, follows, Sparks, and viewer updates.', type: 'standard', instructions: ` ` }, { name: 'Streamlabs', icon: 'streamlabs.png', description: 'Alert box relay for donations, subs, follows, and more.', type: 'websocket', instructions: ` `, notes: 'Send a test alert from Streamlabs to verify events are flowing.' } ]; // Sort sites alphabetically sitesData.sort((a, b) => a.name.localeCompare(b.name)); const sitesGrid = document.getElementById('sitesGrid'); const siteSearch = document.getElementById('siteSearch'); const siteModal = document.getElementById('siteModal'); const modalClose = document.querySelector('.close-modal'); // Populate sites grid function populateSites(sites) { sitesGrid.innerHTML = ''; sites.forEach(site => { const siteItem = document.createElement('div'); siteItem.className = 'site-item'; siteItem.setAttribute('data-site', site.name); // Add tag based on type let tagText = ''; let tagClass = ''; switch(site.type) { case 'popout': tagText = 'Popout'; tagClass = 'popout'; break; case 'websocket': tagText = 'WebSocket'; tagClass = 'websocket'; break; case 'toggle': tagText = 'Toggle Required'; tagClass = 'toggle'; break; case 'manual': tagText = 'Manual Selection'; tagClass = 'manual'; break; } if(tagText) { const tag = document.createElement('span'); tag.className = `site-item-tag ${tagClass}`; tag.textContent = tagText; siteItem.appendChild(tag); } // Site content siteItem.innerHTML += ` ${site.name}

${site.name}

`; // Event listener for opening modal siteItem.addEventListener('click', () => openSiteModal(site)); sitesGrid.appendChild(siteItem); }); } // Search functionality siteSearch.addEventListener('input', () => { const searchValue = siteSearch.value.toLowerCase(); if(searchValue === '') { populateSites(sitesData); return; } const filteredSites = sitesData.filter(site => site.name.toLowerCase().includes(searchValue) || site.description.toLowerCase().includes(searchValue) ); populateSites(filteredSites); }); // Open site modal function openSiteModal(site) { // Set modal content const modalIcon = document.getElementById('modalIcon'); modalIcon.onerror = function() { this.onerror = null; this.src = '../sources/images/generic.png'; }; modalIcon.src = `../sources/images/${site.icon}`; document.getElementById('modalTitle').textContent = site.name; document.getElementById('modalDescription').textContent = site.description; document.getElementById('modalInstructions').innerHTML = site.instructions; // Set notes if they exist const modalNotes = document.getElementById('modalNotes'); if(site.notes) { modalNotes.innerHTML = `

Additional Notes:

${site.notes}

`; modalNotes.classList.remove('hidden'); } else { modalNotes.classList.add('hidden'); } // Show modal siteModal.classList.add('show'); document.body.style.overflow = 'hidden'; } // Close modal modalClose.addEventListener('click', () => { siteModal.classList.remove('show'); document.body.style.overflow = ''; }); // Close modal on click outside window.addEventListener('click', (e) => { if(e.target === siteModal) { siteModal.classList.remove('show'); document.body.style.overflow = ''; } }); // Close modal on escape key document.addEventListener('keydown', (e) => { if(e.key === 'Escape' && siteModal.classList.contains('show')) { siteModal.classList.remove('show'); document.body.style.overflow = ''; } }); // Initial population of sites populateSites(sitesData); });