Welcome!
-Your local network services portal.
-diff --git a/assets/css/style.css b/assets/css/style.css index 955a2d8..ef0a382 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -232,6 +232,7 @@ a { /* ── Service Cards ── */ .service-card { + position: relative; display: flex; flex-direction: column; align-items: center; @@ -405,6 +406,248 @@ a { font-style: normal; } +/* ── Card Status Dots ── */ +.card-status-dot { + position: absolute; + top: 10px; + right: 10px; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-muted); + opacity: 0.3; + transition: background 0.3s, opacity 0.3s, box-shadow 0.3s; +} + +.card-status-dot.loading { + background: var(--yellow); + opacity: 1; + box-shadow: 0 0 6px var(--yellow); + animation: pulse 1.5s ease-in-out infinite; +} + +.card-status-dot.ok { + background: var(--green); + opacity: 1; + box-shadow: 0 0 6px var(--green); + animation: none; +} + +.card-status-dot.error { + background: var(--red); + opacity: 1; + box-shadow: 0 0 6px var(--red); + animation: none; +} + +.card-status-dot.unknown { + background: var(--text-muted); + opacity: 0.3; + box-shadow: none; + animation: none; +} + +/* ── Search Overlay ── */ +.search-overlay { + position: fixed; + inset: 0; + z-index: 200; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 15vh; +} + +.search-overlay.hidden { + display: none; +} + +.search-container { + width: 100%; + max-width: 560px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 16px 64px rgba(0, 0, 0, 0.5); + overflow: hidden; +} + +.search-input-wrap { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} + +.search-input-wrap > i { + font-size: 20px; + color: var(--text-muted); + flex-shrink: 0; +} + +.search-input-wrap input { + flex: 1; + background: transparent; + border: none; + outline: none; + font-size: 16px; + font-family: inherit; + color: var(--text); +} + +.search-input-wrap input::placeholder { + color: var(--text-muted); + opacity: 0.6; +} + +.search-input-wrap kbd { + padding: 2px 8px; + font-size: 12px; + font-family: inherit; + color: var(--text-muted); + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--border); + border-radius: 4px; + flex-shrink: 0; +} + +.search-results { + max-height: 320px; + overflow-y: auto; +} + +.search-result-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 20px; + cursor: pointer; + transition: background 0.15s; +} + +.search-result-item:hover, +.search-result-item.active { + background: rgba(255, 196, 81, 0.08); +} + +.search-result-item i { + font-size: 18px; + color: var(--accent); + flex-shrink: 0; +} + +.search-result-item span { + font-size: 14px; + font-weight: 500; + color: var(--text); +} + +.search-result-empty { + padding: 24px 20px; + text-align: center; + font-size: 14px; + color: var(--text-muted); +} + +/* ── Search Card Visibility ── */ +.search-hidden { + display: none !important; +} + +.search-highlight { + border-color: var(--accent) !important; + box-shadow: 0 0 12px var(--accent-glow) !important; +} + +/* ── Nav Search Button ── */ +.nav-search-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 14px; + border-radius: var(--radius-sm); + font-size: 16px; + color: var(--text-muted); + background: none; + border: none; + cursor: pointer; + transition: color 0.2s, background 0.2s; + font-family: inherit; +} + +.nav-search-btn:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.06); +} + +/* ── Hero Widget ── */ +.hero-greeting { + font-size: clamp(16px, 2.5vw, 20px); + color: var(--text-muted); + margin-bottom: 4px; +} + +.hero-clock { + font-size: clamp(36px, 6vw, 64px); + font-weight: 700; + color: var(--text-heading); + font-variant-numeric: tabular-nums; + letter-spacing: 2px; + line-height: 1.1; + margin-bottom: 4px; +} + +.hero-date { + font-size: clamp(14px, 2vw, 16px); + color: var(--text-muted); + margin-bottom: 12px; +} + +.hero-weather { + font-size: 14px; + color: var(--text-muted); + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.hero-weather i { + font-size: 16px; + color: var(--accent); +} + +/* ── Spin animation for loading icon ── */ +.ri-spin { + display: inline-block; + animation: riSpin 1s linear infinite; +} + +@keyframes riSpin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* ── Mobile Adjustments ── */ +@media (max-width: 768px) { + .search-overlay { + padding: 8vh 16px 0; + } + + .search-input-wrap kbd { + display: none; + } + + .nav-search-btn { + padding: 14px 16px; + font-size: 18px; + } +} + /* ── Reduced Motion ── */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { diff --git a/assets/js/main.js b/assets/js/main.js index baa1407..2526f30 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,105 +1,534 @@ (() => { 'use strict'; - // ── Mobile Nav Toggle ── - const navToggle = document.getElementById('nav-toggle'); - const nav = document.getElementById('nav'); + // ── Config Loader ── + async function loadConfig() { + const res = await fetch('/services.json'); + if (!res.ok) throw new Error(`Failed to load services.json: ${res.status}`); + return res.json(); + } - navToggle.addEventListener('click', () => { - const open = nav.classList.toggle('open'); - navToggle.setAttribute('aria-expanded', open); - navToggle.querySelector('i').className = open ? 'ri-close-line' : 'ri-menu-line'; - }); - - // Close nav on link click (mobile) - nav.querySelectorAll('a').forEach(link => { - link.addEventListener('click', () => { - nav.classList.remove('open'); - navToggle.setAttribute('aria-expanded', 'false'); - navToggle.querySelector('i').className = 'ri-menu-line'; + // ── Nav Renderer ── + function renderNav(config) { + const nav = document.getElementById('nav'); + config.categories.filter(c => c.showInNav).forEach(cat => { + const a = document.createElement('a'); + a.href = `#${cat.id}`; + a.textContent = cat.navLabel; + nav.appendChild(a); }); - }); - // ── Header Scroll Effect ── - const header = document.getElementById('header'); - let ticking = false; + // Search button in nav + const btn = document.createElement('button'); + btn.className = 'nav-search-btn'; + btn.setAttribute('aria-label', 'Search services'); + btn.innerHTML = ''; + btn.addEventListener('click', () => SearchOverlay.open()); + nav.appendChild(btn); + } - window.addEventListener('scroll', () => { - if (!ticking) { - requestAnimationFrame(() => { - header.classList.toggle('scrolled', window.scrollY > 50); - ticking = false; + // ── Services Renderer ── + function renderServices(config) { + const container = document.getElementById('services-container'); + const grouped = {}; + config.categories.forEach(cat => { grouped[cat.id] = []; }); + config.services.forEach(svc => { + if (grouped[svc.category]) grouped[svc.category].push(svc); + }); + + config.categories.forEach(cat => { + const services = grouped[cat.id]; + if (!services || services.length === 0) return; + + const section = document.createElement('section'); + section.id = cat.id; + section.className = 'category'; + + const inner = document.createElement('div'); + inner.className = 'container'; + + const title = document.createElement('h2'); + title.className = 'section-title'; + title.innerHTML = ` ${cat.title}`; + + const grid = document.createElement('div'); + grid.className = 'card-grid'; + + services.forEach((svc, i) => { + const card = document.createElement('a'); + card.href = svc.url; + card.className = 'service-card'; + card.dataset.name = svc.name.toLowerCase(); + card.dataset.category = cat.id; + card.style.animationDelay = `${(i + 1) * 0.02}s`; + + // Status dot + const dot = document.createElement('span'); + dot.className = 'card-status-dot unknown'; + card.appendChild(dot); + + const icon = document.createElement('i'); + icon.className = svc.icon; + card.appendChild(icon); + + const name = document.createElement('span'); + name.className = 'card-name'; + name.textContent = svc.name; + card.appendChild(name); + + if (svc.sub) { + const sub = document.createElement('span'); + sub.className = 'card-sub'; + sub.textContent = svc.sub; + card.appendChild(sub); + } + + grid.appendChild(card); }); - ticking = true; - } - }); + + inner.appendChild(title); + inner.appendChild(grid); + section.appendChild(inner); + container.appendChild(section); + }); + } + + // ── Status Bar Renderer ── + function renderStatusBar(config) { + const grid = document.getElementById('status-grid'); + config.statusMonitors.forEach(mon => { + const card = document.createElement('a'); + card.href = mon.url; + card.className = 'status-card'; + card.id = mon.id; + + card.innerHTML = ` +
+Loading weather...
`; + } + + updateTime() { + const now = new Date(); + const hour = now.getHours(); + + let greeting; + if (hour < 5) greeting = 'Good Night'; + else if (hour < 12) greeting = 'Good Morning'; + else if (hour < 17) greeting = 'Good Afternoon'; + else if (hour < 21) greeting = 'Good Evening'; + else greeting = 'Good Night'; + + const greetEl = document.getElementById('hero-greeting'); + const clockEl = document.getElementById('hero-clock'); + const dateEl = document.getElementById('hero-date'); + + if (greetEl) greetEl.textContent = greeting; + if (clockEl) clockEl.textContent = now.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + if (dateEl) dateEl.textContent = now.toLocaleDateString('en-GB', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); + } + + async fetchWeather() { + const weatherEl = document.getElementById('hero-weather'); + if (!weatherEl) return; + + try { + const res = await fetch(`https://wttr.in/${encodeURIComponent(this.city)}?format=j1`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + + const current = data.current_condition?.[0]; + if (!current) throw new Error('No data'); + + const temp = current.temp_C; + const desc = current.weatherDesc?.[0]?.value || ''; + const code = parseInt(current.weatherCode, 10); + const iconClass = this.weatherIcon(code); + + weatherEl.innerHTML = ` ${temp}°C — ${desc}`; + this.weatherData = current; + } catch { + weatherEl.innerHTML = ` Weather unavailable`; + } + } + + weatherIcon(code) { + // wttr.in weather codes to Remix Icon + if (code === 113) return 'ri-sun-line'; // Clear/Sunny + if (code === 116) return 'ri-sun-cloudy-line'; // Partly cloudy + if (code === 119 || code === 122) return 'ri-cloudy-line'; // Cloudy / Overcast + if (code === 143 || code === 248 || code === 260) return 'ri-mist-line'; // Fog/Mist + if ([176, 263, 266, 293, 296, 299, 302, 305, 308, 353, 356, 359].includes(code)) return 'ri-rainy-line'; // Rain + if ([179, 182, 185, 227, 230, 323, 326, 329, 332, 335, 338, 368, 371, 374, 377].includes(code)) return 'ri-snowy-line'; // Snow + if ([200, 386, 389, 392, 395].includes(code)) return 'ri-thunderstorms-line'; // Thunder + if ([281, 284, 311, 314, 317, 320, 350, 362, 365].includes(code)) return 'ri-drizzle-line'; // Sleet/Freezing + return 'ri-cloudy-line'; + } + } + + // ── Mobile Nav Toggle (event delegation for dynamic links) ── + function initNavToggle() { + const navToggle = document.getElementById('nav-toggle'); + const nav = document.getElementById('nav'); + + navToggle.addEventListener('click', () => { + const open = nav.classList.toggle('open'); + navToggle.setAttribute('aria-expanded', open); + navToggle.querySelector('i').className = open ? 'ri-close-line' : 'ri-menu-line'; + }); + + // Event delegation for dynamically added nav links + nav.addEventListener('click', (e) => { + if (e.target.closest('a')) { + nav.classList.remove('open'); + navToggle.setAttribute('aria-expanded', 'false'); + navToggle.querySelector('i').className = 'ri-menu-line'; + } + }); + } + + // ── Header Scroll Effect ── + function initScrollEffect() { + const header = document.getElementById('header'); + let ticking = false; + + window.addEventListener('scroll', () => { + if (!ticking) { + requestAnimationFrame(() => { + header.classList.toggle('scrolled', window.scrollY > 50); + ticking = false; + }); + ticking = true; + } + }); + } + + // ── Init ── + async function init() { + // These work independently of config + initNavToggle(); + initScrollEffect(); + + try { + const config = await loadConfig(); + + renderNav(config); + renderStatusBar(config); + renderServices(config); + + new StatusMonitor(config); + SearchOverlay.init(config); + new HeroWidget(config); + } catch (err) { + console.error('Failed to initialize dashboard:', err); + const container = document.getElementById('services-container'); + if (container) { + container.innerHTML = ` +Could not load services.json. Check the console for details.
+Your local network services portal.
-