1317 lines
73 KiB
HTML
1317 lines
73 KiB
HTML
<!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} · ${done} ok${failed ? ` · <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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||
function escAttr(s){return String(s).replace(/"/g,'"').replace(/'/g,''');}
|
||
|
||
|
||
// ── 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>
|