From 3b09920eeed1b0252b6c287201f05190ca7e8daa Mon Sep 17 00:00:00 2001 From: MayaChat Date: Mon, 24 Nov 2025 01:32:16 -0500 Subject: [PATCH] Add JavaScript modules for enhanced functionality and dynamic content loading --- docker-compose.yml | 2 + index.html | 464 +--------------------------------------- js/galaxy-background.js | 149 +++++++++++++ js/keyboard-nav.js | 54 +++++ js/readme-loader.js | 13 ++ js/search.js | 58 +++++ js/services-loader.js | 202 +++++++++++++++++ 7 files changed, 485 insertions(+), 457 deletions(-) create mode 100644 js/galaxy-background.js create mode 100644 js/keyboard-nav.js create mode 100644 js/readme-loader.js create mode 100644 js/search.js create mode 100644 js/services-loader.js diff --git a/docker-compose.yml b/docker-compose.yml index 3cf819c..9dc3253 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,8 @@ services: - ./services.xml:/usr/share/nginx/html/services.xml:ro - ./styles.css:/usr/share/nginx/html/styles.css:ro - ./logos:/usr/share/nginx/html/logos:ro + - ./js:/usr/share/nginx/html/js:ro + - ./README.md:/usr/share/nginx/html/README.md:ro restart: unless-stopped logging: driver: json-file diff --git a/index.html b/index.html index fb634c9..5367af5 100644 --- a/index.html +++ b/index.html @@ -33,462 +33,12 @@ - - + + + + + + + diff --git a/js/galaxy-background.js b/js/galaxy-background.js new file mode 100644 index 0000000..cdf1ae3 --- /dev/null +++ b/js/galaxy-background.js @@ -0,0 +1,149 @@ +// Galaxy background - moonlit terrain with floating stars +(function initGalaxy(){ + console.clear(); + + var ww = window.innerWidth, + wh = window.innerHeight; + + var renderer = new THREE.WebGLRenderer({ + antialias: true, + canvas: document.querySelector('#galaxy-canvas') + }); + renderer.setSize(ww, wh); + renderer.setClearColor(0x001a2d); + + var scene = new THREE.Scene(); + scene.fog = new THREE.Fog(0x001a2d, 80, 140); + + var camera = new THREE.PerspectiveCamera(45, ww/wh, 0.1, 200); + camera.position.x = 70; + camera.position.y = 30; + camera.position.z = 5; + camera.lookAt(new THREE.Vector3()); + + /* LIGHTS */ + var moonLight = new THREE.PointLight(0xffffff, 2, 150); + scene.add(moonLight); + + var moon; + function createMoon() { + var geometry = new THREE.SphereGeometry(8, 32, 32); + var material = new THREE.MeshPhongMaterial({ + color: 0x26fdd9, + shininess: 15, + emissive: 0x2bb2e6, + emissiveIntensity: 0.8 + }); + moon = new THREE.Mesh(geometry, material); + moon.position.x = -9; + moon.position.z = -6.5; + moon.position.y = 1; + moon.rotation.y = -1; + scene.add(moon); + moonLight.position.copy(moon.position); + moonLight.position.y += 4; + var moonLight2 = new THREE.PointLight(0xffffff, 0.6, 150); + scene.add(moonLight2); + moonLight2.position.x += 20; + moonLight2.position.y -= 20; + moonLight2.position.z -= 25; + } + + // Initialize simplex noise + var noise = new SimplexNoise(); + + function createTerrain() { + var geometry = new THREE.PlaneGeometry(150, 150, 120, 120); + var m = new THREE.Matrix4(); + m.makeRotationX(Math.PI * -0.5); + geometry.applyMatrix4(m); + + var positions = geometry.attributes.position; + for(var i = 0; i < positions.count; i++) { + var x = positions.getX(i); + var z = positions.getZ(i); + var ratio = noise.noise3D(x * 0.03, z * 0.03, 0); + positions.setY(i, ratio * 10); + } + positions.needsUpdate = true; + geometry.computeVertexNormals(); + + var material = new THREE.MeshPhongMaterial({ + color: 0x198257, + emissive: 0x032f50 + }); + var plane = new THREE.Mesh(geometry, material); + scene.add(plane); + } + + var stars = new THREE.Group(); + scene.add(stars); + var starsLights = new THREE.Group(); + scene.add(starsLights); + var starsAmount = 20; + + function createStars() { + var geometry = new THREE.SphereGeometry(0.3, 16, 16); + var material = new THREE.MeshBasicMaterial({color: 0xffffff}); + for(var i = 0; i < starsAmount; i++) { + var star = new THREE.Mesh(geometry, material); + star.position.x = (Math.random() - 0.5) * 150; + star.position.z = (Math.random() - 0.5) * 150; + var ratio = noise.noise3D(star.position.x * 0.03, star.position.z * 0.03, 0); + star.position.y = ratio * 10 + 0.3; + stars.add(star); + var velX = (Math.random() + 0.1) * 0.1 * (Math.random() < 0.5 ? -1 : 1); + var velY = (Math.random() + 0.1) * 0.1 * (Math.random() < 0.5 ? -1 : 1); + star.vel = new THREE.Vector2(velX, velY); + var starLight = new THREE.PointLight(0xffffff, 0.8, 3); + starLight.position.copy(star.position); + starLight.position.y += 0.5; + starsLights.add(starLight); + } + } + + function updateStar(star, index) { + if(star.position.x < -75) { + star.position.x = 75; + } + if(star.position.x > 75) { + star.position.x = -75; + } + if(star.position.z < -75) { + star.position.z = 75; + } + if(star.position.z > 75) { + star.position.z = -75; + } + + star.position.x += star.vel.x; + star.position.z += star.vel.y; + var ratio = noise.noise3D(star.position.x * 0.03, star.position.z * 0.03, 0); + star.position.y = ratio * 10 + 0.3; + + starsLights.children[index].position.copy(star.position); + starsLights.children[index].position.y += 0.5; + } + + function render(a) { + requestAnimationFrame(render); + for(var i = 0; i < starsAmount; i++) { + updateStar(stars.children[i], i); + } + renderer.render(scene, camera); + } + + function onResize() { + ww = window.innerWidth; + wh = window.innerHeight; + camera.aspect = ww / wh; + camera.updateProjectionMatrix(); + renderer.setSize(ww, wh); + } + + createMoon(); + createTerrain(); + createStars(); + requestAnimationFrame(render); + window.addEventListener('resize', onResize); +})(); diff --git a/js/keyboard-nav.js b/js/keyboard-nav.js new file mode 100644 index 0000000..bcc6909 --- /dev/null +++ b/js/keyboard-nav.js @@ -0,0 +1,54 @@ +// Keyboard navigation for service cards +(function initKeyboardNav(){ + let selectedIndex = -1; + const searchInput = document.getElementById('search-input'); + + // Wait for services to load + const checkServicesLoaded = setInterval(() => { + if(window.servicesData){ + clearInterval(checkServicesLoaded); + setupKeyboardNav(); + } + }, 100); + + function setupKeyboardNav(){ + const { allServices } = window.servicesData; + + const selectCard = (index) => { + const visibleCards = allServices.filter(card => card.style.display !== 'none'); + if(visibleCards.length === 0) return; + + // Remove previous selection + visibleCards.forEach(card => card.classList.remove('selected')); + + // Update index + if(index < 0) index = visibleCards.length - 1; + if(index >= visibleCards.length) index = 0; + selectedIndex = index; + + // Add selection + visibleCards[selectedIndex].classList.add('selected'); + visibleCards[selectedIndex].scrollIntoView({behavior: 'smooth', block: 'nearest'}); + }; + + document.addEventListener('keydown', (e) => { + if(document.activeElement === searchInput) return; + + const visibleCards = allServices.filter(card => card.style.display !== 'none'); + + if(e.key === 'ArrowRight' || e.key === 'ArrowDown'){ + e.preventDefault(); + selectCard(selectedIndex + 1); + } else if(e.key === 'ArrowLeft' || e.key === 'ArrowUp'){ + e.preventDefault(); + selectCard(selectedIndex - 1); + } else if(e.key === 'Enter' && selectedIndex >= 0){ + e.preventDefault(); + visibleCards[selectedIndex].click(); + } else if(e.key === 'Escape'){ + visibleCards.forEach(card => card.classList.remove('selected')); + selectedIndex = -1; + } + }); + } +})(); diff --git a/js/readme-loader.js b/js/readme-loader.js new file mode 100644 index 0000000..151cc2d --- /dev/null +++ b/js/readme-loader.js @@ -0,0 +1,13 @@ +// Fetch and render README.md +(async function loadReadme(){ + try { + const response = await fetch('/README.md', {cache: 'no-cache'}); + if(!response.ok) throw new Error('README not found'); + const markdown = await response.text(); + const html = marked.parse(markdown); + document.getElementById('readme-content').innerHTML = html; + } catch(err) { + console.error('Error loading README:', err); + document.getElementById('readme-content').innerHTML = '

