/* ============================================ PINHO-NETO Dashboard — app.js Apple-inspired interactions Clock · Status checks · Search filter · Bottom tab bar · Loading skeleton · Responsive desktop/mobile rendering ============================================ */ (function () { 'use strict'; /* ---------- Service catalogue ---------- */ var CATEGORIES = [ { name: 'Media', services: [ { name: 'Plex Media Server', desc: 'Streaming de filmes, séries e música.', url: 'http://pinhoneto.duckdns.org:32400/web', accent: '#e5a00d', icon: '🎬' }, { name: 'qBittorrent', desc: 'Gestão de descarregamentos BitTorrent.', url: 'http://pinhoneto.duckdns.org:9080', accent: '#2ec5e3', icon: '🧲' }, { name: 'Jackett', desc: 'Indexadores de torrents como API.', url: 'http://pinhoneto.duckdns.org:9117', accent: '#f97316', icon: '🧥' } ] }, { name: 'Nuvem & Produtividade', services: [ { name: 'Nextcloud', desc: 'Nuvem pessoal: ficheiros, calendário e contactos.', url: 'https://papaai.duckdns.org', accent: '#0a84ff', icon: '☁️' }, { name: 'Vaultwarden', desc: 'Gestor de palavras-passe compatível com Bitwarden.', url: 'https://papaai.duckdns.org:8443', accent: '#bf5af2', icon: '🔐' }, { name: 'Portfolio', desc: 'Site pessoal e portfólio de Paulo Pinho.', url: 'https://pgpinho.duckdns.org', accent: '#ff375f', icon: '🖌️' } ] }, { name: 'Monitorização', services: [ { name: 'Uptime Kuma', desc: 'Monitorização de disponibilidade de serviços.', url: 'https://pgpinho.duckdns.org:9443', accent: '#30d158', icon: '📊' }, { name: 'Hermes Dashboard', desc: 'Painel do agente Hermes — automação e IA.', url: 'https://pgpinho.duckdns.org/hermes/', accent: '#ff9f0a', icon: '🤖' } ] }, { name: 'Inteligência Artificial', services: [ { name: 'Open WebUI', desc: 'Interface web para modelos de IA locais.', url: 'https://pgpinho.duckdns.org/openwebui/', accent: '#64d2ff', icon: '🧠' } ] }, { name: 'Ficheiros', services: [ { name: 'Samba / SMB', desc: 'Partilha de ficheiros na rede local.', url: 'smb://pinhoneto.duckdns.org', accent: '#ffd60a', icon: '📁', noCheck: true } ] } ]; /* ---------- Flatten for search ---------- */ var ALL = CATEGORIES.reduce(function (acc, cat) { cat.services.forEach(function (s) { s._cat = cat.name; acc.push(s); }); return acc; }, []); /* ---------- State ---------- */ var isMobile = window.innerWidth < 768; var currentTab = 'inicio'; var searchQuery = ''; var statusCache = {}; /* ---------- DOM refs ---------- */ var grid = document.getElementById('services'); var noResults = document.getElementById('no-results'); var searchInput = document.getElementById('search'); var clockTime = document.getElementById('clock-time'); var clockDate = document.getElementById('clock-date'); var skeleton = document.getElementById('skeleton'); var aboutSection = document.getElementById('aboutSection'); var tabBar = document.getElementById('tabBar'); var heroSection = document.querySelector('.hero'); var searchSection = document.querySelector('.search-section'); /* ---------- Icon helpers ---------- */ function linkIcon() { return ''; } function chevronSVG() { return ''; } function dotHTML(url, noCheck) { if (noCheck) { return '—'; } if (statusCache[url]) { var c = statusCache[url]; return '' + c.label + ''; } return 'a verificar…'; } /* ---------- Tab bar handling ---------- */ tabBar.querySelectorAll('.tab-item').forEach(function (btn) { btn.addEventListener('click', function () { switchTab(btn.getAttribute('data-tab')); }); }); function switchTab(tab) { currentTab = tab; tabBar.querySelectorAll('.tab-item').forEach(function (b) { var active = b.getAttribute('data-tab') === tab; b.classList.toggle('active', active); }); render(); } /* ---------- Main render ---------- */ function render() { // Hide skeleton once we're rendering real content if (skeleton) skeleton.hidden = true; // About tab if (currentTab === 'acerca') { grid.hidden = true; noResults.hidden = true; if (heroSection) heroSection.hidden = true; if (aboutSection) aboutSection.hidden = false; return; } if (aboutSection) aboutSection.hidden = true; grid.hidden = false; // Início tab on desktop shows hero; Serviços tab always shows services if (heroSection && !isMobile) { heroSection.hidden = (currentTab === 'servicos'); } grid.innerHTML = ''; // Determine visible services var q = searchQuery; var visible; if (q) { visible = ALL.filter(function (s) { return (s.name + ' ' + s.desc + ' ' + s._cat).toLowerCase().indexOf(q) > -1; }); noResults.hidden = !(q && visible.length === 0); } else { noResults.hidden = true; } if (q) { // Flat list when searching var frag = document.createDocumentFragment(); visible.forEach(function (s, i) { frag.appendChild(card(s, i)); }); grid.appendChild(frag); } else { // Grouped by category CATEGORIES.forEach(function (cat) { var lbl = document.createElement('div'); lbl.className = 'cat-label'; lbl.textContent = cat.name; grid.appendChild(lbl); cat.services.forEach(function (s, i) { grid.appendChild(card(s, i)); }); }); } checkStatuses(); } /* ---------- Card factory ---------- */ function card(s, i) { var el = document.createElement('a'); el.className = 'card'; el.style.animationDelay = (i * 40) + 'ms'; el.style.setProperty('--accent', s.accent); el.setAttribute('data-name', s.name.toLowerCase()); el.setAttribute('href', s.url); el.setAttribute('target', '_blank'); el.setAttribute('rel', 'noopener noreferrer'); var statusHTML = dotHTML(s.url, s.noCheck); // Desktop: icon + status on top, body below, link at bottom // Mobile (CSS handles layout): row layout with chevron el.innerHTML = '
' + s.desc + '
' + '