mirror of
https://github.com/sudoxnym/dashd.git
synced 2026-04-14 03:26:22 +00:00
features: - grid-locked card positioning with drag/resize - youtube widgets (click-to-play) - user authentication with server-side storage - per-user localStorage caching - docker deployment ready 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
4837 lines
242 KiB
HTML
4837 lines
242 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>dashd</title>
|
||
<link rel="icon" type="image/png" href="/dashd_icon.png">
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css">
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@400;700&display=swap');
|
||
|
||
:root {
|
||
--bg-dark: #0a0a0f;
|
||
--bg-panel: #12121a;
|
||
--bg-card: #1a1a24;
|
||
--aqua: #00ffff;
|
||
--lavender: #b57edc;
|
||
--magenta: #ff00ff;
|
||
--seafoam: #7fffd4;
|
||
--text: #e0e0e0;
|
||
--text-dim: #666677;
|
||
--border: #2a2a3a;
|
||
}
|
||
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
body {
|
||
font-family: 'Share Tech Mono', monospace;
|
||
background: var(--bg-dark);
|
||
color: var(--text);
|
||
min-height: 100vh;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
body::before {
|
||
content: '';
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0; bottom: 0;
|
||
background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.15) 2px, rgba(0,0,0,0.15) 4px);
|
||
pointer-events: none;
|
||
z-index: 9999;
|
||
}
|
||
|
||
header {
|
||
background: var(--bg-panel);
|
||
border-bottom: 2px solid var(--aqua);
|
||
padding: 12px 20px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 100;
|
||
box-shadow: 0 0 20px rgba(0,255,255,0.2);
|
||
gap: 15px;
|
||
}
|
||
|
||
.logo {
|
||
font-family: 'Orbitron', sans-serif;
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
color: var(--aqua);
|
||
text-shadow: 0 0 10px var(--aqua), 0 0 20px var(--aqua), 0 0 40px var(--aqua);
|
||
letter-spacing: 4px;
|
||
}
|
||
|
||
.search-box {
|
||
flex: 1;
|
||
max-width: 400px;
|
||
margin: 0 auto;
|
||
position: relative;
|
||
}
|
||
|
||
.search-box input {
|
||
width: 100%;
|
||
background: var(--bg-dark);
|
||
border: 1px solid var(--border);
|
||
color: var(--text);
|
||
padding: 8px 12px 8px 30px;
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.search-box input:focus {
|
||
outline: none;
|
||
border-color: var(--aqua);
|
||
}
|
||
|
||
.search-box::before {
|
||
content: '/';
|
||
position: absolute;
|
||
left: 10px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
color: var(--text-dim);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.header-controls { display: flex; gap: 8px; }
|
||
|
||
.btn {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
color: var(--text);
|
||
padding: 8px 16px;
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
text-transform: lowercase;
|
||
}
|
||
|
||
.btn:hover {
|
||
border-color: var(--aqua);
|
||
box-shadow: 0 0 10px rgba(0,255,255,0.4);
|
||
color: var(--aqua);
|
||
}
|
||
|
||
.btn.active {
|
||
background: var(--magenta);
|
||
color: var(--bg-dark);
|
||
border-color: var(--magenta);
|
||
box-shadow: 0 0 15px rgba(255,0,255,0.5);
|
||
}
|
||
|
||
.btn.add-btn {
|
||
background: var(--seafoam);
|
||
color: var(--bg-dark);
|
||
border-color: var(--seafoam);
|
||
font-size: 18px;
|
||
padding: 8px 14px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.btn.add-btn:hover { box-shadow: 0 0 15px rgba(127,255,212,0.5); }
|
||
|
||
.main-container {
|
||
display: flex;
|
||
height: calc(100vh - 60px);
|
||
}
|
||
|
||
.grid-container { display: flex; flex-direction: column;
|
||
flex: 1;
|
||
padding: 20px;
|
||
overflow: auto;
|
||
position: relative;
|
||
}
|
||
|
||
.dashboard-grid {
|
||
position: relative;
|
||
min-height: 100%; flex: 1;
|
||
min-width: 100%;
|
||
background: var(--bg-panel);
|
||
border: 2px solid var(--border);
|
||
border-radius: 4px;
|
||
transition: min-height 0.2s;
|
||
}
|
||
|
||
.dashboard-grid.edit-mode {
|
||
border-color: var(--magenta);
|
||
box-shadow: inset 0 0 30px rgba(255,0,255,0.1);
|
||
}
|
||
|
||
.dashboard-grid.drag-over {
|
||
border-color: var(--seafoam);
|
||
box-shadow: inset 0 0 30px rgba(127,255,212,0.2);
|
||
}
|
||
|
||
/* cards */
|
||
.card { overflow: hidden;
|
||
position: absolute;
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
transition: border-color 0.2s, box-shadow 0.2s;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.card::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0; left: 0;
|
||
width: 3px;
|
||
height: 100%;
|
||
background: linear-gradient(180deg, var(--aqua), var(--lavender));
|
||
z-index: 5;
|
||
}
|
||
|
||
.card:hover {
|
||
border-color: var(--lavender);
|
||
box-shadow: 0 0 15px rgba(181,126,220,0.4);
|
||
}
|
||
|
||
.card.dragging {
|
||
opacity: 0.8;
|
||
cursor: grabbing;
|
||
z-index: 1000;
|
||
border-color: var(--magenta);
|
||
box-shadow: 0 0 20px rgba(255,0,255,0.5);
|
||
}
|
||
|
||
.card.filtered-out {
|
||
opacity: 0.2;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.card-content {
|
||
padding: 8px 10px 8px 12px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.card-title-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.card-favicon {
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 2px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.card-name {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--aqua);
|
||
text-transform: lowercase;
|
||
text-shadow: 0 0 5px rgba(0,255,255,0.3);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.card-status {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: var(--seafoam);
|
||
box-shadow: 0 0 6px var(--seafoam);
|
||
flex-shrink: 0;
|
||
margin-left: 5px;
|
||
}
|
||
|
||
.card-status.up { background: var(--seafoam); box-shadow: 0 0 6px var(--seafoam); }
|
||
.card-status.down { background: #ff4d6d; box-shadow: 0 0 6px #ff4d6d; }
|
||
.card-status.unknown { background: var(--text-dim); box-shadow: none; }
|
||
|
||
.card-info {
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.card-tag {
|
||
display: inline-block;
|
||
background: var(--border);
|
||
padding: 2px 5px;
|
||
border-radius: 2px;
|
||
margin-right: 4px;
|
||
}
|
||
|
||
.card-description {
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
margin-top: 4px;
|
||
font-style: italic;
|
||
}
|
||
|
||
/* tiny card */
|
||
.card.size-tiny .card-content {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100%;
|
||
padding: 5px;
|
||
}
|
||
.card.size-tiny .card-name { font-size: 10px; text-align: center; white-space: normal; line-height: 1.1; }
|
||
.card.size-tiny .card-header { flex-direction: column; gap: 2px; }
|
||
.card.size-tiny .card-info, .card.size-tiny .card-description { display: none; }
|
||
.card.size-tiny .card-status { margin: 0; }
|
||
.card.size-tiny .card-favicon { display: none; }
|
||
.card.size-tiny .card-title-row { flex-direction: column; }
|
||
|
||
/* widget cards */
|
||
.card.widget .card-content { padding: 0; height: 100%; overflow: hidden; min-width: 0; }
|
||
.widget-display { overflow: hidden; min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100%;
|
||
width: 100%;
|
||
padding: 5px;
|
||
}
|
||
.widget-display:has(.widget-mailbox) { align-items: stretch; justify-content: flex-start;
|
||
}
|
||
.widget-clock {
|
||
font-family: 'Orbitron', sans-serif;
|
||
color: var(--aqua);
|
||
text-shadow: 0 0 10px var(--aqua);
|
||
white-space: nowrap;
|
||
}
|
||
.widget-date {
|
||
color: var(--text-dim);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* ha entity widget */
|
||
.widget-ha {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
height: 100%;
|
||
gap: 5px;
|
||
}
|
||
.widget-ha-icon {
|
||
font-size: 28px;
|
||
transition: color 0.3s, text-shadow 0.3s;
|
||
}
|
||
.widget-ha-name {
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
text-align: center;
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.widget-ha-state {
|
||
font-size: 12px;
|
||
font-family: 'Orbitron', sans-serif;
|
||
}
|
||
.widget-ha-state.on { color: var(--seafoam); }
|
||
.widget-ha-state.off { color: var(--text-dim); }
|
||
.widget-ha-toggle {
|
||
background: var(--bg-panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
width: 40px;
|
||
height: 20px;
|
||
cursor: pointer;
|
||
position: relative;
|
||
transition: all 0.2s;
|
||
}
|
||
.widget-ha-toggle.on { background: var(--aqua); border-color: var(--aqua); }
|
||
.widget-ha-toggle::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 2px;
|
||
left: 2px;
|
||
width: 14px;
|
||
height: 14px;
|
||
background: white;
|
||
border-radius: 50%;
|
||
transition: transform 0.2s;
|
||
}
|
||
.widget-ha-toggle.on::after { transform: translateX(20px); }
|
||
|
||
.widget-ha-controls-popup {
|
||
display: none;
|
||
position: fixed;
|
||
background: var(--bg-panel);
|
||
border: 1px solid var(--aqua);
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
min-width: 180px;
|
||
z-index: 3000;
|
||
box-shadow: 0 4px 20px rgba(0,255,255,0.2);
|
||
}
|
||
.widget-ha-controls-popup.open { display: block; }
|
||
.widget-ha-controls-popup .popup-title {
|
||
font-size: 11px;
|
||
color: var(--aqua);
|
||
margin-bottom: 10px;
|
||
padding-bottom: 5px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.widget-ha-slider-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
margin-bottom: 8px;
|
||
}
|
||
.widget-ha-slider-row:last-child { margin-bottom: 0; }
|
||
.widget-ha-slider-row label { width: 14px; flex-shrink: 0; }
|
||
.widget-ha-slider {
|
||
flex: 1;
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
height: 6px;
|
||
background: var(--border);
|
||
border-radius: 3px;
|
||
outline: none;
|
||
}
|
||
.widget-ha-slider::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
width: 14px;
|
||
height: 14px;
|
||
border-radius: 50%;
|
||
background: var(--aqua);
|
||
cursor: pointer;
|
||
}
|
||
.widget-ha-slider::-moz-range-thumb {
|
||
width: 14px;
|
||
height: 14px;
|
||
border-radius: 50%;
|
||
background: var(--aqua);
|
||
cursor: pointer;
|
||
border: none;
|
||
}
|
||
.widget-ha-slider.hue::-webkit-slider-thumb { background: var(--magenta); }
|
||
.widget-ha-slider.sat::-webkit-slider-thumb { background: var(--lavender); }
|
||
.widget-ha-color-preview {
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
border: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
}
|
||
.widget-ha-expand {
|
||
font-size: 8px;
|
||
color: var(--text-dim);
|
||
cursor: pointer;
|
||
margin-top: 2px;
|
||
}
|
||
.widget-ha-expand:hover { color: var(--aqua); }
|
||
|
||
/* generic widget styles */
|
||
.widget-stats { display: flex; flex-direction: column; height: 100%; padding: 8px; }
|
||
.widget-stats-title { font-size: 10px; color: var(--aqua); margin-bottom: 5px; text-transform: uppercase; letter-spacing: 1px; }
|
||
.widget-stats-value { font-family: 'Orbitron', sans-serif; font-size: 24px; color: var(--text); }
|
||
.widget-stats-label { font-size: 9px; color: var(--text-dim); }
|
||
.widget-stats-row { display: flex; justify-content: space-between; margin-top: 5px; }
|
||
.widget-stats-item { text-align: center; flex: 1; }
|
||
.widget-stats-item .value { font-family: 'Orbitron', sans-serif; font-size: 14px; }
|
||
.widget-stats-item .label { font-size: 8px; color: var(--text-dim); }
|
||
|
||
/* proxmox widget */
|
||
.widget-proxmox { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 5px; }
|
||
.widget-proxmox-icon { font-size: 24px; }
|
||
.widget-proxmox-name { font-size: 11px; color: var(--aqua); }
|
||
.widget-proxmox-status { font-size: 10px; padding: 2px 8px; border-radius: 10px; }
|
||
.widget-proxmox-status.running { background: var(--seafoam); color: var(--bg-dark); }
|
||
.widget-proxmox-status.stopped { background: #ff4d6d; color: white; }
|
||
.widget-proxmox-stats { font-size: 9px; color: var(--text-dim); }
|
||
.widget-proxmox-controls { display: flex; gap: 5px; margin-top: 5px; }
|
||
.widget-proxmox-controls button { background: var(--bg-panel); border: 1px solid var(--border); color: var(--text); padding: 3px 8px; font-size: 9px; cursor: pointer; border-radius: 3px; }
|
||
.widget-proxmox-controls button:hover { border-color: var(--aqua); color: var(--aqua); }
|
||
|
||
/* docker widget */
|
||
.widget-docker { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 5px; }
|
||
.widget-docker-icon { font-size: 24px; color: #2496ed; }
|
||
.widget-docker-name { font-size: 11px; color: var(--aqua); max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
|
||
.widget-docker-status { font-size: 10px; padding: 2px 8px; border-radius: 10px; }
|
||
.widget-docker-status.running { background: var(--seafoam); color: var(--bg-dark); }
|
||
.widget-docker-status.exited { background: #ff4d6d; color: white; }
|
||
.widget-docker-controls button { background: var(--bg-panel); border: 1px solid var(--border); color: var(--text); padding: 3px 8px; font-size: 9px; cursor: pointer; border-radius: 3px; margin-top: 5px; }
|
||
|
||
/* adguard widget */
|
||
.widget-adguard { display: flex; flex-direction: column; height: 100%; padding: 8px; }
|
||
.widget-adguard-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||
.widget-adguard-title { font-size: 11px; color: var(--aqua); }
|
||
.widget-adguard-toggle { width: 36px; height: 18px; background: var(--border); border-radius: 9px; cursor: pointer; position: relative; }
|
||
.widget-adguard-toggle.on { background: var(--seafoam); }
|
||
.widget-adguard-toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; background: white; border-radius: 50%; transition: transform 0.2s; }
|
||
.widget-adguard-toggle.on::after { transform: translateX(18px); }
|
||
.widget-adguard-stats { display: flex; flex-wrap: wrap; gap: 8px; }
|
||
.widget-adguard-stat { flex: 1; min-width: 45%; text-align: center; }
|
||
.widget-adguard-stat .value { font-family: 'Orbitron', sans-serif; font-size: 16px; color: var(--text); }
|
||
.widget-adguard-stat .label { font-size: 8px; color: var(--text-dim); }
|
||
|
||
/* jellyfin widget */
|
||
.widget-jellyfin { display: flex; flex-direction: column; height: 100%; }
|
||
.widget-jellyfin-playing { display: flex; align-items: center; gap: 10px; padding: 8px; height: 100%; }
|
||
.widget-jellyfin-poster { width: 50px; height: 75px; background: var(--bg-dark); border-radius: 4px; object-fit: cover; }
|
||
.widget-jellyfin-info { flex: 1; overflow: hidden; }
|
||
.widget-jellyfin-title { font-size: 12px; color: var(--aqua); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.widget-jellyfin-subtitle { font-size: 10px; color: var(--text-dim); }
|
||
.widget-jellyfin-user { font-size: 9px; color: var(--lavender); }
|
||
.widget-jellyfin-empty { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-dim); font-size: 11px; }
|
||
|
||
/* weather widget */
|
||
.widget-weather { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 5px; }
|
||
.widget-weather-icon { font-size: 36px; }
|
||
.widget-weather-temp { font-family: 'Orbitron', sans-serif; font-size: 28px; color: var(--text); }
|
||
.widget-weather-condition { font-size: 11px; color: var(--text-dim); }
|
||
.widget-weather-details { display: flex; gap: 15px; font-size: 9px; color: var(--text-dim); margin-top: 5px; }
|
||
|
||
/* speedtest widget */
|
||
.widget-speedtest { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 8px; }
|
||
.widget-speedtest-speeds { display: flex; gap: 20px; }
|
||
.widget-speedtest-speed { text-align: center; }
|
||
.widget-speedtest-speed .value { font-family: 'Orbitron', sans-serif; font-size: 20px; }
|
||
.widget-speedtest-speed .label { font-size: 9px; color: var(--text-dim); }
|
||
.widget-speedtest-speed.down .value { color: var(--seafoam); }
|
||
.widget-speedtest-speed.up .value { color: var(--lavender); }
|
||
.widget-speedtest-ping { font-size: 10px; color: var(--text-dim); }
|
||
.widget-speedtest-btn { background: var(--bg-panel); border: 1px solid var(--border); color: var(--text); padding: 5px 15px; font-size: 10px; cursor: pointer; border-radius: 3px; }
|
||
.widget-speedtest-btn:hover { border-color: var(--aqua); }
|
||
|
||
/* arr widgets */
|
||
.widget-arr { display: flex; flex-direction: column; height: 100%; padding: 8px; overflow: hidden; }
|
||
.widget-arr-title { font-size: 10px; color: var(--aqua); margin-bottom: 5px; }
|
||
.widget-arr-list { flex: 1; overflow-y: auto; }
|
||
.widget-arr-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px solid var(--border); }
|
||
.widget-arr-item:last-child { border-bottom: none; }
|
||
.widget-arr-poster { width: 30px; height: 45px; background: var(--bg-dark); border-radius: 2px; object-fit: cover; }
|
||
.widget-arr-info { flex: 1; overflow: hidden; }
|
||
.widget-arr-name { font-size: 11px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.widget-arr-date { font-size: 9px; color: var(--text-dim); }
|
||
|
||
/* frigate widget */
|
||
.widget-frigate { position: relative; height: 100%; background: var(--bg-dark); }
|
||
.widget-frigate img { width: 100%; height: 100%; object-fit: cover; }
|
||
.widget-frigate-overlay { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,0.8)); padding: 8px; }
|
||
.widget-frigate-name { font-size: 11px; color: var(--aqua); }
|
||
.widget-frigate-events { font-size: 9px; color: var(--text-dim); }
|
||
|
||
/* rss widget */
|
||
.widget-rss { display: flex; flex-direction: column; height: 100%; padding: 8px; overflow: hidden; }
|
||
.widget-rss-title { font-size: 10px; color: var(--aqua); margin-bottom: 5px; }
|
||
.widget-rss-list { flex: 1; overflow-y: auto; }
|
||
.widget-rss-item { padding: 4px 0; border-bottom: 1px solid var(--border); cursor: pointer; }
|
||
.widget-rss-item:hover { color: var(--aqua); }
|
||
.widget-rss-item-title { font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.widget-rss-item-date { font-size: 9px; color: var(--text-dim); }
|
||
|
||
/* bookmark widget */
|
||
.widget-bookmarks { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px; height: 100%; align-content: flex-start; overflow-y: auto; }
|
||
.widget-bookmark { display: flex; align-items: center; gap: 5px; padding: 5px 10px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-size: 11px; }
|
||
.widget-bookmark:hover { border-color: var(--aqua); color: var(--aqua); }
|
||
.widget-bookmark img { width: 14px; height: 14px; }
|
||
|
||
/* iframe widget */
|
||
.widget-display:has(.widget-iframe) { align-items: stretch; justify-content: flex-start; padding: 0; }
|
||
.widget-iframe { width: 100%; height: 100%; align-self: stretch; }
|
||
.widget-iframe iframe { width: 100%; height: 100%; border: none; }
|
||
|
||
|
||
/* notd (notes) widget */
|
||
.widget-display:has(.widget-notd) { align-items: stretch; justify-content: flex-start; padding: 0; }
|
||
.widget-notd { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
||
.widget-notd-header { display: flex; align-items: center; gap: 6px; padding: 6px 8px; background: var(--bg-card); border-bottom: 1px solid var(--border); }
|
||
.widget-notd-title { font-size: 10px; color: var(--aqua); flex: 1; }
|
||
.widget-notd-save { font-size: 8px; color: var(--seafoam); cursor: pointer; }
|
||
.widget-notd textarea { flex: 1; width: 100%; background: var(--bg-dark); border: none; color: var(--text); padding: 8px; font-family: inherit; font-size: 11px; resize: none; }
|
||
.widget-notd textarea:focus { outline: none; }
|
||
|
||
/* spotify widget */
|
||
.widget-display:has(.widget-spotify) { align-items: stretch; justify-content: flex-start; padding: 0; }
|
||
.widget-spotify { width: 100%; height: 100%; }
|
||
.widget-spotify iframe { width: 100%; height: 100%; border: none; border-radius: 4px; }
|
||
|
||
/* twitch widget */
|
||
.widget-display:has(.widget-twitch) { align-items: stretch; justify-content: flex-start; padding: 0; }
|
||
.widget-twitch { width: 100%; height: 100%; }
|
||
.widget-twitch iframe { width: 100%; height: 100%; border: none; }
|
||
|
||
.widget-display:has(.widget-youtube) { align-items: stretch; justify-content: flex-start; padding: 0; }
|
||
.widget-youtube { width: 100%; height: 100%; position: relative; }
|
||
.widget-youtube iframe { width: 100%; height: 100%; border: none; }
|
||
.widget-youtube-thumb { position: relative; width: 100%; height: 100%; cursor: pointer; background-size: cover; background-position: center; background-color: #000; }
|
||
.widget-youtube-play { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 60px; height: 42px; background: rgba(0,0,0,0.7); border-radius: 10px; display: flex; align-items: center; justify-content: center; transition: background 0.2s; }
|
||
.widget-youtube-thumb:hover .widget-youtube-play { background: #f00; }
|
||
.widget-youtube-play::after { content: ''; border-style: solid; border-width: 10px 0 10px 18px; border-color: transparent transparent transparent #fff; margin-left: 4px; }
|
||
.widget-youtube-controls { position: absolute; top: 5px; right: 5px; background: rgba(0,0,0,0.7); color: white; padding: 4px 8px; border-radius: 4px; cursor: pointer; z-index: 10; opacity: 0; transition: opacity 0.2s; }
|
||
.widget-youtube:hover .widget-youtube-controls { opacity: 1; }
|
||
|
||
.widget-display:has(.widget-browser) { align-items: stretch; justify-content: flex-start; padding: 0; }
|
||
.widget-browser { width: 100%; height: 100%; }
|
||
.widget-browser iframe { width: 100%; height: 100%; border: none; background: white; }
|
||
|
||
/* pomodoro widget */
|
||
.widget-pomodoro { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 8px; }
|
||
.widget-pomodoro-time { font-family: 'Orbitron', sans-serif; font-size: 32px; color: var(--aqua); text-shadow: 0 0 10px var(--aqua); }
|
||
.widget-pomodoro-label { font-size: 10px; color: var(--text-dim); text-transform: uppercase; }
|
||
.widget-pomodoro-controls { display: flex; gap: 8px; }
|
||
.widget-pomodoro-btn { background: var(--bg-panel); border: 1px solid var(--border); color: var(--text); padding: 4px 12px; font-size: 10px; cursor: pointer; border-radius: 4px; font-family: inherit; }
|
||
.widget-pomodoro-btn:hover { border-color: var(--aqua); color: var(--aqua); }
|
||
.widget-pomodoro-btn.active { background: var(--magenta); border-color: var(--magenta); color: var(--bg-dark); }
|
||
|
||
/* search widget */
|
||
.widget-display:has(.widget-search) { align-items: stretch; justify-content: center; padding: 8px; }
|
||
.widget-search { width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; }
|
||
.widget-search-form { display: flex; width: 100%; gap: 4px; }
|
||
.widget-search-input { flex: 1; background: var(--bg-dark); border: 1px solid var(--border); color: var(--text); padding: 8px 12px; font-family: inherit; font-size: 12px; border-radius: 4px; }
|
||
.widget-search-input:focus { outline: none; border-color: var(--aqua); }
|
||
.widget-search-btn { background: var(--aqua); border: none; color: var(--bg-dark); padding: 8px 12px; font-family: inherit; font-size: 12px; cursor: pointer; border-radius: 4px; }
|
||
.widget-search-engine { font-size: 9px; color: var(--text-dim); }
|
||
|
||
/* crypto widget */
|
||
.widget-crypto { display: flex; flex-direction: column; height: 100%; padding: 8px; gap: 6px; overflow-y: auto; }
|
||
.widget-crypto-header { font-size: 10px; color: var(--aqua); text-transform: uppercase; letter-spacing: 1px; }
|
||
.widget-crypto-coin { display: flex; justify-content: space-between; align-items: center; padding: 4px 0; border-bottom: 1px solid var(--border); }
|
||
.widget-crypto-coin:last-child { border-bottom: none; }
|
||
.widget-crypto-name { font-size: 11px; color: var(--text); }
|
||
.widget-crypto-price { font-family: 'Orbitron', sans-serif; font-size: 11px; color: var(--seafoam); }
|
||
.widget-crypto-change { font-size: 9px; }
|
||
.widget-crypto-change.up { color: var(--seafoam); }
|
||
.widget-crypto-change.down { color: #ff4d6d; }
|
||
|
||
/* todolist widget */
|
||
.widget-display:has(.widget-todolist) { align-items: stretch; justify-content: flex-start; padding: 0; }
|
||
.widget-todolist { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
||
.widget-todolist-header { display: flex; align-items: center; gap: 6px; padding: 6px 8px; background: var(--bg-card); border-bottom: 1px solid var(--border); }
|
||
.widget-todolist-title { font-size: 10px; color: var(--aqua); flex: 1; }
|
||
.widget-todolist-add { font-size: 14px; color: var(--seafoam); cursor: pointer; }
|
||
.widget-todolist-items { flex: 1; overflow-y: auto; padding: 4px 0; }
|
||
.widget-todolist-item { display: flex; align-items: center; gap: 6px; padding: 4px 8px; font-size: 11px; }
|
||
.widget-todolist-item:hover { background: var(--bg-panel); }
|
||
.widget-todolist-check { width: 14px; height: 14px; border: 1px solid var(--border); border-radius: 3px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||
.widget-todolist-check.done { background: var(--seafoam); border-color: var(--seafoam); }
|
||
.widget-todolist-check.done::after { content: '✓'; color: var(--bg-dark); font-size: 10px; }
|
||
.widget-todolist-text { flex: 1; color: var(--text); }
|
||
.widget-todolist-text.done { text-decoration: line-through; color: var(--text-dim); }
|
||
.widget-todolist-delete { color: var(--text-dim); cursor: pointer; font-size: 12px; opacity: 0; }
|
||
.widget-todolist-item:hover .widget-todolist-delete { opacity: 1; }
|
||
.widget-todolist-input { width: 100%; background: var(--bg-dark); border: none; border-top: 1px solid var(--border); color: var(--text); padding: 8px; font-family: inherit; font-size: 11px; }
|
||
.widget-todolist-input:focus { outline: none; }
|
||
|
||
/* countdown widget */
|
||
.widget-countdown { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 5px; }
|
||
.widget-countdown-name { font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 1px; }
|
||
.widget-countdown-time { display: flex; gap: 8px; }
|
||
.widget-countdown-unit { text-align: center; }
|
||
.widget-countdown-value { font-family: 'Orbitron', sans-serif; font-size: 20px; color: var(--aqua); text-shadow: 0 0 10px var(--aqua); }
|
||
.widget-countdown-label { font-size: 8px; color: var(--text-dim); text-transform: uppercase; }
|
||
|
||
/* now playing widget */
|
||
.widget-nowplaying { display: flex; align-items: center; gap: 10px; height: 100%; padding: 8px; }
|
||
.widget-nowplaying-art { width: 50px; height: 50px; background: var(--bg-dark); border-radius: 4px; flex-shrink: 0; }
|
||
.widget-nowplaying-art img { width: 100%; height: 100%; object-fit: cover; border-radius: 4px; }
|
||
.widget-nowplaying-info { flex: 1; overflow: hidden; }
|
||
.widget-nowplaying-title { font-size: 12px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.widget-nowplaying-artist { font-size: 10px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.widget-nowplaying-source { font-size: 8px; color: var(--lavender); margin-top: 4px; }
|
||
.widget-nowplaying-idle { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-dim); font-size: 11px; }
|
||
|
||
/* system stats widget */
|
||
.widget-system { display: flex; flex-direction: column; height: 100%; padding: 8px; gap: 8px; }
|
||
.widget-system-bar { height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; }
|
||
.widget-system-bar-fill { height: 100%; transition: width 0.3s; }
|
||
.widget-system-bar-fill.cpu { background: var(--aqua); }
|
||
.widget-system-bar-fill.ram { background: var(--lavender); }
|
||
.widget-system-bar-fill.disk { background: var(--seafoam); }
|
||
.widget-system-row { display: flex; justify-content: space-between; align-items: center; }
|
||
.widget-system-label { font-size: 9px; color: var(--text-dim); }
|
||
.widget-system-value { font-size: 11px; font-family: 'Orbitron', sans-serif; }
|
||
|
||
/* opnsense widget */
|
||
.widget-opnsense { display: flex; flex-direction: column; height: 100%; padding: 8px; }
|
||
.widget-opnsense-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||
.widget-opnsense-icon { font-size: 20px; color: var(--aqua); }
|
||
.widget-opnsense-title { font-size: 11px; color: var(--aqua); }
|
||
.widget-opnsense-status { font-size: 10px; padding: 2px 8px; border-radius: 10px; margin-left: auto; }
|
||
.widget-opnsense-status.online { background: var(--seafoam); color: var(--bg-dark); }
|
||
.widget-opnsense-status.offline { background: #ff4d6d; color: white; }
|
||
.widget-opnsense-stats { display: flex; flex-wrap: wrap; gap: 8px; }
|
||
.widget-opnsense-stat { flex: 1; min-width: 45%; }
|
||
.widget-opnsense-stat .value { font-family: 'Orbitron', sans-serif; font-size: 12px; }
|
||
.widget-opnsense-stat .label { font-size: 8px; color: var(--text-dim); }
|
||
|
||
/* cloudflared widget */
|
||
.widget-cloudflared { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 8px; }
|
||
.widget-cloudflared-icon { font-size: 28px; color: #f38020; }
|
||
.widget-cloudflared-name { font-size: 11px; color: var(--aqua); }
|
||
.widget-cloudflared-status { font-size: 10px; padding: 3px 12px; border-radius: 10px; }
|
||
.widget-cloudflared-status.healthy { background: var(--seafoam); color: var(--bg-dark); }
|
||
.widget-cloudflared-status.degraded { background: #fbbf24; color: var(--bg-dark); }
|
||
.widget-cloudflared-status.down { background: #ff4d6d; color: white; }
|
||
.widget-cloudflared-connections { font-size: 9px; color: var(--text-dim); }
|
||
|
||
/* unbound widget */
|
||
.widget-unbound { display: flex; flex-direction: column; height: 100%; padding: 8px; }
|
||
.widget-unbound-header { font-size: 11px; color: var(--aqua); margin-bottom: 8px; }
|
||
.widget-unbound-stats { display: flex; flex-wrap: wrap; gap: 8px; }
|
||
.widget-unbound-stat { flex: 1; min-width: 45%; text-align: center; }
|
||
.widget-unbound-stat .value { font-family: 'Orbitron', sans-serif; font-size: 14px; }
|
||
.widget-unbound-stat .label { font-size: 8px; color: var(--text-dim); }
|
||
|
||
/* habitica widget */
|
||
.widget-habitica { display: flex; flex-direction: column; height: 100%; padding: 8px; overflow: hidden; }
|
||
.widget-habitica-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||
.widget-habitica-hp { font-size: 10px; color: #ff4d6d; }
|
||
.widget-habitica-gold { font-size: 10px; color: #fbbf24; }
|
||
.widget-habitica-list { flex: 1; overflow-y: auto; }
|
||
.widget-habitica-task { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px solid var(--border); font-size: 11px; }
|
||
.widget-habitica-task:last-child { border-bottom: none; }
|
||
.widget-habitica-check { width: 14px; height: 14px; border: 2px solid var(--border); border-radius: 3px; cursor: pointer; }
|
||
.widget-habitica-check.done { background: var(--seafoam); border-color: var(--seafoam); }
|
||
.widget-habitica-check.daily { border-radius: 50%; }
|
||
.widget-habitica-text { flex: 1; }
|
||
.widget-habitica-text.done { text-decoration: line-through; color: var(--text-dim); }
|
||
|
||
/* calendar widget */
|
||
.widget-calendar { display: flex; flex-direction: column; height: 100%; padding: 8px; overflow: hidden; }
|
||
.widget-calendar-header { font-size: 11px; color: var(--aqua); margin-bottom: 8px; }
|
||
.widget-calendar-list { flex: 1; overflow-y: auto; }
|
||
.widget-calendar-event { padding: 6px 0; border-bottom: 1px solid var(--border); }
|
||
.widget-calendar-event:last-child { border-bottom: none; }
|
||
.widget-calendar-event-title { font-size: 11px; color: var(--text); }
|
||
.widget-calendar-event-time { font-size: 9px; color: var(--lavender); }
|
||
.widget-calendar-event-date { font-size: 9px; color: var(--text-dim); }
|
||
|
||
/* mailbox widget */
|
||
.widget-mailbox { display: flex; flex-direction: column; width: 100%; height: 100%; align-self: stretch; padding: 8px; overflow: hidden; min-width: 0; }
|
||
.widget-mailbox-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||
.widget-mailbox-icon { font-size: 16px; color: var(--aqua); }
|
||
.widget-mailbox-title { font-size: 10px; color: var(--aqua); flex: 1; }
|
||
.widget-mailbox-count { font-size: 10px; background: var(--magenta); color: white; padding: 2px 6px; border-radius: 10px; }
|
||
.widget-mailbox-list { flex: 1; overflow-y: auto; overflow-x: hidden; min-width: 0; }
|
||
.widget-mailbox-email { padding: 6px 0; min-width: 0; overflow: hidden; border-bottom: 1px solid var(--border); cursor: pointer; }
|
||
.widget-mailbox-email:hover { background: var(--bg-panel); }
|
||
.widget-mailbox-email:last-child { border-bottom: none; }
|
||
.widget-mailbox-email.unread .widget-mailbox-from { font-weight: bold; color: var(--text); }
|
||
.widget-mailbox-from { font-size: 9px; min-width: 0; display: block; width: 100%; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.widget-mailbox-subject { font-size: 10px; min-width: 0; display: block; width: 100%; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.widget-mailbox-date { font-size: 8px; color: var(--text-dim); }
|
||
.widget-mailbox-empty { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-dim); font-size: 11px; }
|
||
|
||
/* email modal */
|
||
.email-modal-overlay {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(0,0,0,0.85);
|
||
z-index: 5000;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
.email-modal-overlay.open { display: flex; }
|
||
.email-modal {
|
||
background: var(--bg-panel);
|
||
border: 2px solid var(--aqua);
|
||
border-radius: 8px;
|
||
width: 90%;
|
||
max-width: 800px;
|
||
max-height: 90vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-shadow: 0 0 40px rgba(0,255,255,0.3);
|
||
}
|
||
.email-modal-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 15px 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
background: var(--bg-card);
|
||
}
|
||
.email-modal-title {
|
||
font-family: 'Orbitron', sans-serif;
|
||
color: var(--aqua);
|
||
font-size: 14px;
|
||
text-transform: lowercase;
|
||
}
|
||
.email-modal-close {
|
||
background: none;
|
||
border: none;
|
||
color: var(--text-dim);
|
||
font-size: 24px;
|
||
cursor: pointer;
|
||
transition: color 0.2s;
|
||
}
|
||
.email-modal-close:hover { color: var(--magenta); }
|
||
.email-modal-body {
|
||
display: flex;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
}
|
||
.email-thread-panel {
|
||
width: 200px;
|
||
border-right: 1px solid var(--border);
|
||
overflow-y: auto;
|
||
flex-shrink: 0;
|
||
}
|
||
.email-thread-title {
|
||
padding: 10px;
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.email-thread-item {
|
||
padding: 10px;
|
||
border-bottom: 1px solid var(--border);
|
||
cursor: pointer;
|
||
transition: background 0.2s;
|
||
}
|
||
.email-thread-item:hover { background: var(--bg-card); }
|
||
.email-thread-item.active { background: var(--bg-card); border-left: 3px solid var(--aqua); }
|
||
.email-thread-from { font-size: 11px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.email-thread-date { font-size: 9px; color: var(--text-dim); }
|
||
.email-content-panel {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
.email-content-header {
|
||
padding: 15px 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
}
|
||
.email-subject { font-size: 16px; color: var(--aqua); margin-bottom: 10px; }
|
||
.email-meta { font-size: 11px; color: var(--text-dim); }
|
||
.email-meta-row { display: flex; gap: 5px; margin: 3px 0; }
|
||
.email-meta-label { color: var(--lavender); width: 50px; }
|
||
.email-body {
|
||
flex: 1;
|
||
padding: 20px;
|
||
overflow-y: auto;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
white-space: pre-wrap;
|
||
background: var(--bg-dark);
|
||
}
|
||
.email-reply-panel {
|
||
border-top: 1px solid var(--border);
|
||
padding: 15px;
|
||
background: var(--bg-card);
|
||
flex-shrink: 0;
|
||
}
|
||
.email-reply-toggle {
|
||
background: var(--bg-panel);
|
||
border: 1px solid var(--border);
|
||
color: var(--aqua);
|
||
padding: 8px 16px;
|
||
font-family: inherit;
|
||
font-size: 11px;
|
||
cursor: pointer;
|
||
border-radius: 4px;
|
||
transition: all 0.2s;
|
||
}
|
||
.email-reply-toggle:hover { border-color: var(--aqua); box-shadow: 0 0 10px rgba(0,255,255,0.3); }
|
||
.email-reply-form { display: none; margin-top: 10px; }
|
||
.email-reply-form.open { display: block; }
|
||
.email-reply-textarea {
|
||
width: 100%;
|
||
height: 100px;
|
||
background: var(--bg-dark);
|
||
border: 1px solid var(--border);
|
||
color: var(--text);
|
||
padding: 10px;
|
||
font-family: inherit;
|
||
font-size: 12px;
|
||
resize: vertical;
|
||
border-radius: 4px;
|
||
}
|
||
.email-reply-textarea:focus { outline: none; border-color: var(--aqua); }
|
||
.email-reply-actions { display: flex; gap: 10px; margin-top: 10px; }
|
||
.email-reply-send {
|
||
background: var(--seafoam);
|
||
color: var(--bg-dark);
|
||
border: none;
|
||
padding: 8px 20px;
|
||
font-family: inherit;
|
||
font-size: 11px;
|
||
cursor: pointer;
|
||
border-radius: 4px;
|
||
}
|
||
.email-reply-send:hover { box-shadow: 0 0 10px rgba(127,255,212,0.5); }
|
||
.email-reply-cancel {
|
||
background: var(--bg-panel);
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
padding: 8px 16px;
|
||
font-family: inherit;
|
||
font-size: 11px;
|
||
cursor: pointer;
|
||
border-radius: 4px;
|
||
}
|
||
.email-loading { display: flex; align-items: center; justify-content: center; padding: 40px; color: var(--text-dim); }
|
||
|
||
/* preview */
|
||
.card-preview {
|
||
flex: 1;
|
||
min-height: 0;
|
||
position: relative;
|
||
background: var(--bg-dark);
|
||
display: none;
|
||
overflow: hidden;
|
||
}
|
||
.card-preview.active { display: block; }
|
||
.card-preview iframe { width: 100%; height: 100%; border: none; }
|
||
.card-preview-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 2; pointer-events: none; }
|
||
.edit-mode .card-preview-overlay { pointer-events: auto; }
|
||
|
||
/* card buttons */
|
||
.card-btn {
|
||
position: absolute;
|
||
top: 4px;
|
||
width: 16px;
|
||
height: 16px;
|
||
background: var(--border);
|
||
border: none;
|
||
border-radius: 2px;
|
||
color: var(--text-dim);
|
||
font-size: 10px;
|
||
cursor: pointer;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 10;
|
||
}
|
||
.card-btn:hover { background: var(--lavender); color: var(--bg-dark); }
|
||
.edit-mode .card-btn { display: flex; }
|
||
.card-remove { right: -8px; top: -8px; width: 20px; height: 20px; background: #ff4d6d; border-radius: 50%; color: white; font-size: 14px; z-index: 20; }
|
||
.card-edit-btn { right: 44px; }
|
||
.card-edit-btn:hover { background: var(--seafoam); }
|
||
.card-preview-btn { right: 24px; }
|
||
.card-preview-btn.active { background: var(--aqua); color: var(--bg-dark); }
|
||
|
||
/* edit panel */
|
||
.card-edit-panel {
|
||
position: absolute;
|
||
top: 100%;
|
||
left: 0;
|
||
right: 0;
|
||
background: var(--bg-dark);
|
||
border: 1px solid var(--aqua);
|
||
padding: 10px;
|
||
z-index: 100;
|
||
display: none;
|
||
}
|
||
.card-edit-panel.open { display: block; }
|
||
.card-edit-panel label { display: block; font-size: 10px; color: var(--text-dim); margin-bottom: 2px; }
|
||
.card-edit-panel input, .card-edit-panel select, .card-edit-panel textarea {
|
||
width: 100%;
|
||
background: var(--bg-panel);
|
||
border: 1px solid var(--border);
|
||
color: var(--text);
|
||
padding: 4px 6px;
|
||
font-family: inherit;
|
||
font-size: 11px;
|
||
margin-bottom: 6px;
|
||
}
|
||
.card-edit-panel textarea { resize: vertical; min-height: 40px; }
|
||
.card-edit-panel input:focus, .card-edit-panel select:focus, .card-edit-panel textarea:focus { outline: none; border-color: var(--aqua); }
|
||
.card-edit-panel .save-btn {
|
||
background: var(--seafoam);
|
||
border: none;
|
||
color: var(--bg-dark);
|
||
padding: 5px 10px;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
font-size: 11px;
|
||
width: 100%;
|
||
}
|
||
|
||
/* resize handles */
|
||
.resize-handle {
|
||
position: absolute;
|
||
background: var(--lavender);
|
||
opacity: 0;
|
||
transition: opacity 0.2s;
|
||
z-index: 10;
|
||
}
|
||
.edit-mode .card:hover .resize-handle { opacity: 0.6; }
|
||
.resize-handle:hover { opacity: 1 !important; }
|
||
.resize-handle.right { right: 0; top: 20%; height: 60%; width: 4px; cursor: ew-resize; }
|
||
.resize-handle.bottom { bottom: 0; left: 20%; width: 60%; height: 4px; cursor: ns-resize; }
|
||
.resize-handle.corner { right: 0; bottom: 0; width: 12px; height: 12px; cursor: nwse-resize; background: var(--magenta); }
|
||
|
||
/* context menu */
|
||
.context-menu {
|
||
position: fixed;
|
||
background: var(--bg-panel);
|
||
border: 1px solid var(--border);
|
||
min-width: 150px;
|
||
z-index: 2000;
|
||
display: none;
|
||
}
|
||
.context-menu.open { display: block; }
|
||
.context-menu-item {
|
||
padding: 8px 12px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.context-menu-item:last-child { border-bottom: none; }
|
||
.context-menu-item:hover { background: var(--bg-card); color: var(--aqua); }
|
||
.context-menu-item.danger { color: #ff4d6d; }
|
||
.context-menu-item.danger:hover { background: #ff4d6d; color: white; }
|
||
|
||
/* sidebar */
|
||
.sidebar {
|
||
width: 0;
|
||
background: var(--bg-panel);
|
||
border-left: 1px solid var(--border);
|
||
overflow: hidden;
|
||
transition: width 0.3s;
|
||
}
|
||
.sidebar.open { width: 320px; }
|
||
.sidebar-content { width: 320px; padding: 15px; height: 100%; overflow-y: auto; }
|
||
.sidebar-title {
|
||
font-family: 'Orbitron', sans-serif;
|
||
font-size: 12px;
|
||
color: var(--aqua);
|
||
margin-bottom: 10px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.machine-input-row { display: flex; gap: 8px; margin-bottom: 10px; }
|
||
.machine-input {
|
||
flex: 1;
|
||
background: var(--bg-dark);
|
||
border: 1px solid var(--border);
|
||
color: var(--text);
|
||
padding: 8px;
|
||
font-family: inherit;
|
||
font-size: 12px;
|
||
}
|
||
.machine-input:focus { outline: none; border-color: var(--aqua); }
|
||
.machines-list { margin-bottom: 15px; }
|
||
.machine-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 8px;
|
||
background: var(--bg-dark);
|
||
border: 1px solid var(--border);
|
||
margin-bottom: 4px;
|
||
font-size: 12px;
|
||
}
|
||
.machine-item .ip { color: var(--seafoam); }
|
||
.machine-item .status { font-size: 10px; color: var(--text-dim); }
|
||
.machine-item .remove { background: none; border: none; color: #ff4d6d; cursor: pointer; font-size: 14px; }
|
||
|
||
.discovered-list { max-height: 300px; overflow-y: auto; }
|
||
.discovered-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 8px;
|
||
background: var(--bg-dark);
|
||
border: 1px solid var(--border);
|
||
margin-bottom: 4px;
|
||
cursor: grab;
|
||
font-size: 11px;
|
||
}
|
||
.discovered-item:hover { border-color: var(--lavender); }
|
||
.discovered-item.dragging { opacity: 0.5; }
|
||
.discovered-item .name { color: var(--aqua); }
|
||
.discovered-item .details { font-size: 10px; color: var(--text-dim); }
|
||
.discovered-item .add-service {
|
||
background: var(--seafoam);
|
||
border: none;
|
||
color: var(--bg-dark);
|
||
width: 20px;
|
||
height: 20px;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* settings section */
|
||
.settings-section { margin-top: 20px; padding-top: 15px; border-top: 1px solid var(--border); }
|
||
.settings-row { display: flex; gap: 8px; margin-bottom: 8px; }
|
||
.settings-row .btn { flex: 1; font-size: 11px; padding: 6px 8px; }
|
||
|
||
/* modals */
|
||
.modal-overlay {
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(0,0,0,0.85);
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
}
|
||
.modal-overlay.open { display: flex; }
|
||
.modal {
|
||
background: var(--bg-panel);
|
||
border: 1px solid var(--aqua);
|
||
padding: 25px;
|
||
width: 400px;
|
||
max-width: 90vw;
|
||
box-shadow: 0 0 30px rgba(0,255,255,0.3);
|
||
}
|
||
.modal-title {
|
||
font-family: 'Orbitron', sans-serif;
|
||
font-size: 16px;
|
||
color: var(--magenta);
|
||
margin-bottom: 15px;
|
||
}
|
||
.form-group { margin-bottom: 12px; }
|
||
.form-group label { display: block; font-size: 11px; color: var(--text-dim); margin-bottom: 4px; }
|
||
.form-group input, .form-group select, .form-group textarea {
|
||
width: 100%;
|
||
background: var(--bg-dark);
|
||
border: 1px solid var(--border);
|
||
color: var(--text);
|
||
padding: 8px;
|
||
font-family: inherit;
|
||
font-size: 12px;
|
||
}
|
||
.form-group textarea { resize: vertical; min-height: 60px; }
|
||
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; border-color: var(--aqua); }
|
||
.modal-buttons { display: flex; gap: 10px; margin-top: 15px; }
|
||
.modal-buttons .btn { flex: 1; }
|
||
|
||
/* confirm modal */
|
||
.confirm-text { margin-bottom: 15px; font-size: 13px; }
|
||
.confirm-text strong { color: var(--aqua); }
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 60px 20px;
|
||
color: var(--text-dim);
|
||
}
|
||
.empty-state .icon { font-size: 48px; margin-bottom: 15px; color: var(--lavender); }
|
||
|
||
/* toast notifications */
|
||
.toast {
|
||
position: fixed;
|
||
bottom: 20px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: var(--bg-panel);
|
||
border: 1px solid var(--aqua);
|
||
padding: 10px 20px;
|
||
font-size: 12px;
|
||
z-index: 3000;
|
||
display: none;
|
||
}
|
||
.toast.show { display: block; }
|
||
|
||
/* auth modal */
|
||
.auth-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.9); z-index: 9999; align-items: center; justify-content: center; }
|
||
.auth-modal.open { display: flex; }
|
||
.auth-box { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; padding: 24px; width: 320px; }
|
||
.auth-title { color: var(--seafoam); font-size: 18px; margin-bottom: 16px; text-align: center; }
|
||
.auth-input { width: 100%; background: var(--bg-dark); border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; color: var(--text); font-size: 14px; margin-bottom: 12px; box-sizing: border-box; }
|
||
.auth-input:focus { outline: none; border-color: var(--seafoam); }
|
||
.auth-btn { width: 100%; background: var(--seafoam); color: var(--bg-dark); border: none; border-radius: 6px; padding: 10px; font-size: 14px; font-weight: 600; cursor: pointer; margin-bottom: 8px; }
|
||
.auth-btn:hover { opacity: 0.9; }
|
||
.auth-btn.secondary { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||
.auth-error { color: var(--coral); font-size: 12px; margin-bottom: 12px; text-align: center; }
|
||
.auth-switch { color: var(--text-dim); font-size: 12px; text-align: center; cursor: pointer; }
|
||
.auth-switch:hover { color: var(--text); }
|
||
|
||
|
||
|
||
|
||
.sync-indicator { width: 8px; height: 8px; border-radius: 50%; background: var(--seafoam); }
|
||
.sync-indicator.syncing { background: var(--gold); animation: pulse 1s infinite; }
|
||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- auth modal -->
|
||
<div id="authModal" class="auth-modal">
|
||
<div class="auth-box">
|
||
<div class="auth-title" id="authTitle">sign in</div>
|
||
<div class="auth-error" id="authError" style="display:none"></div>
|
||
<input type="text" class="auth-input" id="authUsername" placeholder="username" autocomplete="username">
|
||
<input type="password" class="auth-input" id="authPassword" placeholder="password" autocomplete="current-password">
|
||
<button class="auth-btn" id="authSubmit" onclick="submitAuth()">sign in</button>
|
||
<button class="auth-btn secondary" onclick="closeAuthModal(); loadLocalSettings();">continue as guest</button>
|
||
<div class="auth-switch" id="authSwitch" onclick="toggleAuthMode()">need an account? register</div>
|
||
</div>
|
||
</div>
|
||
|
||
<header>
|
||
<div class="logo"><img src="/dashd_icon.png" style="height: 28px; vertical-align: middle; margin-right: 8px;">dashd</div>
|
||
<div class="search-box">
|
||
<input type="text" id="searchInput" placeholder="search services...">
|
||
</div>
|
||
<div class="header-controls">
|
||
<button class="btn add-btn" id="addBtn" title="add service">+</button>
|
||
<button class="btn" id="widgetBtn" title="add widget">▥</button>
|
||
<button class="btn" id="editBtn" title="edit mode">edit</button>
|
||
<button class="btn" id="scanBtn" title="scan/settings">⚙</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="main-container">
|
||
<div class="grid-container">
|
||
<div class="dashboard-grid" id="dashboardGrid">
|
||
<div class="empty-state" id="emptyState">
|
||
<div class="icon">◇</div>
|
||
<div>no services pinned</div>
|
||
<div style="font-size: 12px; margin-top: 10px;">click + to add or ⚙ to scan</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sidebar" id="sidebar">
|
||
<div class="sidebar-content">
|
||
<div class="sidebar-title">// machine scanner</div>
|
||
<div class="machine-input-row">
|
||
<input type="text" class="machine-input" id="machineIp" placeholder="192.168.1.x">
|
||
<button class="btn" id="addMachineBtn">+</button>
|
||
</div>
|
||
<div class="machines-list" id="machinesList"></div>
|
||
<button class="btn" style="width: 100%; margin-bottom: 15px;" id="scanMachinesBtn">scan machines</button>
|
||
|
||
<div class="sidebar-title">// discovered (drag to add)</div>
|
||
<input type="text" class="machine-input" id="discoveredSearch" placeholder="search discovered..." style="margin-bottom: 10px;">
|
||
<div class="discovered-list" id="discoveredList">
|
||
<div style="color: var(--text-dim); font-size: 11px; text-align: center; padding: 15px;">
|
||
add machines and scan
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="sidebar-title">// home assistant</div>
|
||
<input type="text" class="machine-input" id="haUrl" placeholder="http://192.168.x.x:8123" style="margin-bottom: 8px;">
|
||
<input type="password" class="machine-input" id="haToken" placeholder="long-lived access token" style="margin-bottom: 8px;">
|
||
<div class="settings-row">
|
||
<button class="btn" id="haConnectBtn">connect</button>
|
||
<button class="btn" id="haRefreshBtn">refresh</button>
|
||
</div>
|
||
<div id="haStatus" style="font-size: 11px; color: var(--text-dim); margin-top: 5px;"></div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="sidebar-title">// proxmox</div>
|
||
<input type="text" class="machine-input" id="pveUrl" placeholder="https://192.168.x.x:8006" style="margin-bottom: 8px;">
|
||
<input type="text" class="machine-input" id="pveUser" placeholder="root@pam" style="margin-bottom: 8px;">
|
||
<input type="password" class="machine-input" id="pvePass" placeholder="password" style="margin-bottom: 8px;">
|
||
<div class="settings-row">
|
||
<button class="btn" id="pveConnectBtn">connect</button>
|
||
</div>
|
||
<div id="pveStatus" style="font-size: 11px; color: var(--text-dim); margin-top: 5px;"></div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="sidebar-title">// docker</div>
|
||
<input type="text" class="machine-input" id="dockerUrl" placeholder="http://192.168.x.x:2375" style="margin-bottom: 8px;">
|
||
<div class="settings-row">
|
||
<button class="btn" id="dockerConnectBtn">connect</button>
|
||
</div>
|
||
<div id="dockerStatus" style="font-size: 11px; color: var(--text-dim); margin-top: 5px;"></div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="sidebar-title">// adguard</div>
|
||
<input type="text" class="machine-input" id="adguardUrl" placeholder="http://192.168.x.x" style="margin-bottom: 8px;">
|
||
<input type="text" class="machine-input" id="adguardUser" placeholder="admin" style="margin-bottom: 8px;">
|
||
<input type="password" class="machine-input" id="adguardPass" placeholder="password" style="margin-bottom: 8px;">
|
||
<div class="settings-row">
|
||
<button class="btn" id="adguardConnectBtn">connect</button>
|
||
</div>
|
||
<div id="adguardStatus" style="font-size: 11px; color: var(--text-dim); margin-top: 5px;"></div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="sidebar-title">// jellyfin</div>
|
||
<input type="text" class="machine-input" id="jellyfinUrl" placeholder="http://192.168.x.x:8096" style="margin-bottom: 8px;">
|
||
<input type="password" class="machine-input" id="jellyfinKey" placeholder="api key" style="margin-bottom: 8px;">
|
||
<div class="settings-row">
|
||
<button class="btn" id="jellyfinConnectBtn">connect</button>
|
||
</div>
|
||
<div id="jellyfinStatus" style="font-size: 11px; color: var(--text-dim); margin-top: 5px;"></div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="sidebar-title">// arr stack</div>
|
||
<input type="text" class="machine-input" id="sonarrUrl" placeholder="http://192.168.x.x:8989" style="margin-bottom: 8px;">
|
||
<input type="password" class="machine-input" id="sonarrKey" placeholder="sonarr api key" style="margin-bottom: 8px;">
|
||
<input type="text" class="machine-input" id="radarrUrl" placeholder="http://192.168.x.x:7878" style="margin-bottom: 8px;">
|
||
<input type="password" class="machine-input" id="radarrKey" placeholder="radarr api key" style="margin-bottom: 8px;">
|
||
<div class="settings-row">
|
||
<button class="btn" id="arrConnectBtn">connect</button>
|
||
</div>
|
||
<div id="arrStatus" style="font-size: 11px; color: var(--text-dim); margin-top: 5px;"></div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="sidebar-title">// frigate</div>
|
||
<input type="text" class="machine-input" id="frigateUrl" placeholder="http://192.168.x.x:5000" style="margin-bottom: 8px;">
|
||
<div class="settings-row">
|
||
<button class="btn" id="frigateConnectBtn">connect</button>
|
||
</div>
|
||
<div id="frigateStatus" style="font-size: 11px; color: var(--text-dim); margin-top: 5px;"></div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="sidebar-title">// opnsense</div>
|
||
<input type="text" class="machine-input" id="opnsenseUrl" placeholder="https://192.168.x.x" style="margin-bottom: 8px;">
|
||
<input type="text" class="machine-input" id="opnsenseKey" placeholder="api key" style="margin-bottom: 8px;">
|
||
<input type="password" class="machine-input" id="opnsenseSecret" placeholder="api secret" style="margin-bottom: 8px;">
|
||
<div class="settings-row">
|
||
<button class="btn" id="opnsenseConnectBtn">connect</button>
|
||
</div>
|
||
<div id="opnsenseStatus" style="font-size: 11px; color: var(--text-dim); margin-top: 5px;"></div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="sidebar-title">// cloudflared</div>
|
||
<input type="text" class="machine-input" id="cloudflaredToken" placeholder="cloudflare api token" style="margin-bottom: 8px;">
|
||
<input type="text" class="machine-input" id="cloudflaredAccount" placeholder="account id" style="margin-bottom: 8px;">
|
||
<input type="text" class="machine-input" id="cloudflaredTunnel" placeholder="tunnel id" style="margin-bottom: 8px;">
|
||
<div class="settings-row">
|
||
<button class="btn" id="cloudflaredConnectBtn">connect</button>
|
||
</div>
|
||
<div id="cloudflaredStatus" style="font-size: 11px; color: var(--text-dim); margin-top: 5px;"></div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="sidebar-title">// habitica</div>
|
||
<input type="text" class="machine-input" id="habiticaUser" placeholder="user id" style="margin-bottom: 8px;">
|
||
<input type="password" class="machine-input" id="habiticaKey" placeholder="api token" style="margin-bottom: 8px;">
|
||
<div class="settings-row">
|
||
<button class="btn" id="habiticaConnectBtn">connect</button>
|
||
</div>
|
||
<div id="habiticaStatus" style="font-size: 11px; color: var(--text-dim); margin-top: 5px;"></div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="sidebar-title">// calendar</div>
|
||
<input type="text" class="machine-input" id="calendarUrl" placeholder="ics/caldav url" style="margin-bottom: 8px;">
|
||
<div class="settings-row">
|
||
<button class="btn" id="calendarConnectBtn">connect</button>
|
||
</div>
|
||
<div id="calendarStatus" style="font-size: 11px; color: var(--text-dim); margin-top: 5px;"></div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="sidebar-title">// mailbox</div>
|
||
<input type="text" class="machine-input" id="mailServer" placeholder="mail.example.com" style="margin-bottom: 8px;">
|
||
<input type="text" class="machine-input" id="mailUser" placeholder="user@domain.com" style="margin-bottom: 8px;">
|
||
<input type="password" class="machine-input" id="mailPass" placeholder="password" style="margin-bottom: 8px;">
|
||
<div class="settings-row">
|
||
<button class="btn" id="mailConnectBtn">connect</button>
|
||
</div>
|
||
<div id="mailStatus" style="font-size: 11px; color: var(--text-dim); margin-top: 5px;"></div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="sidebar-title">// config</div>
|
||
<div class="settings-row">
|
||
<button class="btn" id="exportBtn">export</button>
|
||
<button class="btn" id="importBtn">import</button>
|
||
</div>
|
||
<div class="settings-row">
|
||
<button class="btn" id="healthCheckBtn">check health</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="sidebar-title">// account</div>
|
||
<div id="userLoggedIn" style="display:none;">
|
||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||
<div class="sync-indicator" id="syncIndicator"></div>
|
||
<span style="color: var(--seafoam);" id="userBadgeName"></span>
|
||
</div>
|
||
<button class="btn" onclick="logout()" style="width: 100%;">logout</button>
|
||
</div>
|
||
<div id="userLoggedOut">
|
||
<button class="btn" onclick="showAuthModal()" style="width: 100%;">login / register</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- context menu -->
|
||
<div class="context-menu" id="contextMenu">
|
||
<div class="context-menu-item" data-action="open">open</div>
|
||
<div class="context-menu-item" data-action="copy">copy url</div>
|
||
<div class="context-menu-item" data-action="edit">edit</div>
|
||
<div class="context-menu-item danger" data-action="delete">delete</div>
|
||
</div>
|
||
|
||
<!-- add service modal -->
|
||
<div class="modal-overlay" id="addModal">
|
||
<div class="modal">
|
||
<div class="modal-title">// add service</div>
|
||
<div class="form-group">
|
||
<label>name</label>
|
||
<input type="text" id="serviceName" placeholder="my service">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>url</label>
|
||
<input type="text" id="serviceUrl" placeholder="http://192.168.1.x:8080">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>category</label>
|
||
<select id="serviceCategory">
|
||
<option value="infra">infrastructure</option>
|
||
<option value="media">media</option>
|
||
<option value="network">network</option>
|
||
<option value="home">home automation</option>
|
||
<option value="monitoring">monitoring</option>
|
||
<option value="ai">ai/ml</option>
|
||
<option value="dev">dev</option>
|
||
<option value="productivity">productivity</option>
|
||
<option value="other">other</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>description (optional)</label>
|
||
<textarea id="serviceDescription" placeholder="notes about this service"></textarea>
|
||
</div>
|
||
<div class="modal-buttons">
|
||
<button class="btn" id="cancelBtn">cancel</button>
|
||
<button class="btn active" id="saveBtn">add</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- widget modal -->
|
||
<div class="modal-overlay" id="widgetModal">
|
||
<div class="modal">
|
||
<div class="modal-title">// add widget</div>
|
||
<div class="form-group">
|
||
<label>widget type</label>
|
||
<select id="widgetType">
|
||
<optgroup label="time">
|
||
<option value="clock">clock</option>
|
||
<option value="date">date & time</option>
|
||
</optgroup>
|
||
<optgroup label="home assistant">
|
||
<option value="ha_entity">ha entity</option>
|
||
<option value="weather">weather</option>
|
||
</optgroup>
|
||
<optgroup label="infrastructure">
|
||
<option value="proxmox">proxmox vm/ct</option>
|
||
<option value="docker">docker container</option>
|
||
<option value="system_stats">system stats</option>
|
||
</optgroup>
|
||
<optgroup label="network">
|
||
<option value="adguard">adguard stats</option>
|
||
<option value="speedtest">speedtest</option>
|
||
</optgroup>
|
||
<optgroup label="media">
|
||
<option value="jellyfin">jellyfin now playing</option>
|
||
<option value="sonarr">sonarr upcoming</option>
|
||
<option value="radarr">radarr upcoming</option>
|
||
<option value="frigate">frigate camera</option>
|
||
</optgroup>
|
||
<optgroup label="network">
|
||
<option value="opnsense">opnsense status</option>
|
||
<option value="cloudflared">cloudflare tunnel</option>
|
||
<option value="unbound">unbound dns</option>
|
||
</optgroup>
|
||
<optgroup label="productivity">
|
||
<option value="habitica">habitica tasks</option>
|
||
<option value="calendar">calendar</option>
|
||
<option value="mailbox">mailbox</option>
|
||
</optgroup>
|
||
<optgroup label="other">
|
||
<option value="rss">rss feed</option>
|
||
<option value="iframe">iframe embed</option>
|
||
<option value="browser">browser</option>
|
||
<option value="notd">notd (notes)</option>
|
||
<option value="spotify">spotify</option>
|
||
<option value="twitch">twitch</option>
|
||
<option value="youtube">youtube</option>
|
||
<option value="pomodoro">pomodoro timer</option>
|
||
<option value="search">search</option>
|
||
<option value="crypto">crypto ticker</option>
|
||
<option value="todolist">todo list</option>
|
||
<option value="countdown">countdown</option>
|
||
<option value="nowplaying">now playing</option>
|
||
<option value="bookmark">bookmark links</option>
|
||
</optgroup>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" id="haEntityGroup" style="display: none;">
|
||
<label>entity</label>
|
||
<select id="haEntitySelect">
|
||
<option value="">connect to HA first</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" id="proxmoxGroup" style="display: none;">
|
||
<label>vm/container id</label>
|
||
<select id="proxmoxVmSelect"><option value="">configure proxmox first</option></select>
|
||
</div>
|
||
<div class="form-group" id="dockerGroup" style="display: none;">
|
||
<label>container</label>
|
||
<select id="dockerContainerSelect"><option value="">configure docker first</option></select>
|
||
</div>
|
||
<div class="form-group" id="frigateGroup" style="display: none;">
|
||
<label>camera</label>
|
||
<select id="frigateCameraSelect"><option value="">configure frigate first</option></select>
|
||
</div>
|
||
<div class="form-group" id="rssGroup" style="display: none;">
|
||
<label>feed url</label>
|
||
<input type="text" id="rssFeedUrl" placeholder="https://example.com/feed.xml">
|
||
</div>
|
||
<div class="form-group" id="iframeGroup" style="display: none;">
|
||
<label>embed url</label>
|
||
<input type="text" id="iframeUrl" placeholder="https://example.com">
|
||
</div>
|
||
<div class="form-group" id="browserGroup" style="display: none;">
|
||
<label>url</label>
|
||
<input type="text" id="browserUrl" placeholder="https://youtube.com">
|
||
</div>
|
||
<div class="form-group" id="spotifyGroup" style="display: none;">
|
||
<label>spotify embed url</label>
|
||
<input type="text" id="spotifyUrl" placeholder="https://open.spotify.com/embed/...">
|
||
</div>
|
||
<div class="form-group" id="twitchGroup" style="display: none;">
|
||
<label>twitch channel</label>
|
||
<input type="text" id="twitchChannel" placeholder="channel name">
|
||
</div>
|
||
<div class="form-group" id="youtubeGroup" style="display: none;">
|
||
<label>youtube url or video id</label>
|
||
<input type="text" id="youtubeUrl" placeholder="https://youtube.com/watch?v=... or video ID">
|
||
</div>
|
||
<div class="form-group" id="searchGroup" style="display: none;">
|
||
<label>search engine</label>
|
||
<select id="searchEngine">
|
||
<option value="searxng">searxng</option>
|
||
<option value="whoogle">whoogle</option>
|
||
<option value="ddg">duckduckgo</option>
|
||
<option value="google">google</option>
|
||
</select>
|
||
<label style="margin-top:8px">instance url (for searxng/whoogle)</label>
|
||
<input type="text" id="searchInstance" placeholder="https://your-instance.com">
|
||
</div>
|
||
<div class="form-group" id="cryptoGroup" style="display: none;">
|
||
<label>coins (comma separated)</label>
|
||
<input type="text" id="cryptoCoins" placeholder="bitcoin,ethereum,solana">
|
||
</div>
|
||
<div class="form-group" id="mailboxGroup" style="display: none;">
|
||
<label>imap server</label>
|
||
<input type="text" id="mailboxServer" placeholder="mail.example.com">
|
||
<label style="margin-top:8px">email</label>
|
||
<input type="text" id="mailboxUser" placeholder="you@example.com">
|
||
<label style="margin-top:8px">password</label>
|
||
<input type="password" id="mailboxPass">
|
||
</div>
|
||
<div class="form-group" id="countdownGroup" style="display: none;">
|
||
<label>countdown name</label>
|
||
<input type="text" id="countdownName" placeholder="event name">
|
||
<label style="margin-top:8px">target date</label>
|
||
<input type="datetime-local" id="countdownDate">
|
||
</div>
|
||
<div class="form-group" id="bookmarkGroup" style="display: none;">
|
||
<label>links (name|url, one per line)</label>
|
||
<textarea id="bookmarkLinks" placeholder="github|https://github.com reddit|https://reddit.com"></textarea>
|
||
</div>
|
||
<div class="modal-buttons">
|
||
<button class="btn" id="widgetCancelBtn">cancel</button>
|
||
<button class="btn active" id="widgetSaveBtn">add</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- confirm modal -->
|
||
<div class="modal-overlay" id="confirmModal">
|
||
<div class="modal">
|
||
<div class="modal-title">// confirm</div>
|
||
<div class="confirm-text" id="confirmText"></div>
|
||
<div class="modal-buttons">
|
||
<button class="btn" id="confirmNoBtn">cancel</button>
|
||
<button class="btn active" id="confirmYesBtn">confirm</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<!-- email modal -->
|
||
<div class="email-modal-overlay" id="emailModal">
|
||
<div class="email-modal">
|
||
<div class="email-modal-header">
|
||
<div class="email-modal-title">// email</div>
|
||
<button class="email-modal-close" onclick="closeEmailModal()">×</button>
|
||
</div>
|
||
<div class="email-modal-body">
|
||
<div class="email-thread-panel" id="emailThreadPanel">
|
||
<div class="email-thread-title">thread</div>
|
||
<div id="emailThreadList"></div>
|
||
</div>
|
||
<div class="email-content-panel">
|
||
<div class="email-content-header" id="emailHeader">
|
||
<div class="email-subject" id="emailSubject">loading...</div>
|
||
<div class="email-meta">
|
||
<div class="email-meta-row"><span class="email-meta-label">from:</span><span id="emailFrom"></span></div>
|
||
<div class="email-meta-row"><span class="email-meta-label">to:</span><span id="emailTo"></span></div>
|
||
<div class="email-meta-row"><span class="email-meta-label">date:</span><span id="emailDate"></span></div>
|
||
</div>
|
||
</div>
|
||
<div class="email-body" id="emailBody">loading...</div>
|
||
<div class="email-reply-panel">
|
||
<button class="email-reply-toggle" onclick="toggleReplyForm()">reply</button>
|
||
<div class="email-reply-form" id="emailReplyForm">
|
||
<textarea class="email-reply-textarea" id="emailReplyText" placeholder="type your reply..."></textarea>
|
||
<div class="email-reply-actions">
|
||
<button class="email-reply-send" onclick="sendEmailReply()">send</button>
|
||
<button class="email-reply-cancel" onclick="toggleReplyForm()">cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- import file input -->
|
||
<input type="file" id="importFile" accept=".json" style="display:none">
|
||
|
||
<!-- toast -->
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<!-- ha controls popup -->
|
||
<div class="widget-ha-controls-popup" id="haControlsPopup"></div>
|
||
|
||
<script>
|
||
window.onerror = function(msg, url, line, col, error) {
|
||
// Suppress errors from embedded iframes and CSP
|
||
if (msg && (msg.includes('Script error') || msg.includes('SecurityError'))) return true;
|
||
if (url && (url.includes('youtube') || url.includes('googlevideo'))) return true;
|
||
if (localStorage.getItem("dashd_debug")) console.error("JS Error:", msg, "at line", line);
|
||
return true;
|
||
};
|
||
// Suppress unhandled promise rejections from embeds
|
||
window.onunhandledrejection = function(e) { e.preventDefault(); return true; };
|
||
var GRID_SIZE = 80;
|
||
var GRID_GAP = 15;
|
||
var HEALTH_CHECK_INTERVAL = 60000;
|
||
|
||
// HTML escape helper
|
||
function escapeHtml(text) {
|
||
if (text === null || text === undefined) return '';
|
||
var div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
var services = [];
|
||
var cardPositions = {};
|
||
var cardSizes = {};
|
||
var cardPreviews = JSON.parse(localStorage.getItem('dashd_previews') || '{}');
|
||
var machines = [];
|
||
var discovered = [];
|
||
var editMode = false;
|
||
var sidebarOpen = false;
|
||
var confirmCallback = null;
|
||
var contextTarget = null;
|
||
var healthCheckTimer = null;
|
||
var haConfig = JSON.parse(localStorage.getItem('dashd_ha_config') || '{}');
|
||
var haEntities = [];
|
||
var haUpdateTimer = null;
|
||
|
||
// service configs
|
||
var pveConfig = JSON.parse(localStorage.getItem('dashd_pve_config') || '{}');
|
||
var pveTicket = null;
|
||
var pveResources = [];
|
||
var dockerConfig = JSON.parse(localStorage.getItem('dashd_docker_config') || '{}');
|
||
var dockerContainers = [];
|
||
var adguardConfig = JSON.parse(localStorage.getItem('dashd_adguard_config') || '{}');
|
||
var adguardStats = {};
|
||
var jellyfinConfig = JSON.parse(localStorage.getItem('dashd_jellyfin_config') || '{}');
|
||
var jellyfinSessions = [];
|
||
var sonarrConfig = JSON.parse(localStorage.getItem('dashd_sonarr_config') || '{}');
|
||
var radarrConfig = JSON.parse(localStorage.getItem('dashd_radarr_config') || '{}');
|
||
var sonarrCalendar = [];
|
||
var radarrCalendar = [];
|
||
var frigateConfig = JSON.parse(localStorage.getItem('dashd_frigate_config') || '{}');
|
||
var frigateCameras = [];
|
||
var speedtestResults = JSON.parse(localStorage.getItem('dashd_speedtest') || '{}');
|
||
var widgetUpdateTimer = null;
|
||
|
||
// additional service configs
|
||
var opnsenseConfig = JSON.parse(localStorage.getItem('dashd_opnsense_config') || '{}');
|
||
var opnsenseData = {};
|
||
var cloudflaredConfig = JSON.parse(localStorage.getItem('dashd_cloudflared_config') || '{}');
|
||
var cloudflaredData = {};
|
||
var habiticaConfig = JSON.parse(localStorage.getItem('dashd_habitica_config') || '{}');
|
||
var habiticaData = { user: {}, tasks: [] };
|
||
var calendarConfig = JSON.parse(localStorage.getItem('dashd_calendar_config') || '{}');
|
||
var calendarEvents = [];
|
||
var mailConfig = JSON.parse(localStorage.getItem('dashd_mail_config') || '{}');
|
||
var mailWidgetData = {};
|
||
var notdData = JSON.parse(localStorage.getItem("dashd_notd") || "{}");
|
||
var todolistData = JSON.parse(localStorage.getItem("dashd_todolist") || "{}");
|
||
var mailMessages = []; // legacy fallback
|
||
var currentMailWidget = null;
|
||
|
||
async function fetchMailForWidget(widgetId, server, user, pass) {
|
||
if (!server || !user || !pass) return;
|
||
if (!mailWidgetData[widgetId]) mailWidgetData[widgetId] = { messages: [] };
|
||
var proxyUrl = window.location.origin + "/api/mail";
|
||
try {
|
||
var res = await fetch(proxyUrl + "/inbox?limit=10", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ server: server, user: user, pass: pass })
|
||
});
|
||
if (res.ok) {
|
||
mailWidgetData[widgetId].messages = await res.json();
|
||
mailWidgetData[widgetId].server = server;
|
||
mailWidgetData[widgetId].user = user;
|
||
mailWidgetData[widgetId].pass = pass;
|
||
updateAllWidgets();
|
||
}
|
||
} catch (err) { /* parse error */ }
|
||
}
|
||
|
||
function refreshMailWidget(el) {
|
||
var card = el.closest('.card');
|
||
if (!card) return;
|
||
var svcId = card.dataset.service;
|
||
var svc = services.find(function(s) { return s.id === svcId; });
|
||
if (svc && svc.mailServer) {
|
||
fetchMailForWidget(svc.id, svc.mailServer, svc.mailUser, svc.mailPass);
|
||
}
|
||
}
|
||
|
||
var grid = document.getElementById('dashboardGrid');
|
||
var emptyState = document.getElementById('emptyState');
|
||
var sidebar = document.getElementById('sidebar');
|
||
var searchInput = document.getElementById('searchInput');
|
||
var contextMenu = document.getElementById('contextMenu');
|
||
var toast = document.getElementById('toast');
|
||
|
||
// event listeners
|
||
document.getElementById('addBtn').addEventListener('click', function() { openModal('addModal'); });
|
||
document.getElementById('widgetBtn').addEventListener('click', function() { openModal('widgetModal'); document.getElementById('widgetType').dispatchEvent(new Event('change')); });
|
||
document.getElementById('editBtn').addEventListener('click', toggleEditMode);
|
||
document.getElementById('scanBtn').addEventListener('click', toggleSidebar);
|
||
document.getElementById('addMachineBtn').addEventListener('click', addMachine);
|
||
document.getElementById('scanMachinesBtn').addEventListener('click', scanMachines);
|
||
document.getElementById('cancelBtn').addEventListener('click', function() { closeModal('addModal'); });
|
||
document.getElementById('saveBtn').addEventListener('click', addService);
|
||
document.getElementById('widgetCancelBtn').addEventListener('click', function() { closeModal('widgetModal'); });
|
||
document.getElementById('widgetSaveBtn').addEventListener('click', addWidget);
|
||
document.getElementById('confirmNoBtn').addEventListener('click', function() { closeModal('confirmModal'); });
|
||
document.getElementById('confirmYesBtn').addEventListener('click', function() { closeModal('confirmModal'); if (confirmCallback) { confirmCallback(); } });
|
||
document.getElementById('exportBtn').addEventListener('click', exportConfig);
|
||
document.getElementById('importBtn').addEventListener('click', function() { document.getElementById('importFile').click(); });
|
||
document.getElementById('importFile').addEventListener('change', importConfig);
|
||
document.getElementById('healthCheckBtn').addEventListener('click', runHealthChecks);
|
||
searchInput.addEventListener('input', filterCards);
|
||
document.getElementById('discoveredSearch').addEventListener('input', filterDiscovered);
|
||
document.getElementById('haConnectBtn').addEventListener('click', connectHA);
|
||
document.getElementById('haRefreshBtn').addEventListener('click', refreshHAEntities);
|
||
document.getElementById('widgetType').addEventListener('change', function() {
|
||
var v = this.value;
|
||
document.getElementById('haEntityGroup').style.display = (v === 'ha_entity' || v === 'weather') ? 'block' : 'none';
|
||
document.getElementById('proxmoxGroup').style.display = v === 'proxmox' ? 'block' : 'none';
|
||
document.getElementById('dockerGroup').style.display = v === 'docker' ? 'block' : 'none';
|
||
document.getElementById('frigateGroup').style.display = v === 'frigate' ? 'block' : 'none';
|
||
document.getElementById('rssGroup').style.display = v === 'rss' ? 'block' : 'none';
|
||
document.getElementById('iframeGroup').style.display = v === 'iframe' ? 'block' : 'none';
|
||
document.getElementById('browserGroup').style.display = v === 'browser' ? 'block' : 'none';
|
||
document.getElementById('spotifyGroup').style.display = v === 'spotify' ? 'block' : 'none';
|
||
document.getElementById('twitchGroup').style.display = v === 'twitch' ? 'block' : 'none';
|
||
document.getElementById('youtubeGroup').style.display = v === 'youtube' ? 'block' : 'none';
|
||
document.getElementById('searchGroup').style.display = v === 'search' ? 'block' : 'none';
|
||
document.getElementById('cryptoGroup').style.display = v === 'crypto' ? 'block' : 'none';
|
||
document.getElementById('countdownGroup').style.display = v === 'countdown' ? 'block' : 'none';
|
||
document.getElementById('mailboxGroup').style.display = v === 'mailbox' ? 'block' : 'none';
|
||
document.getElementById('bookmarkGroup').style.display = v === 'bookmark' ? 'block' : 'none';
|
||
});
|
||
|
||
// service connect buttons
|
||
document.getElementById('pveConnectBtn').addEventListener('click', connectPVE);
|
||
document.getElementById('dockerConnectBtn').addEventListener('click', connectDocker);
|
||
document.getElementById('adguardConnectBtn').addEventListener('click', connectAdguard);
|
||
document.getElementById('jellyfinConnectBtn').addEventListener('click', connectJellyfin);
|
||
document.getElementById('arrConnectBtn').addEventListener('click', connectArr);
|
||
document.getElementById('frigateConnectBtn').addEventListener('click', connectFrigate);
|
||
document.getElementById('opnsenseConnectBtn').addEventListener('click', connectOPNsense);
|
||
document.getElementById('cloudflaredConnectBtn').addEventListener('click', connectCloudflared);
|
||
document.getElementById('habiticaConnectBtn').addEventListener('click', connectHabitica);
|
||
document.getElementById('calendarConnectBtn').addEventListener('click', connectCalendar);
|
||
document.getElementById('mailConnectBtn').addEventListener('click', connectMail);
|
||
|
||
// context menu
|
||
document.addEventListener('click', function() { contextMenu.classList.remove('open'); });
|
||
document.querySelectorAll('.context-menu-item').forEach(function(item) {
|
||
item.addEventListener('click', function() {
|
||
var action = this.dataset.action;
|
||
if (contextTarget) handleContextAction(action, contextTarget);
|
||
contextMenu.classList.remove('open');
|
||
});
|
||
});
|
||
|
||
// drag from discovered
|
||
grid.addEventListener('dragover', function(e) { e.preventDefault(); grid.classList.add('drag-over'); });
|
||
grid.addEventListener('dragleave', function() { grid.classList.remove('drag-over'); });
|
||
grid.addEventListener('drop', handleDiscoveredDrop);
|
||
|
||
// keyboard shortcuts
|
||
document.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Escape') {
|
||
closeAllModals();
|
||
contextMenu.classList.remove('open');
|
||
if (editMode) toggleEditMode();
|
||
}
|
||
if (e.key === '/' && !e.target.matches('input, textarea')) {
|
||
e.preventDefault();
|
||
searchInput.focus();
|
||
}
|
||
if (e.key === 'e' && !e.target.matches('input, textarea')) {
|
||
toggleEditMode();
|
||
}
|
||
});
|
||
|
||
|
||
// ========== AUTH ==========
|
||
var authToken = localStorage.getItem('dashd_token');
|
||
var authUser = localStorage.getItem('dashd_user');
|
||
var isRegistering = false;
|
||
var syncTimeout = null;
|
||
|
||
function showAuthModal() {
|
||
document.getElementById('authModal').classList.add('open');
|
||
}
|
||
|
||
function closeAuthModal() {
|
||
document.getElementById('authModal').classList.remove('open');
|
||
}
|
||
|
||
function toggleAuthMode() {
|
||
isRegistering = !isRegistering;
|
||
document.getElementById('authTitle').textContent = isRegistering ? 'register' : 'sign in';
|
||
document.getElementById('authSubmit').textContent = isRegistering ? 'register' : 'sign in';
|
||
document.getElementById('authSwitch').textContent = isRegistering ? 'have an account? sign in' : 'need an account? register';
|
||
document.getElementById('authError').style.display = 'none';
|
||
}
|
||
|
||
async function submitAuth() {
|
||
var username = document.getElementById('authUsername').value.trim();
|
||
var password = document.getElementById('authPassword').value;
|
||
var errorEl = document.getElementById('authError');
|
||
|
||
if (!username || !password) {
|
||
errorEl.textContent = 'enter username and password';
|
||
errorEl.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
var endpoint = isRegistering ? '/api/auth/register' : '/api/auth/login';
|
||
var res = await fetch(endpoint, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username: username, password: password })
|
||
});
|
||
var data = await res.json();
|
||
|
||
if (data.error) {
|
||
errorEl.textContent = data.error;
|
||
errorEl.style.display = 'block';
|
||
} else {
|
||
localStorage.setItem('dashd_token', data.token);
|
||
localStorage.setItem('dashd_user', data.username);
|
||
authToken = data.token;
|
||
authUser = data.username;
|
||
closeAuthModal();
|
||
showUserBadge();
|
||
loadServerSettings();
|
||
}
|
||
} catch (e) {
|
||
errorEl.textContent = 'connection failed';
|
||
errorEl.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
function showUserBadge() {
|
||
document.getElementById('userBadgeName').textContent = authUser;
|
||
document.getElementById('userLoggedIn').style.display = 'block';
|
||
document.getElementById('userLoggedOut').style.display = 'none';
|
||
}
|
||
|
||
function logout() {
|
||
localStorage.removeItem('dashd_token');
|
||
localStorage.removeItem('dashd_user');
|
||
authToken = null;
|
||
authUser = null;
|
||
document.getElementById('userLoggedIn').style.display = 'none';
|
||
document.getElementById('userLoggedOut').style.display = 'block';
|
||
}
|
||
|
||
async function loadServerSettings() {
|
||
if (!authToken) return;
|
||
try {
|
||
var res = await fetch('/api/settings/load', {
|
||
headers: { 'Authorization': 'Bearer ' + authToken }
|
||
});
|
||
if (res.ok) {
|
||
var data = await res.json();
|
||
if (data.services && data.services.length > 0) {
|
||
services = data.services;
|
||
cardPositions = data.cardPositions || {};
|
||
cardSizes = data.cardSizes || {};
|
||
gridSize = data.gridSize || { cols: 10, rows: 8 };
|
||
machines = data.machines || [];
|
||
localStorage.setItem('dashd_services_' + (authUser || 'guest'), JSON.stringify(services));
|
||
localStorage.setItem('dashd_positions_' + (authUser || 'guest'), JSON.stringify(cardPositions));
|
||
localStorage.setItem('dashd_sizes_' + (authUser || 'guest'), JSON.stringify(cardSizes));
|
||
localStorage.setItem('dashd_grid_' + (authUser || 'guest'), JSON.stringify(gridSize));
|
||
localStorage.setItem('dashd_machines_' + (authUser || 'guest'), JSON.stringify(machines));
|
||
renderGrid();
|
||
renderCards();
|
||
updateAllWidgets();
|
||
showToast('settings loaded');
|
||
}
|
||
}
|
||
} catch (e) { /* server unavailable */ }
|
||
}
|
||
|
||
async function saveToServer() {
|
||
if (!authToken) return;
|
||
document.getElementById('syncIndicator').classList.add('syncing');
|
||
try {
|
||
await fetch('/api/settings/save', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer ' + authToken
|
||
},
|
||
body: JSON.stringify({
|
||
services: services,
|
||
cardPositions: cardPositions,
|
||
cardSizes: cardSizes,
|
||
gridSize: gridSize,
|
||
machines: machines
|
||
})
|
||
});
|
||
} catch (e) { /* offline */ }
|
||
document.getElementById('syncIndicator').classList.remove('syncing');
|
||
}
|
||
|
||
function queueSync() {
|
||
if (syncTimeout) clearTimeout(syncTimeout);
|
||
syncTimeout = setTimeout(saveToServer, 2000);
|
||
}
|
||
|
||
// check auth on load
|
||
|
||
function loadLocalSettings() {
|
||
var key = authUser || 'guest';
|
||
services = JSON.parse(localStorage.getItem('dashd_services_' + key) || '[]');
|
||
cardPositions = JSON.parse(localStorage.getItem('dashd_positions_' + key) || '{}');
|
||
cardSizes = JSON.parse(localStorage.getItem('dashd_sizes_' + key) || '{}');
|
||
machines = JSON.parse(localStorage.getItem('dashd_machines_' + key) || '[]');
|
||
renderGrid();
|
||
renderCards();
|
||
updateAllWidgets();
|
||
}
|
||
|
||
async function checkAuth() {
|
||
if (authToken) {
|
||
try {
|
||
var res = await fetch('/api/auth/verify', {
|
||
headers: { 'Authorization': 'Bearer ' + authToken }
|
||
});
|
||
var data = await res.json();
|
||
if (data.valid) {
|
||
showUserBadge();
|
||
loadServerSettings();
|
||
return;
|
||
}
|
||
} catch (e) { /* server unavailable */ }
|
||
// token invalid or server down - clear it
|
||
localStorage.removeItem('dashd_token');
|
||
localStorage.removeItem('dashd_user');
|
||
authToken = null;
|
||
authUser = null;
|
||
}
|
||
showAuthModal();
|
||
document.getElementById('userLoggedOut').style.display = 'block';
|
||
}
|
||
|
||
// init
|
||
renderDashboard();
|
||
renderMachines();
|
||
startHealthChecks();
|
||
setTimeout(updateGridSize, 100);
|
||
|
||
function renderDashboard() {
|
||
grid.querySelectorAll('.card').forEach(function(c) { c.remove(); });
|
||
|
||
if (services.length === 0) {
|
||
emptyState.style.display = 'block';
|
||
return;
|
||
}
|
||
emptyState.style.display = 'none';
|
||
|
||
services.forEach(function(svc, idx) {
|
||
var defaultW = GRID_SIZE * 2 + GRID_GAP;
|
||
var defaultH = GRID_SIZE;
|
||
var col = idx % 3;
|
||
var row = Math.floor(idx / 3);
|
||
var pos = cardPositions[svc.id] || { x: col * (defaultW + GRID_GAP), y: row * (defaultH + GRID_GAP) };
|
||
var size = cardSizes[svc.id] || { w: defaultW, h: defaultH };
|
||
var showPreview = cardPreviews[svc.id] || false;
|
||
|
||
var card = document.createElement('div');
|
||
card.className = 'card' + (svc.type === 'widget' ? ' widget' : '');
|
||
card.dataset.id = svc.id;
|
||
card.dataset.name = (svc.name || '').toLowerCase();
|
||
card.dataset.category = svc.category || '';
|
||
card.style.left = pos.x + 'px';
|
||
card.style.top = pos.y + 'px';
|
||
card.style.width = size.w + 'px';
|
||
card.style.height = size.h + 'px';
|
||
|
||
if (size.w <= GRID_SIZE + 10 && size.h <= GRID_SIZE + 10) {
|
||
card.classList.add('size-tiny');
|
||
}
|
||
|
||
if (svc.type === 'widget') {
|
||
renderWidget(card, svc, size);
|
||
} else {
|
||
renderServiceCard(card, svc, showPreview);
|
||
}
|
||
|
||
// right-click context menu
|
||
card.addEventListener('contextmenu', function(e) {
|
||
e.preventDefault();
|
||
contextTarget = svc;
|
||
contextMenu.style.left = e.pageX + 'px';
|
||
contextMenu.style.top = e.pageY + 'px';
|
||
contextMenu.classList.add('open');
|
||
});
|
||
|
||
// click to open (not in edit mode)
|
||
card.addEventListener('click', function(e) {
|
||
if (editMode) return;
|
||
if (e.target.closest('.card-btn, .card-edit-panel, .resize-handle')) return;
|
||
if (svc.url) window.open(svc.url, '_blank');
|
||
});
|
||
|
||
// drag
|
||
card.addEventListener('mousedown', function(e) {
|
||
if (e.target.closest('.resize-handle, .card-btn, .card-edit-panel')) return;
|
||
if (!editMode) return;
|
||
startDrag(card, e);
|
||
});
|
||
|
||
grid.appendChild(card);
|
||
});
|
||
|
||
updateWidgets();
|
||
setTimeout(updateGridSize, 0);
|
||
}
|
||
|
||
function renderServiceCard(card, svc, showPreview) {
|
||
var removeBtn = createButton('card-btn card-remove', '×', function(e) {
|
||
e.stopPropagation();
|
||
confirmDelete(svc);
|
||
});
|
||
|
||
var editCardBtn = createButton('card-btn card-edit-btn', '✎', function(e) {
|
||
e.stopPropagation();
|
||
toggleEditPanel(card);
|
||
});
|
||
|
||
var previewBtn = createButton('card-btn card-preview-btn' + (showPreview ? ' active' : ''), '▣', function(e) {
|
||
e.stopPropagation();
|
||
togglePreview(svc.id);
|
||
});
|
||
|
||
var content = document.createElement('div');
|
||
content.className = 'card-content';
|
||
|
||
var header = document.createElement('div');
|
||
header.className = 'card-header';
|
||
|
||
var titleRow = document.createElement('div');
|
||
titleRow.className = 'card-title-row';
|
||
|
||
if (svc.url) {
|
||
var favicon = document.createElement('img');
|
||
favicon.className = 'card-favicon';
|
||
favicon.src = getFaviconUrl(svc.url);
|
||
favicon.onerror = function() { this.remove(); };
|
||
titleRow.appendChild(favicon);
|
||
}
|
||
|
||
var nameSpan = document.createElement('span');
|
||
nameSpan.className = 'card-name';
|
||
nameSpan.textContent = svc.name;
|
||
titleRow.appendChild(nameSpan);
|
||
|
||
var statusSpan = document.createElement('span');
|
||
statusSpan.className = 'card-status ' + (svc.status || 'unknown');
|
||
|
||
header.appendChild(titleRow);
|
||
header.appendChild(statusSpan);
|
||
|
||
var info = document.createElement('div');
|
||
info.className = 'card-info';
|
||
info.innerHTML = '<span class="card-tag">' + (svc.machine || extractHost(svc.url) || 'local') + '</span><span class="card-tag">' + (svc.category || 'other') + '</span>';
|
||
|
||
content.appendChild(header);
|
||
content.appendChild(info);
|
||
|
||
if (svc.description) {
|
||
var desc = document.createElement('div');
|
||
desc.className = 'card-description';
|
||
desc.textContent = svc.description;
|
||
content.appendChild(desc);
|
||
}
|
||
|
||
var editPanel = createEditPanel(svc);
|
||
|
||
var preview = document.createElement('div');
|
||
preview.className = 'card-preview' + (showPreview ? ' active' : '');
|
||
if (showPreview && svc.url) {
|
||
preview.innerHTML = '<div class="card-preview-overlay"></div><iframe src="' + svc.url + '" loading="lazy"></iframe>';
|
||
}
|
||
|
||
var resizeRight = document.createElement('div'); resizeRight.className = 'resize-handle right';
|
||
var resizeBottom = document.createElement('div'); resizeBottom.className = 'resize-handle bottom';
|
||
var resizeCorner = document.createElement('div'); resizeCorner.className = 'resize-handle corner';
|
||
|
||
resizeRight.addEventListener('mousedown', function(e) {
|
||
if (!editMode) return;
|
||
e.stopPropagation();
|
||
startResize(card, 'right', e);
|
||
});
|
||
resizeBottom.addEventListener('mousedown', function(e) {
|
||
if (!editMode) return;
|
||
e.stopPropagation();
|
||
startResize(card, 'bottom', e);
|
||
});
|
||
resizeCorner.addEventListener('mousedown', function(e) {
|
||
if (!editMode) return;
|
||
e.stopPropagation();
|
||
startResize(card, 'corner', e);
|
||
});
|
||
|
||
card.appendChild(removeBtn);
|
||
card.appendChild(editCardBtn);
|
||
card.appendChild(previewBtn);
|
||
card.appendChild(content);
|
||
card.appendChild(preview);
|
||
card.appendChild(editPanel);
|
||
card.appendChild(resizeRight);
|
||
card.appendChild(resizeBottom);
|
||
card.appendChild(resizeCorner);
|
||
}
|
||
|
||
function renderWidget(card, svc, size) {
|
||
var content = document.createElement('div');
|
||
content.className = 'card-content';
|
||
|
||
if (svc.widgetType === 'ha_entity') {
|
||
if (!svc.entityId) {
|
||
content.innerHTML = '<div class="widget-ha"><div style="color:var(--error);font-size:10px;">missing entity</div></div>';
|
||
card.appendChild(content);
|
||
return;
|
||
}
|
||
var entity = haEntities.find(function(e) { return e.entity_id === svc.entityId; });
|
||
var isOn = entity && (entity.state === 'on' || entity.state === 'unlocked' || entity.state === 'open' || entity.state === 'home');
|
||
var domain = svc.entityId.split('.')[0];
|
||
var canToggle = ['light', 'switch', 'fan', 'cover', 'lock', 'input_boolean', 'script', 'automation'].indexOf(domain) !== -1;
|
||
var iconClass = getHAIcon(entity || svc.entityId);
|
||
var iconColor = getHAColor(entity);
|
||
var iconStyle = iconColor ? 'color:' + iconColor + ';text-shadow:0 0 10px ' + iconColor : '';
|
||
var attrs = entity ? entity.attributes : {};
|
||
var hasBrightness = domain === 'light' && attrs.supported_color_modes;
|
||
var hasColor = domain === 'light' && attrs.supported_color_modes &&
|
||
(attrs.supported_color_modes.indexOf('hs') !== -1 || attrs.supported_color_modes.indexOf('rgb') !== -1);
|
||
var hasControls = hasBrightness || hasColor || domain === 'fan' || domain === 'cover' || domain === 'climate';
|
||
|
||
var widget = document.createElement('div');
|
||
widget.className = 'widget-ha';
|
||
widget.dataset.entityId = svc.entityId;
|
||
|
||
var html = '<i class="widget-ha-icon ' + iconClass + (hasControls ? ' has-controls' : '') + '" style="' + iconStyle + '"></i>' +
|
||
'<div class="widget-ha-name">' + svc.name + '</div>' +
|
||
'<div class="widget-ha-state ' + (isOn ? 'on' : 'off') + '">' + (entity ? entity.state : 'unknown') + '</div>' +
|
||
(canToggle ? '<div class="widget-ha-toggle ' + (isOn ? 'on' : 'off') + '"></div>' : '');
|
||
|
||
widget.innerHTML = html;
|
||
|
||
var iconEl = widget.querySelector('.widget-ha-icon');
|
||
if (hasControls) {
|
||
iconEl.style.cursor = 'pointer';
|
||
iconEl.addEventListener('click', function(e) {
|
||
e.stopPropagation();
|
||
showHAControlsPopup(svc.entityId, svc.name, e);
|
||
});
|
||
}
|
||
|
||
if (canToggle) {
|
||
widget.querySelector('.widget-ha-toggle').addEventListener('click', function(e) {
|
||
e.stopPropagation();
|
||
toggleHAEntity(svc.entityId);
|
||
});
|
||
}
|
||
content.appendChild(widget);
|
||
} else {
|
||
var display = document.createElement('div');
|
||
display.className = 'widget-display';
|
||
display.dataset.widgetType = svc.widgetType;
|
||
display.dataset.widgetId = svc.id;
|
||
if (size.w <= GRID_SIZE + 10) display.classList.add('small');
|
||
content.appendChild(display);
|
||
}
|
||
|
||
card.appendChild(content);
|
||
|
||
var removeBtn = createButton('card-btn card-remove', '×', function(e) {
|
||
e.stopPropagation();
|
||
confirmDelete(svc);
|
||
});
|
||
card.appendChild(removeBtn);
|
||
|
||
var resizeCorner = document.createElement('div');
|
||
resizeCorner.className = 'resize-handle corner';
|
||
resizeCorner.addEventListener('mousedown', function(e) {
|
||
if (!editMode) return;
|
||
e.stopPropagation();
|
||
startResize(card, 'corner', e);
|
||
});
|
||
card.appendChild(resizeCorner);
|
||
}
|
||
|
||
function updateWidgets() {
|
||
document.querySelectorAll('.widget-display').forEach(function(display) {
|
||
var type = display.dataset.widgetType;
|
||
var card = display.closest('.card');
|
||
var w = card ? card.offsetWidth : 200;
|
||
var h = card ? card.offsetHeight : 80;
|
||
var minDim = Math.min(w, h);
|
||
var clockSize = Math.max(6, Math.floor(minDim * 0.18));
|
||
var dateSize = Math.max(5, Math.floor(minDim * 0.08));
|
||
var now = new Date();
|
||
if (type === 'clock') {
|
||
display.innerHTML = '<div class="widget-clock" style="font-size:' + clockSize + 'px">' +
|
||
now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) + '</div>';
|
||
} else if (type === 'date') {
|
||
display.innerHTML = '<div class="widget-clock" style="font-size:' + clockSize + 'px">' +
|
||
now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) + '</div>' +
|
||
'<div class="widget-date" style="font-size:' + dateSize + 'px;margin-top:' + Math.floor(dateSize * 0.3) + 'px">' +
|
||
now.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) + '</div>';
|
||
}
|
||
});
|
||
// Update all other widget types
|
||
updateAllWidgets();
|
||
}
|
||
setInterval(updateWidgets, 1000);
|
||
|
||
function createButton(className, html, onclick) {
|
||
var btn = document.createElement('button');
|
||
btn.className = className;
|
||
btn.innerHTML = html;
|
||
btn.addEventListener('click', onclick);
|
||
return btn;
|
||
}
|
||
|
||
function createEditPanel(svc) {
|
||
var panel = document.createElement('div');
|
||
panel.className = 'card-edit-panel';
|
||
panel.innerHTML =
|
||
'<label>name</label><input type="text" class="edit-name" value="' + (svc.name || '').replace(/"/g, '"') + '">' +
|
||
'<label>url</label><input type="text" class="edit-url" value="' + (svc.url || '').replace(/"/g, '"') + '">' +
|
||
'<label>status</label><select class="edit-status">' +
|
||
'<option value="unknown"' + (svc.status === 'unknown' ? ' selected' : '') + '>unknown</option>' +
|
||
'<option value="up"' + (svc.status === 'up' ? ' selected' : '') + '>up</option>' +
|
||
'<option value="down"' + (svc.status === 'down' ? ' selected' : '') + '>down</option>' +
|
||
'</select>' +
|
||
'<label>description</label><textarea class="edit-desc">' + (svc.description || '') + '</textarea>' +
|
||
'<button class="save-btn">save</button>';
|
||
|
||
panel.querySelector('.save-btn').addEventListener('click', function(e) {
|
||
e.stopPropagation();
|
||
svc.name = panel.querySelector('.edit-name').value.trim() || svc.name;
|
||
svc.url = panel.querySelector('.edit-url').value.trim();
|
||
svc.status = panel.querySelector('.edit-status').value;
|
||
svc.description = panel.querySelector('.edit-desc').value.trim();
|
||
var idx = services.findIndex(function(s) { return s.id === svc.id; });
|
||
if (idx !== -1) services[idx] = svc;
|
||
save();
|
||
renderDashboard();
|
||
showToast('saved');
|
||
});
|
||
|
||
panel.addEventListener('click', function(e) { e.stopPropagation(); });
|
||
return panel;
|
||
}
|
||
|
||
function toggleEditPanel(card) {
|
||
var panel = card.querySelector('.card-edit-panel');
|
||
document.querySelectorAll('.card-edit-panel.open').forEach(function(p) {
|
||
if (p !== panel) p.classList.remove('open');
|
||
});
|
||
panel.classList.toggle('open');
|
||
}
|
||
|
||
function togglePreview(id) {
|
||
cardPreviews[id] = !cardPreviews[id];
|
||
localStorage.setItem('dashd_previews', JSON.stringify(cardPreviews));
|
||
renderDashboard();
|
||
}
|
||
|
||
function getFaviconUrl(url) {
|
||
try {
|
||
var u = new URL(url);
|
||
return u.origin + '/favicon.ico';
|
||
} catch (e) { return ''; }
|
||
}
|
||
|
||
function extractHost(url) {
|
||
try { return new URL(url).hostname; } catch (e) { return ''; }
|
||
}
|
||
|
||
// collision detection
|
||
function getCardRect(id) {
|
||
var pos = cardPositions[id] || { x: 0, y: 0 };
|
||
var size = cardSizes[id] || { w: GRID_SIZE * 2 + GRID_GAP, h: GRID_SIZE };
|
||
return { x: pos.x, y: pos.y, w: size.w, h: size.h };
|
||
}
|
||
|
||
function rectsOverlap(r1, r2) {
|
||
return !(r1.x + r1.w <= r2.x || r2.x + r2.w <= r1.x || r1.y + r1.h <= r2.y || r2.y + r2.h <= r1.y);
|
||
}
|
||
|
||
function checkCollision(excludeId, x, y, w, h) {
|
||
var testRect = { x: x, y: y, w: w, h: h };
|
||
for (var i = 0; i < services.length; i++) {
|
||
if (services[i].id === excludeId) continue;
|
||
if (rectsOverlap(testRect, getCardRect(services[i].id))) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function findValidPosition(excludeId, targetX, targetY, w, h) {
|
||
if (!checkCollision(excludeId, targetX, targetY, w, h)) return { x: targetX, y: targetY };
|
||
var step = GRID_SIZE + GRID_GAP;
|
||
for (var radius = 1; radius < 30; radius++) {
|
||
for (var dx = -radius; dx <= radius; dx++) {
|
||
for (var dy = -radius; dy <= radius; dy++) {
|
||
if (Math.abs(dx) !== radius && Math.abs(dy) !== radius) continue;
|
||
var testX = Math.max(0, targetX + dx * step);
|
||
var testY = Math.max(0, targetY + dy * step);
|
||
if (!checkCollision(excludeId, testX, testY, w, h)) return { x: testX, y: testY };
|
||
}
|
||
}
|
||
}
|
||
return { x: targetX, y: targetY };
|
||
}
|
||
|
||
// drag
|
||
function startDrag(card, e) {
|
||
e.preventDefault();
|
||
var rect = card.getBoundingClientRect();
|
||
var gridRect = grid.getBoundingClientRect();
|
||
var offsetX = e.clientX - rect.left;
|
||
var offsetY = e.clientY - rect.top;
|
||
card.classList.add('dragging');
|
||
|
||
function onMove(e) {
|
||
var x = e.clientX - gridRect.left - offsetX;
|
||
var y = e.clientY - gridRect.top - offsetY;
|
||
x = Math.round(x / (GRID_SIZE + GRID_GAP)) * (GRID_SIZE + GRID_GAP);
|
||
y = Math.round(y / (GRID_SIZE + GRID_GAP)) * (GRID_SIZE + GRID_GAP);
|
||
card.style.left = Math.max(0, x) + 'px';
|
||
card.style.top = Math.max(0, y) + 'px';
|
||
}
|
||
|
||
function onUp() {
|
||
card.classList.remove('dragging');
|
||
var id = card.dataset.id;
|
||
var x = parseInt(card.style.left);
|
||
var y = parseInt(card.style.top);
|
||
var w = card.offsetWidth;
|
||
var h = card.offsetHeight;
|
||
var validPos = findValidPosition(id, x, y, w, h);
|
||
card.style.left = validPos.x + 'px';
|
||
card.style.top = validPos.y + 'px';
|
||
cardPositions[id] = validPos;
|
||
save();
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
}
|
||
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
}
|
||
|
||
// resize
|
||
function startResize(card, direction, e) {
|
||
e.preventDefault();
|
||
var startX = e.clientX, startY = e.clientY;
|
||
var startW = card.offsetWidth, startH = card.offsetHeight;
|
||
var isRight = direction === 'right' || direction === 'corner';
|
||
var isBottom = direction === 'bottom' || direction === 'corner';
|
||
|
||
function onMove(e) {
|
||
var w = startW, h = startH;
|
||
if (isRight) { w = Math.max(GRID_SIZE, Math.round((startW + e.clientX - startX) / (GRID_SIZE + GRID_GAP)) * (GRID_SIZE + GRID_GAP) - GRID_GAP); }
|
||
if (isBottom) { h = Math.max(GRID_SIZE, Math.round((startH + e.clientY - startY) / (GRID_SIZE + GRID_GAP)) * (GRID_SIZE + GRID_GAP) - GRID_GAP); }
|
||
card.style.width = w + 'px';
|
||
card.style.height = h + 'px';
|
||
card.classList.toggle('size-tiny', w <= GRID_SIZE + 10 && h <= GRID_SIZE + 10);
|
||
}
|
||
|
||
function onUp() {
|
||
var id = card.dataset.id;
|
||
cardSizes[id] = { w: card.offsetWidth, h: card.offsetHeight }; updateAllWidgets();
|
||
save();
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
}
|
||
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
}
|
||
|
||
function toggleEditMode() {
|
||
editMode = !editMode;
|
||
document.getElementById('editBtn').classList.toggle('active', editMode);
|
||
grid.classList.toggle('edit-mode', editMode);
|
||
}
|
||
|
||
function toggleSidebar() {
|
||
sidebarOpen = !sidebarOpen;
|
||
sidebar.classList.toggle('open', sidebarOpen);
|
||
document.getElementById('scanBtn').classList.toggle('active', sidebarOpen);
|
||
}
|
||
|
||
// machines
|
||
function addMachine() {
|
||
var ip = document.getElementById('machineIp').value.trim();
|
||
if (!ip || machines.find(function(m) { return m.ip === ip; })) return;
|
||
machines.push({ ip: ip, status: 'pending' });
|
||
localStorage.setItem('dashd_machines_' + (authUser || 'guest'), JSON.stringify(machines));
|
||
document.getElementById('machineIp').value = '';
|
||
renderMachines();
|
||
}
|
||
|
||
function removeMachine(ip) {
|
||
machines = machines.filter(function(m) { return m.ip !== ip; });
|
||
localStorage.setItem('dashd_machines_' + (authUser || 'guest'), JSON.stringify(machines));
|
||
renderMachines();
|
||
}
|
||
|
||
function renderMachines() {
|
||
var list = document.getElementById('machinesList');
|
||
list.innerHTML = '';
|
||
machines.forEach(function(m) {
|
||
var item = document.createElement('div');
|
||
item.className = 'machine-item';
|
||
item.innerHTML = '<div><div class="ip">' + m.ip + '</div><div class="status">' + (m.status || 'not scanned') + '</div></div>';
|
||
var removeBtn = document.createElement('button');
|
||
removeBtn.className = 'remove';
|
||
removeBtn.innerHTML = '×';
|
||
removeBtn.onclick = function() { removeMachine(m.ip); };
|
||
item.appendChild(removeBtn);
|
||
list.appendChild(item);
|
||
});
|
||
}
|
||
|
||
async function scanMachines() {
|
||
discovered = [];
|
||
var API_URL = '/scan';
|
||
for (var i = 0; i < machines.length; i++) {
|
||
var machine = machines[i];
|
||
machine.status = 'scanning...';
|
||
renderMachines();
|
||
try {
|
||
var response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ip: machine.ip }) });
|
||
var data = await response.json();
|
||
if (data.services) discovered = discovered.concat(data.services);
|
||
machine.status = (data.services ? data.services.length : 0) + ' services';
|
||
} catch (e) { machine.status = 'failed'; }
|
||
}
|
||
renderMachines();
|
||
renderDiscovered();
|
||
autoDetectServices();
|
||
}
|
||
|
||
function autoDetectServices() {
|
||
var detected = [];
|
||
|
||
// Home Assistant (port 8123)
|
||
if (!haConfig.url) {
|
||
var ha = discovered.find(function(d) { return d.port === 8123; });
|
||
if (ha) {
|
||
document.getElementById('haUrl').value = 'http://' + ha.machine + ':8123';
|
||
document.getElementById('haStatus').innerHTML = '<span style="color:var(--lavender)">detected - add token</span>';
|
||
detected.push('home assistant');
|
||
}
|
||
}
|
||
|
||
// Proxmox (port 8006)
|
||
if (!pveConfig.url) {
|
||
var pve = discovered.find(function(d) { return d.port === 8006; });
|
||
if (pve) {
|
||
document.getElementById('pveUrl').value = 'https://' + pve.machine + ':8006';
|
||
document.getElementById('pveStatus').innerHTML = '<span style="color:var(--lavender)">detected - add credentials</span>';
|
||
detected.push('proxmox');
|
||
}
|
||
}
|
||
|
||
// Docker (ports 2375, 2376, or portainer 9000)
|
||
if (!dockerConfig.url) {
|
||
var docker = discovered.find(function(d) { return d.port === 2375 || d.port === 2376; });
|
||
if (!docker) docker = discovered.find(function(d) { return d.port === 9000 && d.name && d.name.toLowerCase().indexOf('portainer') !== -1; });
|
||
if (docker) {
|
||
var dPort = docker.port === 9000 ? 2375 : docker.port;
|
||
document.getElementById('dockerUrl').value = 'http://' + docker.machine + ':' + dPort;
|
||
document.getElementById('dockerStatus').innerHTML = '<span style="color:var(--lavender)">detected</span>';
|
||
detected.push('docker');
|
||
}
|
||
}
|
||
|
||
// AdGuard (typically 3000 or custom)
|
||
if (!adguardConfig.url) {
|
||
var adguard = discovered.find(function(d) { return d.name && d.name.toLowerCase().indexOf('adguard') !== -1; });
|
||
if (adguard) {
|
||
document.getElementById('adguardUrl').value = 'http://' + adguard.machine + ':' + adguard.port;
|
||
document.getElementById('adguardStatus').innerHTML = '<span style="color:var(--lavender)">detected - add credentials</span>';
|
||
detected.push('adguard');
|
||
}
|
||
}
|
||
|
||
// Jellyfin (port 8096)
|
||
if (!jellyfinConfig.url) {
|
||
var jellyfin = discovered.find(function(d) { return d.port === 8096 || (d.name && d.name.toLowerCase().indexOf('jellyfin') !== -1); });
|
||
if (jellyfin) {
|
||
document.getElementById('jellyfinUrl').value = 'http://' + jellyfin.machine + ':' + (jellyfin.port || 8096);
|
||
document.getElementById('jellyfinStatus').innerHTML = '<span style="color:var(--lavender)">detected - add api key</span>';
|
||
detected.push('jellyfin');
|
||
}
|
||
}
|
||
|
||
// Frigate (port 5000)
|
||
if (!frigateConfig.url) {
|
||
var frigate = discovered.find(function(d) { return d.port === 5000 && d.name && d.name.toLowerCase().indexOf('frigate') !== -1; });
|
||
if (frigate) {
|
||
document.getElementById('frigateUrl').value = 'http://' + frigate.machine + ':5000';
|
||
document.getElementById('frigateStatus').innerHTML = '<span style="color:var(--lavender)">detected</span>';
|
||
detected.push('frigate');
|
||
}
|
||
}
|
||
|
||
// OPNsense (port 443 or 80 with opnsense in name)
|
||
if (!opnsenseConfig.url) {
|
||
var opn = discovered.find(function(d) { return d.name && d.name.toLowerCase().indexOf('opnsense') !== -1; });
|
||
if (opn) {
|
||
document.getElementById('opnsenseUrl').value = 'https://' + opn.machine;
|
||
document.getElementById('opnsenseStatus').innerHTML = '<span style="color:var(--lavender)">detected - add api keys</span>';
|
||
detected.push('opnsense');
|
||
}
|
||
}
|
||
|
||
if (detected.length > 0) {
|
||
showToast('detected: ' + detected.join(', '));
|
||
}
|
||
}
|
||
|
||
function renderDiscovered() {
|
||
var list = document.getElementById('discoveredList');
|
||
list.innerHTML = '';
|
||
if (discovered.length === 0) {
|
||
list.innerHTML = '<div style="color:var(--text-dim);font-size:11px;text-align:center;padding:15px;">no services found</div>';
|
||
return;
|
||
}
|
||
discovered.forEach(function(d) {
|
||
var item = document.createElement('div');
|
||
item.className = 'discovered-item';
|
||
item.draggable = true;
|
||
item.dataset.serviceId = d.id;
|
||
item.dataset.name = d.name;
|
||
item.dataset.url = d.url || d.machine + ':' + d.port;
|
||
item.innerHTML = '<div><div class="name">' + d.name + '</div><div class="details">' + d.machine + ':' + d.port + '</div></div>';
|
||
var addBtn = document.createElement('button');
|
||
addBtn.className = 'add-service';
|
||
addBtn.textContent = '+';
|
||
addBtn.onclick = function() { addDiscovered(d.id); };
|
||
item.appendChild(addBtn);
|
||
|
||
item.addEventListener('dragstart', function(e) {
|
||
e.dataTransfer.setData('text/plain', d.id);
|
||
item.classList.add('dragging');
|
||
});
|
||
item.addEventListener('dragend', function() { item.classList.remove('dragging'); });
|
||
|
||
list.appendChild(item);
|
||
});
|
||
}
|
||
|
||
function handleDiscoveredDrop(e) {
|
||
e.preventDefault();
|
||
grid.classList.remove('drag-over');
|
||
var id = e.dataTransfer.getData('text/plain');
|
||
if (id) addDiscovered(id);
|
||
}
|
||
|
||
function addDiscovered(id) {
|
||
var svc = discovered.find(function(d) { return d.id === id; });
|
||
if (!svc || services.find(function(s) { return s.id === svc.id; })) return;
|
||
var pos = findValidPosition(svc.id, 0, 0, GRID_SIZE * 2 + GRID_GAP, GRID_SIZE);
|
||
services.push(svc);
|
||
cardPositions[svc.id] = pos;
|
||
cardSizes[svc.id] = { w: GRID_SIZE * 2 + GRID_GAP, h: GRID_SIZE };
|
||
save();
|
||
renderDashboard();
|
||
showToast('added: ' + svc.name);
|
||
}
|
||
|
||
// modals
|
||
function openModal(id) { document.getElementById(id).classList.add('open'); }
|
||
function closeModal(id) { var el = document.getElementById(id); if(el) { el.classList.remove("open"); } }
|
||
function closeAllModals() { document.querySelectorAll('.modal-overlay').forEach(function(m) { m.classList.remove('open'); }); }
|
||
|
||
function addService() {
|
||
var name = document.getElementById('serviceName').value.trim();
|
||
var url = document.getElementById('serviceUrl').value.trim();
|
||
var category = document.getElementById('serviceCategory').value;
|
||
var description = document.getElementById('serviceDescription').value.trim();
|
||
if (!name) return;
|
||
|
||
var svc = { id: 'manual_' + Date.now(), name: name, url: url, category: category, description: description, status: 'unknown' };
|
||
var pos = findValidPosition(svc.id, 0, 0, GRID_SIZE * 2 + GRID_GAP, GRID_SIZE);
|
||
services.push(svc);
|
||
cardPositions[svc.id] = pos;
|
||
cardSizes[svc.id] = { w: GRID_SIZE * 2 + GRID_GAP, h: GRID_SIZE };
|
||
save();
|
||
renderDashboard();
|
||
closeModal('addModal');
|
||
document.getElementById('serviceName').value = '';
|
||
document.getElementById('serviceUrl').value = '';
|
||
document.getElementById('serviceDescription').value = '';
|
||
showToast('added: ' + name);
|
||
}
|
||
|
||
function addWidget() {
|
||
var type = document.getElementById('widgetType').value;
|
||
var svc = { id: 'widget_' + Date.now(), name: type, type: 'widget', widgetType: type };
|
||
var defaultW = GRID_SIZE, defaultH = GRID_SIZE;
|
||
|
||
if (type === 'ha_entity' || type === 'weather') {
|
||
var entityId = document.getElementById('haEntitySelect').value;
|
||
if (!entityId) { showToast('select an entity'); return; }
|
||
var entity = haEntities.find(function(e) { return e.entity_id === entityId; });
|
||
svc.entityId = entityId;
|
||
svc.name = entity ? (entity.attributes.friendly_name || entityId) : entityId;
|
||
if (type === 'weather') { defaultW = GRID_SIZE * 2 + GRID_GAP; defaultH = GRID_SIZE * 2 + GRID_GAP; }
|
||
} else if (type === 'proxmox') {
|
||
var vmId = document.getElementById('proxmoxVmSelect').value;
|
||
if (!vmId) { showToast('select a vm/ct'); return; }
|
||
var vm = pveResources.find(function(r) { return r.vmid == vmId; });
|
||
svc.vmid = vmId;
|
||
svc.name = vm ? vm.name : 'VM ' + vmId;
|
||
svc.node = vm ? vm.node : 'tux';
|
||
svc.vmtype = vm ? vm.type : 'qemu';
|
||
} else if (type === 'docker') {
|
||
var containerId = document.getElementById('dockerContainerSelect').value;
|
||
if (!containerId) { showToast('select a container'); return; }
|
||
var container = dockerContainers.find(function(c) { return c.Id === containerId; });
|
||
svc.containerId = containerId;
|
||
svc.name = container ? container.Names[0].replace(/^\//, '') : containerId.substr(0, 12);
|
||
} else if (type === 'frigate') {
|
||
var camera = document.getElementById('frigateCameraSelect').value;
|
||
if (!camera) { showToast('select a camera'); return; }
|
||
svc.camera = camera;
|
||
svc.name = camera;
|
||
defaultW = GRID_SIZE * 3 + GRID_GAP * 2;
|
||
defaultH = GRID_SIZE * 2 + GRID_GAP;
|
||
} else if (type === 'rss') {
|
||
var feedUrl = document.getElementById('rssFeedUrl').value.trim();
|
||
if (!feedUrl) { showToast('enter feed url'); return; }
|
||
svc.feedUrl = feedUrl;
|
||
svc.name = 'rss feed';
|
||
defaultW = GRID_SIZE * 2 + GRID_GAP;
|
||
defaultH = GRID_SIZE * 3 + GRID_GAP * 2;
|
||
} else if (type === 'iframe') {
|
||
var iframeUrl = document.getElementById('iframeUrl').value.trim();
|
||
if (!iframeUrl) { showToast('enter url'); return; }
|
||
svc.iframeUrl = iframeUrl;
|
||
svc.name = "embed";
|
||
} else if (type === "notd") {
|
||
svc.name = "notd";
|
||
svc.notes = "";
|
||
defaultW = GRID_SIZE * 2 + GRID_GAP;
|
||
defaultH = GRID_SIZE * 2 + GRID_GAP;
|
||
} else if (type === "spotify") {
|
||
var spotifyUrl = document.getElementById("spotifyUrl").value.trim();
|
||
svc.spotifyUrl = spotifyUrl;
|
||
svc.name = "spotify";
|
||
defaultW = GRID_SIZE * 3 + GRID_GAP * 2;
|
||
defaultH = GRID_SIZE + 10;
|
||
} else if (type === "twitch") {
|
||
var twitchChannel = document.getElementById("twitchChannel").value.trim();
|
||
if (!twitchChannel) { showToast("enter channel"); return; }
|
||
svc.twitchChannel = twitchChannel;
|
||
svc.name = twitchChannel;
|
||
defaultW = GRID_SIZE * 4 + GRID_GAP * 3;
|
||
defaultH = GRID_SIZE * 3 + GRID_GAP * 2;
|
||
} else if (type === "youtube") {
|
||
var ytUrl = document.getElementById("youtubeUrl").value.trim();
|
||
if (!ytUrl) { showToast("enter youtube url or id"); return; }
|
||
svc.youtubeUrl = ytUrl;
|
||
svc.name = "youtube";
|
||
defaultW = GRID_SIZE * 4 + GRID_GAP * 3;
|
||
defaultH = GRID_SIZE * 3 + GRID_GAP * 2;
|
||
} else if (type === "pomodoro") {
|
||
svc.name = "pomodoro";
|
||
defaultW = GRID_SIZE * 2 + GRID_GAP;
|
||
defaultH = GRID_SIZE * 2 + GRID_GAP;
|
||
} else if (type === "search") {
|
||
svc.searchEngine = document.getElementById("searchEngine").value;
|
||
svc.searchInstance = document.getElementById("searchInstance").value.trim();
|
||
svc.name = "search";
|
||
defaultW = GRID_SIZE * 3 + GRID_GAP * 2;
|
||
defaultH = GRID_SIZE;
|
||
} else if (type === "crypto") {
|
||
var coins = document.getElementById("cryptoCoins").value.trim() || "bitcoin,ethereum";
|
||
svc.cryptoCoins = coins;
|
||
svc.name = "crypto";
|
||
defaultW = GRID_SIZE * 2 + GRID_GAP;
|
||
defaultH = GRID_SIZE * 2 + GRID_GAP;
|
||
} else if (type === "todolist") {
|
||
svc.name = "todo";
|
||
svc.todos = [];
|
||
defaultW = GRID_SIZE * 2 + GRID_GAP;
|
||
defaultH = GRID_SIZE * 3 + GRID_GAP * 2;
|
||
} else if (type === "countdown") {
|
||
var cdName = document.getElementById("countdownName").value.trim() || "countdown";
|
||
var cdDate = document.getElementById("countdownDate").value;
|
||
if (!cdDate) { showToast("enter date"); return; }
|
||
svc.countdownName = cdName;
|
||
svc.countdownDate = cdDate;
|
||
svc.name = cdName;
|
||
defaultW = GRID_SIZE * 2 + GRID_GAP;
|
||
defaultH = GRID_SIZE;
|
||
} else if (type === "browser") {
|
||
var browserUrl = document.getElementById("browserUrl").value.trim();
|
||
if (!browserUrl) { showToast("enter url"); return; }
|
||
svc.browserUrl = browserUrl;
|
||
svc.name = "browser";
|
||
defaultW = GRID_SIZE * 4 + GRID_GAP * 3;
|
||
defaultH = GRID_SIZE * 4 + GRID_GAP * 3;
|
||
} else if (type === "nowplaying") {
|
||
svc.name = "now playing";
|
||
defaultW = GRID_SIZE * 3 + GRID_GAP * 2;
|
||
defaultH = GRID_SIZE;
|
||
defaultW = GRID_SIZE * 3 + GRID_GAP * 2;
|
||
defaultH = GRID_SIZE * 2 + GRID_GAP;
|
||
} else if (type === 'bookmark') {
|
||
var links = document.getElementById('bookmarkLinks').value.trim();
|
||
if (!links) { showToast('enter links'); return; }
|
||
svc.links = links.split('\n').map(function(l) {
|
||
var parts = l.split('|');
|
||
return { name: parts[0].trim(), url: parts[1] ? parts[1].trim() : '' };
|
||
}).filter(function(l) { return l.name && l.url; });
|
||
svc.name = 'bookmarks';
|
||
defaultW = GRID_SIZE * 2 + GRID_GAP;
|
||
} else if (type === 'adguard') {
|
||
svc.name = 'adguard';
|
||
defaultW = GRID_SIZE * 2 + GRID_GAP;
|
||
defaultH = GRID_SIZE * 2 + GRID_GAP;
|
||
} else if (type === 'jellyfin') {
|
||
svc.name = 'now playing';
|
||
defaultW = GRID_SIZE * 3 + GRID_GAP * 2;
|
||
} else if (type === 'sonarr' || type === 'radarr') {
|
||
svc.name = type + ' upcoming';
|
||
defaultW = GRID_SIZE * 2 + GRID_GAP;
|
||
defaultH = GRID_SIZE * 3 + GRID_GAP * 2;
|
||
} else if (type === 'speedtest') {
|
||
svc.name = 'speedtest';
|
||
defaultW = GRID_SIZE * 2 + GRID_GAP;
|
||
} else if (type === 'system_stats') {
|
||
svc.name = 'system';
|
||
defaultW = GRID_SIZE * 2 + GRID_GAP;
|
||
} else if (type === 'opnsense') {
|
||
svc.name = 'opnsense';
|
||
defaultW = GRID_SIZE * 2 + GRID_GAP;
|
||
defaultH = GRID_SIZE * 2 + GRID_GAP;
|
||
} else if (type === 'cloudflared') {
|
||
svc.name = 'cloudflare tunnel';
|
||
defaultW = GRID_SIZE * 2 + GRID_GAP;
|
||
} else if (type === 'unbound') {
|
||
svc.name = 'unbound dns';
|
||
defaultW = GRID_SIZE * 2 + GRID_GAP;
|
||
} else if (type === 'habitica') {
|
||
svc.name = 'habitica';
|
||
defaultW = GRID_SIZE * 2 + GRID_GAP;
|
||
defaultH = GRID_SIZE * 3 + GRID_GAP * 2;
|
||
} else if (type === 'calendar') {
|
||
svc.name = 'calendar';
|
||
defaultW = GRID_SIZE * 2 + GRID_GAP;
|
||
defaultH = GRID_SIZE * 3 + GRID_GAP * 2;
|
||
} else if (type === 'mailbox') {
|
||
var mServer = document.getElementById('mailboxServer').value.trim();
|
||
var mUser = document.getElementById('mailboxUser').value.trim();
|
||
var mPass = document.getElementById('mailboxPass').value;
|
||
if (!mServer || !mUser || !mPass) { showToast('enter mail config'); return; }
|
||
svc.mailServer = mServer;
|
||
svc.mailUser = mUser;
|
||
svc.mailPass = mPass;
|
||
svc.name = 'inbox';
|
||
defaultW = GRID_SIZE * 3 + GRID_GAP * 2;
|
||
defaultH = GRID_SIZE * 3 + GRID_GAP * 2;
|
||
}
|
||
|
||
var pos = findValidPosition(svc.id, 0, 0, defaultW, defaultH);
|
||
services.push(svc);
|
||
cardPositions[svc.id] = pos;
|
||
cardSizes[svc.id] = { w: defaultW, h: defaultH };
|
||
save();
|
||
renderDashboard();
|
||
closeModal('widgetModal');
|
||
showToast('widget added');
|
||
}
|
||
|
||
function confirmDelete(svc) {
|
||
document.getElementById('confirmText').innerHTML = 'delete <strong>' + svc.name + '</strong>?';
|
||
confirmCallback = function() { removeService(svc.id); };
|
||
openModal('confirmModal');
|
||
}
|
||
|
||
function removeService(id) {
|
||
services = services.filter(function(s) { return s.id !== id; });
|
||
delete cardPositions[id];
|
||
delete cardSizes[id];
|
||
delete cardPreviews[id];
|
||
save();
|
||
renderDashboard();
|
||
showToast('deleted');
|
||
}
|
||
|
||
// context menu
|
||
function handleContextAction(action, svc) {
|
||
if (action === 'open' && svc.url) window.open(svc.url, '_blank');
|
||
if (action === 'copy' && svc.url) { navigator.clipboard.writeText(svc.url); showToast('copied'); }
|
||
if (action === 'edit') { editMode = true; grid.classList.add('edit-mode'); document.getElementById('editBtn').classList.add('active'); }
|
||
if (action === 'delete') confirmDelete(svc);
|
||
}
|
||
|
||
// search/filter
|
||
function filterCards() {
|
||
var term = searchInput.value.toLowerCase();
|
||
document.querySelectorAll('.card').forEach(function(card) {
|
||
var name = card.dataset.name || '';
|
||
var cat = card.dataset.category || '';
|
||
var match = !term || name.includes(term) || cat.includes(term);
|
||
card.classList.toggle('filtered-out', !match);
|
||
});
|
||
}
|
||
|
||
function filterDiscovered() {
|
||
var term = document.getElementById('discoveredSearch').value.toLowerCase();
|
||
document.querySelectorAll('.discovered-item').forEach(function(item) {
|
||
var name = (item.dataset.name || '').toLowerCase();
|
||
var url = (item.dataset.url || '').toLowerCase();
|
||
var match = !term || name.includes(term) || url.includes(term);
|
||
item.style.display = match ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
// health checks
|
||
function startHealthChecks() {
|
||
if (healthCheckTimer) clearInterval(healthCheckTimer);
|
||
healthCheckTimer = setInterval(runHealthChecks, HEALTH_CHECK_INTERVAL);
|
||
}
|
||
|
||
async function runHealthChecks() {
|
||
showToast('checking health...');
|
||
for (var i = 0; i < services.length; i++) {
|
||
var svc = services[i];
|
||
if (!svc.url || svc.type === 'widget') continue;
|
||
try {
|
||
var controller = new AbortController();
|
||
var timeout = setTimeout(function() { controller.abort(); }, 5000);
|
||
await fetch(svc.url, { method: 'HEAD', mode: 'no-cors', signal: controller.signal });
|
||
clearTimeout(timeout);
|
||
svc.status = 'up';
|
||
} catch (e) {
|
||
svc.status = 'down';
|
||
}
|
||
}
|
||
save();
|
||
renderDashboard();
|
||
showToast('health check complete');
|
||
}
|
||
|
||
// import/export
|
||
function exportConfig() {
|
||
var config = { services: services, positions: cardPositions, sizes: cardSizes, previews: cardPreviews, machines: machines };
|
||
var blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
|
||
var url = URL.createObjectURL(blob);
|
||
var a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'dashd-config-' + new Date().toISOString().slice(0, 10) + '.json';
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
showToast('exported');
|
||
}
|
||
|
||
function importConfig(e) {
|
||
var file = e.target.files[0];
|
||
if (!file) return;
|
||
var reader = new FileReader();
|
||
reader.onload = function(ev) {
|
||
try {
|
||
var config = JSON.parse(ev.target.result);
|
||
if (config.services) services = config.services;
|
||
if (config.positions) cardPositions = config.positions;
|
||
if (config.sizes) cardSizes = config.sizes;
|
||
if (config.previews) cardPreviews = config.previews;
|
||
if (config.machines) machines = config.machines;
|
||
save();
|
||
localStorage.setItem('dashd_machines_' + (authUser || 'guest'), JSON.stringify(machines));
|
||
renderDashboard();
|
||
renderMachines();
|
||
showToast('imported');
|
||
} catch (err) {
|
||
showToast('import failed');
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
e.target.value = '';
|
||
}
|
||
|
||
// update grid size based on content
|
||
function updateGridSize() { var containerHeight = grid.parentElement.clientHeight - 40;
|
||
var maxBottom = containerHeight;
|
||
var cards = grid.querySelectorAll('.card');
|
||
cards.forEach(function(card) {
|
||
var bottom = card.offsetTop + card.offsetHeight + 40;
|
||
if (bottom > maxBottom) maxBottom = bottom;
|
||
});
|
||
grid.style.minHeight = maxBottom + 'px';
|
||
}
|
||
|
||
// save
|
||
function save() { queueSync();
|
||
localStorage.setItem('dashd_services_' + (authUser || 'guest'), JSON.stringify(services));
|
||
localStorage.setItem('dashd_positions_' + (authUser || 'guest'), JSON.stringify(cardPositions));
|
||
localStorage.setItem('dashd_sizes_' + (authUser || 'guest'), JSON.stringify(cardSizes));
|
||
localStorage.setItem('dashd_previews', JSON.stringify(cardPreviews));
|
||
updateGridSize();
|
||
}
|
||
|
||
// toast
|
||
function showToast(msg) {
|
||
toast.textContent = msg;
|
||
toast.classList.add('show');
|
||
setTimeout(function() { toast.classList.remove('show'); }, 2000);
|
||
}
|
||
|
||
// home assistant
|
||
function initHA() {
|
||
if (haConfig.url) document.getElementById('haUrl').value = haConfig.url;
|
||
if (haConfig.token) document.getElementById('haToken').value = haConfig.token;
|
||
if (haConfig.url && haConfig.token) {
|
||
refreshHAEntities();
|
||
startHAUpdates();
|
||
}
|
||
}
|
||
|
||
async function connectHA() {
|
||
var url = document.getElementById('haUrl').value.trim().replace(/\/$/, '');
|
||
var token = document.getElementById('haToken').value.trim();
|
||
if (!url || !token) { showToast('need url and token'); return; }
|
||
haConfig = { url: url, token: token };
|
||
localStorage.setItem('dashd_ha_config', JSON.stringify(haConfig));
|
||
document.getElementById('haStatus').textContent = 'connecting...';
|
||
try {
|
||
var res = await fetch(url + '/api/', { headers: { 'Authorization': 'Bearer ' + token } });
|
||
if (res.ok) {
|
||
document.getElementById('haStatus').innerHTML = '<span style="color:var(--seafoam)">connected</span>';
|
||
refreshHAEntities();
|
||
startHAUpdates();
|
||
} else {
|
||
document.getElementById('haStatus').innerHTML = '<span style="color:var(--error)">auth failed</span>';
|
||
}
|
||
} catch (err) {
|
||
document.getElementById('haStatus').innerHTML = '<span style="color:var(--error)">connection failed</span>';
|
||
}
|
||
}
|
||
|
||
async function refreshHAEntities() {
|
||
if (!haConfig.url || !haConfig.token) return;
|
||
try {
|
||
var res = await fetch(haConfig.url + '/api/states', { headers: { 'Authorization': 'Bearer ' + haConfig.token } });
|
||
if (res.ok) {
|
||
haEntities = await res.json();
|
||
populateEntitySelect();
|
||
updateHAWidgets();
|
||
}
|
||
} catch (err) { /* HA unavailable */ }
|
||
}
|
||
|
||
function populateEntitySelect() {
|
||
var select = document.getElementById('haEntitySelect');
|
||
select.innerHTML = '';
|
||
var domains = {};
|
||
haEntities.forEach(function(e) {
|
||
var domain = e.entity_id.split('.')[0];
|
||
if (!domains[domain]) domains[domain] = [];
|
||
domains[domain].push(e);
|
||
});
|
||
var priority = ['light', 'switch', 'fan', 'cover', 'climate', 'lock', 'media_player', 'sensor', 'binary_sensor'];
|
||
var sorted = Object.keys(domains).sort(function(a, b) {
|
||
var ai = priority.indexOf(a), bi = priority.indexOf(b);
|
||
if (ai === -1) ai = 99; if (bi === -1) bi = 99;
|
||
return ai - bi;
|
||
});
|
||
sorted.forEach(function(domain) {
|
||
var group = document.createElement('optgroup');
|
||
group.label = domain;
|
||
domains[domain].sort(function(a, b) {
|
||
return (a.attributes.friendly_name || a.entity_id).localeCompare(b.attributes.friendly_name || b.entity_id);
|
||
}).forEach(function(e) {
|
||
var opt = document.createElement('option');
|
||
opt.value = e.entity_id;
|
||
opt.textContent = e.attributes.friendly_name || e.entity_id;
|
||
group.appendChild(opt);
|
||
});
|
||
select.appendChild(group);
|
||
});
|
||
}
|
||
|
||
function startHAUpdates() {
|
||
if (haUpdateTimer) clearInterval(haUpdateTimer);
|
||
haUpdateTimer = setInterval(function() {
|
||
refreshHAEntities();
|
||
}, 5000);
|
||
}
|
||
|
||
function updateHAWidgets() {
|
||
document.querySelectorAll('.widget-ha').forEach(function(widget) {
|
||
var entityId = widget.dataset.entityId;
|
||
var entity = haEntities.find(function(e) { return e.entity_id === entityId; });
|
||
if (!entity) return;
|
||
var isOn = entity.state === 'on' || entity.state === 'unlocked' || entity.state === 'open' || entity.state === 'home';
|
||
var icon = widget.querySelector('.widget-ha-icon');
|
||
var state = widget.querySelector('.widget-ha-state');
|
||
var toggle = widget.querySelector('.widget-ha-toggle');
|
||
if (icon) {
|
||
var iconClass = getHAIcon(entity);
|
||
var iconColor = getHAColor(entity);
|
||
icon.className = 'widget-ha-icon ' + iconClass;
|
||
icon.style.color = iconColor || '';
|
||
icon.style.textShadow = iconColor ? '0 0 10px ' + iconColor : '';
|
||
}
|
||
if (state) {
|
||
state.textContent = entity.state;
|
||
state.className = 'widget-ha-state ' + (isOn ? 'on' : 'off');
|
||
}
|
||
if (toggle) { toggle.className = 'widget-ha-toggle ' + (isOn ? 'on' : 'off'); }
|
||
});
|
||
}
|
||
|
||
async function toggleHAEntity(entityId) {
|
||
if (!haConfig.url || !haConfig.token) return;
|
||
var domain = entityId.split('.')[0];
|
||
var service = 'toggle';
|
||
if (domain === 'cover') service = 'toggle';
|
||
else if (domain === 'lock') service = 'toggle';
|
||
try {
|
||
await fetch(haConfig.url + '/api/services/' + domain + '/' + service, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': 'Bearer ' + haConfig.token, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ entity_id: entityId })
|
||
});
|
||
setTimeout(refreshHAEntities, 500);
|
||
} catch (err) { showToast('toggle failed'); }
|
||
}
|
||
|
||
var haControlsPopup = document.getElementById('haControlsPopup');
|
||
var currentPopupEntityId = null;
|
||
|
||
function hslToHex(h, s, l) {
|
||
s /= 100; l /= 100;
|
||
var c = (1 - Math.abs(2 * l - 1)) * s;
|
||
var x = c * (1 - Math.abs((h / 60) % 2 - 1));
|
||
var m = l - c / 2;
|
||
var r = 0, g = 0, b = 0;
|
||
if (h < 60) { r = c; g = x; }
|
||
else if (h < 120) { r = x; g = c; }
|
||
else if (h < 180) { g = c; b = x; }
|
||
else if (h < 240) { g = x; b = c; }
|
||
else if (h < 300) { r = x; b = c; }
|
||
else { r = c; b = x; }
|
||
r = Math.round((r + m) * 255).toString(16).padStart(2, '0');
|
||
g = Math.round((g + m) * 255).toString(16).padStart(2, '0');
|
||
b = Math.round((b + m) * 255).toString(16).padStart(2, '0');
|
||
return '#' + r + g + b;
|
||
}
|
||
|
||
function hexToRgb(hex) {
|
||
var r = parseInt(hex.slice(1, 3), 16);
|
||
var g = parseInt(hex.slice(3, 5), 16);
|
||
var b = parseInt(hex.slice(5, 7), 16);
|
||
return [r, g, b];
|
||
}
|
||
|
||
function showHAControlsPopup(entityId, name, e) {
|
||
var entity = haEntities.find(function(en) { return en.entity_id === entityId; });
|
||
if (!entity) return;
|
||
var attrs = entity.attributes || {};
|
||
var domain = entityId.split('.')[0];
|
||
currentPopupEntityId = entityId;
|
||
|
||
var html = '<div class="popup-title">' + name + '</div>';
|
||
|
||
if (domain === 'light' && attrs.supported_color_modes) {
|
||
var bri = attrs.brightness || 0;
|
||
html += '<div class="widget-ha-slider-row"><label>☀</label>' +
|
||
'<input type="range" class="widget-ha-slider bri" min="0" max="255" value="' + bri + '" data-attr="brightness"></div>';
|
||
|
||
var hasColor = attrs.supported_color_modes.indexOf('hs') !== -1 || attrs.supported_color_modes.indexOf('rgb') !== -1;
|
||
if (hasColor) {
|
||
var colorHex = '#ff0000';
|
||
if (attrs.rgb_color) {
|
||
colorHex = '#' + attrs.rgb_color.map(function(c) { return c.toString(16).padStart(2, '0'); }).join('');
|
||
} else if (attrs.hs_color) {
|
||
colorHex = hslToHex(attrs.hs_color[0], attrs.hs_color[1], 50);
|
||
}
|
||
html += '<div class="widget-ha-slider-row"><label>🎨</label>' +
|
||
'<input type="color" class="widget-ha-color-picker" value="' + colorHex + '" data-attr="color" style="flex:1;height:28px;border:none;background:none;cursor:pointer;"></div>';
|
||
}
|
||
if (attrs.effect_list && attrs.effect_list.length > 0) {
|
||
var currentEffect = attrs.effect || '';
|
||
html += '<div class="widget-ha-slider-row"><label>✨</label>' +
|
||
'<select class="widget-ha-effect-select" style="flex:1;background:var(--bg-dark);border:1px solid var(--border);color:var(--text);padding:4px;font-family:inherit;font-size:11px;">' +
|
||
'<option value="">none</option>';
|
||
attrs.effect_list.forEach(function(fx) {
|
||
html += '<option value="' + fx + '"' + (fx === currentEffect ? ' selected' : '') + '>' + fx + '</option>';
|
||
});
|
||
html += '</select></div>';
|
||
}
|
||
}
|
||
if (domain === 'fan' && attrs.percentage !== undefined) {
|
||
html += '<div class="widget-ha-slider-row"><label>%</label>' +
|
||
'<input type="range" class="widget-ha-slider" min="0" max="100" value="' + (attrs.percentage || 0) + '" data-attr="fan_speed"></div>';
|
||
}
|
||
if (domain === 'cover' && attrs.current_position !== undefined) {
|
||
html += '<div class="widget-ha-slider-row"><label>↕</label>' +
|
||
'<input type="range" class="widget-ha-slider" min="0" max="100" value="' + (attrs.current_position || 0) + '" data-attr="cover_pos"></div>';
|
||
}
|
||
if (domain === 'climate') {
|
||
var temp = attrs.temperature || 70;
|
||
html += '<div class="widget-ha-slider-row"><label>°</label>' +
|
||
'<input type="range" class="widget-ha-slider" min="50" max="90" value="' + temp + '" data-attr="temperature"></div>';
|
||
}
|
||
|
||
haControlsPopup.innerHTML = html;
|
||
var rect = e.target.getBoundingClientRect();
|
||
haControlsPopup.style.left = (rect.left + rect.width / 2) + 'px';
|
||
haControlsPopup.style.top = (rect.bottom + 10) + 'px';
|
||
haControlsPopup.classList.add('open');
|
||
|
||
haControlsPopup.querySelectorAll('.widget-ha-slider').forEach(function(slider) {
|
||
slider.addEventListener('input', function(ev) {
|
||
ev.stopPropagation();
|
||
var attr = this.dataset.attr;
|
||
if (attr === 'hue' || attr === 'sat') {
|
||
var hueSlider = haControlsPopup.querySelector('[data-attr="hue"]');
|
||
var satSlider = haControlsPopup.querySelector('[data-attr="sat"]');
|
||
var h = hueSlider ? parseInt(hueSlider.value) : 0;
|
||
var s = satSlider ? parseInt(satSlider.value) : 100;
|
||
var preview = haControlsPopup.querySelector('.widget-ha-color-preview');
|
||
if (preview) preview.style.background = 'hsl(' + h + ',' + s + '%,50%)';
|
||
}
|
||
});
|
||
slider.addEventListener('change', function(ev) {
|
||
ev.stopPropagation();
|
||
var attr = this.dataset.attr;
|
||
var val = parseInt(this.value);
|
||
setHAAttributePopup(currentPopupEntityId, attr, val);
|
||
});
|
||
});
|
||
|
||
var colorPicker = haControlsPopup.querySelector('.widget-ha-color-picker');
|
||
if (colorPicker) {
|
||
colorPicker.addEventListener('input', function(ev) {
|
||
ev.stopPropagation();
|
||
var rgb = hexToRgb(this.value);
|
||
setHAAttributePopup(currentPopupEntityId, 'color', rgb);
|
||
});
|
||
}
|
||
|
||
var effectSelect = haControlsPopup.querySelector('.widget-ha-effect-select');
|
||
if (effectSelect) {
|
||
effectSelect.addEventListener('change', function(ev) {
|
||
ev.stopPropagation();
|
||
setHAAttributePopup(currentPopupEntityId, 'effect', this.value);
|
||
});
|
||
}
|
||
}
|
||
|
||
document.addEventListener('click', function(e) {
|
||
if (!haControlsPopup.contains(e.target) && !e.target.classList.contains('widget-ha-expand')) {
|
||
haControlsPopup.classList.remove('open');
|
||
}
|
||
});
|
||
|
||
async function setHAAttributePopup(entityId, attr, val) {
|
||
if (!haConfig.url || !haConfig.token) return;
|
||
var domain = entityId.split('.')[0];
|
||
var service, data = { entity_id: entityId };
|
||
|
||
if (attr === 'brightness') {
|
||
service = 'turn_on';
|
||
data.brightness = val;
|
||
} else if (attr === 'color') {
|
||
service = 'turn_on';
|
||
data.rgb_color = val;
|
||
} else if (attr === 'effect') {
|
||
service = 'turn_on';
|
||
if (val) data.effect = val;
|
||
} else if (attr === 'hue' || attr === 'sat') {
|
||
service = 'turn_on';
|
||
var hueSlider = haControlsPopup.querySelector('[data-attr="hue"]');
|
||
var satSlider = haControlsPopup.querySelector('[data-attr="sat"]');
|
||
var h = hueSlider ? parseInt(hueSlider.value) : 0;
|
||
var s = satSlider ? parseInt(satSlider.value) : 100;
|
||
data.hs_color = [h, s];
|
||
} else if (attr === 'fan_speed') {
|
||
service = 'set_percentage';
|
||
data.percentage = val;
|
||
} else if (attr === 'cover_pos') {
|
||
service = 'set_cover_position';
|
||
data.position = val;
|
||
} else if (attr === 'temperature') {
|
||
service = 'set_temperature';
|
||
data.temperature = val;
|
||
} else {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await fetch(haConfig.url + '/api/services/' + domain + '/' + service, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': 'Bearer ' + haConfig.token, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(data)
|
||
});
|
||
setTimeout(refreshHAEntities, 300);
|
||
} catch (err) { showToast('set failed'); }
|
||
}
|
||
|
||
async function setHAAttribute(entityId, attr, val, widget) {
|
||
if (!haConfig.url || !haConfig.token) return;
|
||
var domain = entityId.split('.')[0];
|
||
var service, data = { entity_id: entityId };
|
||
|
||
if (attr === 'brightness') {
|
||
service = 'turn_on';
|
||
data.brightness = val;
|
||
} else if (attr === 'hue' || attr === 'sat') {
|
||
service = 'turn_on';
|
||
var hueSlider = widget.querySelector('[data-attr="hue"]');
|
||
var satSlider = widget.querySelector('[data-attr="sat"]');
|
||
var h = hueSlider ? parseInt(hueSlider.value) : 0;
|
||
var s = satSlider ? parseInt(satSlider.value) : 100;
|
||
data.hs_color = [h, s];
|
||
} else if (attr === 'fan_speed') {
|
||
service = 'set_percentage';
|
||
data.percentage = val;
|
||
} else if (attr === 'cover_pos') {
|
||
service = 'set_cover_position';
|
||
data.position = val;
|
||
} else if (attr === 'temperature') {
|
||
service = 'set_temperature';
|
||
data.temperature = val;
|
||
} else {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await fetch(haConfig.url + '/api/services/' + domain + '/' + service, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': 'Bearer ' + haConfig.token, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(data)
|
||
});
|
||
setTimeout(refreshHAEntities, 300);
|
||
} catch (err) { showToast('set failed'); }
|
||
}
|
||
|
||
function getHAIcon(entity) {
|
||
var entityId = typeof entity === 'string' ? entity : entity.entity_id;
|
||
var domain = entityId.split('.')[0];
|
||
var defaultIcons = {
|
||
light: 'lightbulb', switch: 'power-plug', fan: 'fan', cover: 'window-shutter',
|
||
climate: 'thermostat', lock: 'lock', media_player: 'cast', sensor: 'eye',
|
||
binary_sensor: 'checkbox-blank-circle', camera: 'camera', vacuum: 'robot-vacuum',
|
||
script: 'script-text', automation: 'robot', input_boolean: 'toggle-switch',
|
||
scene: 'palette', person: 'account', device_tracker: 'cellphone', sun: 'weather-sunny',
|
||
weather: 'weather-cloudy', button: 'gesture-tap-button', number: 'ray-vertex',
|
||
select: 'format-list-bulleted', input_number: 'numeric', input_select: 'form-dropdown'
|
||
};
|
||
var icon = defaultIcons[domain] || 'checkbox-blank-circle';
|
||
if (entity && entity.attributes && entity.attributes.icon) {
|
||
icon = entity.attributes.icon.replace('mdi:', '');
|
||
}
|
||
return 'mdi mdi-' + icon;
|
||
}
|
||
|
||
function getHAColor(entity) {
|
||
if (!entity || !entity.attributes) return null;
|
||
var state = entity.state;
|
||
var attrs = entity.attributes;
|
||
if (attrs.rgb_color) {
|
||
return 'rgb(' + attrs.rgb_color.join(',') + ')';
|
||
}
|
||
if (attrs.hs_color && state === 'on') {
|
||
var h = attrs.hs_color[0], s = attrs.hs_color[1];
|
||
return 'hsl(' + h + ',' + s + '%,50%)';
|
||
}
|
||
if (state === 'on' || state === 'unlocked' || state === 'open' || state === 'home') {
|
||
return 'var(--aqua)';
|
||
}
|
||
return 'var(--text-dim)';
|
||
}
|
||
|
||
// ========== PROXMOX ==========
|
||
function initPVE() {
|
||
if (pveConfig.url) document.getElementById('pveUrl').value = pveConfig.url;
|
||
if (pveConfig.user) document.getElementById('pveUser').value = pveConfig.user;
|
||
if (pveConfig.pass) document.getElementById('pvePass').value = pveConfig.pass;
|
||
if (pveConfig.url && pveConfig.pass) { connectPVE(); }
|
||
}
|
||
|
||
async function connectPVE() {
|
||
var url = document.getElementById('pveUrl').value.trim().replace(/\/$/, '');
|
||
var user = document.getElementById('pveUser').value.trim() || 'root@pam';
|
||
var pass = document.getElementById('pvePass').value.trim();
|
||
if (!url || !pass) { showToast('need url and password'); return; }
|
||
pveConfig = { url: url, user: user, pass: pass };
|
||
localStorage.setItem('dashd_pve_config', JSON.stringify(pveConfig));
|
||
document.getElementById('pveStatus').textContent = 'connecting...';
|
||
try {
|
||
var formData = new URLSearchParams();
|
||
formData.append('username', user);
|
||
formData.append('password', pass);
|
||
var res = await fetch(url + '/api2/json/access/ticket', { method: 'POST', body: formData });
|
||
var data = await res.json();
|
||
if (data.data && data.data.ticket) {
|
||
pveTicket = data.data.ticket;
|
||
document.getElementById('pveStatus').innerHTML = '<span style="color:var(--seafoam)">connected</span>';
|
||
refreshPVEResources();
|
||
} else {
|
||
document.getElementById('pveStatus').innerHTML = '<span style="color:#ff4d6d">auth failed</span>';
|
||
}
|
||
} catch (err) {
|
||
document.getElementById('pveStatus').innerHTML = '<span style="color:#ff4d6d">connection failed</span>';
|
||
}
|
||
}
|
||
|
||
async function refreshPVEResources() {
|
||
if (!pveConfig.url || !pveTicket) return;
|
||
try {
|
||
var res = await fetch(pveConfig.url + '/api2/json/cluster/resources', {
|
||
headers: { 'Cookie': 'PVEAuthCookie=' + pveTicket }
|
||
});
|
||
var data = await res.json();
|
||
pveResources = (data.data || []).filter(function(r) { return r.type === 'qemu' || r.type === 'lxc'; });
|
||
populatePVESelect();
|
||
updateAllWidgets();
|
||
} catch (err) { /* PVE unavailable */ }
|
||
}
|
||
|
||
function populatePVESelect() {
|
||
var select = document.getElementById('proxmoxVmSelect');
|
||
select.innerHTML = '<option value="">select vm/ct</option>';
|
||
pveResources.sort(function(a, b) { return a.vmid - b.vmid; }).forEach(function(r) {
|
||
var opt = document.createElement('option');
|
||
opt.value = r.vmid;
|
||
opt.textContent = r.vmid + ' - ' + (r.name || 'unnamed') + ' (' + r.type + ')';
|
||
select.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
async function pveAction(vmid, node, vmtype, action) {
|
||
if (!pveConfig.url || !pveTicket) return;
|
||
var endpoint = vmtype === 'lxc' ? 'lxc' : 'qemu';
|
||
try {
|
||
await fetch(pveConfig.url + '/api2/json/nodes/' + node + '/' + endpoint + '/' + vmid + '/status/' + action, {
|
||
method: 'POST',
|
||
headers: { 'Cookie': 'PVEAuthCookie=' + pveTicket, 'CSRFPreventionToken': pveTicket }
|
||
});
|
||
showToast(action + ' sent');
|
||
setTimeout(refreshPVEResources, 2000);
|
||
} catch (err) { showToast('action failed'); }
|
||
}
|
||
|
||
// ========== DOCKER ==========
|
||
function initDocker() {
|
||
if (dockerConfig.url) document.getElementById('dockerUrl').value = dockerConfig.url;
|
||
if (dockerConfig.url) { connectDocker(); }
|
||
}
|
||
|
||
async function connectDocker() {
|
||
var url = document.getElementById('dockerUrl').value.trim().replace(/\/$/, '');
|
||
if (!url) { showToast('need docker api url'); return; }
|
||
dockerConfig = { url: url };
|
||
localStorage.setItem('dashd_docker_config', JSON.stringify(dockerConfig));
|
||
document.getElementById('dockerStatus').textContent = 'connecting...';
|
||
try {
|
||
var res = await fetch(url + '/containers/json?all=true');
|
||
if (res.ok) {
|
||
dockerContainers = await res.json();
|
||
document.getElementById('dockerStatus').innerHTML = '<span style="color:var(--seafoam)">connected (' + dockerContainers.length + ')</span>';
|
||
populateDockerSelect();
|
||
} else {
|
||
document.getElementById('dockerStatus').innerHTML = '<span style="color:#ff4d6d">failed</span>';
|
||
}
|
||
} catch (err) {
|
||
document.getElementById('dockerStatus').innerHTML = '<span style="color:#ff4d6d">connection failed</span>';
|
||
}
|
||
}
|
||
|
||
async function refreshDockerContainers() {
|
||
if (!dockerConfig.url) return;
|
||
try {
|
||
var res = await fetch(dockerConfig.url + '/containers/json?all=true');
|
||
if (res.ok) {
|
||
dockerContainers = await res.json();
|
||
populateDockerSelect();
|
||
updateAllWidgets();
|
||
}
|
||
} catch (err) { /* Docker unavailable */ }
|
||
}
|
||
|
||
function populateDockerSelect() {
|
||
var select = document.getElementById('dockerContainerSelect');
|
||
select.innerHTML = '<option value="">select container</option>';
|
||
dockerContainers.sort(function(a, b) { return a.Names[0].localeCompare(b.Names[0]); }).forEach(function(c) {
|
||
var opt = document.createElement('option');
|
||
opt.value = c.Id;
|
||
opt.textContent = c.Names[0].replace(/^\//, '') + ' (' + c.State + ')';
|
||
select.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
async function dockerAction(containerId, action) {
|
||
if (!dockerConfig.url) return;
|
||
try {
|
||
await fetch(dockerConfig.url + '/containers/' + containerId + '/' + action, { method: 'POST' });
|
||
showToast(action + ' sent');
|
||
setTimeout(refreshDockerContainers, 2000);
|
||
} catch (err) { showToast('action failed'); }
|
||
}
|
||
|
||
// ========== ADGUARD ==========
|
||
function initAdguard() {
|
||
if (adguardConfig.url) document.getElementById('adguardUrl').value = adguardConfig.url;
|
||
if (adguardConfig.user) document.getElementById('adguardUser').value = adguardConfig.user;
|
||
if (adguardConfig.pass) document.getElementById('adguardPass').value = adguardConfig.pass;
|
||
if (adguardConfig.url) { connectAdguard(); }
|
||
}
|
||
|
||
async function connectAdguard() {
|
||
var url = document.getElementById('adguardUrl').value.trim().replace(/\/$/, '');
|
||
var user = document.getElementById('adguardUser').value.trim();
|
||
var pass = document.getElementById('adguardPass').value.trim();
|
||
if (!url) { showToast('need adguard url'); return; }
|
||
adguardConfig = { url: url, user: user, pass: pass };
|
||
localStorage.setItem('dashd_adguard_config', JSON.stringify(adguardConfig));
|
||
document.getElementById('adguardStatus').textContent = 'connecting...';
|
||
try {
|
||
var headers = {};
|
||
if (user && pass) headers['Authorization'] = 'Basic ' + btoa(user + ':' + pass);
|
||
var res = await fetch(url + '/control/stats', { headers: headers });
|
||
if (res.ok) {
|
||
adguardStats = await res.json();
|
||
document.getElementById('adguardStatus').innerHTML = '<span style="color:var(--seafoam)">connected</span>';
|
||
updateAllWidgets();
|
||
} else {
|
||
document.getElementById('adguardStatus').innerHTML = '<span style="color:#ff4d6d">failed</span>';
|
||
}
|
||
} catch (err) {
|
||
document.getElementById('adguardStatus').innerHTML = '<span style="color:#ff4d6d">connection failed</span>';
|
||
}
|
||
}
|
||
|
||
async function refreshAdguardStats() {
|
||
if (!adguardConfig.url) return;
|
||
try {
|
||
var headers = {};
|
||
if (adguardConfig.user && adguardConfig.pass) headers['Authorization'] = 'Basic ' + btoa(adguardConfig.user + ':' + adguardConfig.pass);
|
||
var res = await fetch(adguardConfig.url + '/control/stats', { headers: headers });
|
||
if (res.ok) { adguardStats = await res.json(); updateAllWidgets(); }
|
||
} catch (err) { /* AdGuard unavailable */ }
|
||
}
|
||
|
||
async function toggleAdguard(enable) {
|
||
if (!adguardConfig.url) return;
|
||
try {
|
||
var headers = { 'Content-Type': 'application/json' };
|
||
if (adguardConfig.user && adguardConfig.pass) headers['Authorization'] = 'Basic ' + btoa(adguardConfig.user + ':' + adguardConfig.pass);
|
||
await fetch(adguardConfig.url + '/control/dns_config', {
|
||
method: 'POST', headers: headers,
|
||
body: JSON.stringify({ protection_enabled: enable })
|
||
});
|
||
showToast(enable ? 'protection enabled' : 'protection disabled');
|
||
setTimeout(refreshAdguardStats, 1000);
|
||
} catch (err) { showToast('toggle failed'); }
|
||
}
|
||
|
||
// ========== JELLYFIN ==========
|
||
function initJellyfin() {
|
||
if (jellyfinConfig.url) document.getElementById('jellyfinUrl').value = jellyfinConfig.url;
|
||
if (jellyfinConfig.key) document.getElementById('jellyfinKey').value = jellyfinConfig.key;
|
||
if (jellyfinConfig.url) { connectJellyfin(); }
|
||
}
|
||
|
||
async function connectJellyfin() {
|
||
var url = document.getElementById('jellyfinUrl').value.trim().replace(/\/$/, '');
|
||
var key = document.getElementById('jellyfinKey').value.trim();
|
||
if (!url) { showToast('need jellyfin url'); return; }
|
||
jellyfinConfig = { url: url, key: key };
|
||
localStorage.setItem('dashd_jellyfin_config', JSON.stringify(jellyfinConfig));
|
||
document.getElementById('jellyfinStatus').textContent = 'connecting...';
|
||
try {
|
||
var headers = {};
|
||
if (key) headers['X-Emby-Token'] = key;
|
||
var res = await fetch(url + '/Sessions', { headers: headers });
|
||
if (res.ok) {
|
||
jellyfinSessions = await res.json();
|
||
document.getElementById('jellyfinStatus').innerHTML = '<span style="color:var(--seafoam)">connected</span>';
|
||
updateAllWidgets();
|
||
} else {
|
||
document.getElementById('jellyfinStatus').innerHTML = '<span style="color:#ff4d6d">failed</span>';
|
||
}
|
||
} catch (err) {
|
||
document.getElementById('jellyfinStatus').innerHTML = '<span style="color:#ff4d6d">connection failed</span>';
|
||
}
|
||
}
|
||
|
||
async function refreshJellyfinSessions() {
|
||
if (!jellyfinConfig.url) return;
|
||
try {
|
||
var headers = {};
|
||
if (jellyfinConfig.key) headers['X-Emby-Token'] = jellyfinConfig.key;
|
||
var res = await fetch(jellyfinConfig.url + '/Sessions', { headers: headers });
|
||
if (res.ok) { jellyfinSessions = await res.json(); updateAllWidgets(); }
|
||
} catch (err) { /* Jellyfin unavailable */ }
|
||
}
|
||
|
||
// ========== ARR STACK ==========
|
||
function initArr() {
|
||
if (sonarrConfig.url) document.getElementById('sonarrUrl').value = sonarrConfig.url;
|
||
if (sonarrConfig.key) document.getElementById('sonarrKey').value = sonarrConfig.key;
|
||
if (radarrConfig.url) document.getElementById('radarrUrl').value = radarrConfig.url;
|
||
if (radarrConfig.key) document.getElementById('radarrKey').value = radarrConfig.key;
|
||
if (sonarrConfig.url || radarrConfig.url) { connectArr(); }
|
||
}
|
||
|
||
async function connectArr() {
|
||
var sonarrUrl = document.getElementById('sonarrUrl').value.trim().replace(/\/$/, '');
|
||
var sonarrKey = document.getElementById('sonarrKey').value.trim();
|
||
var radarrUrl = document.getElementById('radarrUrl').value.trim().replace(/\/$/, '');
|
||
var radarrKey = document.getElementById('radarrKey').value.trim();
|
||
sonarrConfig = { url: sonarrUrl, key: sonarrKey };
|
||
radarrConfig = { url: radarrUrl, key: radarrKey };
|
||
localStorage.setItem('dashd_sonarr_config', JSON.stringify(sonarrConfig));
|
||
localStorage.setItem('dashd_radarr_config', JSON.stringify(radarrConfig));
|
||
document.getElementById('arrStatus').textContent = 'connecting...';
|
||
var connected = [];
|
||
if (sonarrUrl && sonarrKey) {
|
||
try {
|
||
var res = await fetch(sonarrUrl + '/api/v3/calendar?apikey=' + sonarrKey);
|
||
if (res.ok) { sonarrCalendar = await res.json(); connected.push('sonarr'); }
|
||
} catch (e) { /* service unavailable */ }
|
||
}
|
||
if (radarrUrl && radarrKey) {
|
||
try {
|
||
var res = await fetch(radarrUrl + '/api/v3/calendar?apikey=' + radarrKey);
|
||
if (res.ok) { radarrCalendar = await res.json(); connected.push('radarr'); }
|
||
} catch (e) { /* service unavailable */ }
|
||
}
|
||
if (connected.length > 0) {
|
||
document.getElementById('arrStatus').innerHTML = '<span style="color:var(--seafoam)">' + connected.join(', ') + '</span>';
|
||
updateAllWidgets();
|
||
} else {
|
||
document.getElementById('arrStatus').innerHTML = '<span style="color:#ff4d6d">no connections</span>';
|
||
}
|
||
}
|
||
|
||
async function refreshArrCalendars() {
|
||
if (sonarrConfig.url && sonarrConfig.key) {
|
||
try {
|
||
var res = await fetch(sonarrConfig.url + '/api/v3/calendar?apikey=' + sonarrConfig.key);
|
||
if (res.ok) sonarrCalendar = await res.json();
|
||
} catch (e) { /* service unavailable */ }
|
||
}
|
||
if (radarrConfig.url && radarrConfig.key) {
|
||
try {
|
||
var res = await fetch(radarrConfig.url + '/api/v3/calendar?apikey=' + radarrConfig.key);
|
||
if (res.ok) radarrCalendar = await res.json();
|
||
} catch (e) { /* service unavailable */ }
|
||
}
|
||
updateAllWidgets();
|
||
}
|
||
|
||
// ========== FRIGATE ==========
|
||
function initFrigate() {
|
||
if (frigateConfig.url) document.getElementById('frigateUrl').value = frigateConfig.url;
|
||
if (frigateConfig.url) { connectFrigate(); }
|
||
}
|
||
|
||
async function connectFrigate() {
|
||
var url = document.getElementById('frigateUrl').value.trim().replace(/\/$/, '');
|
||
if (!url) { showToast('need frigate url'); return; }
|
||
frigateConfig = { url: url };
|
||
localStorage.setItem('dashd_frigate_config', JSON.stringify(frigateConfig));
|
||
document.getElementById('frigateStatus').textContent = 'connecting...';
|
||
try {
|
||
var res = await fetch(url + '/api/config');
|
||
if (res.ok) {
|
||
var config = await res.json();
|
||
frigateCameras = Object.keys(config.cameras || {});
|
||
document.getElementById('frigateStatus').innerHTML = '<span style="color:var(--seafoam)">connected (' + frigateCameras.length + ' cams)</span>';
|
||
populateFrigateSelect();
|
||
} else {
|
||
document.getElementById('frigateStatus').innerHTML = '<span style="color:#ff4d6d">failed</span>';
|
||
}
|
||
} catch (err) {
|
||
document.getElementById('frigateStatus').innerHTML = '<span style="color:#ff4d6d">connection failed</span>';
|
||
}
|
||
}
|
||
|
||
function populateFrigateSelect() {
|
||
var select = document.getElementById('frigateCameraSelect');
|
||
select.innerHTML = '<option value="">select camera</option>';
|
||
frigateCameras.forEach(function(cam) {
|
||
var opt = document.createElement('option');
|
||
opt.value = cam;
|
||
opt.textContent = cam;
|
||
select.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
// ========== WIDGET UPDATE FUNCTIONS ==========
|
||
function updateAllWidgets() {
|
||
document.querySelectorAll('.widget-display').forEach(function(display) {
|
||
var type = display.dataset.widgetType;
|
||
var id = display.dataset.widgetId;
|
||
var svc = services.find(function(s) { return s.id === id; });
|
||
if (!svc) return;
|
||
var card = display.closest('.card');
|
||
var w = card ? card.offsetWidth : 200;
|
||
var h = card ? card.offsetHeight : 80;
|
||
|
||
if (type === 'proxmox') renderProxmoxWidget(display, svc);
|
||
else if (type === 'docker') renderDockerWidget(display, svc);
|
||
else if (type === 'adguard') renderAdguardWidget(display, svc);
|
||
else if (type === 'jellyfin') renderJellyfinWidget(display, svc);
|
||
else if (type === 'weather') renderWeatherWidget(display, svc);
|
||
else if (type === 'speedtest') renderSpeedtestWidget(display, svc);
|
||
else if (type === 'sonarr') renderSonarrWidget(display, svc);
|
||
else if (type === 'radarr') renderRadarrWidget(display, svc);
|
||
else if (type === 'frigate') renderFrigateWidget(display, svc);
|
||
else if (type === 'rss') renderRSSWidget(display, svc);
|
||
else if (type === 'bookmark') renderBookmarkWidget(display, svc);
|
||
else if (type === 'iframe') renderIframeWidget(display, svc);
|
||
else if (type === 'notd') renderNotdWidget(display, svc);
|
||
else if (type === 'spotify') renderSpotifyWidget(display, svc);
|
||
else if (type === 'twitch') renderTwitchWidget(display, svc);
|
||
else if (type === 'youtube') renderYoutubeWidget(display, svc);
|
||
else if (type === 'pomodoro') renderPomodoroWidget(display, svc);
|
||
else if (type === 'search') renderSearchWidget(display, svc);
|
||
else if (type === 'crypto') renderCryptoWidget(display, svc);
|
||
else if (type === 'todolist') renderTodolistWidget(display, svc);
|
||
else if (type === 'countdown') renderCountdownWidget(display, svc);
|
||
else if (type === 'nowplaying') renderNowplayingWidget(display, svc);
|
||
else if (type === 'browser') renderBrowserWidget(display, svc);
|
||
else if (type === 'system_stats') renderSystemWidget(display, svc);
|
||
else if (type === 'opnsense') renderOPNsenseWidget(display, svc);
|
||
else if (type === 'cloudflared') renderCloudflaredWidget(display, svc);
|
||
else if (type === 'unbound') renderUnboundWidget(display, svc);
|
||
else if (type === 'habitica') renderHabiticaWidget(display, svc);
|
||
else if (type === 'calendar') renderCalendarWidget(display, svc);
|
||
else if (type === 'mailbox') renderMailboxWidget(display, svc);
|
||
});
|
||
}
|
||
|
||
function renderProxmoxWidget(display, svc) {
|
||
var vm = pveResources.find(function(r) { return r.vmid == svc.vmid; });
|
||
var status = vm ? vm.status : 'unknown';
|
||
var cpu = vm && vm.cpu ? Math.round(vm.cpu * 100) : 0;
|
||
var mem = vm && vm.mem && vm.maxmem ? Math.round(vm.mem / vm.maxmem * 100) : 0;
|
||
var icon = svc.vmtype === 'lxc' ? 'mdi-cube-outline' : 'mdi-server';
|
||
display.innerHTML = '<div class="widget-proxmox">' +
|
||
'<i class="widget-proxmox-icon mdi ' + icon + '" style="color:' + (status === 'running' ? 'var(--seafoam)' : 'var(--text-dim)') + '"></i>' +
|
||
'<div class="widget-proxmox-name">' + svc.name + '</div>' +
|
||
'<div class="widget-proxmox-status ' + status + '">' + status + '</div>' +
|
||
(status === 'running' ? '<div class="widget-proxmox-stats">cpu ' + cpu + '% | mem ' + mem + '%</div>' : '') +
|
||
'<div class="widget-proxmox-controls">' +
|
||
(status === 'stopped' ? '<button onclick="pveAction(' + svc.vmid + ',\'' + svc.node + '\',\'' + svc.vmtype + '\',\'start\')">start</button>' : '') +
|
||
(status === 'running' ? '<button onclick="pveAction(' + svc.vmid + ',\'' + svc.node + '\',\'' + svc.vmtype + '\',\'shutdown\')">stop</button>' : '') +
|
||
'</div></div>';
|
||
}
|
||
|
||
function renderDockerWidget(display, svc) {
|
||
var container = dockerContainers.find(function(c) { return c.Id === svc.containerId; });
|
||
var status = container ? container.State : 'unknown';
|
||
display.innerHTML = '<div class="widget-docker">' +
|
||
'<i class="widget-docker-icon mdi mdi-docker"></i>' +
|
||
'<div class="widget-docker-name">' + svc.name + '</div>' +
|
||
'<div class="widget-docker-status ' + status + '">' + status + '</div>' +
|
||
'<div class="widget-docker-controls">' +
|
||
(status === 'exited' ? '<button onclick="dockerAction(\'' + svc.containerId + '\',\'start\')">start</button>' : '') +
|
||
(status === 'running' ? '<button onclick="dockerAction(\'' + svc.containerId + '\',\'stop\')">stop</button>' : '') +
|
||
'<button onclick="dockerAction(\'' + svc.containerId + '\',\'restart\')">restart</button>' +
|
||
'</div></div>';
|
||
}
|
||
|
||
function renderAdguardWidget(display, svc) {
|
||
var queries = adguardStats.num_dns_queries || 0;
|
||
var blocked = adguardStats.num_blocked_filtering || 0;
|
||
var pct = queries > 0 ? Math.round(blocked / queries * 100) : 0;
|
||
display.innerHTML = '<div class="widget-adguard">' +
|
||
'<div class="widget-adguard-header"><div class="widget-adguard-title">adguard</div>' +
|
||
'<div class="widget-adguard-toggle ' + (adguardStats.protection_enabled !== false ? 'on' : '') + '" onclick="toggleAdguard(' + (adguardStats.protection_enabled === false ? 'true' : 'false') + ')"></div></div>' +
|
||
'<div class="widget-adguard-stats">' +
|
||
'<div class="widget-adguard-stat"><div class="value" style="color:var(--aqua)">' + formatNumber(queries) + '</div><div class="label">queries</div></div>' +
|
||
'<div class="widget-adguard-stat"><div class="value" style="color:#ff4d6d">' + formatNumber(blocked) + '</div><div class="label">blocked</div></div>' +
|
||
'<div class="widget-adguard-stat"><div class="value" style="color:var(--lavender)">' + pct + '%</div><div class="label">filtered</div></div>' +
|
||
'</div></div>';
|
||
}
|
||
|
||
function renderJellyfinWidget(display, svc) {
|
||
var playing = jellyfinSessions.filter(function(s) { return s.NowPlayingItem; });
|
||
if (playing.length === 0) {
|
||
display.innerHTML = '<div class="widget-jellyfin"><div class="widget-jellyfin-empty">nothing playing</div></div>';
|
||
return;
|
||
}
|
||
var session = playing[0];
|
||
var item = session.NowPlayingItem;
|
||
var poster = jellyfinConfig.url + '/Items/' + item.Id + '/Images/Primary?maxHeight=150';
|
||
display.innerHTML = '<div class="widget-jellyfin"><div class="widget-jellyfin-playing">' +
|
||
'<img class="widget-jellyfin-poster" src="' + poster + '" onerror="this.style.display=\'none\'">' +
|
||
'<div class="widget-jellyfin-info">' +
|
||
'<div class="widget-jellyfin-title">' + item.Name + '</div>' +
|
||
(item.SeriesName ? '<div class="widget-jellyfin-subtitle">' + item.SeriesName + '</div>' : '') +
|
||
'<div class="widget-jellyfin-user">' + session.UserName + '</div>' +
|
||
'</div></div></div>';
|
||
}
|
||
|
||
function renderWeatherWidget(display, svc) {
|
||
var entity = haEntities.find(function(e) { return e.entity_id === svc.entityId; });
|
||
if (!entity) { display.innerHTML = '<div class="widget-weather"><div style="color:var(--text-dim)">no data</div></div>'; return; }
|
||
var temp = entity.attributes.temperature || entity.state;
|
||
var condition = entity.state;
|
||
var humidity = entity.attributes.humidity || '--';
|
||
var wind = entity.attributes.wind_speed || '--';
|
||
var weatherIcons = { 'sunny': '☀️', 'clear-night': '🌙', 'cloudy': '☁️', 'partlycloudy': '⛅', 'rainy': '🌧️', 'snowy': '❄️', 'fog': '🌫️', 'lightning': '⚡' };
|
||
var icon = weatherIcons[condition] || '🌡️';
|
||
display.innerHTML = '<div class="widget-weather">' +
|
||
'<div class="widget-weather-icon">' + icon + '</div>' +
|
||
'<div class="widget-weather-temp">' + Math.round(temp) + '°</div>' +
|
||
'<div class="widget-weather-condition">' + condition + '</div>' +
|
||
'<div class="widget-weather-details"><span>💧 ' + humidity + '%</span><span>💨 ' + wind + '</span></div>' +
|
||
'</div>';
|
||
}
|
||
|
||
function renderSpeedtestWidget(display, svc) {
|
||
var down = speedtestResults.download || '--';
|
||
var up = speedtestResults.upload || '--';
|
||
var ping = speedtestResults.ping || '--';
|
||
display.innerHTML = '<div class="widget-speedtest">' +
|
||
'<div class="widget-speedtest-speeds">' +
|
||
'<div class="widget-speedtest-speed down"><div class="value">' + down + '</div><div class="label">↓ mbps</div></div>' +
|
||
'<div class="widget-speedtest-speed up"><div class="value">' + up + '</div><div class="label">↑ mbps</div></div>' +
|
||
'</div>' +
|
||
'<div class="widget-speedtest-ping">ping: ' + ping + 'ms</div>' +
|
||
'<button class="widget-speedtest-btn" onclick="runSpeedtest()">run test</button>' +
|
||
'</div>';
|
||
}
|
||
|
||
async function runSpeedtest() {
|
||
showToast('running speedtest...');
|
||
// This would need a backend speedtest service
|
||
// For now, simulate with random values
|
||
speedtestResults = {
|
||
download: Math.round(Math.random() * 500 + 100),
|
||
upload: Math.round(Math.random() * 100 + 20),
|
||
ping: Math.round(Math.random() * 30 + 5),
|
||
timestamp: Date.now()
|
||
};
|
||
localStorage.setItem('dashd_speedtest', JSON.stringify(speedtestResults));
|
||
updateAllWidgets();
|
||
showToast('speedtest complete');
|
||
}
|
||
|
||
function renderSonarrWidget(display, svc) {
|
||
if (sonarrCalendar.length === 0) {
|
||
display.innerHTML = '<div class="widget-arr"><div class="widget-arr-title">sonarr upcoming</div><div style="color:var(--text-dim);font-size:11px">no upcoming episodes</div></div>';
|
||
return;
|
||
}
|
||
var html = '<div class="widget-arr"><div class="widget-arr-title">sonarr upcoming</div><div class="widget-arr-list">';
|
||
sonarrCalendar.slice(0, 5).forEach(function(ep) {
|
||
var airDate = ep.airDateUtc ? new Date(ep.airDateUtc).toLocaleDateString() : '';
|
||
html += '<div class="widget-arr-item"><div class="widget-arr-info">' +
|
||
'<div class="widget-arr-name">' + ep.series.title + ' S' + ep.seasonNumber + 'E' + ep.episodeNumber + '</div>' +
|
||
'<div class="widget-arr-date">' + airDate + '</div></div></div>';
|
||
});
|
||
html += '</div></div>';
|
||
display.innerHTML = html;
|
||
}
|
||
|
||
function renderRadarrWidget(display, svc) {
|
||
if (radarrCalendar.length === 0) {
|
||
display.innerHTML = '<div class="widget-arr"><div class="widget-arr-title">radarr upcoming</div><div style="color:var(--text-dim);font-size:11px">no upcoming movies</div></div>';
|
||
return;
|
||
}
|
||
var html = '<div class="widget-arr"><div class="widget-arr-title">radarr upcoming</div><div class="widget-arr-list">';
|
||
radarrCalendar.slice(0, 5).forEach(function(movie) {
|
||
var releaseDate = movie.physicalRelease ? new Date(movie.physicalRelease).toLocaleDateString() : (movie.digitalRelease ? new Date(movie.digitalRelease).toLocaleDateString() : '');
|
||
html += '<div class="widget-arr-item"><div class="widget-arr-info">' +
|
||
'<div class="widget-arr-name">' + movie.title + '</div>' +
|
||
'<div class="widget-arr-date">' + releaseDate + '</div></div></div>';
|
||
});
|
||
html += '</div></div>';
|
||
display.innerHTML = html;
|
||
}
|
||
|
||
function renderFrigateWidget(display, svc) {
|
||
if (!frigateConfig.url || !svc.camera) {
|
||
display.innerHTML = '<div class="widget-frigate"><div style="color:var(--text-dim);padding:20px;text-align:center">configure frigate</div></div>';
|
||
return;
|
||
}
|
||
var snapshotUrl = frigateConfig.url + '/api/' + svc.camera + '/latest.jpg?h=480&ts=' + Date.now();
|
||
display.innerHTML = '<div class="widget-frigate">' +
|
||
'<img src="' + snapshotUrl + '" onerror="this.src=\'\'">' +
|
||
'<div class="widget-frigate-overlay"><div class="widget-frigate-name">' + svc.camera + '</div></div>' +
|
||
'</div>';
|
||
}
|
||
|
||
function renderRSSWidget(display, svc) {
|
||
if (!svc.feedUrl) {
|
||
display.innerHTML = '<div class="widget-rss"><div style="color:var(--text-dim)">no feed configured</div></div>';
|
||
return;
|
||
}
|
||
// RSS needs a proxy due to CORS - show placeholder
|
||
display.innerHTML = '<div class="widget-rss"><div class="widget-rss-title">rss feed</div>' +
|
||
'<div style="color:var(--text-dim);font-size:10px;padding:10px">rss feeds need a CORS proxy backend</div></div>';
|
||
}
|
||
|
||
function renderBookmarkWidget(display, svc) {
|
||
if (!svc.links || svc.links.length === 0) {
|
||
display.innerHTML = '<div class="widget-bookmarks"><div style="color:var(--text-dim)">no bookmarks</div></div>';
|
||
return;
|
||
}
|
||
var html = '<div class="widget-bookmarks">';
|
||
svc.links.forEach(function(link) {
|
||
var favicon = (function(){ try { return new URL(link.url).origin + '/favicon.ico'; } catch(e) { return ''; } })();
|
||
html += '<div class="widget-bookmark" onclick="window.open(\'' + link.url + '\',\'_blank\')">' +
|
||
'<img src="' + favicon + '" onerror="this.style.display=\'none\'">' +
|
||
'<span>' + link.name + '</span></div>';
|
||
});
|
||
html += '</div>';
|
||
display.innerHTML = html;
|
||
}
|
||
|
||
function renderIframeWidget(display, svc) {
|
||
if (!svc.iframeUrl) {
|
||
display.innerHTML = '<div class="widget-iframe"><div style="color:var(--text-dim);padding:20px;text-align:center">no url</div></div>';
|
||
return;
|
||
}
|
||
display.innerHTML = '<div class="widget-iframe"><iframe src="' + svc.iframeUrl + '" loading="lazy"></iframe></div>';
|
||
}
|
||
|
||
// ========== NOTD (NOTES) WIDGET ==========
|
||
|
||
function renderNotdWidget(display, svc) {
|
||
// Skip re-render if textarea is focused
|
||
var existingTextarea = document.getElementById("notd-" + svc.id);
|
||
if (existingTextarea && document.activeElement === existingTextarea) return;
|
||
var noteId = svc.id;
|
||
var noteText = notdData[noteId] || '';
|
||
display.innerHTML = '<div class="widget-notd">' +
|
||
'<div class="widget-notd-header">' +
|
||
'<span class="widget-notd-title">notd</span>' +
|
||
'<span class="widget-notd-save" onclick="saveNotd(\'' + noteId + '\')">saved</span>' +
|
||
'</div>' +
|
||
'<textarea id="notd-' + noteId + '" placeholder="write something...">' + escapeHtml(noteText) + '</textarea>' +
|
||
'</div>';
|
||
var textarea = document.getElementById('notd-' + noteId);
|
||
if (textarea) {
|
||
textarea.addEventListener('input', function() {
|
||
notdData[noteId] = this.value;
|
||
localStorage.setItem('dashd_notd', JSON.stringify(notdData));
|
||
});
|
||
}
|
||
}
|
||
|
||
function saveNotd(noteId) {
|
||
showToast('note saved');
|
||
}
|
||
|
||
// ========== SPOTIFY WIDGET ==========
|
||
function renderSpotifyWidget(display, svc) {
|
||
if (!svc.spotifyUrl) {
|
||
display.innerHTML = '<div class="widget-spotify" style="display:flex;align-items:center;justify-content:center;color:var(--text-dim);font-size:11px">add spotify embed url</div>';
|
||
return;
|
||
}
|
||
var embedUrl = svc.spotifyUrl.replace('open.spotify.com/', 'open.spotify.com/embed/');
|
||
display.innerHTML = '<div class="widget-spotify"><iframe src="' + embedUrl + '" allow="encrypted-media" loading="lazy"></iframe></div>';
|
||
}
|
||
|
||
// ========== TWITCH WIDGET ==========
|
||
function renderTwitchWidget(display, svc) {
|
||
if (!svc.twitchChannel) {
|
||
display.innerHTML = '<div class="widget-twitch" style="display:flex;align-items:center;justify-content:center;color:var(--text-dim);font-size:11px">no channel</div>';
|
||
return;
|
||
}
|
||
var parent = window.location.hostname;
|
||
display.innerHTML = '<div class="widget-twitch"><iframe src="https://player.twitch.tv/?channel=' + svc.twitchChannel + '&parent=' + parent + '" allowfullscreen loading="lazy"></iframe></div>';
|
||
}
|
||
|
||
// ========== POMODORO WIDGET ==========
|
||
var pomodoroTimers = {};
|
||
|
||
function renderPomodoroWidget(display, svc) {
|
||
var timerId = svc.id;
|
||
if (!pomodoroTimers[timerId]) {
|
||
pomodoroTimers[timerId] = { time: 25 * 60, running: false, mode: 'work' };
|
||
}
|
||
var timer = pomodoroTimers[timerId];
|
||
var mins = Math.floor(timer.time / 60);
|
||
var secs = timer.time % 60;
|
||
var timeStr = String(mins).padStart(2, '0') + ':' + String(secs).padStart(2, '0');
|
||
|
||
display.innerHTML = '<div class="widget-pomodoro">' +
|
||
'<div class="widget-pomodoro-label">' + timer.mode + '</div>' +
|
||
'<div class="widget-pomodoro-time">' + timeStr + '</div>' +
|
||
'<div class="widget-pomodoro-controls">' +
|
||
'<button class="widget-pomodoro-btn ' + (timer.running ? 'active' : '') + '" onclick="togglePomodoro(\'' + timerId + '\')">' + (timer.running ? 'pause' : 'start') + '</button>' +
|
||
'<button class="widget-pomodoro-btn" onclick="resetPomodoro(\'' + timerId + '\')">reset</button>' +
|
||
'</div></div>';
|
||
}
|
||
|
||
// ========== YOUTUBE WIDGET ==========
|
||
function renderYoutubeWidget(display, svc) {
|
||
if (!svc.youtubeUrl) {
|
||
display.innerHTML = '<div class="widget-youtube" style="display:flex;align-items:center;justify-content:center;color:var(--text-dim);font-size:11px">no video</div>';
|
||
return;
|
||
}
|
||
|
||
// Skip if already showing player or same thumbnail
|
||
if (display.querySelector('iframe')) return;
|
||
if (display.querySelector('.widget-youtube-thumb[data-url="' + svc.youtubeUrl + '"]')) return;
|
||
|
||
var url = svc.youtubeUrl;
|
||
var videoId = '';
|
||
var embedUrl = '';
|
||
var isPlaylist = url.includes('list=');
|
||
|
||
// Extract video ID for thumbnail
|
||
if (url.includes('youtube.com/watch')) {
|
||
var match = url.match(/v=([^&]+)/);
|
||
if (match) videoId = match[1];
|
||
} else if (url.includes('youtu.be/')) {
|
||
videoId = url.split('youtu.be/')[1].split('?')[0];
|
||
} else if (url.includes('youtube.com/embed/')) {
|
||
videoId = url.split('embed/')[1].split('?')[0];
|
||
} else if (!url.includes('/') && url.length === 11) {
|
||
videoId = url;
|
||
}
|
||
|
||
// Build embed URL
|
||
if (isPlaylist) {
|
||
var listMatch = url.match(/list=([^&]+)/);
|
||
if (listMatch) {
|
||
embedUrl = videoId ?
|
||
'https://www.youtube.com/embed/' + videoId + '?list=' + listMatch[1] + '&autoplay=1' :
|
||
'https://www.youtube.com/embed/videoseries?list=' + listMatch[1] + '&autoplay=1';
|
||
}
|
||
} else {
|
||
embedUrl = 'https://www.youtube.com/embed/' + videoId + '?autoplay=1';
|
||
}
|
||
|
||
var thumbUrl = videoId ? 'https://img.youtube.com/vi/' + videoId + '/hqdefault.jpg' : '';
|
||
|
||
display.innerHTML = '<div class="widget-youtube">' +
|
||
'<div class="widget-youtube-controls" onclick="event.stopPropagation(); changeYoutubeVideo(this)" data-svcid="' + svc.id + '">' +
|
||
'<i class="mdi mdi-pencil"></i>' +
|
||
'</div>' +
|
||
'<div class="widget-youtube-thumb" data-url="' + svc.youtubeUrl + '" data-embed="' + embedUrl + '" style="background-image:url(' + thumbUrl + ')" onclick="playYoutube(this)">' +
|
||
'<div class="widget-youtube-play"></div>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
function playYoutube(el) {
|
||
var embedUrl = el.dataset.embed;
|
||
var container = el.parentElement;
|
||
container.innerHTML = '<iframe src="' + embedUrl + '" frameborder="0" allowfullscreen allow="autoplay"></iframe>';
|
||
}
|
||
|
||
function changeYoutubeVideo(el) { var svcId = el.dataset.svcid;
|
||
var newUrl = prompt('enter youtube url or video id:');
|
||
if (newUrl && newUrl.trim()) {
|
||
var svc = services.find(function(s) { return s.id === svcId; });
|
||
if (svc) {
|
||
svc.youtubeUrl = newUrl.trim();
|
||
save();
|
||
updateAllWidgets();
|
||
showToast('video updated');
|
||
}
|
||
}
|
||
}
|
||
|
||
function togglePomodoro(timerId) {
|
||
var timer = pomodoroTimers[timerId];
|
||
timer.running = !timer.running;
|
||
if (timer.running) {
|
||
timer.interval = setInterval(function() {
|
||
timer.time--;
|
||
if (timer.time <= 0) {
|
||
clearInterval(timer.interval);
|
||
timer.running = false;
|
||
timer.mode = timer.mode === 'work' ? 'break' : 'work';
|
||
timer.time = timer.mode === 'work' ? 25 * 60 : 5 * 60;
|
||
showToast(timer.mode + ' time!');
|
||
}
|
||
updateAllWidgets();
|
||
}, 1000);
|
||
} else {
|
||
clearInterval(timer.interval);
|
||
}
|
||
updateAllWidgets();
|
||
}
|
||
|
||
function resetPomodoro(timerId) {
|
||
var timer = pomodoroTimers[timerId];
|
||
clearInterval(timer.interval);
|
||
timer.running = false;
|
||
timer.time = 25 * 60;
|
||
timer.mode = 'work';
|
||
updateAllWidgets();
|
||
}
|
||
|
||
// ========== SEARCH WIDGET ==========
|
||
function renderSearchWidget(display, svc) {
|
||
// Skip re-render if input is focused
|
||
var existingInput = document.getElementById("search-" + svc.id);
|
||
if (existingInput && document.activeElement === existingInput) return;
|
||
var engine = svc.searchEngine || 'ddg';
|
||
var instance = svc.searchInstance || '';
|
||
display.innerHTML = '<div class="widget-search">' +
|
||
'<form class="widget-search-form" onsubmit="doSearch(event, \'' + engine + '\', \'' + instance + '\')">' +
|
||
'<input type="text" class="widget-search-input" placeholder="search..." id="search-' + svc.id + '">' +
|
||
'<button type="submit" class="widget-search-btn"><i class="mdi mdi-magnify"></i></button>' +
|
||
'</form>' +
|
||
'<div class="widget-search-engine">' + engine + '</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
function doSearch(e, engine, instance) {
|
||
e.preventDefault();
|
||
var input = e.target.querySelector('input');
|
||
var query = input.value.trim();
|
||
if (!query) return;
|
||
var url = '';
|
||
switch(engine) {
|
||
case 'searxng': url = (instance || 'https://searx.be') + '/search?q=' + encodeURIComponent(query); break;
|
||
case 'whoogle': url = (instance || 'https://whoogle.io') + '/search?q=' + encodeURIComponent(query); break;
|
||
case 'ddg': url = 'https://duckduckgo.com/?q=' + encodeURIComponent(query); break;
|
||
case 'google': url = 'https://www.google.com/search?q=' + encodeURIComponent(query); break;
|
||
}
|
||
window.open(url, '_blank');
|
||
input.value = '';
|
||
}
|
||
|
||
// ========== CRYPTO WIDGET ==========
|
||
var cryptoPrices = {};
|
||
|
||
function renderCryptoWidget(display, svc) {
|
||
var coins = (svc.cryptoCoins || 'bitcoin,ethereum').split(',').map(function(c) { return c.trim(); });
|
||
var html = '<div class="widget-crypto"><div class="widget-crypto-header">crypto</div>';
|
||
coins.forEach(function(coin) {
|
||
var price = cryptoPrices[coin] || { usd: '...', change: 0 };
|
||
var changeClass = price.change >= 0 ? 'up' : 'down';
|
||
var changeStr = price.change >= 0 ? '+' + price.change.toFixed(2) + '%' : price.change.toFixed(2) + '%';
|
||
html += '<div class="widget-crypto-coin">' +
|
||
'<span class="widget-crypto-name">' + coin + '</span>' +
|
||
'<span class="widget-crypto-price">$' + (typeof price.usd === 'number' ? price.usd.toLocaleString() : price.usd) + '</span>' +
|
||
'<span class="widget-crypto-change ' + changeClass + '">' + changeStr + '</span>' +
|
||
'</div>';
|
||
});
|
||
html += '</div>';
|
||
display.innerHTML = html;
|
||
}
|
||
|
||
async function fetchCryptoPrices() {
|
||
var allCoins = new Set();
|
||
services.forEach(function(svc) {
|
||
if (svc.type === 'crypto' && svc.cryptoCoins) {
|
||
svc.cryptoCoins.split(',').forEach(function(c) { allCoins.add(c.trim().toLowerCase()); });
|
||
}
|
||
});
|
||
if (allCoins.size === 0) return;
|
||
try {
|
||
var ids = Array.from(allCoins).join(',');
|
||
var res = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=' + ids + '&vs_currencies=usd&include_24hr_change=true');
|
||
var data = await res.json();
|
||
for (var coin in data) {
|
||
cryptoPrices[coin] = { usd: data[coin].usd, change: data[coin].usd_24h_change || 0 };
|
||
}
|
||
updateAllWidgets();
|
||
} catch (err) { /* crypto unavailable */ }
|
||
}
|
||
|
||
// ========== TODOLIST WIDGET ==========
|
||
|
||
function renderTodolistWidget(display, svc) {
|
||
// Skip re-render if input is focused
|
||
var existingInput = display.querySelector(".widget-todolist-input");
|
||
if (existingInput && document.activeElement === existingInput) return;
|
||
var listId = svc.id;
|
||
if (!todolistData[listId]) todolistData[listId] = [];
|
||
var todos = todolistData[listId];
|
||
|
||
var html = '<div class="widget-todolist">' +
|
||
'<div class="widget-todolist-header">' +
|
||
'<span class="widget-todolist-title">todo</span>' +
|
||
'<span class="widget-todolist-add" onclick="addTodoItem(\'' + listId + '\')">+</span>' +
|
||
'</div>' +
|
||
'<div class="widget-todolist-items">';
|
||
|
||
todos.forEach(function(todo, idx) {
|
||
html += '<div class="widget-todolist-item">' +
|
||
'<div class="widget-todolist-check ' + (todo.done ? 'done' : '') + '" onclick="toggleTodoItem(\'' + listId + '\', ' + idx + ')"></div>' +
|
||
'<span class="widget-todolist-text ' + (todo.done ? 'done' : '') + '">' + escapeHtml(todo.text) + '</span>' +
|
||
'<span class="widget-todolist-delete" onclick="deleteTodoItem(\'' + listId + '\', ' + idx + ')">×</span>' +
|
||
'</div>';
|
||
});
|
||
|
||
html += '</div>' +
|
||
'<input type="text" class="widget-todolist-input" placeholder="add task..." onkeypress="handleTodoInput(event, \'' + listId + '\')">' +
|
||
'</div>';
|
||
display.innerHTML = html;
|
||
}
|
||
|
||
function addTodoItem(listId) {
|
||
var text = prompt('new task:');
|
||
if (text && text.trim()) {
|
||
if (!todolistData[listId]) todolistData[listId] = [];
|
||
todolistData[listId].push({ text: text.trim(), done: false });
|
||
localStorage.setItem('dashd_todolist', JSON.stringify(todolistData));
|
||
updateAllWidgets();
|
||
}
|
||
}
|
||
|
||
function handleTodoInput(e, listId) {
|
||
if (e.key === 'Enter') {
|
||
var text = e.target.value.trim();
|
||
if (text) {
|
||
if (!todolistData[listId]) todolistData[listId] = [];
|
||
todolistData[listId].push({ text: text, done: false });
|
||
localStorage.setItem('dashd_todolist', JSON.stringify(todolistData));
|
||
e.target.value = '';
|
||
updateAllWidgets();
|
||
}
|
||
}
|
||
}
|
||
|
||
function toggleTodoItem(listId, idx) {
|
||
todolistData[listId][idx].done = !todolistData[listId][idx].done;
|
||
localStorage.setItem('dashd_todolist', JSON.stringify(todolistData));
|
||
updateAllWidgets();
|
||
}
|
||
|
||
function deleteTodoItem(listId, idx) {
|
||
todolistData[listId].splice(idx, 1);
|
||
localStorage.setItem('dashd_todolist', JSON.stringify(todolistData));
|
||
updateAllWidgets();
|
||
}
|
||
|
||
// ========== COUNTDOWN WIDGET ==========
|
||
function renderCountdownWidget(display, svc) {
|
||
if (!svc.countdownDate) {
|
||
display.innerHTML = '<div class="widget-countdown"><div style="color:var(--text-dim)">no date set</div></div>';
|
||
return;
|
||
}
|
||
var target = new Date(svc.countdownDate).getTime();
|
||
var now = Date.now();
|
||
var diff = target - now;
|
||
|
||
if (diff <= 0) {
|
||
display.innerHTML = '<div class="widget-countdown"><div class="widget-countdown-name">' + escapeHtml(svc.countdownName || 'countdown') + '</div><div style="color:var(--seafoam);font-size:16px">complete!</div></div>';
|
||
return;
|
||
}
|
||
|
||
var days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||
var hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||
var mins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||
var secs = Math.floor((diff % (1000 * 60)) / 1000);
|
||
|
||
display.innerHTML = '<div class="widget-countdown">' +
|
||
'<div class="widget-countdown-name">' + escapeHtml(svc.countdownName || 'countdown') + '</div>' +
|
||
'<div class="widget-countdown-time">' +
|
||
'<div class="widget-countdown-unit"><div class="widget-countdown-value">' + days + '</div><div class="widget-countdown-label">days</div></div>' +
|
||
'<div class="widget-countdown-unit"><div class="widget-countdown-value">' + String(hours).padStart(2, '0') + '</div><div class="widget-countdown-label">hrs</div></div>' +
|
||
'<div class="widget-countdown-unit"><div class="widget-countdown-value">' + String(mins).padStart(2, '0') + '</div><div class="widget-countdown-label">min</div></div>' +
|
||
'<div class="widget-countdown-unit"><div class="widget-countdown-value">' + String(secs).padStart(2, '0') + '</div><div class="widget-countdown-label">sec</div></div>' +
|
||
'</div></div>';
|
||
}
|
||
|
||
// ========== NOW PLAYING WIDGET ==========
|
||
function renderNowplayingWidget(display, svc) {
|
||
// Check jellyfin for now playing
|
||
if (jellyfinNowPlaying && jellyfinNowPlaying.title) {
|
||
display.innerHTML = '<div class="widget-nowplaying">' +
|
||
'<div class="widget-nowplaying-art">' + (jellyfinNowPlaying.art ? '<img src="' + jellyfinNowPlaying.art + '">' : '') + '</div>' +
|
||
'<div class="widget-nowplaying-info">' +
|
||
'<div class="widget-nowplaying-title">' + escapeHtml(jellyfinNowPlaying.title) + '</div>' +
|
||
'<div class="widget-nowplaying-artist">' + escapeHtml(jellyfinNowPlaying.artist || '') + '</div>' +
|
||
'<div class="widget-nowplaying-source">jellyfin</div>' +
|
||
'</div></div>';
|
||
} else {
|
||
display.innerHTML = '<div class="widget-nowplaying-idle">nothing playing</div>';
|
||
}
|
||
}
|
||
|
||
var jellyfinNowPlaying = null;
|
||
|
||
async function fetchJellyfinNowPlaying() {
|
||
if (!jellyfinConfig.url) return;
|
||
try {
|
||
var res = await fetch(jellyfinConfig.url + '/Sessions' + (jellyfinConfig.key ? '?api_key=' + jellyfinConfig.key : ''));
|
||
if (res.ok) {
|
||
var sessions = await res.json();
|
||
var playing = sessions.find(function(s) { return s.NowPlayingItem; });
|
||
if (playing && playing.NowPlayingItem) {
|
||
var item = playing.NowPlayingItem;
|
||
jellyfinNowPlaying = {
|
||
title: item.Name,
|
||
artist: item.Artists ? item.Artists.join(', ') : item.SeriesName || '',
|
||
art: item.ImageTags && item.ImageTags.Primary ? jellyfinConfig.url + '/Items/' + item.Id + '/Images/Primary?maxHeight=100&api_key=' + jellyfinConfig.key : ''
|
||
};
|
||
} else {
|
||
jellyfinNowPlaying = null;
|
||
}
|
||
updateAllWidgets();
|
||
}
|
||
} catch (err) { /* parse error */ }
|
||
}
|
||
|
||
// ========== BROWSER WIDGET ==========
|
||
function renderBrowserWidget(display, svc) {
|
||
if (!svc.browserUrl) {
|
||
display.innerHTML = '<div class="widget-browser" style="display:flex;align-items:center;justify-content:center;color:var(--text-dim);font-size:11px">add browser url</div>';
|
||
return;
|
||
}
|
||
var proxyUrl = "/proxy?url=" + encodeURIComponent(svc.browserUrl);
|
||
display.innerHTML = '<div class="widget-browser"><iframe src="'+proxyUrl+'" loading="lazy"></iframe></div>';
|
||
}
|
||
|
||
function renderSystemWidget(display, svc) {
|
||
// Would need a backend to fetch real system stats
|
||
// Show placeholder with simulated values
|
||
var cpu = Math.round(Math.random() * 60 + 10);
|
||
var ram = Math.round(Math.random() * 50 + 30);
|
||
var disk = Math.round(Math.random() * 40 + 40);
|
||
display.innerHTML = '<div class="widget-system">' +
|
||
'<div class="widget-system-row"><span class="widget-system-label">cpu</span><span class="widget-system-value" style="color:var(--aqua)">' + cpu + '%</span></div>' +
|
||
'<div class="widget-system-bar"><div class="widget-system-bar-fill cpu" style="width:' + cpu + '%"></div></div>' +
|
||
'<div class="widget-system-row"><span class="widget-system-label">ram</span><span class="widget-system-value" style="color:var(--lavender)">' + ram + '%</span></div>' +
|
||
'<div class="widget-system-bar"><div class="widget-system-bar-fill ram" style="width:' + ram + '%"></div></div>' +
|
||
'<div class="widget-system-row"><span class="widget-system-label">disk</span><span class="widget-system-value" style="color:var(--seafoam)">' + disk + '%</span></div>' +
|
||
'<div class="widget-system-bar"><div class="widget-system-bar-fill disk" style="width:' + disk + '%"></div></div>' +
|
||
'</div>';
|
||
}
|
||
|
||
function formatNumber(n) {
|
||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
||
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
||
return n.toString();
|
||
}
|
||
|
||
// ========== OPNSENSE ==========
|
||
function initOPNsense() {
|
||
if (opnsenseConfig.url) document.getElementById('opnsenseUrl').value = opnsenseConfig.url;
|
||
if (opnsenseConfig.key) document.getElementById('opnsenseKey').value = opnsenseConfig.key;
|
||
if (opnsenseConfig.secret) document.getElementById('opnsenseSecret').value = opnsenseConfig.secret;
|
||
if (opnsenseConfig.url && opnsenseConfig.key && opnsenseConfig.secret) { connectOPNsense(); }
|
||
}
|
||
|
||
async function connectOPNsense() {
|
||
var url = document.getElementById('opnsenseUrl').value.trim().replace(/\/$/, '');
|
||
var key = document.getElementById('opnsenseKey').value.trim();
|
||
var secret = document.getElementById('opnsenseSecret').value.trim();
|
||
if (!url || !key || !secret) { showToast('need url, key, and secret'); return; }
|
||
opnsenseConfig = { url: url, key: key, secret: secret };
|
||
localStorage.setItem('dashd_opnsense_config', JSON.stringify(opnsenseConfig));
|
||
document.getElementById('opnsenseStatus').textContent = 'connecting...';
|
||
try {
|
||
var auth = btoa(key + ':' + secret);
|
||
var res = await fetch(url + '/api/diagnostics/interface/getInterfaceStatistics', {
|
||
headers: { 'Authorization': 'Basic ' + auth }
|
||
});
|
||
if (res.ok) {
|
||
opnsenseData = await res.json();
|
||
document.getElementById('opnsenseStatus').innerHTML = '<span style="color:var(--seafoam)">connected</span>';
|
||
updateAllWidgets();
|
||
} else {
|
||
document.getElementById('opnsenseStatus').innerHTML = '<span style="color:#ff4d6d">auth failed</span>';
|
||
}
|
||
} catch (err) {
|
||
document.getElementById('opnsenseStatus').innerHTML = '<span style="color:#ff4d6d">connection failed</span>';
|
||
}
|
||
}
|
||
|
||
async function refreshOPNsense() {
|
||
if (!opnsenseConfig.url || !opnsenseConfig.key) return;
|
||
try {
|
||
var auth = btoa(opnsenseConfig.key + ':' + opnsenseConfig.secret);
|
||
var res = await fetch(opnsenseConfig.url + '/api/diagnostics/interface/getInterfaceStatistics', {
|
||
headers: { 'Authorization': 'Basic ' + auth }
|
||
});
|
||
if (res.ok) { opnsenseData = await res.json(); updateAllWidgets(); }
|
||
} catch (err) { /* OPNsense unavailable */ }
|
||
}
|
||
|
||
function renderOPNsenseWidget(display, svc) {
|
||
var stats = opnsenseData.statistics || {};
|
||
var wan = stats.wan || stats.opt3 || stats.igb0 || {};
|
||
var bytesIn = wan['bytes received'] || 0;
|
||
var bytesOut = wan['bytes transmitted'] || 0;
|
||
var online = Object.keys(stats).length > 0;
|
||
display.innerHTML = '<div class="widget-opnsense">' +
|
||
'<div class="widget-opnsense-header">' +
|
||
'<i class="widget-opnsense-icon mdi mdi-router-network"></i>' +
|
||
'<div class="widget-opnsense-title">opnsense</div>' +
|
||
'<div class="widget-opnsense-status ' + (online ? 'online' : 'offline') + '">' + (online ? 'online' : 'offline') + '</div>' +
|
||
'</div>' +
|
||
'<div class="widget-opnsense-stats">' +
|
||
'<div class="widget-opnsense-stat"><div class="value" style="color:var(--seafoam)">↓ ' + formatBytes(bytesIn) + '</div><div class="label">received</div></div>' +
|
||
'<div class="widget-opnsense-stat"><div class="value" style="color:var(--lavender)">↑ ' + formatBytes(bytesOut) + '</div><div class="label">transmitted</div></div>' +
|
||
'</div></div>';
|
||
}
|
||
|
||
// ========== CLOUDFLARED ==========
|
||
function initCloudflared() {
|
||
if (cloudflaredConfig.token) document.getElementById('cloudflaredToken').value = cloudflaredConfig.token;
|
||
if (cloudflaredConfig.account) document.getElementById('cloudflaredAccount').value = cloudflaredConfig.account;
|
||
if (cloudflaredConfig.tunnel) document.getElementById('cloudflaredTunnel').value = cloudflaredConfig.tunnel;
|
||
if (cloudflaredConfig.token && cloudflaredConfig.account && cloudflaredConfig.tunnel) { connectCloudflared(); }
|
||
}
|
||
|
||
async function connectCloudflared() {
|
||
var token = document.getElementById('cloudflaredToken').value.trim();
|
||
var account = document.getElementById('cloudflaredAccount').value.trim();
|
||
var tunnel = document.getElementById('cloudflaredTunnel').value.trim();
|
||
if (!token || !account || !tunnel) { showToast('need token, account id, and tunnel id'); return; }
|
||
cloudflaredConfig = { token: token, account: account, tunnel: tunnel };
|
||
localStorage.setItem('dashd_cloudflared_config', JSON.stringify(cloudflaredConfig));
|
||
document.getElementById('cloudflaredStatus').textContent = 'connecting...';
|
||
try {
|
||
var res = await fetch('https://api.cloudflare.com/client/v4/accounts/' + account + '/cfd_tunnel/' + tunnel, {
|
||
headers: { 'Authorization': 'Bearer ' + token }
|
||
});
|
||
if (res.ok) {
|
||
var data = await res.json();
|
||
cloudflaredData = data.result || {};
|
||
document.getElementById('cloudflaredStatus').innerHTML = '<span style="color:var(--seafoam)">connected</span>';
|
||
updateAllWidgets();
|
||
} else {
|
||
document.getElementById('cloudflaredStatus').innerHTML = '<span style="color:#ff4d6d">failed</span>';
|
||
}
|
||
} catch (err) {
|
||
document.getElementById('cloudflaredStatus').innerHTML = '<span style="color:#ff4d6d">connection failed</span>';
|
||
}
|
||
}
|
||
|
||
async function refreshCloudflared() {
|
||
if (!cloudflaredConfig.token || !cloudflaredConfig.account || !cloudflaredConfig.tunnel) return;
|
||
try {
|
||
var res = await fetch('https://api.cloudflare.com/client/v4/accounts/' + cloudflaredConfig.account + '/cfd_tunnel/' + cloudflaredConfig.tunnel, {
|
||
headers: { 'Authorization': 'Bearer ' + cloudflaredConfig.token }
|
||
});
|
||
if (res.ok) { var data = await res.json(); cloudflaredData = data.result || {}; updateAllWidgets(); }
|
||
} catch (err) { /* Cloudflared unavailable */ }
|
||
}
|
||
|
||
function renderCloudflaredWidget(display, svc) {
|
||
var status = cloudflaredData.status || 'unknown';
|
||
var name = cloudflaredData.name || 'tunnel';
|
||
var conns = cloudflaredData.connections ? cloudflaredData.connections.length : 0;
|
||
var statusClass = status === 'healthy' ? 'healthy' : (status === 'degraded' ? 'degraded' : 'down');
|
||
display.innerHTML = '<div class="widget-cloudflared">' +
|
||
'<i class="widget-cloudflared-icon mdi mdi-cloud-outline"></i>' +
|
||
'<div class="widget-cloudflared-name">' + name + '</div>' +
|
||
'<div class="widget-cloudflared-status ' + statusClass + '">' + status + '</div>' +
|
||
'<div class="widget-cloudflared-connections">' + conns + ' active connections</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
// ========== UNBOUND (via OPNsense) ==========
|
||
function renderUnboundWidget(display, svc) {
|
||
// Unbound stats would come from OPNsense API
|
||
// For now show placeholder
|
||
display.innerHTML = '<div class="widget-unbound">' +
|
||
'<div class="widget-unbound-header">unbound dns</div>' +
|
||
'<div class="widget-unbound-stats">' +
|
||
'<div class="widget-unbound-stat"><div class="value" style="color:var(--aqua)">--</div><div class="label">queries</div></div>' +
|
||
'<div class="widget-unbound-stat"><div class="value" style="color:var(--seafoam)">--</div><div class="label">cache hits</div></div>' +
|
||
'</div>' +
|
||
'<div style="color:var(--text-dim);font-size:9px;margin-top:8px;text-align:center">connect opnsense for stats</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
// ========== HABITICA ==========
|
||
function initHabitica() {
|
||
if (habiticaConfig.user) document.getElementById('habiticaUser').value = habiticaConfig.user;
|
||
if (habiticaConfig.key) document.getElementById('habiticaKey').value = habiticaConfig.key;
|
||
if (habiticaConfig.user && habiticaConfig.key) { connectHabitica(); }
|
||
}
|
||
|
||
async function connectHabitica() {
|
||
var user = document.getElementById('habiticaUser').value.trim();
|
||
var key = document.getElementById('habiticaKey').value.trim();
|
||
if (!user || !key) { showToast('need user id and api token'); return; }
|
||
habiticaConfig = { user: user, key: key };
|
||
localStorage.setItem('dashd_habitica_config', JSON.stringify(habiticaConfig));
|
||
document.getElementById('habiticaStatus').textContent = 'connecting...';
|
||
try {
|
||
var res = await fetch('https://habitica.com/api/v3/user', {
|
||
headers: { 'x-api-user': user, 'x-api-key': key, 'x-client': 'dashd-dashboard' }
|
||
});
|
||
if (res.ok) {
|
||
var data = await res.json();
|
||
habiticaData.user = data.data || {};
|
||
await refreshHabiticaTasks();
|
||
document.getElementById('habiticaStatus').innerHTML = '<span style="color:var(--seafoam)">connected</span>';
|
||
updateAllWidgets();
|
||
} else {
|
||
document.getElementById('habiticaStatus').innerHTML = '<span style="color:#ff4d6d">auth failed</span>';
|
||
}
|
||
} catch (err) {
|
||
document.getElementById('habiticaStatus').innerHTML = '<span style="color:#ff4d6d">connection failed</span>';
|
||
}
|
||
}
|
||
|
||
async function refreshHabiticaTasks() {
|
||
if (!habiticaConfig.user || !habiticaConfig.key) return;
|
||
try {
|
||
var res = await fetch('https://habitica.com/api/v3/tasks/user?type=dailys,todos', {
|
||
headers: { 'x-api-user': habiticaConfig.user, 'x-api-key': habiticaConfig.key, 'x-client': 'dashd-dashboard' }
|
||
});
|
||
if (res.ok) { var data = await res.json(); habiticaData.tasks = data.data || []; updateAllWidgets(); }
|
||
} catch (err) { /* Habitica unavailable */ }
|
||
}
|
||
|
||
async function scoreHabiticaTask(taskId, direction) {
|
||
if (!habiticaConfig.user || !habiticaConfig.key) return;
|
||
try {
|
||
await fetch('https://habitica.com/api/v3/tasks/' + taskId + '/score/' + direction, {
|
||
method: 'POST',
|
||
headers: { 'x-api-user': habiticaConfig.user, 'x-api-key': habiticaConfig.key, 'x-client': 'dashd-dashboard' }
|
||
});
|
||
showToast('task scored');
|
||
setTimeout(refreshHabiticaTasks, 500);
|
||
} catch (err) { showToast('failed'); }
|
||
}
|
||
|
||
function renderHabiticaWidget(display, svc) {
|
||
var user = habiticaData.user.stats || {};
|
||
var tasks = habiticaData.tasks || [];
|
||
var hp = Math.round(user.hp || 0);
|
||
var maxHp = user.maxHealth || 50;
|
||
var gold = Math.round(user.gp || 0);
|
||
|
||
var html = '<div class="widget-habitica">' +
|
||
'<div class="widget-habitica-header">' +
|
||
'<span class="widget-habitica-hp">❤️ ' + hp + '/' + maxHp + '</span>' +
|
||
'<span class="widget-habitica-gold">💰 ' + gold + '</span>' +
|
||
'</div><div class="widget-habitica-list">';
|
||
|
||
tasks.slice(0, 8).forEach(function(task) {
|
||
var done = task.completed || (task.type === 'daily' && task.isDue === false);
|
||
var isDaily = task.type === 'daily';
|
||
html += '<div class="widget-habitica-task">' +
|
||
'<div class="widget-habitica-check ' + (done ? 'done' : '') + (isDaily ? ' daily' : '') + '" ' +
|
||
'onclick="scoreHabiticaTask(\'' + task._id + '\',\'up\')"></div>' +
|
||
'<span class="widget-habitica-text ' + (done ? 'done' : '') + '">' + task.text + '</span></div>';
|
||
});
|
||
|
||
html += '</div></div>';
|
||
display.innerHTML = html;
|
||
}
|
||
|
||
// ========== CALENDAR ==========
|
||
function initCalendar() {
|
||
if (calendarConfig.url) document.getElementById('calendarUrl').value = calendarConfig.url;
|
||
if (calendarConfig.url) { connectCalendar(); }
|
||
}
|
||
|
||
async function connectCalendar() {
|
||
var url = document.getElementById('calendarUrl').value.trim();
|
||
if (!url) { showToast('need calendar url'); return; }
|
||
calendarConfig = { url: url };
|
||
localStorage.setItem('dashd_calendar_config', JSON.stringify(calendarConfig));
|
||
document.getElementById('calendarStatus').textContent = 'connecting...';
|
||
// ICS parsing would need a CORS proxy for most external calendars
|
||
document.getElementById('calendarStatus').innerHTML = '<span style="color:var(--seafoam)">saved (needs CORS proxy)</span>';
|
||
updateAllWidgets();
|
||
}
|
||
|
||
function renderCalendarWidget(display, svc) {
|
||
if (calendarEvents.length === 0) {
|
||
display.innerHTML = '<div class="widget-calendar">' +
|
||
'<div class="widget-calendar-header">calendar</div>' +
|
||
'<div style="color:var(--text-dim);font-size:10px;padding:10px">ics feeds need a CORS proxy backend</div>' +
|
||
'</div>';
|
||
return;
|
||
}
|
||
var html = '<div class="widget-calendar"><div class="widget-calendar-header">upcoming</div><div class="widget-calendar-list">';
|
||
calendarEvents.slice(0, 5).forEach(function(ev) {
|
||
html += '<div class="widget-calendar-event">' +
|
||
'<div class="widget-calendar-event-title">' + ev.title + '</div>' +
|
||
'<div class="widget-calendar-event-time">' + ev.time + '</div>' +
|
||
'</div>';
|
||
});
|
||
html += '</div></div>';
|
||
display.innerHTML = html;
|
||
}
|
||
|
||
function formatBytes(bytes) {
|
||
if (bytes === 0) return '0 B';
|
||
var k = 1024, sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
var i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||
}
|
||
|
||
// ========== MAILBOX ==========
|
||
function initMail() {
|
||
// Auto-connect if config exists
|
||
if (mailConfig.server) document.getElementById('mailServer').value = mailConfig.server;
|
||
if (mailConfig.user) document.getElementById('mailUser').value = mailConfig.user;
|
||
if (mailConfig.pass) document.getElementById('mailPass').value = mailConfig.pass;
|
||
if (mailConfig.server && mailConfig.user && mailConfig.pass) { connectMail(); }
|
||
}
|
||
|
||
async function connectMail() {
|
||
var server = document.getElementById('mailServer').value.trim();
|
||
var user = document.getElementById('mailUser').value.trim();
|
||
var pass = document.getElementById('mailPass').value.trim();
|
||
if (!server || !user || !pass) { showToast('need server, email, and password'); return; }
|
||
mailConfig = { server: server, user: user, pass: pass };
|
||
localStorage.setItem('dashd_mail_config', JSON.stringify(mailConfig));
|
||
document.getElementById('mailStatus').textContent = 'checking...';
|
||
|
||
// Try to fetch from mail proxy API (needs backend)
|
||
var proxyUrl = window.location.origin + '/api/mail';
|
||
try {
|
||
var res = await fetch(proxyUrl + '/inbox?limit=10', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ server: server, user: user, pass: pass })
|
||
});
|
||
if (res.ok) {
|
||
mailMessages = await res.json();
|
||
document.getElementById('mailStatus').innerHTML = '<span style="color:var(--seafoam)">connected (' + mailMessages.length + ' messages)</span>';
|
||
updateAllWidgets();
|
||
} else {
|
||
document.getElementById('mailStatus').innerHTML = '<span style="color:var(--seafoam)">saved (proxy not running)</span>';
|
||
}
|
||
} catch (err) {
|
||
document.getElementById('mailStatus').innerHTML = '<span style="color:var(--seafoam)">saved (needs mail proxy)</span>';
|
||
}
|
||
}
|
||
|
||
async function refreshMail() {
|
||
if (!mailConfig.server || !mailConfig.user) return;
|
||
var proxyUrl = window.location.origin + '/api/mail';
|
||
try {
|
||
var res = await fetch(proxyUrl + '/inbox?limit=10', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ server: (mailWidgetData[currentMailWidget]||{}).server, user: (mailWidgetData[currentMailWidget]||{}).user, pass: (mailWidgetData[currentMailWidget]||{}).pass })
|
||
});
|
||
if (res.ok) { mailMessages = await res.json(); updateAllWidgets(); }
|
||
} catch (err) { mailMessages = []; }
|
||
}
|
||
|
||
function renderMailboxWidget(display, svc) {
|
||
var widgetId = svc.id;
|
||
var data = mailWidgetData[widgetId] || { messages: [] };
|
||
var messages = data.messages || [];
|
||
var unreadCount = messages.filter(function(m) { return !m.read; }).length;
|
||
|
||
var card = display.closest('.card');
|
||
var cardWidth = card ? card.offsetWidth : 280;
|
||
var baseFontScale = Math.min(1, (cardWidth - 20) / 260);
|
||
var fromSize = Math.max(7, Math.floor(9 * baseFontScale));
|
||
var subjectSize = Math.max(8, Math.floor(10 * baseFontScale));
|
||
var dateSize = Math.max(6, Math.floor(8 * baseFontScale));
|
||
var titleSize = Math.max(8, Math.floor(10 * baseFontScale));
|
||
|
||
var html = '<div class="widget-mailbox">' +
|
||
'<div class="widget-mailbox-header">' +
|
||
'<i class="widget-mailbox-icon mdi mdi-email-outline"></i>' +
|
||
'<div class="widget-mailbox-title" style="font-size:' + titleSize + 'px">' + escapeHtml(svc.mailUser || 'inbox') + '</div>' +
|
||
(unreadCount > 0 ? '<div class="widget-mailbox-count">' + unreadCount + '</div>' : '') +
|
||
'<i class="mdi mdi-refresh widget-mailbox-refresh" style="cursor:pointer;margin-left:auto;opacity:0.6" onclick="event.stopPropagation(); refreshMailWidget(this)"></i>' +
|
||
'</div>';
|
||
|
||
if (messages.length === 0) {
|
||
if (!svc.mailServer) {
|
||
html += '<div class="widget-mailbox-empty">no mail config</div>';
|
||
} else if (!data.fetched) {
|
||
html += '<div class="widget-mailbox-empty">loading...</div>';
|
||
mailWidgetData[widgetId] = mailWidgetData[widgetId] || {};
|
||
mailWidgetData[widgetId].fetched = true;
|
||
setTimeout(function() { fetchMailForWidget(widgetId, svc.mailServer, svc.mailUser, svc.mailPass); }, 100);
|
||
} else {
|
||
html += '<div class="widget-mailbox-empty">no messages</div>';
|
||
}
|
||
} else {
|
||
html += '<div class="widget-mailbox-list">';
|
||
messages.forEach(function(msg) {
|
||
html += '<div class="widget-mailbox-email ' + (msg.read ? '' : 'unread') + '" data-msgid="' + msg.id + '" data-widget="' + widgetId + '">' +
|
||
'<div class="widget-mailbox-from" style="font-size:' + fromSize + 'px">' + escapeHtml(msg.from || 'unknown') + '</div>' +
|
||
'<div class="widget-mailbox-subject" style="font-size:' + subjectSize + 'px">' + escapeHtml(msg.subject || '(no subject)') + '</div>' +
|
||
'<div class="widget-mailbox-date" style="font-size:' + dateSize + 'px">' + escapeHtml(msg.date || '') + '</div>' +
|
||
'</div>';
|
||
});
|
||
html += '</div>';
|
||
}
|
||
html += '</div>';
|
||
display.innerHTML = html;
|
||
|
||
display.querySelectorAll('.widget-mailbox-email').forEach(function(el) {
|
||
el.onclick = function() {
|
||
openEmailPopup(el.dataset.msgid, el.dataset.widget);
|
||
};
|
||
});
|
||
}
|
||
|
||
// Email popup functions
|
||
var currentEmailId = null;
|
||
var currentEmailData = null;
|
||
|
||
function openEmailPopup(msgId, widgetId) {
|
||
currentMailWidget = widgetId;
|
||
currentEmailId = msgId;
|
||
document.getElementById('emailModal').classList.add('open');
|
||
document.getElementById('emailSubject').textContent = 'loading...';
|
||
document.getElementById('emailBody').textContent = 'loading...';
|
||
document.getElementById('emailFrom').textContent = '';
|
||
document.getElementById('emailTo').textContent = '';
|
||
document.getElementById('emailDate').textContent = '';
|
||
document.getElementById('emailThreadList').innerHTML = '<div class="email-loading">loading thread...</div>';
|
||
document.getElementById('emailReplyForm').classList.remove('open');
|
||
document.getElementById('emailReplyText').value = '';
|
||
|
||
loadEmailContent(msgId);
|
||
loadEmailThread(msgId);
|
||
}
|
||
|
||
function closeEmailModal() {
|
||
document.getElementById('emailModal').classList.remove('open');
|
||
currentEmailId = null;
|
||
currentEmailData = null;
|
||
}
|
||
|
||
async function loadEmailContent(msgId) {
|
||
try {
|
||
var res = await fetch('/api/mail/read/' + msgId, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ server: (mailWidgetData[currentMailWidget]||{}).server, user: (mailWidgetData[currentMailWidget]||{}).user, pass: (mailWidgetData[currentMailWidget]||{}).pass })
|
||
});
|
||
if (res.ok) {
|
||
currentEmailData = await res.json();
|
||
if (currentEmailData.error) {
|
||
document.getElementById('emailSubject').textContent = 'error';
|
||
document.getElementById('emailBody').textContent = currentEmailData.error;
|
||
return;
|
||
}
|
||
document.getElementById('emailSubject').textContent = currentEmailData.subject || '(no subject)';
|
||
document.getElementById('emailFrom').textContent = currentEmailData.from_name + ' <' + currentEmailData.from_email + '>';
|
||
document.getElementById('emailTo').textContent = currentEmailData.to || '';
|
||
document.getElementById('emailDate').textContent = currentEmailData.date || '';
|
||
document.getElementById('emailBody').textContent = currentEmailData.body || '';
|
||
|
||
// Mark as read in local list
|
||
var msg = mailMessages.find(function(m) { return m.id === msgId; });
|
||
if (msg) msg.read = true;
|
||
updateAllWidgets();
|
||
}
|
||
} catch (err) {
|
||
document.getElementById('emailBody').textContent = 'failed to load email: ' + err.message;
|
||
}
|
||
}
|
||
|
||
async function loadEmailThread(msgId) {
|
||
try {
|
||
var res = await fetch('/api/mail/thread/' + msgId, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ server: (mailWidgetData[currentMailWidget]||{}).server, user: (mailWidgetData[currentMailWidget]||{}).user, pass: (mailWidgetData[currentMailWidget]||{}).pass })
|
||
});
|
||
if (res.ok) {
|
||
var thread = await res.json();
|
||
var html = '';
|
||
if (thread.length > 0 && !thread[0].error) {
|
||
thread.forEach(function(item) {
|
||
var activeClass = item.id === msgId ? ' active' : '';
|
||
html += '<div class="email-thread-item' + activeClass + '" onclick="loadEmailContent(\'' + item.id + '\'); highlightThreadItem(this);">' +
|
||
'<div class="email-thread-from">' + escapeHtml(item.from || '') + '</div>' +
|
||
'<div class="email-thread-date">' + escapeHtml(item.date || '') + '</div>' +
|
||
'</div>';
|
||
});
|
||
} else {
|
||
html = '<div class="email-thread-item active"><div class="email-thread-from">this email</div></div>';
|
||
}
|
||
document.getElementById('emailThreadList').innerHTML = html;
|
||
}
|
||
} catch (err) {
|
||
document.getElementById('emailThreadList').innerHTML = '<div class="email-thread-item active"><div class="email-thread-from">this email</div></div>';
|
||
}
|
||
}
|
||
|
||
function highlightThreadItem(el) {
|
||
document.querySelectorAll('.email-thread-item').forEach(function(item) {
|
||
item.classList.remove('active');
|
||
});
|
||
el.classList.add('active');
|
||
}
|
||
|
||
function toggleReplyForm() {
|
||
var form = document.getElementById('emailReplyForm');
|
||
form.classList.toggle('open');
|
||
if (form.classList.contains('open')) {
|
||
document.getElementById('emailReplyText').focus();
|
||
}
|
||
}
|
||
|
||
async function sendEmailReply() {
|
||
if (!currentEmailData) { showToast('no email loaded', 'error'); return; }
|
||
var replyText = document.getElementById('emailReplyText').value.trim();
|
||
if (!replyText) { showToast('reply text is empty', 'error'); return; }
|
||
|
||
var replySubject = currentEmailData.subject || '';
|
||
if (!replySubject.toLowerCase().startsWith('re:')) {
|
||
replySubject = 'Re: ' + replySubject;
|
||
}
|
||
|
||
try {
|
||
var res = await fetch('/api/mail/send', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
server: mailConfig.server,
|
||
user: mailConfig.user,
|
||
pass: mailConfig.pass,
|
||
to: currentEmailData.from_email,
|
||
subject: replySubject,
|
||
body: replyText,
|
||
in_reply_to: currentEmailData.message_id || '',
|
||
references: currentEmailData.references || ''
|
||
})
|
||
});
|
||
var result = await res.json();
|
||
if (result.success) {
|
||
showToast('reply sent', 'success');
|
||
document.getElementById('emailReplyText').value = '';
|
||
document.getElementById('emailReplyForm').classList.remove('open');
|
||
} else {
|
||
showToast('send failed: ' + (result.error || 'unknown error'), 'error');
|
||
}
|
||
} catch (err) {
|
||
showToast('send failed: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Close email modal on escape key
|
||
document.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Escape' && document.getElementById('emailModal').classList.contains('open')) {
|
||
closeEmailModal();
|
||
}
|
||
});
|
||
|
||
// Close email modal on overlay click
|
||
document.getElementById('emailModal').addEventListener('click', function(e) {
|
||
if (e.target === this) closeEmailModal();
|
||
});
|
||
// Initialize all services
|
||
function initAllServices() {
|
||
initPVE();
|
||
initDocker();
|
||
initAdguard();
|
||
initJellyfin();
|
||
initArr();
|
||
initFrigate();
|
||
initOPNsense();
|
||
initCloudflared();
|
||
initHabitica();
|
||
initCalendar();
|
||
initMail();
|
||
fetchCryptoPrices();
|
||
fetchJellyfinNowPlaying();
|
||
startWidgetUpdates();
|
||
}
|
||
|
||
// Update widget refresh to include new services
|
||
function startWidgetUpdates() {
|
||
if (widgetUpdateTimer) clearInterval(widgetUpdateTimer);
|
||
widgetUpdateTimer = setInterval(function() {
|
||
refreshPVEResources();
|
||
refreshDockerContainers();
|
||
refreshAdguardStats();
|
||
refreshJellyfinSessions();
|
||
refreshArrCalendars();
|
||
refreshOPNsense();
|
||
refreshCloudflared();
|
||
refreshHabiticaTasks();
|
||
refreshMail();
|
||
fetchCryptoPrices();
|
||
fetchJellyfinNowPlaying();
|
||
updateAllWidgets();
|
||
}, 30000);
|
||
}
|
||
|
||
initHA();
|
||
initAllServices();
|
||
checkAuth();
|
||
</script>
|
||
</body>
|
||
</html>
|