Documentation unavailable.

'; + } +})(); diff --git a/js/search.js b/js/search.js new file mode 100644 index 0000000..254348a --- /dev/null +++ b/js/search.js @@ -0,0 +1,58 @@ +// Search functionality +(function initSearch(){ + const searchInput = document.getElementById('search-input'); + + // Wait for services to load + const checkServicesLoaded = setInterval(() => { + if(window.servicesData){ + clearInterval(checkServicesLoaded); + setupSearch(); + } + }, 100); + + function setupSearch(){ + const { allServices, container } = window.servicesData; + + searchInput.addEventListener('input', (e) => { + const searchTerm = e.target.value.toLowerCase().trim(); + let visibleCount = 0; + + // Also hide/show group headers + const groupSections = container.querySelectorAll('.service-group'); + + allServices.forEach(card => { + const serviceName = card.dataset.serviceName; + if(serviceName.includes(searchTerm)){ + card.style.display = ''; + visibleCount++; + } else { + card.style.display = 'none'; + } + }); + + // Hide empty groups + groupSections.forEach(section => { + const visibleCards = section.querySelectorAll('.card:not([style*="display: none"])'); + section.style.display = visibleCards.length > 0 ? '' : 'none'; + }); + + // Show message if no results + const existingMsg = container.querySelector('.no-results'); + if(existingMsg) existingMsg.remove(); + if(visibleCount === 0 && searchTerm !== ''){ + const msg = document.createElement('p'); + msg.className = 'notes no-results'; + msg.textContent = `No services found matching "${e.target.value}"`; + container.appendChild(msg); + } + }); + + // Focus search on '/' key + document.addEventListener('keydown', (e) => { + if(e.key === '/' && document.activeElement !== searchInput){ + e.preventDefault(); + searchInput.focus(); + } + }); + } +})(); diff --git a/js/services-loader.js b/js/services-loader.js new file mode 100644 index 0000000..2009309 --- /dev/null +++ b/js/services-loader.js @@ -0,0 +1,202 @@ +// Fetch services.xml and render the service cards with logos. +(async function(){ + const container = document.getElementById('services-container'); + const searchInput = document.getElementById('search-input'); + const host = window.location.hostname; + let allServices = []; // Store all service elements for filtering + + try{ + const res = await fetch('/services.xml', {cache: 'no-cache'}); + if(!res.ok){ throw new Error('Failed to load services.xml'); } + const text = await res.text(); + const parser = new DOMParser(); + const doc = parser.parseFromString(text, 'application/xml'); + + // Check for XML parsing errors + const parseError = doc.querySelector('parsererror'); + if(parseError){ + throw new Error('XML parsing error: ' + parseError.textContent); + } + + // Check if we have groups or just services + const groups = Array.from(doc.getElementsByTagName('group')); + const hasGroups = groups.length > 0; + + console.log('Found', groups.length, 'groups'); + + if(hasGroups){ + // Render grouped services + groups.forEach(group => { + const groupName = group.getAttribute('name') || 'Services'; + const services = Array.from(group.getElementsByTagName('service')); + + console.log('Group:', groupName, 'has', services.length, 'services'); + + if(services.length === 0) return; + + // Create group section + const groupSection = document.createElement('section'); + groupSection.className = 'service-group'; + + const groupHeader = document.createElement('h2'); + groupHeader.className = 'group-header'; + groupHeader.textContent = groupName; + groupSection.appendChild(groupHeader); + + const grid = document.createElement('div'); + grid.className = 'grid'; + + services.forEach(s => { + const card = createServiceCard(s, host, allServices); + grid.appendChild(card); + }); + + groupSection.appendChild(grid); + container.appendChild(groupSection); + }); + } else { + // Fallback: render ungrouped services + const services = Array.from(doc.getElementsByTagName('service')); + if(services.length === 0){ + container.innerHTML = '

