Files
Dashgaard/frontend/index.html
2026-03-28 21:05:02 +00:00

1317 lines
73 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>Dashgaard</title>
<link rel="apple-touch-icon" href="/uploads/favicon.png">
<link rel="manifest" href="/uploads/manifest.json">
<meta property="og:type" content="website">
<meta property="og:title" content="Dashgaard">
<meta property="og:description" content="Homelab service dashboard">
<meta property="og:image" content="/uploads/og-banner.png">
<meta property="og:url" content="https://dashgaard.boogaardmusic.com">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Dashgaard">
<meta name="twitter:description" content="Homelab service dashboard">
<meta name="twitter:image" content="/uploads/og-banner.png">
<link rel="icon" id="favicon-el" href="favicon.png" type="image/png">
<link rel="preload" href="/uploads/fonts/RomanSD.ttf" as="font" type="font/truetype" crossorigin="anonymous">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@300;400;600;700&family=JetBrains+Mono:wght@400;700;800&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0a0600; --bg1:#100800; --bg2:#160b00; --bg3:#1e1000; --bg4:#261400;
--accent:#f5a623; --accent-hot:#ffcf40; --accent-dim:rgba(245,166,35,0.15); --accent-glow:rgba(245,166,35,0.35);
--border:rgba(245,166,35,0.10); --border2:rgba(245,166,35,0.22); --border3:rgba(245,166,35,0.45);
--text:rgba(255,220,170,0.92); --text2:rgba(245,185,110,0.65); --text3:rgba(200,140,70,0.40);
--ok:#00e87a; --warn:#ffb830; --down:#ff2020; --pend:#c084fc;
--mono:'Share Tech Mono',monospace; --display:'JetBrains Mono','Share Tech Mono',monospace; --body:'Rajdhani',sans-serif;
--page-max:960px; --base-font:16px; --card-gap:10px; --card-radius:10px; --beat-h:16px;
--card-icon-size:2rem; --card-name-size:15px; --card-justify:flex-start; --card-text-align:left;
}
html{scroll-behavior:smooth;font-size:var(--base-font)}
body{background:var(--bg);color:var(--text);font-family:var(--body);min-height:100vh;overflow-x:hidden}
body::before{content:'';position:fixed;inset:0;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,.06) 2px,rgba(0,0,0,.06) 4px);pointer-events:none;z-index:200}
body::after{content:'';position:fixed;inset:0;background:radial-gradient(ellipse at center,transparent 40%,rgba(0,0,0,.65) 100%);pointer-events:none;z-index:199}
/* Corners */
.corner{position:fixed;width:48px;height:48px;z-index:300;pointer-events:none}
.corner-tl{top:14px;left:14px;border-top:1px solid rgba(245,166,35,.25);border-left:1px solid rgba(245,166,35,.25)}
.corner-tr{top:14px;right:14px;border-top:1px solid rgba(245,166,35,.25);border-right:1px solid rgba(245,166,35,.25)}
.corner-bl{bottom:14px;left:14px;border-bottom:1px solid rgba(245,166,35,.25);border-left:1px solid rgba(245,166,35,.25)}
.corner-br{bottom:14px;right:14px;border-bottom:1px solid rgba(245,166,35,.25);border-right:1px solid rgba(245,166,35,.25)}
/* Banner */
.banner{width:100%;max-width:var(--page-max);margin:0 auto;aspect-ratio:3/1;position:relative;overflow:hidden;border-bottom:1px solid var(--border2)}
.banner img{width:100%;height:100%;object-fit:cover;filter:saturate(1.2) brightness(.6)}
.banner-placeholder{width:100%;height:100%;background:radial-gradient(ellipse at 30% 50%,rgba(200,100,0,.35),transparent 60%),radial-gradient(ellipse at 70% 50%,rgba(150,60,0,.25),transparent 60%),var(--bg1);display:flex;align-items:center;justify-content:center}
.banner-placeholder span{font-family:var(--mono);font-size:10px;letter-spacing:.25em;color:var(--text3);text-transform:uppercase;border:1px solid var(--border);padding:8px 18px}
.banner-overlay{position:absolute;inset:0;background:linear-gradient(to bottom,transparent 30%,var(--bg) 100%)}
.banner-grid{position:absolute;inset:0;background-image:linear-gradient(rgba(245,166,35,.05) 1px,transparent 1px),linear-gradient(90deg,rgba(245,166,35,.05) 1px,transparent 1px);background-size:44px 44px}
/* Header */
.header{max-width:var(--page-max);margin:-80px auto 0;padding:0 2rem 2rem;position:relative;z-index:10}
.logo-row{display:flex;align-items:flex-end;gap:1.5rem;margin-bottom:1.5rem}
.logo{width:110px;height:110px;border-radius:22px;border:1px solid var(--border3);overflow:hidden;flex-shrink:0;box-shadow:0 0 40px var(--accent-glow),0 0 100px rgba(245,166,35,.10)}
.logo img,.logo>*{width:100%;height:100%;object-fit:cover}
.logo-fallback{width:100%;height:100%;background:var(--bg3);display:flex;align-items:center;justify-content:center;font-family:var(--display);font-size:36px;font-weight:800;color:var(--accent);text-shadow:0 0 20px var(--accent-glow)}
.title-block{flex:1;padding-bottom:6px}
.site-title{font-family:var(--display);font-size:clamp(2rem,5vw,3.6rem);font-weight:800;letter-spacing:.05em;color:var(--accent);text-shadow:0 0 12px var(--accent-glow),0 0 40px rgba(245,166,35,.25);line-height:1;margin-bottom:8px;opacity:0;transition:opacity .3s}
.site-title.font-ready{opacity:1}
.site-slogan{font-family:var(--mono);font-size:11px;letter-spacing:.16em;color:var(--text2);min-height:18px;transition:opacity .4s}
.site-slogan.fade{opacity:0}
.header-actions{display:flex;align-items:center;gap:8px;margin-left:auto;padding-bottom:6px;flex-shrink:0}
.btn-hdr{background:var(--bg3);border:1px solid var(--border2);color:var(--text2);font-family:var(--mono);font-size:10px;letter-spacing:.12em;padding:7px 14px;border-radius:6px;cursor:pointer;transition:all 120ms;text-transform:uppercase;white-space:nowrap}
.btn-hdr:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-dim)}
.btn-hdr.active{border-color:var(--accent);color:var(--accent);background:var(--accent-dim)}
/* Overall bar */
.status-badge{display:inline-flex;align-items:stretch;border-radius:5px;overflow:hidden;margin-bottom:2rem;font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;border:1px solid rgba(255,255,255,.08)}
.status-badge-label{background:#1a1a1a;color:var(--text2);padding:5px 10px}
.status-badge-val{padding:5px 10px;font-weight:700;background:#333;color:var(--text3);transition:background .4s,color .4s}
.status-badge.ok .status-badge-val{background:#1a3a25;color:var(--ok)}
.status-badge.degraded .status-badge-val{background:#3a2e0a;color:var(--warn)}
.status-badge.down .status-badge-val{background:#3a1010;color:var(--down)}
@keyframes pulse-dot{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(1.5)}}
/* Main */
.main{max-width:var(--page-max);margin:0 auto;padding:0 2rem 5rem}
/* Group */
.group{margin-bottom:2.5rem}
.group-header{display:flex;align-items:center;gap:10px;margin-bottom:14px}
.group-label{font-family:var(--mono);font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:var(--text3);display:flex;align-items:center;gap:10px;flex:1}
.group-label::before{content:'';display:inline-block;width:20px;height:1px;background:var(--accent);box-shadow:0 0 4px var(--accent-glow);flex-shrink:0}
.group-label::after{content:'';flex:1;height:1px;background:var(--border);margin-left:6px}
/* Card grid */
.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:var(--card-gap)}
[data-card-size="small"] .card-grid{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}
[data-card-size="large"] .card-grid{grid-template-columns:repeat(auto-fill,minmax(320px,1fr))}
/* Card */
.card{
background:var(--bg2);border:1px solid var(--border);border-radius:var(--card-radius);
padding:20px;min-height:150px;display:flex;flex-direction:column;gap:10px;
text-decoration:none;color:inherit;position:relative;overflow:hidden;
transition:border-color 150ms,background 150ms,transform 150ms,box-shadow 150ms,opacity 150ms;
animation:fadein .35s ease both;cursor:pointer;user-select:none;
}
.card::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--card-accent,var(--accent));opacity:.6;transition:opacity 150ms}
.card:hover{border-color:var(--border2);background:var(--bg3);transform:translateY(-2px);box-shadow:0 8px 32px rgba(0,0,0,.4),0 0 20px var(--accent-dim)}
.card:hover::before{opacity:1}
.card.status-down{border-color:rgba(255,32,32,.18)}
.card.dragging{opacity:.4;transform:scale(.97);border-color:var(--accent) !important}
.card.drag-over{border-color:var(--accent) !important;background:var(--bg4) !important;box-shadow:0 0 20px var(--accent-dim)}
[data-card-size="small"] .card{padding:14px;min-height:120px}
[data-card-size="large"] .card{padding:26px;min-height:180px}
.card-top{display:flex;align-items:var(--card-valign,flex-start);gap:14px;justify-content:var(--card-justify,flex-start);flex-direction:var(--card-direction,row);flex:1}
.card-icon{font-size:var(--card-icon-size);line-height:1;flex-shrink:0;filter:drop-shadow(0 0 6px var(--card-accent,var(--accent-glow)));transition:filter 150ms}
.card-icon img{width:var(--card-icon-size,2rem);height:var(--card-icon-size,2rem);object-fit:contain;border-radius:4px;display:block}
.card:hover .card-icon{filter:drop-shadow(0 0 12px var(--card-accent,var(--accent-glow))) brightness(1.15)}
[data-card-size="small"] .card-icon{font-size:1.5rem}
[data-card-size="large"] .card-icon{font-size:2.6rem}
.card-info{min-width:0;text-align:var(--card-text-align,left)}
.card-name{font-family:var(--display);font-size:var(--card-name-size);font-weight:700;letter-spacing:.04em;color:var(--text);margin-bottom:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:var(--card-text-align)}
[data-card-size="small"] .card-name{font-size:13px}
[data-card-size="large"] .card-name{font-size:17px}
.card-desc{font-family:var(--body);font-size:13px;color:var(--text2);line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;text-align:var(--card-text-align)}
.card-badge{font-family:var(--mono);font-size:8px;letter-spacing:.1em;padding:3px 7px;border-radius:3px;text-transform:uppercase;border:1px solid;flex-shrink:0;margin-top:2px}
.badge-up{color:var(--ok);border-color:rgba(0,232,122,.25);text-shadow:0 0 6px rgba(0,232,122,.5)}
.badge-down{color:var(--down);border-color:rgba(255,32,32,.25)}
.badge-warn{color:var(--warn);border-color:rgba(255,184,48,.25)}
.badge-pend{color:var(--pend);border-color:rgba(192,132,252,.25)}
.badge-off{color:var(--text3);border-color:var(--border)}
.card-bottom{display:flex;align-items:center;gap:8px;margin-top:auto}
.heartbeat-mini{display:flex;gap:2px;flex:1}
.beat{flex:1;height:var(--beat-h);border-radius:2px;background:var(--border)}
.beat.up{background:var(--ok);box-shadow:0 0 3px rgba(0,232,122,.3)}
.beat.down{background:var(--down);box-shadow:0 0 3px rgba(255,32,32,.4)}
.beat.warn{background:var(--warn)}
.uptime-pct{font-family:var(--mono);font-size:9px;color:var(--text3);letter-spacing:.06em;white-space:nowrap}
/* Edit mode overlay on card */
.card-edit-handle{display:none;position:absolute;top:8px;right:8px;width:20px;height:20px;background:var(--accent-dim);border:1px solid var(--border2);border-radius:4px;align-items:center;justify-content:center;font-size:10px;color:var(--text3);cursor:grab}
body.edit-mode .card-edit-handle{display:flex}
body.edit-mode .card{cursor:grab}
body.edit-mode .card:active{cursor:grabbing}
/* ── Context Menu ── */
.ctx-menu{
position:fixed;z-index:1000;
background:var(--bg1);border:1px solid var(--border2);border-radius:8px;
padding:4px;min-width:180px;
box-shadow:0 8px 32px rgba(0,0,0,.6),0 0 20px rgba(245,166,35,.08);
animation:ctx-in .12s ease;
}
@keyframes ctx-in{from{opacity:0;transform:scale(.95)}to{opacity:1;transform:none}}
.ctx-item{
display:flex;align-items:center;gap:10px;
padding:9px 14px;border-radius:5px;cursor:pointer;
font-family:var(--mono);font-size:11px;letter-spacing:.08em;color:var(--text2);
transition:background 100ms,color 100ms;white-space:nowrap;
}
.ctx-item:hover{background:var(--accent-dim);color:var(--accent)}
.ctx-item.danger:hover{background:rgba(255,32,32,.1);color:#ff4444}
.ctx-item .ctx-icon{width:16px;text-align:center;font-size:13px;flex-shrink:0}
.ctx-sep{height:1px;background:var(--border);margin:3px 0}
.ctx-submenu{position:relative}
.ctx-submenu-arrow{margin-left:auto;color:var(--text3);font-size:9px}
.ctx-submenu-panel{
display:none;position:absolute;left:100%;top:-4px;
background:var(--bg1);border:1px solid var(--border2);border-radius:8px;
padding:4px;min-width:160px;
box-shadow:0 8px 32px rgba(0,0,0,.6);
}
.ctx-submenu:hover .ctx-submenu-panel{display:block}
/* ── Modals ── */
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:500;align-items:center;justify-content:center}
.modal-overlay.open{display:flex}
.modal{background:var(--bg1);border:1px solid var(--border2);border-radius:10px;width:min(480px,95vw);max-height:88vh;display:flex;flex-direction:column}
.modal-head{display:flex;align-items:center;padding:16px 20px;border-bottom:1px solid var(--border);gap:10px;flex-shrink:0}
.modal-title{font-family:var(--display);font-size:13px;font-weight:700;letter-spacing:.06em;color:var(--accent);flex:1}
.modal-close{background:none;border:none;color:var(--text3);font-size:18px;cursor:pointer;padding:2px 6px;border-radius:4px;transition:color 120ms}
.modal-close:hover{color:var(--accent)}
.modal-body{padding:20px;overflow-y:auto;flex:1}
.modal-body::-webkit-scrollbar{width:4px}
.modal-body::-webkit-scrollbar-thumb{background:var(--accent-dim);border-radius:2px}
.modal-foot{padding:14px 20px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:8px;flex-shrink:0}
/* ── Settings Panel ── */
.overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:400;backdrop-filter:blur(4px)}
.overlay.open{display:block}
.settings-panel{position:fixed;top:0;right:0;bottom:0;width:min(600px,100vw);background:var(--bg1);border-left:1px solid var(--border2);z-index:401;display:flex;flex-direction:column;transform:translateX(100%);transition:transform .25s cubic-bezier(.4,0,.2,1)}
.settings-panel.open{transform:none}
.settings-head{display:flex;align-items:center;padding:20px 24px;border-bottom:1px solid var(--border);gap:12px;flex-shrink:0}
.settings-head-title{font-family:var(--display);font-size:14px;font-weight:700;letter-spacing:.08em;color:var(--accent);flex:1}
.settings-close{background:none;border:1px solid var(--border2);color:var(--text2);font-family:var(--mono);font-size:10px;padding:6px 12px;border-radius:5px;cursor:pointer;letter-spacing:.1em;transition:all 120ms}
.settings-close:hover{border-color:var(--accent);color:var(--accent)}
.settings-tabs{display:flex;border-bottom:1px solid var(--border);flex-shrink:0;overflow-x:auto}
.stab{flex:1;min-width:fit-content;background:none;border:none;color:var(--text3);font-family:var(--mono);font-size:9px;letter-spacing:.15em;padding:12px 10px;cursor:pointer;text-transform:uppercase;border-bottom:2px solid transparent;transition:all 120ms;white-space:nowrap}
.stab.active{color:var(--accent);border-bottom-color:var(--accent)}
.stab:hover:not(.active){color:var(--text2)}
.settings-body{flex:1;overflow-y:auto;padding:20px 24px}
.settings-body::-webkit-scrollbar{width:4px}
.settings-body::-webkit-scrollbar-thumb{background:var(--accent-dim);border-radius:2px}
.s-section{margin-bottom:24px}
.s-section-title{font-family:var(--mono);font-size:9px;letter-spacing:.2em;text-transform:uppercase;color:var(--text3);margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid var(--border)}
.field{margin-bottom:14px}
.field label{display:block;font-family:var(--mono);font-size:9px;letter-spacing:.14em;text-transform:uppercase;color:var(--text2);margin-bottom:6px}
.field input,.field select,.field textarea{width:100%;background:var(--bg);border:1px solid var(--border2);color:var(--text);font-family:var(--mono);font-size:12px;padding:8px 12px;border-radius:5px;outline:none;transition:border-color 120ms}
.field input:focus,.field select:focus,.field textarea:focus{border-color:var(--accent)}
.field textarea{resize:vertical;min-height:70px;line-height:1.5}
.field select option{background:var(--bg2)}
.field-row{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.field-row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px}
.field-color{display:flex;align-items:center;gap:8px}
.field-color input[type=color]{width:40px;height:32px;padding:2px;border-radius:4px;cursor:pointer;border:1px solid var(--border2);background:var(--bg);flex-shrink:0}
.field-color input[type=text]{flex:1}
.field-range{display:flex;align-items:center;gap:10px}
.field-range input[type=range]{flex:1;accent-color:var(--accent)}
.field-range .range-val{font-family:var(--mono);font-size:11px;color:var(--accent);min-width:44px;text-align:right}
.btn{font-family:var(--mono);font-size:10px;letter-spacing:.12em;padding:8px 16px;border-radius:5px;cursor:pointer;border:1px solid;transition:all 120ms;text-transform:uppercase}
.btn-primary{background:var(--accent);color:var(--bg);border-color:var(--accent)}
.btn-primary:hover{background:var(--accent-hot);border-color:var(--accent-hot)}
.btn-secondary{background:var(--bg3);color:var(--text2);border-color:var(--border2)}
.btn-secondary:hover{border-color:var(--accent);color:var(--accent)}
.btn-danger{background:rgba(255,32,32,.1);color:#ff4444;border-color:rgba(255,32,32,.25)}
.btn-danger:hover{background:rgba(255,32,32,.2)}
.btn-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
.save-bar{padding:16px 24px;border-top:1px solid var(--border);flex-shrink:0;display:flex;justify-content:flex-end;gap:10px}
/* Services tab */
.svc-list{display:flex;flex-direction:column;gap:6px;margin-bottom:12px}
.svc-item{background:var(--bg2);border:1px solid var(--border);border-radius:6px;padding:10px 14px;display:flex;align-items:center;gap:10px;cursor:grab}
.svc-item:hover{border-color:var(--border2)}
.svc-item.dragging{opacity:.5}
.svc-item-icon{font-size:1.4rem;flex-shrink:0}
.svc-item-info{flex:1;min-width:0}
.svc-item-name{font-family:var(--display);font-size:13px;font-weight:700;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.svc-item-url{font-family:var(--mono);font-size:10px;color:var(--text3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.svc-item-actions{display:flex;gap:5px;flex-shrink:0}
.svc-btn{background:none;border:1px solid var(--border);color:var(--text3);font-family:var(--mono);font-size:9px;padding:4px 8px;border-radius:4px;cursor:pointer;letter-spacing:.08em;transition:all 120ms}
.svc-btn:hover{border-color:var(--accent);color:var(--accent)}
.svc-btn.del:hover{border-color:rgba(255,32,32,.4);color:#ff4444}
.drag-handle{color:var(--text3);font-size:14px;cursor:grab;flex-shrink:0;user-select:none}
.group-item{background:var(--bg2);border:1px solid var(--border);border-radius:6px;padding:10px 14px;display:flex;align-items:center;gap:10px;margin-bottom:6px}
.group-item-name{flex:1;font-family:var(--display);font-size:13px;font-weight:600;color:var(--text)}
.group-item-count{font-family:var(--mono);font-size:10px;color:var(--text3)}
/* Footer */
.footer{max-width:var(--page-max);margin:0 auto;padding:1.5rem 2rem;border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
.footer span{font-family:var(--mono);font-size:9px;letter-spacing:.12em;color:var(--text3)}
@keyframes fadein{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
@media(max-width:600px){
.header,.main{padding-left:1rem;padding-right:1rem}
.header{margin-top:-20px}
.logo{width:64px;height:64px;border-radius:12px}
.logo-fallback{font-size:22px}
.site-title{font-size:1.5rem}
.logo-row{gap:.85rem;flex-wrap:wrap;align-items:center}
.title-block{min-width:0;flex:1}
.header-actions{margin-left:0;width:100%;justify-content:flex-end;padding-bottom:0;gap:6px}
.btn-hdr{padding:5px 10px;font-size:9px}
.banner{aspect-ratio:4/1}
.status-badge{font-size:9px;margin-bottom:0}
.main{padding-top:0;margin-top:-2rem}
.modal{width:100vw !important;max-width:100vw !important;right:0 !important;left:0 !important;border-radius:0 !important}
.settings-panel{width:100vw !important}
.settings-body{padding:16px}
.footer{flex-direction:column;align-items:flex-start;gap:6px}
.card-grid{grid-template-columns:1fr}
}
@font-face {
font-family: 'Roman SD';
src: url('/uploads/fonts/RomanSD.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: block;
}
</style>
</head>
<body>
<div class="corner corner-tl"></div><div class="corner corner-tr"></div>
<div class="corner corner-bl"></div><div class="corner corner-br"></div>
<div class="banner" id="banner-wrap">
<div class="banner-grid"></div>
<div class="banner-placeholder" id="banner-ph"><span>// dashgaard</span></div>
<div class="banner-overlay"></div>
</div>
<div class="header">
<div class="logo-row">
<div class="logo" id="logo-wrap"><div class="logo-fallback" id="logo-fallback">D</div></div>
<div class="title-block">
<div class="site-title" id="site-title">Dashgaard</div>
<div class="site-slogan" id="slogan"></div>
</div>
<div class="header-actions">
<div class="status-badge" id="status-badge" style="margin-bottom:0;border-radius:6px"><div class="status-badge-label">status</div><div class="status-badge-val" id="status-label"></div></div>
<button class="btn-hdr" id="edit-toggle" onclick="toggleEditMode()">✏ Edit</button>
<button class="btn-hdr" onclick="openSettings()">⚙ Settings</button>
</div>
</div>
</div>
<div class="main" id="main"></div>
<div class="footer">
<span>dashgaard v2</span>
<span id="footer-time"></span>
</div>
<!-- Context menu -->
<div id="ctx-menu" class="ctx-menu" style="display:none"></div>
<!-- Edit service modal -->
<div class="modal-overlay" id="edit-modal">
<div class="modal">
<div class="modal-head">
<div class="modal-title" id="edit-modal-title">// edit service</div>
<button class="modal-close" onclick="closeEditModal()"></button>
</div>
<div class="modal-body" id="edit-modal-body"></div>
<div class="modal-foot">
<button class="btn btn-secondary" onclick="closeEditModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveEditModal()">Save</button>
</div>
</div>
</div>
<!-- Settings panel -->
<div class="overlay" id="settings-overlay" onclick="closeSettings()"></div>
<div class="settings-panel" id="settings-panel">
<div class="settings-head">
<div class="settings-head-title">// settings</div>
<button class="settings-close" onclick="closeSettings()">✕ close</button>
</div>
<div class="settings-tabs">
<button class="stab active" onclick="switchTab('site')" data-tab="site">Site</button>
<button class="stab" onclick="switchTab('theme')" data-tab="theme">Theme</button>
<button class="stab" onclick="switchTab('services')" data-tab="services">Services</button>
<button class="stab" onclick="switchTab('uptime')" data-tab="uptime">Uptime</button>
<button class="stab" onclick="switchTab('mobile')" data-tab="mobile">Mobile</button>
</div>
<div class="settings-body" id="settings-body"></div>
<div class="save-bar">
<button class="btn btn-secondary" onclick="closeSettings()">Cancel</button>
<button class="btn btn-primary" onclick="saveSettings()">Save</button>
</div>
</div>
<script>
'use strict';
// ── State ─────────────────────────────────────────────────────────────────────
let CFG = null;
let UPTIME = {};
let activeTab = 'site';
let pendingCfg = null;
let editMode = false;
// Drag state (dashboard)
let dragSvcId = null, dragSrcGid = null;
// Context menu state
let ctxSvcId = null, ctxGid = null;
// Edit modal state
let editingSvcId = null, editingGid = null;
// ── Boot ──────────────────────────────────────────────────────────────────────
async function boot() {
tick(); setInterval(tick, 1000);
CFG = await fetch('/api/config').then(r => r.json()).catch(() => null);
if (!CFG) { document.getElementById('main').innerHTML = '<div style="font-family:var(--mono);font-size:11px;color:var(--text3);padding:4rem 2rem;text-align:center;letter-spacing:.1em;text-transform:uppercase">// failed to load config</div>'; return; }
applyTheme(CFG.theme);
applySite(CFG.site);
// Wait for custom font before revealing title, then fade in
document.fonts.ready.then(() => {
const t = document.getElementById('site-title');
if (t) t.classList.add('font-ready');
});
render(); applyMobileStyles();
if (CFG.uptime?.enabled !== false) pollUptime();
document.addEventListener('click', dismissCtx);
document.addEventListener('contextmenu', onCtxMenu);
}
function tick() {
const n = new Date(), t = n.toLocaleTimeString('en-US', { hour12: false });
document.getElementById('footer-time').textContent = '// ' + n.toLocaleDateString('en-US') + ' ' + t;
}
// ── Theme ─────────────────────────────────────────────────────────────────────
function applyTheme(t) {
if (!t) return;
const r = document.documentElement.style;
if (t.accentColor) { r.setProperty('--accent', t.accentColor); r.setProperty('--accent-hot', lighten(t.accentColor)); r.setProperty('--accent-glow', t.accentColor + '55'); r.setProperty('--accent-dim', t.accentColor + '22'); }
if (t.bgColor) r.setProperty('--bg', t.bgColor);
if (t.cardBg) r.setProperty('--bg2', t.cardBg);
if (t.textColor) r.setProperty('--text', t.textColor);
if (t.fontBody) r.setProperty('--body', `'${t.fontBody}',sans-serif`);
if (t.fontMono) r.setProperty('--mono', `'${t.fontMono}',monospace`);
if (t.fontDisplay) r.setProperty('--display', `'${t.fontDisplay}',monospace`);
if (t.pageMaxWidth) r.setProperty('--page-max', t.pageMaxWidth + 'px');
if (t.baseFontSize) r.setProperty('--base-font', t.baseFontSize + 'px');
if (t.cardGap) r.setProperty('--card-gap', t.cardGap + 'px');
if (t.cardRadius) r.setProperty('--card-radius', t.cardRadius + 'px');
if (t.heartbeatHeight) r.setProperty('--beat-h', t.heartbeatHeight + 'px');
if (t.cardIconSize) r.setProperty('--card-icon-size', t.cardIconSize + 'rem');
if (t.cardNameSize) r.setProperty('--card-name-size', t.cardNameSize + 'px');
if (t.cardJustify) { r.setProperty('--card-justify', t.cardJustify); r.setProperty('--card-text-align', t.cardJustify === 'center' ? 'center' : t.cardJustify === 'flex-end' ? 'right' : 'left'); }
if (t.cardValign) r.setProperty('--card-valign', t.cardValign);
if (t.cardDirection) r.setProperty('--card-direction', t.cardDirection);
document.getElementById('main').dataset.cardSize = t.cardSize || 'medium';
}
function lighten(hex) {
try { const n = parseInt(hex.replace('#',''),16); const r=Math.min(255,(n>>16)+30),g=Math.min(255,((n>>8)&0xff)+30),b=Math.min(255,(n&0xff)+30); return '#'+[r,g,b].map(x=>x.toString(16).padStart(2,'0')).join(''); } catch { return hex; }
}
// ── Site ──────────────────────────────────────────────────────────────────────
function applySite(s) {
if (!s) return;
document.title = s.title || 'Dashgaard';
document.getElementById('site-title').textContent = s.title || 'Dashgaard';
if (s.favicon) document.getElementById('favicon-el').href = s.favicon;
if (s.logo) document.getElementById('logo-wrap').innerHTML = `<img src="${s.logo}" alt="" onerror="this.outerHTML='<div class=\\"logo-fallback\\">${(s.title||'D')[0]}</div>'">`;
if (s.banner) { const ph = document.getElementById('banner-ph'); if (ph) ph.outerHTML = `<img src="${s.banner}" alt="">`; }
const slogans = s.slogans?.length ? s.slogans : ['// dashgaard'];
let si = 0;
const el = document.getElementById('slogan');
el.textContent = '// ' + slogans[si];
if (slogans.length > 1) setInterval(() => { el.classList.add('fade'); setTimeout(() => { si=(si+1)%slogans.length; el.textContent='// '+slogans[si]; el.classList.remove('fade'); },400); }, 20000);
}
// ── Render ────────────────────────────────────────────────────────────────────
function render() {
const main = document.getElementById('main');
main.innerHTML = '';
main.dataset.cardSize = CFG.theme?.cardSize || 'medium';
const groups = [...(CFG.groups||[])].sort((a,b)=>(a.order??0)-(b.order??0));
groups.forEach(g => {
if (!g.services?.length && !editMode) return;
const sec = document.createElement('div');
sec.className = 'group';
sec.dataset.gid = g.id;
const lbl = document.createElement('div');
lbl.className = 'group-header';
lbl.innerHTML = `<div class="group-label">${escH(g.name||'services')}</div>`;
sec.appendChild(lbl);
const grid = document.createElement('div');
grid.className = 'card-grid';
grid.dataset.gid = g.id;
const svcs = [...(g.services||[])].sort((a,b)=>(a.order??0)-(b.order??0));
svcs.forEach((svc,i) => grid.appendChild(buildCard(svc, i)));
sec.appendChild(grid);
main.appendChild(sec);
if (editMode) setupGridDrag(grid);
});
}
function buildCard(svc, idx) {
const status = UPTIME[svc.name] || UPTIME[svc.id] || null;
const st = status?.status || 'off';
const card = document.createElement('div');
card.className = 'card status-' + st;
card.dataset.sid = svc.id;
card.dataset.gid = svc._gid || '';
card.style.setProperty('--card-accent', svc.color || 'var(--accent)');
card.style.animationDelay = (idx * 0.04) + 's';
card.draggable = true;
// Only navigate on click if not in edit mode
card.addEventListener('click', e => {
if (editMode) return;
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
window.open(svc.url, '_blank', 'noopener');
});
const badgeClass = {up:'badge-up',down:'badge-down',warn:'badge-warn',pend:'badge-pend',off:'badge-off'}[st]||'badge-off';
const badgeText = {up:'online',down:'offline',warn:'degraded',pend:'pending',off:'—'}[st]||'—';
const beats = status?.beats || [];
const N = 20;
let beatsHtml = '';
for (let p = 0; p < N - beats.length; p++) beatsHtml += '<div class="beat"></div>';
beats.slice(-N).forEach(b => { beatsHtml += `<div class="beat ${b.status===1?'up':b.status===2?'warn':'down'}"></div>`; });
const upt = status?.uptime != null ? (status.uptime*100).toFixed(1)+'% (24h)' : '';
const iconHtml = isImgUrl(svc.icon)
? `<img src="${escAttr(svc.icon)}" style="width:var(--card-icon-size,2rem);height:var(--card-icon-size,2rem);object-fit:contain;border-radius:4px;flex-shrink:0">`
: escH(svc.icon||'🔗');
card.innerHTML = `
<div class="card-top">
<div class="card-icon">${iconHtml}</div>
<div class="card-info">
<div class="card-name">${escH(svc.name)}</div>
${svc.description?`<div class="card-desc">${escH(svc.description)}</div>`:''}
</div>
<div class="card-badge ${badgeClass}">${badgeText}</div>
</div>
`; // card-bottom removed
return card;
}
// ── Edit Mode ─────────────────────────────────────────────────────────────────
function toggleEditMode() {
editMode = !editMode;
document.body.classList.toggle('edit-mode', editMode);
document.getElementById('edit-toggle').classList.toggle('active', editMode);
render();
}
// ── Dashboard Drag-and-Drop ───────────────────────────────────────────────────
function setupGridDrag(grid) {
grid.querySelectorAll('.card').forEach(card => {
card.addEventListener('dragstart', e => {
dragSvcId = card.dataset.sid;
dragSrcGid = grid.dataset.gid;
card.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', dragSvcId);
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
});
card.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
card.classList.add('drag-over');
});
card.addEventListener('drop', e => {
e.preventDefault();
card.classList.remove('drag-over');
if (!dragSvcId || dragSvcId === card.dataset.sid) return;
const targetGid = grid.dataset.gid;
const targetSid = card.dataset.sid;
moveSvcBeforeTarget(dragSvcId, dragSrcGid, targetSid, targetGid);
});
});
// Drop on empty space in grid
grid.addEventListener('dragover', e => { e.preventDefault(); });
grid.addEventListener('drop', e => {
e.preventDefault();
if (!dragSvcId) return;
const targetGid = grid.dataset.gid;
if (dragSrcGid !== targetGid) {
moveSvcToGroup(dragSvcId, dragSrcGid, targetGid);
}
});
}
function moveSvcBeforeTarget(srcId, srcGid, tgtId, tgtGid) {
const srcGroup = CFG.groups.find(g => g.id === srcGid);
const tgtGroup = CFG.groups.find(g => g.id === tgtGid);
if (!srcGroup || !tgtGroup) return;
const svc = srcGroup.services.find(s => s.id === srcId);
if (!svc) return;
// Remove from source
srcGroup.services = srcGroup.services.filter(s => s.id !== srcId);
// Insert before target in dest
const tgtIdx = tgtGroup.services.findIndex(s => s.id === tgtId);
if (tgtIdx === -1) tgtGroup.services.push(svc);
else tgtGroup.services.splice(tgtIdx, 0, svc);
// Reorder
tgtGroup.services.forEach((s,i) => s.order = i);
if (srcGid !== tgtGid) srcGroup.services.forEach((s,i) => s.order = i);
saveAndRender();
}
function moveSvcToGroup(srcId, srcGid, tgtGid) {
const srcGroup = CFG.groups.find(g => g.id === srcGid);
const tgtGroup = CFG.groups.find(g => g.id === tgtGid);
if (!srcGroup || !tgtGroup || srcGid === tgtGid) return;
const svc = srcGroup.services.find(s => s.id === srcId);
if (!svc) return;
srcGroup.services = srcGroup.services.filter(s => s.id !== srcId);
tgtGroup.services.push(svc);
tgtGroup.services.forEach((s,i) => s.order = i);
srcGroup.services.forEach((s,i) => s.order = i);
saveAndRender();
}
// ── Context Menu ──────────────────────────────────────────────────────────────
function onCtxMenu(e) {
const card = e.target.closest('.card');
if (!card) return;
e.preventDefault();
ctxSvcId = card.dataset.sid;
// Find which group this card belongs to
const grid = card.closest('.card-grid');
ctxGid = grid?.dataset.gid || findGidForSvc(ctxSvcId);
showCtx(e.clientX, e.clientY);
}
function findGidForSvc(sid) {
for (const g of CFG.groups) { if (g.services?.find(s => s.id === sid)) return g.id; }
return null;
}
function showCtx(x, y) {
const menu = document.getElementById('ctx-menu');
const otherGroups = CFG.groups.filter(g => g.id !== ctxGid);
let moveItems = otherGroups.map(g => `<div class="ctx-item" onclick="ctxMove('${g.id}')"><span class="ctx-icon">→</span>${escH(g.name)}</div>`).join('');
menu.innerHTML = `
<div class="ctx-item" onclick="ctxEdit()"><span class="ctx-icon">✏</span>Edit</div>
<div class="ctx-sep"></div>
${otherGroups.length ? `
<div class="ctx-item ctx-submenu">
<span class="ctx-icon">⇄</span>Move to group<span class="ctx-submenu-arrow">▶</span>
<div class="ctx-submenu-panel">${moveItems}</div>
</div>` : ''}
<div class="ctx-sep"></div>
<div class="ctx-item danger" onclick="ctxDelete()"><span class="ctx-icon">✕</span>Delete</div>`;
menu.style.display = 'block';
// Position, keep in viewport
const vw = window.innerWidth, vh = window.innerHeight;
const mw = 200, mh = 160;
menu.style.left = Math.min(x, vw - mw - 8) + 'px';
menu.style.top = Math.min(y, vh - mh - 8) + 'px';
}
function dismissCtx(e) {
const menu = document.getElementById('ctx-menu');
if (!e.target.closest('#ctx-menu')) { menu.style.display = 'none'; ctxSvcId = null; ctxGid = null; }
}
function ctxEdit() {
document.getElementById('ctx-menu').style.display = 'none';
openEditModal(ctxSvcId, ctxGid);
}
function ctxMove(targetGid) {
document.getElementById('ctx-menu').style.display = 'none';
moveSvcToGroup(ctxSvcId, ctxGid, targetGid);
}
function ctxDelete() {
document.getElementById('ctx-menu').style.display = 'none';
const g = CFG.groups.find(x => x.id === ctxGid);
if (!g) return;
const svc = g.services.find(s => s.id === ctxSvcId);
if (!svc) return;
if (!confirm(`Delete "${svc.name}"?`)) return;
g.services = g.services.filter(s => s.id !== ctxSvcId);
saveAndRender();
}
// ── Edit Modal ────────────────────────────────────────────────────────────────
function openEditModal(svcId, gid, isNew) {
editingSvcId = svcId;
editingGid = gid;
const g = CFG.groups.find(x => x.id === gid);
const svc = svcId ? g?.services?.find(s => s.id === svcId) : null;
document.getElementById('edit-modal-title').textContent = isNew ? '// add service' : '// edit service';
document.getElementById('edit-modal-body').innerHTML = `
<div class="field"><label>Name</label><input type="text" id="em-name" value="${escAttr(svc?.name||'')}"></div>
<div class="field"><label>URL</label><input type="text" id="em-url" value="${escAttr(svc?.url||'')}"></div>
<div class="field"><label>Description</label><input type="text" id="em-desc" value="${escAttr(svc?.description||'')}"></div>
<div class="field-row">
<div class="field">
<label>Icon <span style="color:var(--text3);font-size:9px">— emoji or image URL</span></label>
<div style="display:flex;gap:8px;align-items:center">
<div style="width:44px;height:44px;border:1px solid var(--border2);border-radius:6px;background:var(--bg);display:flex;align-items:center;justify-content:center;font-size:1.6rem;flex-shrink:0;overflow:hidden" id="em-icon-preview">${svc?.icon && isImgUrl(svc.icon) ? `<img src="${escAttr(svc.icon)}" style="width:100%;height:100%;object-fit:contain">` : escH(svc?.icon||'🔗')}</div>
<input type="text" id="em-icon" value="${escAttr(svc?.icon||'🔗')}" style="flex:1" oninput="previewIcon(this.value)">
<label class="btn btn-secondary" style="cursor:pointer;padding:7px 10px;white-space:nowrap">
↑<input type="file" accept="image/*" style="display:none" onchange="uploadIconFile(this)">
</label>
</div>
<div id="em-icon-gallery" style="margin-top:10px"></div>
</div>
<div class="field"><label>Accent Color</label>
<div class="field-color">
<input type="color" id="em-color-p" value="${svc?.color||'#f5a623'}" oninput="document.getElementById('em-color').value=this.value">
<input type="text" id="em-color" value="${escAttr(svc?.color||'#f5a623')}" oninput="document.getElementById('em-color-p').value=this.value">
</div>
</div>
</div>`;
document.getElementById('edit-modal').classList.add('open');
setTimeout(() => { document.getElementById('em-name').focus(); loadIconGallery(); }, 50);
}
function closeEditModal() { document.getElementById('edit-modal').classList.remove('open'); }
function saveEditModal() {
const g = CFG.groups.find(x => x.id === editingGid);
if (!g) return;
if (!g.services) g.services = [];
if (editingSvcId) {
const s = g.services.find(x => x.id === editingSvcId);
if (s) { s.name=v('em-name'); s.url=v('em-url'); s.description=v('em-desc'); s.icon=v('em-icon'); s.color=v('em-color'); }
} else {
g.services.push({ id:'s'+Date.now(), name:v('em-name'), url:v('em-url'), description:v('em-desc'), icon:v('em-icon'), color:v('em-color'), order:g.services.length });
}
closeEditModal();
saveAndRender();
}
// ── Settings Panel ────────────────────────────────────────────────────────────
function openSettings() {
pendingCfg = JSON.parse(JSON.stringify(CFG));
renderSettingsTab(activeTab);
document.getElementById('settings-overlay').classList.add('open');
document.getElementById('settings-panel').classList.add('open');
}
function closeSettings() {
document.getElementById('settings-overlay').classList.remove('open');
document.getElementById('settings-panel').classList.remove('open');
pendingCfg = null;
}
function switchTab(tab) {
activeTab = tab;
document.querySelectorAll('.stab').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
renderSettingsTab(tab);
}
function renderSettingsTab(tab) {
const body = document.getElementById('settings-body');
if (tab === 'site') { body.innerHTML = renderSiteTab(); loadUploadGallery(); }
else if (tab === 'theme') body.innerHTML = renderThemeTab();
else if (tab === 'services') { body.innerHTML = renderServicesTab(); attachSettingsDrag(); }
else if (tab === 'uptime') body.innerHTML = renderUptimeTab();
else if (tab === 'mobile') body.innerHTML = renderMobileTab();
}
function renderSiteTab() {
const s = pendingCfg.site;
return `
<div class="s-section">
<div class="s-section-title">Branding</div>
<div class="field"><label>Title</label><input type="text" id="cfg-title" value="${escAttr(s.title||'')}"></div>
<div class="field"><label>Slogans (one per line)</label><textarea id="cfg-slogans">${escH((s.slogans||[]).join('\n'))}</textarea></div>
<div class="field">
<label>Banner <span style="color:var(--text3);font-size:9px">(960×320 recommended, 3:1)</span></label>
<div style="display:flex;gap:8px;align-items:center">
<input type="text" id="cfg-banner" value="${escAttr(s.banner||'')}" style="flex:1">
<label class="btn btn-secondary" style="cursor:pointer;white-space:nowrap;padding:7px 12px">
↑ Upload<input type="file" accept="image/*" style="display:none" onchange="uploadAsset(this,'banner','cfg-banner')">
</label>
</div>
${s.banner ? `<div style="margin-top:8px"><img src="${escAttr(s.banner)}" style="max-width:100%;max-height:80px;border-radius:4px;border:1px solid var(--border2);object-fit:cover"></div>` : ''}
</div>
<div class="field">
<label>Logo <span style="color:var(--text3);font-size:9px">(square, min 110×110)</span></label>
<div style="display:flex;gap:8px;align-items:center">
<input type="text" id="cfg-logo" value="${escAttr(s.logo||'')}" style="flex:1">
<label class="btn btn-secondary" style="cursor:pointer;white-space:nowrap;padding:7px 12px">
↑ Upload<input type="file" accept="image/*" style="display:none" onchange="uploadAsset(this,'logo','cfg-logo')">
</label>
</div>
${s.logo ? `<div style="margin-top:8px"><img src="${escAttr(s.logo)}" style="width:60px;height:60px;border-radius:8px;border:1px solid var(--border2);object-fit:cover"></div>` : ''}
</div>
<div class="field">
<label>Favicon</label>
<div style="display:flex;gap:8px;align-items:center">
<input type="text" id="cfg-favicon" value="${escAttr(s.favicon||'')}" style="flex:1">
<label class="btn btn-secondary" style="cursor:pointer;white-space:nowrap;padding:7px 12px">
↑ Upload<input type="file" accept="image/*,.ico" style="display:none" onchange="uploadAsset(this,'favicon','cfg-favicon')">
</label>
</div>
</div>
</div>
<div class="s-section">
<div class="s-section-title">Bulk Upload <span style="color:var(--text3);font-size:9px">— /data/icons/</span></div>
<div id="bulk-dropzone"
style="border:2px dashed var(--border2);border-radius:8px;padding:28px 20px;text-align:center;cursor:pointer;transition:all 150ms;margin-bottom:12px;position:relative"
ondragover="bulkDragOver(event)" ondragleave="bulkDragLeave(event)" ondrop="bulkDrop(event)" onclick="document.getElementById('bulk-file-input').click()">
<input type="file" id="bulk-file-input" multiple accept="image/*,.ico,.svg" style="display:none" onchange="bulkUploadFiles(this.files)">
<div style="font-family:var(--mono);font-size:11px;letter-spacing:.1em;color:var(--text2);text-transform:uppercase">↑ Drop images here or click to select</div>
<div style="font-family:var(--mono);font-size:9px;color:var(--text3);margin-top:6px">jpg · png · gif · webp · svg · ico · 10MB max each</div>
<div id="bulk-progress" style="display:none;margin-top:12px"></div>
</div>
<div id="upload-gallery" style="display:flex;flex-wrap:wrap;gap:8px;margin-top:4px">
<div style="font-family:var(--mono);font-size:10px;color:var(--text3)">// loading...</div>
</div>
</div>`;
}
async function uploadAsset(input, slot, targetInputId) {
const file = input.files[0];
if (!file) return;
const fd = new FormData();
fd.append('file', file);
const btn = input.parentElement;
btn.textContent = '↑ Uploading...';
try {
const res = await fetch(`/api/upload?slot=${slot}`, { method: 'POST', body: fd });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
document.getElementById(targetInputId).value = data.url;
pendingCfg.site[slot] = data.url;
// Re-render site tab to show preview
document.getElementById('settings-body').innerHTML = renderSiteTab();
loadUploadGallery();
} catch (e) {
alert('Upload failed: ' + e.message);
btn.textContent = '↑ Upload';
}
}
async function loadUploadGallery() {
const el = document.getElementById('upload-gallery');
if (!el) return;
try {
const files = await fetch('/api/uploads').then(r => r.json());
if (!files.length) { el.innerHTML = '<div style="font-family:var(--mono);font-size:10px;color:var(--text3)">// no uploads yet</div>'; return; }
el.innerHTML = files.map(f => `
<div style="position:relative;cursor:pointer" title="${escH(f.filename)}" onclick="copyUrl('${escAttr(f.url)}',this)">
<img src="${escAttr(f.url)}" style="width:64px;height:64px;object-fit:cover;border-radius:6px;border:1px solid var(--border2)">
<div style="position:absolute;inset:0;background:rgba(0,0,0,0);border-radius:6px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:8px;color:transparent;transition:all 120ms" class="gal-overlay">copy</div>
</div>`).join('');
el.querySelectorAll('.gal-overlay').forEach(ov => {
ov.parentElement.addEventListener('mouseenter', () => { ov.style.background='rgba(0,0,0,0.6)'; ov.style.color='var(--accent)'; });
ov.parentElement.addEventListener('mouseleave', () => { ov.style.background='rgba(0,0,0,0)'; ov.style.color='transparent'; });
});
} catch { el.innerHTML = '<div style="font-family:var(--mono);font-size:10px;color:var(--text3)">// error loading files</div>'; }
}
function copyUrl(url, el) {
navigator.clipboard.writeText(url).catch(() => {});
const ov = el.querySelector('.gal-overlay');
if (ov) { ov.textContent = '✓'; setTimeout(() => { ov.textContent = 'copy'; }, 1200); }
}
// ── Bulk upload ───────────────────────────────────────────────────────────────
function bulkDragOver(e) {
e.preventDefault();
const dz = document.getElementById('bulk-dropzone');
if (dz) { dz.style.borderColor = 'var(--accent)'; dz.style.background = 'var(--accent-dim)'; }
}
function bulkDragLeave(e) {
const dz = document.getElementById('bulk-dropzone');
if (dz) { dz.style.borderColor = 'var(--border2)'; dz.style.background = ''; }
}
function bulkDrop(e) {
e.preventDefault();
bulkDragLeave(e);
const files = e.dataTransfer?.files;
if (files?.length) bulkUploadFiles(files);
}
async function bulkUploadFiles(files) {
const arr = [...files];
if (!arr.length) return;
const prog = document.getElementById('bulk-progress');
if (prog) prog.style.display = 'block';
let done = 0, failed = 0;
const total = arr.length;
function updateProg() {
if (!prog) return;
const pct = Math.round(((done + failed) / total) * 100);
prog.innerHTML = `
<div style="font-family:var(--mono);font-size:10px;color:var(--text2);margin-bottom:6px">
${done + failed} / ${total} &nbsp;·&nbsp; ${done} ok${failed ? ` &nbsp;·&nbsp; <span style="color:var(--down)">${failed} failed</span>` : ''}
</div>
<div style="background:var(--border);border-radius:3px;height:4px;overflow:hidden">
<div style="background:var(--accent);height:100%;width:${pct}%;transition:width 200ms;border-radius:3px"></div>
</div>`;
}
updateProg();
// Upload sequentially to avoid hammering the server
for (const file of arr) {
const fd = new FormData();
fd.append('file', file);
try {
const res = await fetch('/api/upload', { method: 'POST', body: fd });
if (res.ok) done++; else failed++;
} catch { failed++; }
updateProg();
}
// Done — show result, reload gallery
if (prog) {
setTimeout(() => { prog.style.display = 'none'; }, 2000);
}
// Reset file input
const inp = document.getElementById('bulk-file-input');
if (inp) inp.value = '';
// Reload gallery and icon gallery if open
loadUploadGallery();
loadIconGallery();
}
function rangeField(id, label, val, min, max, unit='') {
return `<div class="field"><label>${label}</label>
<div class="field-range">
<input type="range" id="${id}" min="${min}" max="${max}" value="${val}" oninput="document.getElementById('${id}-v').textContent=this.value+'${unit}'">
<span class="range-val" id="${id}-v">${val}${unit}</span>
</div></div>`;
}
function renderThemeTab() {
const t = pendingCfg.theme;
return `
<div class="s-section">
<div class="s-section-title">Colors</div>
<div class="field"><label>Accent</label><div class="field-color"><input type="color" id="cfg-accent-p" value="${t.accentColor||'#f5a623'}" oninput="document.getElementById('cfg-accent').value=this.value"><input type="text" id="cfg-accent" value="${escAttr(t.accentColor||'#f5a623')}" oninput="document.getElementById('cfg-accent-p').value=this.value"></div></div>
<div class="field-row">
<div class="field"><label>Background</label><div class="field-color"><input type="color" id="cfg-bg-p" value="${t.bgColor||'#0a0600'}" oninput="document.getElementById('cfg-bg').value=this.value"><input type="text" id="cfg-bg" value="${escAttr(t.bgColor||'#0a0600')}" oninput="document.getElementById('cfg-bg-p').value=this.value"></div></div>
<div class="field"><label>Card BG</label><div class="field-color"><input type="color" id="cfg-cbg-p" value="${t.cardBg||'#120c04'}" oninput="document.getElementById('cfg-cbg').value=this.value"><input type="text" id="cfg-cbg" value="${escAttr(t.cardBg||'#120c04')}" oninput="document.getElementById('cfg-cbg-p').value=this.value"></div></div>
</div>
</div>
<div class="s-section">
<div class="s-section-title">Typography</div>
<div class="field-row">
<div class="field"><label>Body Font</label><input type="text" id="cfg-font-body" value="${escAttr(t.fontBody||'Rajdhani')}"></div>
<div class="field"><label>Mono Font</label><input type="text" id="cfg-font-mono" value="${escAttr(t.fontMono||'Share Tech Mono')}"></div>
</div>
<div class="field"><label>Display Font</label>
<select id="cfg-font-display-sel" onchange="if(this.value)document.getElementById('cfg-font-display').value=this.value" style="margin-bottom:6px;width:100%;background:var(--bg);border:1px solid var(--border2);color:var(--text);font-family:var(--mono);font-size:12px;padding:8px 12px;border-radius:5px;outline:none">
<option value="">— choose or type below —</option>
<option value="JetBrains Mono" ${t.fontDisplay==='JetBrains Mono'||!t.fontDisplay?'selected':''}>JetBrains Mono</option>
<option value="Share Tech Mono" ${t.fontDisplay==='Share Tech Mono'?'selected':''}>Share Tech Mono</option>
<option value="Roman SD" ${t.fontDisplay==='Roman SD'?'selected':''}>Roman SD ★</option>
</select>
<input type="text" id="cfg-font-display" value="${escAttr(t.fontDisplay||'JetBrains Mono')}" placeholder="or type any font name">
</div>
${rangeField('cfg-fontsize','Base Font Size',t.baseFontSize||16,12,24,'px')}
</div>
<div class="s-section">
<div class="s-section-title">Layout</div>
${rangeField('cfg-maxwidth','Page Max Width',t.pageMaxWidth||960,600,1600,'px')}
<div class="field"><label>Card Size</label>
<select id="cfg-card-size">
<option value="small" ${t.cardSize==='small'?'selected':''}>Small</option>
<option value="medium" ${(!t.cardSize||t.cardSize==='medium')?'selected':''}>Medium</option>
<option value="large" ${t.cardSize==='large'?'selected':''}>Large</option>
</select>
</div>
${rangeField('cfg-gap','Card Gap',t.cardGap||10,4,32,'px')}
${rangeField('cfg-radius','Card Border Radius',t.cardRadius||10,0,24,'px')}
${rangeField('cfg-icon-size','Card Icon Size',t.cardIconSize||2.0,0.8,4.0,'')}
${rangeField('cfg-name-size','Card Name Font Size',t.cardNameSize||15,10,24,'px')}
<div class="field"><label>Card Content Alignment</label>
<select id="cfg-card-justify">
<option value="flex-start" ${(!t.cardJustify||t.cardJustify==='flex-start')?'selected':''}>Left</option>
<option value="center" ${t.cardJustify==='center'?'selected':''}>Center</option>
<option value="flex-end" ${t.cardJustify==='flex-end'?'selected':''}>Right</option>
</select>
</div>
<div class="field"><label>Card Vertical Alignment</label>
<select id="cfg-card-valign">
<option value="flex-start" ${(!t.cardValign||t.cardValign==='flex-start')?'selected':''}>Top</option>
<option value="center" ${t.cardValign==='center'?'selected':''}>Center</option>
<option value="flex-end" ${t.cardValign==='flex-end'?'selected':''}>Bottom</option>
</select>
</div>
<div class="field"><label>Card Layout Direction</label>
<select id="cfg-card-direction">
<option value="row" ${(!t.cardDirection||t.cardDirection==='row')?'selected':''}>Icon left, text right</option>
<option value="column" ${t.cardDirection==='column'?'selected':''}>Icon above text</option>
</select>
</div>
</div>`;
}
function renderServicesTab() {
const groups = [...(pendingCfg.groups||[])].sort((a,b)=>(a.order??0)-(b.order??0));
let html = `<div class="s-section"><div class="s-section-title">Groups</div>
<div id="grp-list">
${groups.map(g=>`
<div class="group-item" data-gid="${g.id}">
<span class="drag-handle">⠿</span>
<span class="group-item-name">${escH(g.name)}</span>
<span class="group-item-count">${g.services?.length||0} svc</span>
<input type="text" style="flex:1;background:var(--bg);border:1px solid var(--border2);color:var(--text);font-family:var(--mono);font-size:11px;padding:4px 8px;border-radius:4px;outline:none;max-width:140px" value="${escAttr(g.name)}" oninput="renamePendingGroup('${g.id}',this.value)">
<button class="svc-btn del" onclick="deletePendingGroup('${g.id}')">del</button>
</div>`).join('')}
</div>
<div class="btn-row"><button class="btn btn-secondary" onclick="addPendingGroup()">+ Group</button></div>
</div>`;
groups.forEach(g => {
const svcs = [...(g.services||[])].sort((a,b)=>(a.order??0)-(b.order??0));
html += `<div class="s-section"><div class="s-section-title">${escH(g.name)}</div>
<div class="svc-list" id="svclist-${g.id}">
${svcs.map(s=>`
<div class="svc-item" data-sid="${s.id}" data-gid="${g.id}">
<span class="drag-handle">⠿</span>
<span class="svc-item-icon">${isImgUrl(s.icon) ? `<img src="${escAttr(s.icon)}" style="width:1.4rem;height:1.4rem;object-fit:contain;border-radius:3px">` : escH(s.icon||'🔗')}</span>
<div class="svc-item-info"><div class="svc-item-name">${escH(s.name)}</div><div class="svc-item-url">${escH(s.url||'')}</div></div>
<div class="svc-item-actions">
<button class="svc-btn" onclick="openSettingsSvcEdit('${s.id}','${g.id}')">edit</button>
<button class="svc-btn del" onclick="deletePendingSvc('${s.id}','${g.id}')">del</button>
</div>
</div>`).join('')}
</div>
<div class="btn-row"><button class="btn btn-secondary" onclick="openSettingsSvcEdit(null,'${g.id}')">+ Service</button></div>
</div>`;
});
return html;
}
function renderUptimeTab() {
const u = pendingCfg.uptime||{};
return `<div class="s-section"><div class="s-section-title">Uptime Kuma</div>
<div class="field"><label>Enabled</label><select id="cfg-uptime-en"><option value="true" ${u.enabled!==false?'selected':''}>Yes</option><option value="false" ${u.enabled===false?'selected':''}>No</option></select></div>
<div class="field"><label>Base URL</label><input type="text" id="cfg-uptime-url" value="${escAttr(u.url||'')}"></div>
<div class="field"><label>Status Page Slug</label><input type="text" id="cfg-uptime-slug" value="${escAttr(u.slug||'homelab')}"></div>
</div>`;
}
// Settings service edit (opens edit modal over settings panel)
function openSettingsSvcEdit(svcId, gid) {
// Temporarily operate on pendingCfg
editingSvcId = svcId; editingGid = gid;
const g = pendingCfg.groups.find(x => x.id === gid);
const svc = svcId ? g?.services?.find(s => s.id === svcId) : null;
document.getElementById('edit-modal-title').textContent = svcId ? '// edit service' : '// add service';
document.getElementById('edit-modal-body').innerHTML = `
<div class="field"><label>Name</label><input type="text" id="em-name" value="${escAttr(svc?.name||'')}"></div>
<div class="field"><label>URL</label><input type="text" id="em-url" value="${escAttr(svc?.url||'')}"></div>
<div class="field"><label>Description</label><input type="text" id="em-desc" value="${escAttr(svc?.description||'')}"></div>
<div class="field-row">
<div class="field">
<label>Icon <span style="color:var(--text3);font-size:9px">— emoji or image URL</span></label>
<div style="display:flex;gap:8px;align-items:center">
<div style="width:44px;height:44px;border:1px solid var(--border2);border-radius:6px;background:var(--bg);display:flex;align-items:center;justify-content:center;font-size:1.6rem;flex-shrink:0;overflow:hidden" id="em-icon-preview">${svc?.icon && isImgUrl(svc.icon) ? `<img src="${escAttr(svc.icon)}" style="width:100%;height:100%;object-fit:contain">` : escH(svc?.icon||'🔗')}</div>
<input type="text" id="em-icon" value="${escAttr(svc?.icon||'🔗')}" style="flex:1" oninput="previewIcon(this.value)">
<label class="btn btn-secondary" style="cursor:pointer;padding:7px 10px;white-space:nowrap">
↑<input type="file" accept="image/*" style="display:none" onchange="uploadIconFile(this)">
</label>
</div>
<div id="em-icon-gallery" style="margin-top:10px"></div>
</div>
<div class="field"><label>Accent Color</label>
<div class="field-color">
<input type="color" id="em-color-p" value="${svc?.color||'#f5a623'}" oninput="document.getElementById('em-color').value=this.value">
<input type="text" id="em-color" value="${escAttr(svc?.color||'#f5a623')}" oninput="document.getElementById('em-color-p').value=this.value">
</div>
</div>
</div>`;
// Override save to write to pendingCfg
document.querySelector('#edit-modal .modal-foot .btn-primary').onclick = () => {
const cfg = pendingCfg;
const grp = cfg.groups.find(x => x.id === editingGid);
if (!grp) return;
if (!grp.services) grp.services = [];
if (editingSvcId) {
const s = grp.services.find(x => x.id === editingSvcId);
if (s) { s.name=v('em-name'); s.url=v('em-url'); s.description=v('em-desc'); s.icon=v('em-icon'); s.color=v('em-color'); }
} else {
grp.services.push({id:'s'+Date.now(),name:v('em-name'),url:v('em-url'),description:v('em-desc'),icon:v('em-icon'),color:v('em-color'),order:grp.services.length});
}
closeEditModal();
document.getElementById('settings-body').innerHTML = renderServicesTab();
attachSettingsDrag();
// Restore default save handler
document.querySelector('#edit-modal .modal-foot .btn-primary').onclick = saveEditModal;
};
document.getElementById('edit-modal').classList.add('open');
setTimeout(() => loadIconGallery(), 50);
}
function renamePendingGroup(gid, name) { const g = pendingCfg.groups.find(x=>x.id===gid); if (g) g.name = name; }
function deletePendingGroup(gid) { pendingCfg.groups = pendingCfg.groups.filter(x=>x.id!==gid); document.getElementById('settings-body').innerHTML = renderServicesTab(); attachSettingsDrag(); }
function addPendingGroup() { pendingCfg.groups.push({id:'g'+Date.now(),name:'New Group',order:pendingCfg.groups.length,services:[]}); document.getElementById('settings-body').innerHTML = renderServicesTab(); attachSettingsDrag(); }
function deletePendingSvc(sid, gid) { const g=pendingCfg.groups.find(x=>x.id===gid); if(g) g.services=g.services.filter(x=>x.id!==sid); document.getElementById('settings-body').innerHTML=renderServicesTab(); attachSettingsDrag(); }
function collectThemeSettings() {
if (activeTab !== 'theme') return;
const t = pendingCfg.theme;
t.accentColor = v('cfg-accent'); t.bgColor = v('cfg-bg'); t.cardBg = v('cfg-cbg');
t.fontBody = v('cfg-font-body'); t.fontMono = v('cfg-font-mono'); t.fontDisplay = v('cfg-font-display');
t.cardSize = v('cfg-card-size');
t.baseFontSize = document.getElementById('cfg-fontsize')?.value;
t.pageMaxWidth = document.getElementById('cfg-maxwidth')?.value;
t.cardGap = document.getElementById('cfg-gap')?.value;
t.cardRadius = document.getElementById('cfg-radius')?.value;
t.cardIconSize = document.getElementById('cfg-icon-size')?.value;
t.cardNameSize = document.getElementById('cfg-name-size')?.value;
t.cardJustify = v('cfg-card-justify');
t.cardValign = v('cfg-card-valign');
t.cardDirection = v('cfg-card-direction');
}
function collectSiteSettings() {
if (activeTab !== 'site') return;
pendingCfg.site.title = v('cfg-title');
pendingCfg.site.slogans = v('cfg-slogans').split('\n').map(l=>l.trim()).filter(Boolean);
pendingCfg.site.banner = v('cfg-banner'); pendingCfg.site.logo = v('cfg-logo'); pendingCfg.site.favicon = v('cfg-favicon');
}
function collectUptimeSettings() {
if (activeTab !== 'uptime') return;
pendingCfg.uptime.url = v('cfg-uptime-url'); pendingCfg.uptime.slug = v('cfg-uptime-slug'); pendingCfg.uptime.enabled = v('cfg-uptime-en') === 'true';
}
function renderMobileTab() {
const m = pendingCfg.mobile || {};
const cols = m.cols || '1';
const iconSize = m.cardIconSize != null ? m.cardIconSize : 1.4;
const nameSize = m.cardNameSize != null ? m.cardNameSize : 11;
const justify = m.cardJustify || 'center';
const valign = m.cardValign || 'flex-start';
const direction = m.cardDirection || 'column';
const showDesc = m.showDesc !== false;
return `
<div class="s-section">
<div class="s-section-title">Mobile Card Layout</div>
<div class="field"><label>Columns</label>
<select id="mob-cols">
<option value="1" ${cols==='1'?'selected':''}>1 column</option>
<option value="2" ${cols==='2'?'selected':''}>2 columns</option>
</select>
</div>
<div class="field"><label>Card Layout Direction</label>
<select id="mob-direction">
<option value="row" ${direction==='row'?'selected':''}>Icon left, text right</option>
<option value="column" ${direction==='column'?'selected':''}>Icon above text</option>
</select>
</div>
<div class="field"><label>Card Content Alignment</label>
<select id="mob-justify">
<option value="flex-start" ${justify==='flex-start'?'selected':''}>Left</option>
<option value="center" ${justify==='center'?'selected':''}>Center</option>
<option value="flex-end" ${justify==='flex-end'?'selected':''}>Right</option>
</select>
</div>
<div class="field"><label>Card Vertical Alignment</label>
<select id="mob-valign">
<option value="flex-start" ${valign==='flex-start'?'selected':''}>Top</option>
<option value="center" ${valign==='center'?'selected':''}>Center</option>
<option value="flex-end" ${valign==='flex-end'?'selected':''}>Bottom</option>
</select>
</div>
<div class="field"><label>Card Icon Size</label>
<div class="field-range">
<input type="range" id="mob-icon-size" min="0.8" max="4.0" step="0.1" value="${iconSize}" oninput="document.getElementById('mob-icon-size-v').textContent=this.value">
<span class="range-val" id="mob-icon-size-v">${iconSize}</span>
</div>
</div>
<div class="field"><label>Card Name Font Size</label>
<div class="field-range">
<input type="range" id="mob-name-size" min="8" max="22" value="${nameSize}" oninput="document.getElementById('mob-name-size-v').textContent=this.value+'px'">
<span class="range-val" id="mob-name-size-v">${nameSize}px</span>
</div>
</div>
<div class="field"><label>Show Description</label>
<select id="mob-show-desc">
<option value="true" ${showDesc?'selected':''}>Yes</option>
<option value="false" ${!showDesc?'selected':''}>No</option>
</select>
</div>
</div>`;
}
function collectMobileSettings() {
if (activeTab !== 'mobile') return;
if (!pendingCfg.mobile) pendingCfg.mobile = {};
const m = pendingCfg.mobile;
m.cols = document.getElementById('mob-cols')?.value || '1';
m.cardDirection = document.getElementById('mob-direction')?.value || 'column';
m.cardJustify = document.getElementById('mob-justify')?.value || 'center';
m.cardValign = document.getElementById('mob-valign')?.value || 'flex-start';
m.cardIconSize = parseFloat(document.getElementById('mob-icon-size')?.value) || 1.4;
m.cardNameSize = parseInt(document.getElementById('mob-name-size')?.value) || 11;
m.showDesc = document.getElementById('mob-show-desc')?.value === 'true';
}
function applyMobileStyles() {
if (window.innerWidth > 600) return;
const m = CFG.mobile || {};
const r = document.documentElement.style;
document.querySelectorAll('.card-grid').forEach(g => {
g.style.gridTemplateColumns = (m.cols === '2') ? '1fr 1fr' : '';
});
if (m.cardIconSize) r.setProperty('--card-icon-size', m.cardIconSize + 'rem');
if (m.cardNameSize) r.setProperty('--card-name-size', m.cardNameSize + 'px');
if (m.cardJustify) {
r.setProperty('--card-justify', m.cardJustify);
r.setProperty('--card-text-align', m.cardJustify==='center'?'center':m.cardJustify==='flex-end'?'right':'left');
}
if (m.cardValign) r.setProperty('--card-valign', m.cardValign);
if (m.cardDirection) r.setProperty('--card-direction', m.cardDirection);
document.querySelectorAll('.card-desc').forEach(el => {
el.style.display = m.showDesc === false ? 'none' : '';
});
}
async function saveSettings() {
collectSiteSettings(); collectThemeSettings(); collectUptimeSettings(); collectMobileSettings();
const res = await fetch('/api/config', { method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(pendingCfg) });
if (res.ok) { CFG = pendingCfg; applyTheme(CFG.theme); render(); closeSettings(); }
else alert('Save failed: ' + res.status);
}
// Settings panel drag
function attachSettingsDrag() {
document.querySelectorAll('.svc-list').forEach(list => {
setupListDrag(list, '.svc-item', items => {
const gid = items[0]?.dataset.gid;
const g = pendingCfg.groups.find(x=>x.id===gid);
if (g) items.forEach((el,i)=>{ const s=g.services.find(x=>x.id===el.dataset.sid); if(s) s.order=i; });
});
});
const gl = document.getElementById('grp-list');
if (gl) setupListDrag(gl, '.group-item', items => { items.forEach((el,i)=>{ const g=pendingCfg.groups.find(x=>x.id===el.dataset.gid); if(g) g.order=i; }); });
}
function setupListDrag(container, sel, onDone) {
let dragged = null;
container.querySelectorAll(sel).forEach(el => {
el.draggable = true;
el.addEventListener('dragstart', e => { dragged=el; el.classList.add('dragging'); e.dataTransfer.effectAllowed='move'; });
el.addEventListener('dragend', () => { dragged=null; el.classList.remove('dragging'); });
el.addEventListener('dragover', e => { e.preventDefault(); if(dragged&&dragged!==el){const r=el.getBoundingClientRect();container.insertBefore(dragged,e.clientY<r.top+r.height/2?el:el.nextSibling);} });
el.addEventListener('drop', e => { e.preventDefault(); onDone([...container.querySelectorAll(sel)]); });
});
}
// ── Uptime Kuma ───────────────────────────────────────────────────────────────
async function pollUptime() {
try {
const slug = CFG.uptime?.slug || 'homelab';
const data = await fetch('/api/uptime/' + slug).then(r => r.json());
if (data.error) throw new Error(data.error);
const beats = data.heartbeat?.heartbeatList || {}, ups = data.heartbeat?.uptimeList || {};
UPTIME = {};
let total = 0, down = 0;
(data.page?.publicGroupList || []).forEach(g => {
(g.monitorList || []).forEach(mon => {
total++;
const b = beats[mon.id] || [];
const last = b.length ? b[b.length-1].status : null;
const st = last===1?'up':last===2?'warn':last===0?'down':'pend';
if (st==='down') down++;
const upt = ups[mon.id+'_24'] ?? ups[mon.id+'_720'] ?? null;
UPTIME[mon.name] = UPTIME[mon.id] = { status:st, beats:b.slice(-20), uptime:upt };
});
});
const badge = document.getElementById('status-badge'), lbl = document.getElementById('status-label');
if (down===0){badge.className='status-badge ok';lbl.textContent='up';}
else if(down<total){badge.className='status-badge degraded';lbl.textContent='degraded';}
else{badge.className='status-badge down';lbl.textContent='down';}
render(); applyMobileStyles();
} catch(e) {
const badge = document.getElementById('status-badge'), lbl = document.getElementById('status-label');
if(badge) badge.className='status-badge';
if(lbl) lbl.textContent='unavailable';
}
setTimeout(pollUptime, 60000);
}
// ── Save & Render ─────────────────────────────────────────────────────────────
async function saveAndRender() {
await fetch('/api/config', { method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(CFG) });
render();
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function v(id){const el=document.getElementById(id);return el?el.value:'';}
function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function escAttr(s){return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;');}
// ── Icon upload helpers ───────────────────────────────────────────────────────
function isImgUrl(s) { return s && (s.startsWith('/') || s.startsWith('http') || s.startsWith('data:')); }
function previewIcon(val) {
const el = document.getElementById('em-icon-preview');
if (!el) return;
if (isImgUrl(val)) el.innerHTML = `<img src="${val}" style="width:100%;height:100%;object-fit:contain">`;
else el.textContent = val || '🔗';
}
async function uploadIconFile(input) {
const file = input.files[0];
if (!file) return;
const fd = new FormData();
fd.append('file', file);
try {
const res = await fetch('/api/upload', { method: 'POST', body: fd });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
document.getElementById('em-icon').value = data.url;
previewIcon(data.url);
loadIconGallery();
} catch (e) { alert('Upload failed: ' + e.message); }
}
async function loadIconGallery() {
const el = document.getElementById('em-icon-gallery');
if (!el) return;
try {
const files = await fetch('/api/uploads').then(r => r.json());
if (!files.length) { el.innerHTML = ''; return; }
el.innerHTML = `<div style="font-family:var(--mono);font-size:9px;letter-spacing:.1em;color:var(--text3);margin-bottom:6px;text-transform:uppercase">// uploaded icons</div>
<div style="display:flex;flex-wrap:wrap;gap:6px">
${files.map(f => `<img src="${escAttr(f.url)}" title="${escAttr(f.filename)}"
style="width:40px;height:40px;object-fit:contain;border-radius:5px;border:1px solid var(--border);cursor:pointer;background:var(--bg)"
onclick="selectIcon('${escAttr(f.url)}')"
onmouseover="this.style.borderColor='var(--accent)'"
onmouseout="this.style.borderColor='var(--border)'">`).join('')}
</div>`;
} catch { el.innerHTML = ''; }
}
function selectIcon(url) {
const input = document.getElementById('em-icon');
if (input) { input.value = url; previewIcon(url); }
}
boot();
</script>
</body>
</html>