Initial commit: HA Voice Control MCP server
This commit is contained in:
@@ -0,0 +1,950 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Home Dashboard</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0e14;
|
||||
--surface: #131820;
|
||||
--surface2: #1a202c;
|
||||
--border: #262d38;
|
||||
--text: #cdd6e0;
|
||||
--text-dim: #6b7280;
|
||||
--accent: #5c9eff;
|
||||
--accent2: #7c3aed;
|
||||
--danger: #f85149;
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
--orange: #f0883e;
|
||||
--radius: 10px;
|
||||
--radius-sm: 6px;
|
||||
--transition: 0.15s ease;
|
||||
}
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
background: var(--bg); color: var(--text);
|
||||
min-height: 100vh; display: flex;
|
||||
}
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
|
||||
/* ── Sidebar ──────────────────────────── */
|
||||
.sidebar {
|
||||
width: 220px; background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
padding: 20px 0; flex-shrink: 0;
|
||||
}
|
||||
.sidebar-logo {
|
||||
padding: 0 20px 20px; font-size: 1.1rem;
|
||||
font-weight: 700; letter-spacing: -0.02em;
|
||||
border-bottom: 1px solid var(--border); margin-bottom: 12px;
|
||||
}
|
||||
.sidebar-logo span { color: var(--accent); }
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 20px; cursor: pointer;
|
||||
color: var(--text-dim); transition: var(--transition);
|
||||
font-size: 0.9rem; border-left: 3px solid transparent;
|
||||
user-select: none;
|
||||
}
|
||||
.nav-item:hover { color: var(--text); background: var(--surface2); }
|
||||
.nav-item.active { color: var(--accent); border-left-color: var(--accent); background: rgba(92,158,255,0.06); }
|
||||
.nav-item .icon { font-size: 1.1rem; width: 22px; text-align: center; }
|
||||
.nav-item .badge {
|
||||
margin-left: auto; background: var(--accent);
|
||||
color: #fff; font-size: 0.7rem; padding: 1px 7px;
|
||||
border-radius: 10px; font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Main content ─────────────────────── */
|
||||
.main {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.topbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 28px; background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.topbar h1 { font-size: 1.15rem; font-weight: 600; }
|
||||
.topbar-right { display: flex; gap: 16px; align-items: center; font-size: 0.85rem; color: var(--text-dim); }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--success); }
|
||||
.content {
|
||||
flex: 1; overflow-y: auto; padding: 24px 28px;
|
||||
}
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
/* ── Cards / Widgets ──────────────────── */
|
||||
.card {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 18px 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.card-header h3 { font-size: 0.95rem; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.card-header .count { font-size: 0.8rem; color: var(--text-dim); }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
||||
|
||||
/* ── Quick link tiles ─────────────────── */
|
||||
.quick-link {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 14px 16px; background: var(--surface2);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
cursor: pointer; transition: var(--transition);
|
||||
text-decoration: none; color: var(--text);
|
||||
}
|
||||
.quick-link:hover { border-color: var(--accent); background: rgba(92,158,255,0.06); transform: translateY(-1px); }
|
||||
.quick-link .ql-icon { font-size: 1.8rem; }
|
||||
.quick-link .ql-info { flex: 1; min-width: 0; }
|
||||
.quick-link .ql-title { font-size: 0.9rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.quick-link .ql-url { font-size: 0.75rem; color: var(--text-dim); }
|
||||
|
||||
/* ── Buttons ──────────────────────────── */
|
||||
.btn {
|
||||
padding: 7px 16px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border); background: var(--surface2);
|
||||
color: var(--text); cursor: pointer; font-size: 0.85rem;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.btn:hover { border-color: var(--accent); }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn-danger { border-color: var(--danger); color: var(--danger); }
|
||||
.btn-sm { padding: 4px 10px; font-size: 0.78rem; }
|
||||
|
||||
/* ── Forms ────────────────────────────── */
|
||||
input, textarea, select {
|
||||
padding: 8px 12px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border); background: var(--surface2);
|
||||
color: var(--text); font-size: 0.85rem; font-family: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none; border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(92,158,255,0.1);
|
||||
}
|
||||
label { display: block; font-size: 0.8rem; color: var(--text-dim); margin-bottom: 4px; }
|
||||
.form-group { margin-bottom: 14px; }
|
||||
.form-row { display: flex; gap: 12px; }
|
||||
.form-row > * { flex: 1; }
|
||||
|
||||
/* ── Network graph placeholder ────────── */
|
||||
.network-node {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 10px 14px; background: var(--surface2);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
font-size: 0.85rem; cursor: pointer; transition: var(--transition);
|
||||
margin: 4px;
|
||||
}
|
||||
.network-node:hover { border-color: var(--accent); }
|
||||
.network-node .status { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.network-node .status.online { background: var(--success); }
|
||||
.network-node .status.offline { background: var(--text-dim); }
|
||||
.node-ports { font-size: 0.7rem; color: var(--text-dim); margin-top: 2px; }
|
||||
|
||||
/* ── Calendar ─────────────────────────── */
|
||||
.calendar-grid {
|
||||
display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px;
|
||||
}
|
||||
.cal-day-header {
|
||||
text-align: center; font-size: 0.75rem; color: var(--text-dim);
|
||||
padding: 8px 0; font-weight: 600;
|
||||
}
|
||||
.cal-day {
|
||||
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
|
||||
border-radius: var(--radius-sm); cursor: pointer;
|
||||
font-size: 0.85rem; transition: var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
.cal-day:hover { background: var(--surface2); }
|
||||
.cal-day.today { border: 2px solid var(--accent); font-weight: 700; }
|
||||
.cal-day.has-event::after {
|
||||
content: ''; position: absolute; bottom: 4px;
|
||||
width: 5px; height: 5px; border-radius: 50%; background: var(--accent);
|
||||
}
|
||||
.cal-day.other-month { color: var(--border); }
|
||||
.event-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 14px; border-left: 3px solid var(--accent);
|
||||
background: var(--surface2); border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.event-time { font-size: 0.78rem; color: var(--text-dim); min-width: 50px; }
|
||||
.event-title { font-size: 0.88rem; }
|
||||
|
||||
/* ── Password list ────────────────────── */
|
||||
.pw-item {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 14px; border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); margin-bottom: 6px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.pw-item:hover { border-color: var(--accent); }
|
||||
.pw-icon { font-size: 1.3rem; }
|
||||
.pw-info { flex: 1; }
|
||||
.pw-title { font-size: 0.88rem; font-weight: 500; }
|
||||
.pw-user { font-size: 0.75rem; color: var(--text-dim); }
|
||||
.pw-actions { display: flex; gap: 6px; }
|
||||
|
||||
/* ── Files table ──────────────────────── */
|
||||
.file-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
.file-table th {
|
||||
text-align: left; padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-dim); font-weight: 600; font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.file-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
||||
.file-table tr:hover td { background: var(--surface2); }
|
||||
|
||||
/* ── Photo grid ───────────────────────── */
|
||||
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; }
|
||||
.photo-thumb {
|
||||
aspect-ratio: 1; background: var(--surface2);
|
||||
border-radius: var(--radius-sm); overflow: hidden;
|
||||
cursor: pointer; transition: var(--transition); position: relative;
|
||||
}
|
||||
.photo-thumb:hover { transform: scale(1.03); }
|
||||
.photo-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.photo-face-badge {
|
||||
position: absolute; top: 6px; right: 6px;
|
||||
background: var(--accent2); color: #fff;
|
||||
font-size: 0.7rem; padding: 2px 8px; border-radius: 10px;
|
||||
}
|
||||
|
||||
/* ── Modal ────────────────────────────── */
|
||||
.modal-overlay {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.7); z-index: 100;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-overlay.show { display: flex; }
|
||||
.modal {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 24px; max-width: 500px;
|
||||
width: 90%; max-height: 80vh; overflow-y: auto;
|
||||
}
|
||||
.modal h2 { margin-bottom: 16px; font-size: 1.1rem; }
|
||||
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
|
||||
|
||||
/* ── Responsive ───────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { width: 56px; }
|
||||
.sidebar .nav-item span:not(.icon) { display: none; }
|
||||
.sidebar .badge { display: none; }
|
||||
.sidebar-logo { font-size: 1.3rem; text-align: center; padding: 0 0 16px; }
|
||||
.sidebar-logo span:last-child { display: none; }
|
||||
.grid-2 { grid-template-columns: 1fr; }
|
||||
.content { padding: 16px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ═══════════════ SIDEBAR ═══════════════ -->
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-logo"><span>◈</span> Home<span> Dashboard</span></div>
|
||||
<div class="nav-item active" data-tab="home"><span class="icon">🏠</span><span>Overzicht</span></div>
|
||||
<div class="nav-item" data-tab="network"><span class="icon">🌐</span><span>Netwerk</span><span class="badge" id="net-count">--</span></div>
|
||||
<div class="nav-item" data-tab="systems"><span class="icon">🖥️</span><span>Systemen</span></div>
|
||||
<div class="nav-item" data-tab="config"><span class="icon">⚙️</span><span>Config</span></div>
|
||||
<div class="nav-item" data-tab="control"><span class="icon">🎮</span><span>Control Center</span></div>
|
||||
<div class="nav-item" data-tab="calendar"><span class="icon">📅</span><span>Agenda</span></div>
|
||||
<div class="nav-item" data-tab="files"><span class="icon">📁</span><span>Bestanden</span></div>
|
||||
<div class="nav-item" data-tab="photos"><span class="icon">🖼️</span><span>Foto's</span></div>
|
||||
<div class="nav-item" data-tab="passwords"><span class="icon">🔑</span><span>Wachtwoorden</span></div>
|
||||
<div class="nav-item" data-tab="links"><span class="icon">⚡</span><span>Quick Links</span></div>
|
||||
</nav>
|
||||
|
||||
<!-- ═══════════════ MAIN ═══════════════ -->
|
||||
<div class="main">
|
||||
<div class="topbar">
|
||||
<h1 id="page-title">Overzicht</h1>
|
||||
<div class="topbar-right">
|
||||
<div class="status-dot" id="status-dot"></div>
|
||||
<span id="clock">--:--:--</span>
|
||||
<span id="date-display"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content" id="content">
|
||||
<!-- TAB: Home/Overzicht -->
|
||||
<div class="tab-content active" id="tab-home">
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>📊 Stats</h3></div>
|
||||
<div class="grid-3" id="home-stats"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>📅 Vandaag</h3></div>
|
||||
<div id="home-today-events"><span style="color:var(--text-dim)">Laden...</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>⚡ Quick Links</h3><button class="btn btn-sm" onclick="switchTab('links')">Alle</button></div>
|
||||
<div class="grid-3" id="home-links"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: Netwerk -->
|
||||
<div class="tab-content" id="tab-network">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>🌐 Thuisnetwerk 192.168.1.0/24</h3><span id="net-summary" style="color:var(--text-dim);font-size:0.85rem">Laden...</span></div>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:6px; margin-bottom:16px" id="network-nodes"></div>
|
||||
<div class="card" style="background:var(--surface2);margin-top:8px">
|
||||
<div class="card-header"><h4>📊 Poort-statistieken</h4></div>
|
||||
<div id="port-stats" style="display:flex; flex-wrap:wrap; gap:8px"></div>
|
||||
</div>
|
||||
<div class="card" style="background:var(--surface2);margin-top:8px">
|
||||
<div class="card-header"><h4>📋 Laatste scans</h4></div>
|
||||
<div id="scan-history" style="color:var(--text-dim);font-size:0.85rem"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: Systemen -->
|
||||
<div class="tab-content" id="tab-systems">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>🖥️ Alle Systemen</h3>
|
||||
<div style="display:flex;gap:8px">
|
||||
<select id="sys-filter" onchange="loadSystems()" style="width:auto">
|
||||
<option value="">Alle systemen</option>
|
||||
<option value="Windows">Windows</option>
|
||||
<option value="Linux">Linux</option>
|
||||
<option value="ESP32">ESP32 IoT</option>
|
||||
<option value="UniFi">UniFi</option>
|
||||
<option value="Router">Router/Gateway</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="systems-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: Config -->
|
||||
<div class="tab-content" id="tab-config">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>⚙️ Configuratie & Status</h3></div>
|
||||
<div id="config-content"><span style="color:var(--text-dim)">Laden...</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: Control Center -->
|
||||
<div class="tab-content" id="tab-control">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>💡 Lampen</h3></div>
|
||||
<div class="grid-3" id="lights-grid"><span style="color:var(--text-dim)">Laden...</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: Calendar -->
|
||||
<div class="tab-content" id="tab-calendar">
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 id="cal-month-label"></h3>
|
||||
<div style="display:flex; gap:6px">
|
||||
<button class="btn btn-sm" onclick="calShift(-1)"><</button>
|
||||
<button class="btn btn-sm" onclick="calShift(0)">Vandaag</button>
|
||||
<button class="btn btn-sm" onclick="calShift(1)">></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="calendar-grid" id="calendar-grid"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 id="cal-date-label">Events</h3>
|
||||
<button class="btn btn-sm btn-primary" onclick="showEventModal()">+ Nieuw</button>
|
||||
</div>
|
||||
<div id="event-list"><span style="color:var(--text-dim)">Selecteer een dag</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: Files -->
|
||||
<div class="tab-content" id="tab-files">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>📁 Bestanden</h3>
|
||||
<input type="text" placeholder="Zoeken..." style="width:200px" oninput="loadFiles(this.value)">
|
||||
</div>
|
||||
<div style="overflow-x:auto"><table class="file-table" id="file-table"></table></div>
|
||||
<div style="margin-top:12px; color:var(--text-dim); font-size:0.82rem" id="file-count"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: Photos -->
|
||||
<div class="tab-content" id="tab-photos">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>🖼️ Foto's</h3>
|
||||
<select id="photo-filter" onchange="loadPhotos()" style="width:auto">
|
||||
<option value="">Alle foto's</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="photo-grid" id="photo-grid"><span style="color:var(--text-dim)">Nog geen foto's geindexeerd</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: Passwords -->
|
||||
<div class="tab-content" id="tab-passwords">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>🔑 Opgeslagen wachtwoorden</h3>
|
||||
<div style="display:flex; gap:8px">
|
||||
<input type="text" placeholder="Zoeken..." style="width:180px" oninput="loadPasswords(this.value)">
|
||||
<button class="btn btn-primary btn-sm" onclick="showPasswordModal()">+ Nieuw</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="password-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: Quick Links -->
|
||||
<div class="tab-content" id="tab-links">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>⚡ Favorieten & Quick Links</h3>
|
||||
<button class="btn btn-primary btn-sm" onclick="showLinkModal()">+ Nieuw</button>
|
||||
</div>
|
||||
<div class="grid-3" id="all-links"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════ MODALS ═══════════════ -->
|
||||
<div class="modal-overlay" id="modal-overlay">
|
||||
<div class="modal" id="modal-content"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ═══════════ STATE ═══════════
|
||||
const API = '/api/dashboard';
|
||||
let activeTab = 'home';
|
||||
let calYear, calMonth, calSelectedDate;
|
||||
|
||||
// ═══════════ NAVIGATION ═══════════
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.addEventListener('click', () => switchTab(item.dataset.tab));
|
||||
});
|
||||
|
||||
function switchTab(tab) {
|
||||
activeTab = tab;
|
||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
document.getElementById(`tab-${tab}`)?.classList.add('active');
|
||||
document.getElementById('page-title').textContent =
|
||||
document.querySelector(`.nav-item[data-tab="${tab}"] span:last-child`)?.textContent || tab;
|
||||
loadTab(tab);
|
||||
}
|
||||
|
||||
function loadTab(tab) {
|
||||
switch(tab) {
|
||||
case 'home': loadOverview(); break;
|
||||
case 'network': loadNetwork(); break;
|
||||
case 'systems': loadSystems(); break;
|
||||
case 'config': loadConfig(); break;
|
||||
case 'control': loadLights(); break;
|
||||
case 'calendar': loadCalendar(); break;
|
||||
case 'files': loadFiles(); break;
|
||||
case 'photos': loadPhotos(); break;
|
||||
case 'passwords': loadPasswords(); break;
|
||||
case 'links': loadLinks(); break;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════ CLOCK ═══════════
|
||||
function updateClock() {
|
||||
const now = new Date();
|
||||
document.getElementById('clock').textContent = now.toLocaleTimeString('nl-NL');
|
||||
document.getElementById('date-display').textContent = now.toLocaleDateString('nl-NL', {weekday:'long', day:'numeric', month:'long'});
|
||||
}
|
||||
setInterval(updateClock, 1000); updateClock();
|
||||
|
||||
// ═══════════ OVERVIEW ═══════════
|
||||
async function loadOverview() {
|
||||
try {
|
||||
const r = await fetch(`${API}/overview`); const d = await r.json();
|
||||
document.getElementById('home-stats').innerHTML = `
|
||||
<div class="card" style="text-align:center"><div style="font-size:2rem">⚡</div><div style="font-size:1.5rem;font-weight:700">${d.favorites_count||0}</div><div style="font-size:0.8rem;color:var(--text-dim)">Favorieten</div></div>
|
||||
<div class="card" style="text-align:center"><div style="font-size:2rem">🌐</div><div style="font-size:1.5rem;font-weight:700">${d.network_devices||0}</div><div style="font-size:0.8rem;color:var(--text-dim)">Netwerk Devices</div></div>
|
||||
<div class="card" style="text-align:center"><div style="font-size:2rem">🔑</div><div style="font-size:1.5rem;font-weight:700">${d.password_count||0}</div><div style="font-size:0.8rem;color:var(--text-dim)">Wachtwoorden</div></div>
|
||||
<div class="card" style="text-align:center"><div style="font-size:2rem">🖼️</div><div style="font-size:1.5rem;font-weight:700">${d.photo_count||0}</div><div style="font-size:0.8rem;color:var(--text-dim)">Foto's</div></div>
|
||||
`;
|
||||
// Update sidebar network count
|
||||
document.getElementById('net-count').textContent = d.network_devices || '--';
|
||||
const todayEv = (d.today_events||[]).map(e => `<div class="event-item"><span class="event-time">${e.event_start?.split('T')[1]?.substring(0,5)||'--:--'}</span><span class="event-title">${esc(e.title)}</span></div>`).join('') || '<span style="color:var(--text-dim)">Geen events vandaag</span>';
|
||||
document.getElementById('home-today-events').innerHTML = todayEv;
|
||||
const links = await fetch(`${API}/favorites`).then(r=>r.json());
|
||||
document.getElementById('home-links').innerHTML = (links||[]).slice(0,6).map(l => `<a class="quick-link" href="${esc(l.url)}" target="_blank"><span class="ql-icon">${l.icon||'⭐'}</span><div class="ql-info"><div class="ql-title">${esc(l.title)}</div><div class="ql-url">${esc(l.url)}</div></div></a>`).join('');
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
// ═══════════ NETWORK (live uit Neo4j) ═══════════
|
||||
async function loadNetwork() {
|
||||
try {
|
||||
const r = await fetch(`${API}/network`);
|
||||
if (!r.ok) throw new Error('API niet beschikbaar');
|
||||
const d = await r.json();
|
||||
|
||||
document.getElementById('net-summary').textContent =
|
||||
`${d.summary.total_devices} devices • ${d.summary.total_ports} unieke poorten`;
|
||||
|
||||
// Device nodes
|
||||
document.getElementById('network-nodes').innerHTML = (d.devices||[]).map(dev => {
|
||||
const osIcon = dev.os_guess.includes('Windows') ? '🪟' :
|
||||
dev.os_guess.includes('ESP32') ? '📡' :
|
||||
dev.os_guess.includes('Router') ? '📶' :
|
||||
dev.os_guess.includes('UniFi') ? '📡' :
|
||||
dev.os_guess.includes('Home Assistant') ? '🏠' :
|
||||
dev.os_guess.includes('Synology') ? '🗄️' :
|
||||
dev.os_guess.includes('Linux') ? '🐧' : '💻';
|
||||
return `
|
||||
<div class="network-node" onclick="showSystemDetail('${dev.ip}')" title="Klik voor details">
|
||||
<div class="status online"></div>
|
||||
<div><strong>${osIcon} ${esc(dev.hostname||dev.ip)}</strong><br>
|
||||
<span class="node-ports">${dev.ip} • ${(dev.open_ports||[]).join(', ')||'geen open poorten'}</span></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Poort stats
|
||||
document.getElementById('port-stats').innerHTML = (d.summary.top_ports||[]).map(p => `
|
||||
<div class="network-node" style="cursor:default">
|
||||
<span style="font-weight:600">:${p.port}</span>
|
||||
<span style="font-size:0.75rem;color:var(--text-dim)">${p.count}x</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Scan history
|
||||
document.getElementById('scan-history').innerHTML = (d.scan_history||[]).map(s => `
|
||||
<div style="margin-bottom:4px">📅 ${(s.timestamp||'').substring(0,16).replace('T',' ')} — ${s.id} — ${s.hosts_active} hosts</div>
|
||||
`).join('') || 'Geen scan historie';
|
||||
|
||||
// Update sidebar badge
|
||||
document.getElementById('net-count').textContent = d.summary.total_devices;
|
||||
|
||||
} catch(e) {
|
||||
document.getElementById('network-nodes').innerHTML =
|
||||
'<span style="color:var(--danger)">Kan netwerkdata niet laden. Draait de web server met Neo4j?</span>';
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════ SYSTEMS ═══════════
|
||||
async function loadSystems() {
|
||||
try {
|
||||
const filter = document.getElementById('sys-filter')?.value || '';
|
||||
const url = filter ? `${API}/systems?os=${encodeURIComponent(filter)}` : `${API}/systems`;
|
||||
const systems = await fetch(url).then(r => r.json());
|
||||
|
||||
document.getElementById('systems-list').innerHTML = systems.map(s => {
|
||||
const ports = (s.open_ports||[]).map(p => {
|
||||
let svc = '';
|
||||
const common = {22:'SSH',53:'DNS',80:'HTTP',443:'HTTPS',445:'SMB',3000:'Grafana',3306:'MySQL',3389:'RDP',5000:'DSM',5432:'PostgreSQL',5433:'PostgreSQL',8000:'HTTP',8080:'HTTP',8123:'HA API',8443:'HTTPS',9000:'Portainer',9443:'UniFi',49153:'Neo4j',49154:'Neo4j Browser'};
|
||||
if (common[p]) svc = common[p];
|
||||
return `<span style="background:var(--surface2);padding:2px 8px;border-radius:4px;font-size:0.78rem;margin:2px">:${p}${svc?' '+svc:''}</span>`;
|
||||
}).join('');
|
||||
|
||||
const osIcon = s.os_guess?.includes('Windows') ? '🪟' :
|
||||
s.os_guess?.includes('ESP32') ? '📡' :
|
||||
s.os_guess?.includes('Router') ? '📶' :
|
||||
s.os_guess?.includes('UniFi') ? '📡' :
|
||||
s.os_guess?.includes('Home Assistant') ? '🏠' :
|
||||
s.os_guess?.includes('Synology') ? '🗄️' :
|
||||
s.os_guess?.includes('Stofzuiger') ? '🧹' :
|
||||
s.os_guess?.includes('Linux') ? '🐧' : '💻';
|
||||
|
||||
return `
|
||||
<div class="pw-item" style="cursor:pointer" onclick="showSystemDetail('${s.ip}')">
|
||||
<span style="font-size:1.5rem">${osIcon}</span>
|
||||
<div class="pw-info">
|
||||
<div class="pw-title">${esc(s.hostname||s.ip)} <span style="font-size:0.75rem;color:var(--text-dim)">${s.ip}</span></div>
|
||||
<div style="font-size:0.78rem;color:var(--text-dim)">${esc(s.os_guess||'Onbekend')}${s.mac?' • MAC: '+s.mac:''}</div>
|
||||
<div style="margin-top:4px">${ports||'<span style="color:var(--text-dim)">geen open poorten</span>'}</div>
|
||||
</div>
|
||||
<span style="color:var(--text-dim);font-size:0.75rem">${s.port_count} poorten</span>
|
||||
</div>`;
|
||||
}).join('') || '<span style="color:var(--text-dim)">Geen systemen gevonden</span>';
|
||||
|
||||
} catch(e) {
|
||||
document.getElementById('systems-list').innerHTML =
|
||||
'<span style="color:var(--danger)">Kan systemen niet laden. Is de Neo4j database bereikbaar?</span>';
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════ SYSTEM DETAIL ═══════════
|
||||
async function showSystemDetail(ip) {
|
||||
try {
|
||||
const sys = await fetch(`${API}/systems/${ip}`).then(r => r.json());
|
||||
const portsHtml = (sys.ports||[]).map(p => `
|
||||
<tr><td>:${p.port}</td><td>${p.service||'?'}</td><td style="color:var(--text-dim);font-size:0.8rem">${esc(p.banner||'')}</td></tr>
|
||||
`).join('');
|
||||
|
||||
document.getElementById('modal-content').innerHTML = `
|
||||
<h2>🖥️ ${esc(sys.hostname||sys.ip)}</h2>
|
||||
<div style="display:grid;grid-template-columns:120px 1fr;gap:8px;margin:16px 0;font-size:0.9rem">
|
||||
<div style="color:var(--text-dim)">IP</div><div><strong>${sys.ip}</strong></div>
|
||||
<div style="color:var(--text-dim)">MAC</div><div>${sys.mac||'onbekend'}</div>
|
||||
<div style="color:var(--text-dim)">OS</div><div>${esc(sys.os_guess||'Onbekend')}</div>
|
||||
<div style="color:var(--text-dim)">Eerste scan</div><div>${(sys.first_seen||'').substring(0,16).replace('T',' ')}</div>
|
||||
<div style="color:var(--text-dim)">Laatste scan</div><div>${(sys.last_seen||'').substring(0,16).replace('T',' ')}</div>
|
||||
</div>
|
||||
<h4 style="margin-bottom:8px">Open poorten (${sys.ports?.length||0})</h4>
|
||||
<table class="file-table" style="width:100%">
|
||||
<tr><th>Poort</th><th>Service</th><th>Banner</th></tr>
|
||||
${portsHtml||'<tr><td colspan="3">Geen open poorten</td></tr>'}
|
||||
</table>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" onclick="closeModal()">Sluiten</button>
|
||||
</div>`;
|
||||
document.getElementById('modal-overlay').classList.add('show');
|
||||
} catch(e) {
|
||||
alert('Kan systeem-details niet laden: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════ CONFIG ═══════════
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const cfg = await fetch(`${API}/config`).then(r => r.json());
|
||||
|
||||
const connStatus = (connected) => connected
|
||||
? '<span style="color:var(--success)">● verbonden</span>'
|
||||
: '<span style="color:var(--warning)">○ niet geconfigureerd</span>';
|
||||
|
||||
document.getElementById('config-content').innerHTML = `
|
||||
<div class="grid-2">
|
||||
<div class="card" style="background:var(--surface2)">
|
||||
<h4>🔌 Connecties</h4>
|
||||
<div style="font-size:0.85rem;margin-top:8px">
|
||||
<div style="margin-bottom:6px"><strong>Home Assistant</strong><br>${cfg.connections.home_assistant.url} ${connStatus(cfg.connections.home_assistant.connected)}</div>
|
||||
<div style="margin-bottom:6px"><strong>PostgreSQL</strong><br>${cfg.connections.postgresql.host}:${cfg.connections.postgresql.port}/${cfg.connections.postgresql.database} (${cfg.connections.postgresql.user})</div>
|
||||
<div style="margin-bottom:6px"><strong>Neo4j</strong><br>${cfg.connections.neo4j.uri} ${cfg.connections.neo4j.available ? '<span style="color:var(--success)">● bereikbaar</span>' : '<span style="color:var(--danger)">✗ niet bereikbaar</span>'}<br>
|
||||
<span style="color:var(--text-dim)">${cfg.connections.neo4j.devices} devices • laatste scan: ${(cfg.connections.neo4j.last_scan||'nooit').substring(0,16).replace('T',' ')}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="background:var(--surface2)">
|
||||
<h4>📊 Database Status</h4>
|
||||
<div style="font-size:0.85rem;margin-top:8px;display:grid;grid-template-columns:1fr 1fr;gap:4px">
|
||||
<div>Favorieten:</div><div><strong>${cfg.counts.favorites}</strong></div>
|
||||
<div>Wachtwoorden:</div><div><strong>${cfg.counts.passwords}</strong></div>
|
||||
<div>Agenda events:</div><div><strong>${cfg.counts.calendar_events}</strong></div>
|
||||
<div>Bestanden:</div><div><strong>${cfg.counts.files_indexed}</strong></div>
|
||||
<div>Foto's:</div><div><strong>${cfg.counts.photos_indexed}</strong></div>
|
||||
<div>Netwerk devices:</div><div><strong>${cfg.counts.network_devices}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="background:var(--surface2);margin-top:16px">
|
||||
<h4>⚙️ Settings</h4>
|
||||
<div style="font-size:0.85rem;margin-top:8px">
|
||||
${Object.entries(cfg.settings||{}).map(([k,v]) => `<div style="margin-bottom:4px"><span style="color:var(--text-dim)">${k}:</span> <strong>${esc(v)}</strong></div>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="background:var(--surface2);margin-top:16px">
|
||||
<h4>🖥️ Server</h4>
|
||||
<div style="font-size:0.85rem;margin-top:8px">
|
||||
<div>Host: ${cfg.server.host}:${cfg.server.port}</div>
|
||||
<div>Whisper: ${cfg.server.whisper_mode} (${cfg.server.whisper_model}) op ${cfg.server.whisper_device}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
} catch(e) {
|
||||
document.getElementById('config-content').innerHTML =
|
||||
'<span style="color:var(--danger)">Kan config niet laden.</span>';
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════ LIGHTS ═══════════
|
||||
async function loadLights() {
|
||||
try {
|
||||
const r = await fetch('/api/lights'); const lights = await r.json();
|
||||
document.getElementById('lights-grid').innerHTML = lights.map(l => `
|
||||
<div class="card" style="cursor:pointer" onclick="toggleLight('${l.entity_id}','${l.state==='on'?'turn_off':'turn_on'}')">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<span style="font-size:2rem">${l.state==='on'?'💡':'🌑'}</span>
|
||||
<div style="flex:1">
|
||||
<div style="font-weight:500">${esc(l.friendly_name||l.entity_id)}</div>
|
||||
<div style="font-size:0.8rem;color:var(--text-dim)">${l.state==='on'?'Aan'+(l.brightness?` ${Math.round(l.brightness/2.55)}%`:'') : 'Uit'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('') || '<span style="color:var(--text-dim)">Geen lampen</span>';
|
||||
} catch(e) { document.getElementById('lights-grid').innerHTML = '<span style="color:var(--danger)">Fout bij laden</span>'; }
|
||||
}
|
||||
|
||||
async function toggleLight(entityId, action) {
|
||||
await fetch('/api/light/control', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({entity_id:entityId, action})
|
||||
});
|
||||
setTimeout(loadLights, 300);
|
||||
}
|
||||
|
||||
// ═══════════ CALENDAR ═══════════
|
||||
async function loadCalendar() {
|
||||
const now = new Date();
|
||||
if (!calYear) { calYear = now.getFullYear(); calMonth = now.getMonth(); }
|
||||
const months = ['Jan','Feb','Mrt','Apr','Mei','Jun','Jul','Aug','Sep','Okt','Nov','Dec'];
|
||||
document.getElementById('cal-month-label').textContent = `${months[calMonth]} ${calYear}`;
|
||||
|
||||
const firstDay = new Date(calYear, calMonth, 1).getDay(); // 0=Sun
|
||||
const daysInMonth = new Date(calYear, calMonth+1, 0).getDate();
|
||||
const prevDays = new Date(calYear, calMonth, 0).getDate();
|
||||
|
||||
// Haal events
|
||||
let events = [];
|
||||
try {
|
||||
events = await fetch(`${API}/calendar?days=90`).then(r=>r.json());
|
||||
} catch(e) {}
|
||||
|
||||
const eventDays = new Set(events.map(e => e.event_start?.substring(0,10)));
|
||||
|
||||
let html = '<div class="cal-day-header">Zo</div><div class="cal-day-header">Ma</div><div class="cal-day-header">Di</div><div class="cal-day-header">Wo</div><div class="cal-day-header">Do</div><div class="cal-day-header">Vr</div><div class="cal-day-header">Za</div>';
|
||||
|
||||
const today = new Date().toISOString().substring(0,10);
|
||||
|
||||
for (let i = firstDay-1; i >= 0; i--) {
|
||||
html += `<div class="cal-day other-month">${prevDays - i}</div>`;
|
||||
}
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = `${calYear}-${String(calMonth+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
|
||||
const classes = ['cal-day'];
|
||||
if (dateStr === calSelectedDate) classes.push('selected');
|
||||
if (dateStr === today) classes.push('today');
|
||||
if (eventDays.has(dateStr)) classes.push('has-event');
|
||||
html += `<div class="${classes.join(' ')}" onclick="selectDate('${dateStr}')">${d}</div>`;
|
||||
}
|
||||
const remaining = 42 - (firstDay + daysInMonth);
|
||||
for (let d = 1; d <= remaining; d++) {
|
||||
html += `<div class="cal-day other-month">${d}</div>`;
|
||||
}
|
||||
|
||||
document.getElementById('calendar-grid').innerHTML = html;
|
||||
if (calSelectedDate) selectDate(calSelectedDate);
|
||||
}
|
||||
|
||||
function calShift(dir) {
|
||||
if (dir === 0) { const n=new Date(); calYear=n.getFullYear(); calMonth=n.getMonth(); }
|
||||
else { calMonth += dir; if(calMonth<0){calMonth=11;calYear--;} if(calMonth>11){calMonth=0;calYear++;} }
|
||||
loadCalendar();
|
||||
}
|
||||
|
||||
async function selectDate(dateStr) {
|
||||
calSelectedDate = dateStr;
|
||||
document.getElementById('cal-date-label').textContent = `Events — ${dateStr}`;
|
||||
try {
|
||||
const events = await fetch(`${API}/calendar?days=90`).then(r=>r.json());
|
||||
const dayEvents = events.filter(e => e.event_start?.substring(0,10) === dateStr);
|
||||
document.getElementById('event-list').innerHTML = dayEvents.map(e => `
|
||||
<div class="event-item" style="border-left-color:${e.color||'var(--accent)'}">
|
||||
<span class="event-time">${e.event_start?.split('T')[1]?.substring(0,5)||'--:--'}</span>
|
||||
<span class="event-title">${esc(e.title)}</span>
|
||||
<button class="btn btn-sm btn-danger" style="margin-left:auto" onclick="deleteEvent(${e.id})">x</button>
|
||||
</div>
|
||||
`).join('') || '<span style="color:var(--text-dim)">Geen events op deze dag</span>';
|
||||
} catch(e) {}
|
||||
loadCalendar();
|
||||
}
|
||||
|
||||
function showEventModal() {
|
||||
document.getElementById('modal-content').innerHTML = `
|
||||
<h2>Nieuw event</h2>
|
||||
<div class="form-group"><label>Titel</label><input id="ev-title"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Datum</label><input type="date" id="ev-date" value="${calSelectedDate||new Date().toISOString().substring(0,10)}"></div>
|
||||
<div class="form-group"><label>Tijd</label><input type="time" id="ev-time" value="09:00"></div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" onclick="closeModal()">Annuleren</button>
|
||||
<button class="btn btn-primary" onclick="saveEvent()">Opslaan</button>
|
||||
</div>`;
|
||||
document.getElementById('modal-overlay').classList.add('show');
|
||||
}
|
||||
|
||||
async function saveEvent() {
|
||||
const title = document.getElementById('ev-title').value;
|
||||
const date = document.getElementById('ev-date').value;
|
||||
const time = document.getElementById('ev-time').value;
|
||||
if(!title){alert('Titel is verplicht');return;}
|
||||
await fetch(`${API}/calendar`, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({title, event_start: `${date}T${time}:00`, all_day: false})
|
||||
});
|
||||
closeModal(); loadCalendar();
|
||||
}
|
||||
|
||||
async function deleteEvent(id) {
|
||||
await fetch(`${API}/calendar/${id}`, {method:'DELETE'});
|
||||
loadCalendar(); selectDate(calSelectedDate);
|
||||
}
|
||||
|
||||
// ═══════════ FILES ═══════════
|
||||
async function loadFiles(search='') {
|
||||
try {
|
||||
const r = await fetch(`${API}/files?search=${encodeURIComponent(search)}&limit=100`);
|
||||
const d = await r.json();
|
||||
document.getElementById('file-count').textContent = `${d.total||0} bestanden`;
|
||||
document.getElementById('file-table').innerHTML = `
|
||||
<tr><th>Naam</th><th>Type</th><th>Grootte</th><th>Locatie</th><th>Datum</th></tr>
|
||||
${(d.files||[]).map(f => `
|
||||
<tr>
|
||||
<td>${esc(f.file_name)}</td>
|
||||
<td>${f.file_type||'-'}</td>
|
||||
<td>${formatSize(f.file_size)}</td>
|
||||
<td>${esc(f.source_host||'')}</td>
|
||||
<td>${f.last_modified?.substring(0,10)||'-'}</td>
|
||||
</tr>`).join('')}`;
|
||||
} catch(e) { document.getElementById('file-table').innerHTML = '<tr><td colspan="5">Geen bestanden geindexeerd. Run de file scanner.</td></tr>'; }
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '-';
|
||||
const units = ['B','KB','MB','GB','TB'];
|
||||
let i = 0; while (bytes >= 1024 && i < 4) { bytes /= 1024; i++; }
|
||||
return `${bytes.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
// ═══════════ PHOTOS ═══════════
|
||||
async function loadPhotos() {
|
||||
try {
|
||||
// Laad persons voor filter
|
||||
const persons = await fetch(`${API}/photos/persons`).then(r=>r.json());
|
||||
const sel = document.getElementById('photo-filter');
|
||||
sel.innerHTML = '<option value="">Alle foto\'s</option>' + persons.map(p => `<option value="${p.id}">${esc(p.name)} (${p.photo_count})</option>`).join('');
|
||||
|
||||
const personId = sel.value;
|
||||
const url = personId ? `${API}/photos?person_id=${personId}&limit=50` : `${API}/photos?limit=50`;
|
||||
const photos = await fetch(url).then(r=>r.json());
|
||||
document.getElementById('photo-grid').innerHTML = photos.map(p => `
|
||||
<div class="photo-thumb">
|
||||
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-dim);font-size:0.8rem">
|
||||
📷 ${esc(p.file_name||'')}
|
||||
${p.faces_detected ? `<div class="photo-face-badge">${p.faces_detected} 👤</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('') || '<span style="color:var(--text-dim);grid-column:1/-1">Nog geen foto\'s geindexeerd. Gebruik de file scanner met face recognition.</span>';
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
// ═══════════ PASSWORDS ═══════════
|
||||
async function loadPasswords(search='') {
|
||||
try {
|
||||
const url = search ? `${API}/passwords?search=${encodeURIComponent(search)}` : `${API}/passwords`;
|
||||
const pws = await fetch(url).then(r=>r.json());
|
||||
document.getElementById('password-list').innerHTML = pws.map(p => `
|
||||
<div class="pw-item">
|
||||
<span class="pw-icon">🔐</span>
|
||||
<div class="pw-info">
|
||||
<div class="pw-title">${esc(p.title)}</div>
|
||||
<div class="pw-user">${esc(p.username||'')} ${p.url ? '• '+esc(p.url) : ''}</div>
|
||||
</div>
|
||||
<div class="pw-actions">
|
||||
<button class="btn btn-sm" onclick="copyPassword(${p.id})">📋</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deletePassword(${p.id})">x</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('') || '<span style="color:var(--text-dim)">Geen wachtwoorden</span>';
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function copyPassword(id) {
|
||||
const r = await fetch(`${API}/passwords/${id}`); const d = await r.json();
|
||||
await navigator.clipboard.writeText(d.password);
|
||||
alert('Wachtwoord gekopieerd!');
|
||||
}
|
||||
|
||||
function showPasswordModal() {
|
||||
document.getElementById('modal-content').innerHTML = `
|
||||
<h2>Nieuw wachtwoord</h2>
|
||||
<div class="form-group"><label>Titel</label><input id="pw-title"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Gebruikersnaam</label><input id="pw-user"></div>
|
||||
<div class="form-group"><label>Wachtwoord</label><input type="password" id="pw-pass"></div>
|
||||
</div>
|
||||
<div class="form-group"><label>URL</label><input id="pw-url"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" onclick="closeModal()">Annuleren</button>
|
||||
<button class="btn btn-primary" onclick="savePassword()">Opslaan</button>
|
||||
</div>`;
|
||||
document.getElementById('modal-overlay').classList.add('show');
|
||||
}
|
||||
|
||||
async function savePassword() {
|
||||
const title=document.getElementById('pw-title').value, username=document.getElementById('pw-user').value;
|
||||
const password=document.getElementById('pw-pass').value, url=document.getElementById('pw-url').value;
|
||||
if(!title||!password){alert('Titel en wachtwoord zijn verplicht');return;}
|
||||
await fetch(`${API}/passwords`, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({title,username,password,url})
|
||||
});
|
||||
closeModal(); loadPasswords();
|
||||
}
|
||||
|
||||
async function deletePassword(id) {
|
||||
if(!confirm('Verwijderen?'))return;
|
||||
await fetch(`${API}/passwords/${id}`, {method:'DELETE'});
|
||||
loadPasswords();
|
||||
}
|
||||
|
||||
// ═══════════ QUICK LINKS ═══════════
|
||||
async function loadLinks() {
|
||||
try {
|
||||
const links = await fetch(`${API}/favorites`).then(r=>r.json());
|
||||
const grouped = {}; links.forEach(l => { if(!grouped[l.category])grouped[l.category]=[]; grouped[l.category].push(l); });
|
||||
let html = '';
|
||||
for(const [cat, items] of Object.entries(grouped)) {
|
||||
html += `<div style="grid-column:1/-1;margin-top:12px;color:var(--text-dim);font-size:0.8rem;font-weight:600;text-transform:uppercase">${esc(cat)}</div>`;
|
||||
html += items.map(l => `<a class="quick-link" href="${esc(l.url)}" target="_blank"><span class="ql-icon">${l.icon||'⭐'}</span><div class="ql-info"><div class="ql-title">${esc(l.title)}</div><div class="ql-url">${esc(l.url)}</div></div></a>`).join('');
|
||||
}
|
||||
document.getElementById('all-links').innerHTML = html || '<span style="color:var(--text-dim)">Geen links</span>';
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
function showLinkModal() {
|
||||
document.getElementById('modal-content').innerHTML = `
|
||||
<h2>Nieuwe Quick Link</h2>
|
||||
<div class="form-group"><label>Titel</label><input id="ql-title"></div>
|
||||
<div class="form-group"><label>URL</label><input id="ql-url"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Icoon</label><input id="ql-icon" value="⭐"></div>
|
||||
<div class="form-group"><label>Categorie</label><input id="ql-cat" value="Algemeen"></div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" onclick="closeModal()">Annuleren</button>
|
||||
<button class="btn btn-primary" onclick="saveLink()">Opslaan</button>
|
||||
</div>`;
|
||||
document.getElementById('modal-overlay').classList.add('show');
|
||||
}
|
||||
|
||||
async function saveLink() {
|
||||
const title=document.getElementById('ql-title').value, url=document.getElementById('ql-url').value;
|
||||
const icon=document.getElementById('ql-icon').value, category=document.getElementById('ql-cat').value;
|
||||
if(!title||!url){alert('Titel en URL zijn verplicht');return;}
|
||||
await fetch(`${API}/favorites`, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({title,url,icon,category})
|
||||
});
|
||||
closeModal(); loadLinks();
|
||||
}
|
||||
|
||||
// ═══════════ MODAL ═══════════
|
||||
function closeModal() { document.getElementById('modal-overlay').classList.remove('show'); }
|
||||
document.getElementById('modal-overlay').addEventListener('click', function(e) { if(e.target===this) closeModal(); });
|
||||
|
||||
// ═══════════ HELPERS ═══════════
|
||||
function esc(s) { if(!s) return ''; const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
|
||||
|
||||
// ═══════════ INIT ═══════════
|
||||
loadOverview();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,591 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HA Voice Control</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--surface: #161b22;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--text-dim: #8b949e;
|
||||
--accent: #58a6ff;
|
||||
--danger: #f85149;
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
--mic-bg: #1a2332;
|
||||
--mic-active: #da3633;
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
header h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
header p {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
}
|
||||
.status-dot.connected { background: var(--success); }
|
||||
.status-dot.error { background: var(--danger); }
|
||||
|
||||
/* ── mic button ─────────────────────────────── */
|
||||
.mic-container {
|
||||
position: relative;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.mic-ripple {
|
||||
position: absolute;
|
||||
inset: -8px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--mic-active);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mic-ripple.active {
|
||||
animation: ripple 1.5s ease-out infinite;
|
||||
}
|
||||
@keyframes ripple {
|
||||
0% { transform: scale(1); opacity: 0.6; }
|
||||
100% { transform: scale(1.4); opacity: 0; }
|
||||
}
|
||||
|
||||
#mic-btn {
|
||||
width: 120px; height: 120px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--border);
|
||||
background: var(--mic-bg);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
#mic-btn:hover {
|
||||
border-color: var(--accent);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
#mic-btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
#mic-btn.recording {
|
||||
border-color: var(--mic-active);
|
||||
background: #1f1217;
|
||||
box-shadow: 0 0 32px rgba(248, 81, 73, 0.25);
|
||||
}
|
||||
#mic-btn svg {
|
||||
width: 48px; height: 48px;
|
||||
fill: var(--text);
|
||||
transition: fill 0.2s;
|
||||
}
|
||||
#mic-btn.recording svg {
|
||||
fill: var(--mic-active);
|
||||
}
|
||||
.mic-label {
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-dim);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.mic-label.recording {
|
||||
color: var(--mic-active);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── transcriptie resultaten ────────────────── */
|
||||
.result-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.result-card .icon {
|
||||
font-size: 1.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.result-card .content {
|
||||
flex: 1;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.result-card .text-transcript {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.result-card .text-response {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.result-card.error { border-color: var(--danger); }
|
||||
.result-card.success { border-color: var(--success); }
|
||||
|
||||
/* ── lichten paneel ──────────────────────────── */
|
||||
.lights-panel {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
.lights-panel h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.light-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.light-item:hover {
|
||||
background: #1c2129;
|
||||
}
|
||||
.light-icon {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.light-info {
|
||||
flex: 1;
|
||||
}
|
||||
.light-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.light-state {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.light-toggle {
|
||||
width: 48px; height: 28px;
|
||||
border-radius: 14px;
|
||||
background: var(--border);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
border: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.light-toggle.on {
|
||||
background: var(--accent);
|
||||
}
|
||||
.light-toggle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px; left: 3px;
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.light-toggle.on::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* ── spinner ─────────────────────────────────── */
|
||||
.spinner {
|
||||
width: 20px; height: 20px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── responsive ──────────────────────────────── */
|
||||
@media (max-width: 500px) {
|
||||
#mic-btn { width: 100px; height: 100px; }
|
||||
#mic-btn svg { width: 38px; height: 38px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>🏠 Home Assistant Voice Control</h1>
|
||||
<p>Druk op de microfoon, spreek je commando, en laat los</p>
|
||||
</header>
|
||||
|
||||
<div class="status-bar">
|
||||
<div class="status-dot" id="status-dot"></div>
|
||||
<span id="status-text">Verbinden...</span>
|
||||
</div>
|
||||
|
||||
<!-- microfoon knop -->
|
||||
<div class="mic-container">
|
||||
<div class="mic-ripple" id="mic-ripple"></div>
|
||||
<button id="mic-btn" aria-label="Microfoon">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 14a3 3 0 0 0 3-3V5a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3zm5-3a5 5 0 0 1-10 0H5a7 7 0 0 0 6 6.93V21h2v-3.07A7 7 0 0 0 19 11h-2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mic-label" id="mic-label">Houd ingedrukt om te spreken</div>
|
||||
|
||||
<!-- resultaten -->
|
||||
<div id="results"></div>
|
||||
|
||||
<!-- laad spinner -->
|
||||
<div id="loading" style="display:none; justify-content:center; margin:16px 0;">
|
||||
<div class="spinner"></div>
|
||||
<span style="margin-left:10px; color:var(--text-dim)">Verwerken...</span>
|
||||
</div>
|
||||
|
||||
<!-- lichten paneel -->
|
||||
<div class="lights-panel">
|
||||
<h2>💡 Lampen</h2>
|
||||
<div id="lights-list">
|
||||
<div style="color:var(--text-dim); font-size:0.9rem;">Laden...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── configuratie ──────────────────────────────────────
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
// ── state ─────────────────────────────────────────────
|
||||
let mediaRecorder = null;
|
||||
let audioChunks = [];
|
||||
let isRecording = false;
|
||||
|
||||
// ── DOM refs ──────────────────────────────────────────
|
||||
const micBtn = document.getElementById('mic-btn');
|
||||
const micLabel = document.getElementById('mic-label');
|
||||
const micRipple = document.getElementById('mic-ripple');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const lightsList = document.getElementById('lights-list');
|
||||
const loadingEl = document.getElementById('loading');
|
||||
const statusDot = document.getElementById('status-dot');
|
||||
const statusText = document.getElementById('status-text');
|
||||
|
||||
// ── health check ──────────────────────────────────────
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/health`);
|
||||
const data = await resp.json();
|
||||
statusDot.className = 'status-dot connected';
|
||||
statusText.textContent = `Verbonden met HA (${data.ha_url})`;
|
||||
} catch (e) {
|
||||
statusDot.className = 'status-dot error';
|
||||
statusText.textContent = 'Geen verbinding met server';
|
||||
}
|
||||
}
|
||||
|
||||
// ── lichten laden ─────────────────────────────────────
|
||||
async function loadLights() {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/lights`);
|
||||
const lights = await resp.json();
|
||||
renderLights(lights);
|
||||
} catch (e) {
|
||||
lightsList.innerHTML = '<span style="color:var(--danger)">Fout bij laden lampen</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderLights(lights) {
|
||||
if (!lights || lights.length === 0) {
|
||||
lightsList.innerHTML = '<span style="color:var(--text-dim)">Geen lampen gevonden</span>';
|
||||
return;
|
||||
}
|
||||
lightsList.innerHTML = lights.map(l => {
|
||||
const isOn = l.state === 'on';
|
||||
const name = l.friendly_name || l.entity_id;
|
||||
const brightness = l.brightness != null ? `, ${Math.round(l.brightness / 2.55)}%` : '';
|
||||
return `
|
||||
<div class="light-item" data-entity="${l.entity_id}">
|
||||
<span class="light-icon">${isOn ? '💡' : '🌑'}</span>
|
||||
<div class="light-info">
|
||||
<div class="light-name">${escapeHtml(name)}</div>
|
||||
<div class="light-state">${isOn ? 'Aan' + brightness : 'Uit'}</div>
|
||||
</div>
|
||||
<button class="light-toggle ${isOn ? 'on' : ''}" data-entity="${l.entity_id}" data-action="${isOn ? 'turn_off' : 'turn_on'}"></button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// event listeners voor toggle knoppen
|
||||
document.querySelectorAll('.light-toggle').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const entityId = btn.dataset.entity;
|
||||
const action = btn.dataset.action;
|
||||
await toggleLight(entityId, action);
|
||||
});
|
||||
});
|
||||
|
||||
// klik op hele rij togglet ook
|
||||
document.querySelectorAll('.light-item').forEach(item => {
|
||||
item.addEventListener('click', async () => {
|
||||
const btn = item.querySelector('.light-toggle');
|
||||
const entityId = btn.dataset.entity;
|
||||
const action = btn.dataset.action;
|
||||
await toggleLight(entityId, action);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleLight(entityId, action) {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/light/control`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ entity_id: entityId, action })
|
||||
});
|
||||
if (resp.ok) {
|
||||
addResult(action === 'turn_on' ? '💡' : '🌑', `Lamp ${action === 'turn_on' ? 'aangezet' : 'uitgezet'}`, '', 'success');
|
||||
await loadLights();
|
||||
}
|
||||
} catch (e) {
|
||||
addResult('⚠️', 'Fout bij schakelen lamp', '', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── resultaten tonen ──────────────────────────────────
|
||||
function addResult(icon, transcript, response, type = '') {
|
||||
const card = document.createElement('div');
|
||||
card.className = `result-card ${type}`;
|
||||
card.innerHTML = `
|
||||
<span class="icon">${icon}</span>
|
||||
<div class="content">
|
||||
${transcript ? `<div class="text-transcript">"${escapeHtml(transcript)}"</div>` : ''}
|
||||
${response ? `<div class="text-response">${escapeHtml(response)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
resultsEl.prepend(card);
|
||||
|
||||
// max 10 resultaten bewaren
|
||||
while (resultsEl.children.length > 10) {
|
||||
resultsEl.lastChild.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ── audio opname ──────────────────────────────────────
|
||||
async function initMicrophone() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
sampleRate: 16000,
|
||||
}
|
||||
});
|
||||
return stream;
|
||||
} catch (e) {
|
||||
console.error('Microfoon toegang geweigerd:', e);
|
||||
micLabel.textContent = 'Microfoon niet beschikbaar';
|
||||
micLabel.style.color = 'var(--danger)';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
const stream = await initMicrophone();
|
||||
if (!stream) return;
|
||||
|
||||
// bepaal ondersteund MIME type
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus'
|
||||
: MediaRecorder.isTypeSupported('audio/webm')
|
||||
? 'audio/webm'
|
||||
: 'audio/wav';
|
||||
|
||||
try {
|
||||
mediaRecorder = new MediaRecorder(stream, { mimeType });
|
||||
} catch (e) {
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
}
|
||||
|
||||
audioChunks = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) audioChunks.push(e.data);
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
// stop alle tracks
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
|
||||
if (audioChunks.length === 0) return;
|
||||
|
||||
const audioBlob = new Blob(audioChunks, { type: mediaRecorder.mimeType || 'audio/webm' });
|
||||
await sendAudio(audioBlob);
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
isRecording = true;
|
||||
updateMicUI();
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (mediaRecorder && isRecording) {
|
||||
mediaRecorder.stop();
|
||||
isRecording = false;
|
||||
updateMicUI();
|
||||
}
|
||||
}
|
||||
|
||||
function updateMicUI() {
|
||||
if (isRecording) {
|
||||
micBtn.classList.add('recording');
|
||||
micLabel.classList.add('recording');
|
||||
micLabel.textContent = 'Opnemen... laat los om te versturen';
|
||||
micRipple.classList.add('active');
|
||||
} else {
|
||||
micBtn.classList.remove('recording');
|
||||
micLabel.classList.remove('recording');
|
||||
micLabel.textContent = 'Houd ingedrukt om te spreken';
|
||||
micRipple.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
// ── audio versturen ───────────────────────────────────
|
||||
async function sendAudio(audioBlob) {
|
||||
loadingEl.style.display = 'flex';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('audio', audioBlob, 'recording.wav');
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/transcribe`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.error) {
|
||||
addResult('⚠️', data.text || '', data.error, 'error');
|
||||
} else if (!data.text) {
|
||||
addResult('🔇', '', data.message || 'Geen spraak gedetecteerd', '');
|
||||
} else {
|
||||
// HA response parsen voor weergave
|
||||
const speech = extractSpeech(data.ha_result);
|
||||
addResult('🎤', data.text, speech, 'success');
|
||||
// herlaad lichten na actie
|
||||
await loadLights();
|
||||
}
|
||||
} catch (e) {
|
||||
addResult('❌', '', `Fout: ${e.message}`, 'error');
|
||||
} finally {
|
||||
loadingEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function extractSpeech(haResult) {
|
||||
if (!haResult) return '';
|
||||
try {
|
||||
const speech = haResult?.response?.speech?.plain?.speech;
|
||||
if (speech) return speech;
|
||||
// alternatieve paden
|
||||
if (haResult?.response?.speech) return JSON.stringify(haResult.response.speech);
|
||||
if (haResult?.speech) return haResult.speech;
|
||||
return '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── event listeners ───────────────────────────────────
|
||||
micBtn.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
startRecording();
|
||||
});
|
||||
|
||||
micBtn.addEventListener('pointerup', (e) => {
|
||||
e.preventDefault();
|
||||
stopRecording();
|
||||
});
|
||||
|
||||
micBtn.addEventListener('pointerleave', (e) => {
|
||||
if (isRecording) stopRecording();
|
||||
});
|
||||
|
||||
// touch events voor mobiel
|
||||
micBtn.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
startRecording();
|
||||
});
|
||||
|
||||
micBtn.addEventListener('touchend', (e) => {
|
||||
e.preventDefault();
|
||||
stopRecording();
|
||||
});
|
||||
|
||||
// keyboard support
|
||||
micBtn.addEventListener('keydown', (e) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
startRecording();
|
||||
}
|
||||
});
|
||||
|
||||
micBtn.addEventListener('keyup', (e) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
stopRecording();
|
||||
}
|
||||
});
|
||||
|
||||
// ── init ──────────────────────────────────────────────
|
||||
checkHealth();
|
||||
loadLights();
|
||||
// periodiek verversen
|
||||
setInterval(loadLights, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user