No services found in services.xml

'; + return; + } + + const grid = document.createElement('div'); + grid.className = 'grid'; + grid.id = 'services-grid'; + + services.forEach(s => { + const card = createServiceCard(s, host, allServices); + grid.appendChild(card); + }); + + container.appendChild(grid); + } + + // Function to create a service card + function createServiceCard(s, host, allServices) { + const name = s.getAttribute('name') || s.getAttribute('id') || 'unknown'; + const proto = s.getAttribute('proto') || 'http'; + const port = s.getAttribute('port') || ''; + const logo = s.getAttribute('logo') || ''; + const hostAttr = s.getAttribute('host'); // optional public hostname or full URL + const manualStatus = s.getAttribute('status'); // optional: 'online', 'offline', 'maintenance' + const checkHealth = s.getAttribute('check-health') !== 'false'; // default true, set to false to disable + + // Build href: prefer explicit host attribute when present. + let href = ''; + if(hostAttr){ + if(/^https?:\/\//i.test(hostAttr)){ + href = hostAttr; + } else { + const hasPortInHost = /:\d+$/.test(hostAttr); + if(hasPortInHost){ + href = `${proto}://${hostAttr}`; + } else { + href = `https://${hostAttr}`; + } + } + } else { + let portPart = ''; + if(port && !((proto==='http'&&port==='80')||(proto==='https'&&port==='443'))){ portPart = ':'+port; } + href = `${proto}://${host}${portPart}`; + } + + const a = document.createElement('a'); + a.className = 'card'; + a.href = href; + a.target = '_blank'; + a.rel = 'noreferrer'; + a.dataset.serviceName = name.toLowerCase(); // For search filtering + + const img = document.createElement('img'); + img.className = 'logo'; + img.src = logo ? ('/logos/'+logo) : '/logos/default.svg'; + img.alt = name + ' logo'; + + const span = document.createElement('div'); + span.className = 'label'; + span.textContent = `${name}`; + + a.appendChild(img); + a.appendChild(span); + + // Add status indicator - will be updated by health check + const statusDot = document.createElement('span'); + statusDot.className = 'status-dot status-checking'; + statusDot.title = 'Checking status...'; + let currentStatus = 'checking'; + + // If maintenance mode is set manually, use that and skip health check + if(manualStatus === 'maintenance'){ + statusDot.className = 'status-dot status-maintenance'; + statusDot.title = 'Status: maintenance'; + currentStatus = 'maintenance'; + a.appendChild(statusDot); + } else if(checkHealth && href){ + // Show checking indicator initially + a.appendChild(statusDot); + + // Perform health check + (async function performHealthCheck(){ + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout + + const response = await fetch(href, { + method: 'HEAD', + mode: 'no-cors', // Allow cross-origin requests + cache: 'no-cache', + signal: controller.signal + }); + + clearTimeout(timeoutId); + + // In no-cors mode, opaque responses mean the server responded + currentStatus = 'online'; + statusDot.className = 'status-dot status-online'; + statusDot.title = 'Status: online'; + } catch(err) { + // Connection failed or timed out + currentStatus = 'offline'; + statusDot.className = 'status-dot status-offline'; + statusDot.title = 'Status: offline'; + } + })(); + } else if(manualStatus){ + // Use manual status if health check is disabled + statusDot.className = `status-dot status-${manualStatus}`; + statusDot.title = `Status: ${manualStatus}`; + currentStatus = manualStatus; + a.appendChild(statusDot); + } + + // Add info button if both hostname and port are available + if((hostAttr || host) && port){ + const infoBtn = document.createElement('button'); + infoBtn.className = 'info-btn'; + infoBtn.title = 'Connection details'; + infoBtn.innerHTML = 'ⓘ'; + infoBtn.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + const displayHost = hostAttr || host; + const details = `Service: ${name}\nHost: ${displayHost}\nPort: ${port}\nProtocol: ${proto}`; + alert(details); + }; + a.appendChild(infoBtn); + } + + allServices.push(a); + return a; + } + + // Export allServices for use by search and keyboard navigation + window.servicesData = { allServices, container }; + + }catch(err){ + console.error(err); + container.innerHTML = '

Error loading services.xml — see console for details.

'; + } +})();