dashd/dashboard.html
Your Name 57c920b57f initial release - dashd cyberpunk infrastructure dashboard
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>
2025-12-14 03:05:43 -06:00

4837 lines
242 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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">&#9637;</button>
<button class="btn" id="editBtn" title="edit mode">edit</button>
<button class="btn" id="scanBtn" title="scan/settings">&#9881;</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">&#9671;</div>
<div>no services pinned</div>
<div style="font-size: 12px; margin-top: 10px;">click + to add or &#9881; 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&#10;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()">&times;</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', '&times;', function(e) {
e.stopPropagation();
confirmDelete(svc);
});
var editCardBtn = createButton('card-btn card-edit-btn', '&#9998;', function(e) {
e.stopPropagation();
toggleEditPanel(card);
});
var previewBtn = createButton('card-btn card-preview-btn' + (showPreview ? ' active' : ''), '&#9635;', 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', '&times;', 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, '&quot;') + '">' +
'<label>url</label><input type="text" class="edit-url" value="' + (svc.url || '').replace(/"/g, '&quot;') + '">' +
'<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 = '&times;';
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>