index.html 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>Dis2Hook — Relay Console</title>
  7. <style>
  8. :root{
  9. --ink:#14161C; --panel:#1C1F28; --panel-2:#22263100; --well:#171A22;
  10. --line:#2C3040; --line-soft:#242836;
  11. --text:#E8E6DF; --muted:#8B8FA0; --faint:#5A5F70;
  12. --amber:#F0A93B; --amber-dim:#7A5B26;
  13. --blurple:#5865F2; --blurple-dim:#3A4290;
  14. --ok:#57C08A; --fail:#E06060; --warn:#E0B060;
  15. --mono:ui-monospace,"JetBrains Mono","Cascadia Code",Menlo,Consolas,monospace;
  16. --sans:system-ui,-apple-system,"Segoe UI",sans-serif;
  17. }
  18. *{box-sizing:border-box;margin:0;padding:0}
  19. html{color-scheme:dark}
  20. body{background:var(--ink);color:var(--text);font-family:var(--sans);font-size:15px;line-height:1.5;min-height:100vh}
  21. body::before{content:"";position:fixed;inset:0;pointer-events:none;z-index:0;
  22. background:repeating-linear-gradient(0deg,transparent 0 3px,rgba(255,255,255,.012) 3px 4px)}
  23. button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
  24. input,select,textarea{font-family:var(--mono);font-size:13px;color:var(--text);
  25. background:var(--well);border:1px solid var(--line);border-radius:4px;padding:7px 9px;width:100%}
  26. input:focus,select:focus,textarea:focus,button:focus-visible{outline:2px solid var(--amber);outline-offset:1px}
  27. input::placeholder,textarea::placeholder{color:var(--faint)}
  28. textarea{resize:vertical;min-height:72px;line-height:1.55}
  29. ::selection{background:var(--amber-dim);color:var(--text)}
  30. .eyebrow{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--muted)}
  31. /* ---------- rack header ---------- */
  32. header{position:relative;z-index:1;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#1E212B,#181B23);
  33. display:flex;align-items:center;gap:22px;padding:14px 22px;flex-wrap:wrap}
  34. .plate{display:flex;align-items:baseline;gap:10px}
  35. .plate h1{font-family:var(--sans);font-weight:800;font-size:19px;letter-spacing:.28em;text-transform:uppercase}
  36. .plate h1 .two{color:var(--amber)}
  37. .plate .ver{font-family:var(--mono);font-size:11px;color:var(--faint)}
  38. .lamp{width:9px;height:9px;border-radius:50%;background:var(--faint);flex:none;box-shadow:0 0 0 3px rgba(255,255,255,.03)}
  39. .lamp.ok{background:var(--ok);box-shadow:0 0 8px rgba(87,192,138,.7)}
  40. .lamp.fail{background:var(--fail);box-shadow:0 0 8px rgba(224,96,96,.7)}
  41. .meter{display:flex;gap:20px;margin-left:auto;align-items:center;flex-wrap:wrap}
  42. .gauge{text-align:right}
  43. .gauge b{display:block;font-family:var(--mono);font-size:16px;font-weight:600}
  44. .gauge span{font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted)}
  45. .gauge.relayed b{color:var(--ok)} .gauge.failed b{color:var(--fail)}
  46. #botLabel{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12px;color:var(--muted)}
  47. main{position:relative;z-index:1;max-width:1180px;margin:0 auto;padding:26px 22px 90px;
  48. display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:26px}
  49. @media(max-width:960px){main{grid-template-columns:1fr}}
  50. section h2{display:flex;align-items:center;gap:10px;margin-bottom:14px;
  51. font-size:12px;font-weight:700;letter-spacing:.2em;text-transform:uppercase;color:var(--muted)}
  52. section h2::after{content:"";flex:1;height:1px;background:var(--line-soft)}
  53. /* ---------- patch strips (source cards) ---------- */
  54. .strip{background:var(--panel);border:1px solid var(--line);border-radius:6px;margin-bottom:14px;overflow:hidden}
  55. .strip.disabled{opacity:.55}
  56. .strip-head{display:flex;align-items:center;gap:14px;padding:14px 16px;cursor:pointer}
  57. .strip-head:hover{background:rgba(255,255,255,.02)}
  58. .strip-name{font-weight:600;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  59. /* signature: the relay line */
  60. .relay{flex:1;display:flex;align-items:center;min-width:120px;height:22px}
  61. .jack{width:10px;height:10px;border-radius:50%;flex:none;border:2px solid}
  62. .jack.src{border-color:var(--amber);background:var(--amber-dim)}
  63. .jack.dst{border-color:var(--blurple);background:var(--blurple-dim)}
  64. .wire{flex:1;height:2px;background:linear-gradient(90deg,var(--amber-dim),var(--blurple-dim));position:relative;overflow:visible}
  65. .pulse{position:absolute;top:-2px;left:0;width:14px;height:6px;border-radius:3px;background:var(--amber);
  66. filter:drop-shadow(0 0 5px var(--amber));opacity:0}
  67. .strip.firing .pulse{animation:travel .9s ease-in-out}
  68. @keyframes travel{0%{opacity:1;left:0;background:var(--amber)}100%{opacity:1;left:calc(100% - 14px);background:var(--blurple);filter:drop-shadow(0 0 6px var(--blurple))}}
  69. @media(prefers-reduced-motion:reduce){.strip.firing .pulse{animation:none}}
  70. .chan-tag{font-family:var(--mono);font-size:11px;color:var(--blurple);flex:none;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  71. .caret{color:var(--faint);flex:none;transition:transform .15s}
  72. .strip.open .caret{transform:rotate(90deg)}
  73. .strip-body{display:none;border-top:1px solid var(--line-soft);padding:16px;background:var(--well)}
  74. .strip.open .strip-body{display:block}
  75. .grid2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
  76. @media(max-width:640px){.grid2{grid-template-columns:1fr}}
  77. .field{margin-bottom:12px;min-width:0}
  78. .field label{display:block;font-family:var(--mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted);margin-bottom:5px}
  79. .field .hint{font-size:11px;color:var(--faint);margin-top:4px;font-family:var(--sans)}
  80. .inline{display:flex;gap:6px}
  81. .inline input{flex:1;min-width:0}
  82. .hookurl{display:flex;gap:6px;align-items:center;background:var(--panel);border:1px dashed var(--line);
  83. border-radius:4px;padding:7px 9px;font-family:var(--mono);font-size:12px;color:var(--amber);overflow:hidden}
  84. .hookurl code{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  85. .filters .filter-row{display:grid;grid-template-columns:1fr 110px 1fr 30px;gap:6px;margin-bottom:6px}
  86. @media(max-width:640px){.filters .filter-row{grid-template-columns:1fr 90px 1fr 30px}}
  87. .strip-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:6px;align-items:center}
  88. /* ---------- buttons ---------- */
  89. .btn{font-family:var(--mono);font-size:12px;padding:7px 13px;border-radius:4px;border:1px solid var(--line);
  90. background:var(--panel);color:var(--text);letter-spacing:.04em}
  91. .btn:hover{border-color:var(--muted)}
  92. .btn.primary{background:var(--amber);border-color:var(--amber);color:#1A150A;font-weight:700}
  93. .btn.primary:hover{filter:brightness(1.08)}
  94. .btn.ghost{border-color:transparent;color:var(--muted)} .btn.ghost:hover{color:var(--text)}
  95. .btn.danger{color:var(--fail);border-color:transparent} .btn.danger:hover{border-color:var(--fail)}
  96. .btn.tiny{padding:4px 8px;font-size:11px}
  97. .btn:disabled{opacity:.45;cursor:default}
  98. .toggle{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-family:var(--mono);font-size:11px;
  99. letter-spacing:.1em;text-transform:uppercase;color:var(--muted);user-select:none}
  100. .toggle input{display:none}
  101. .toggle .track{width:30px;height:16px;border-radius:8px;background:var(--line);position:relative;transition:background .15s;flex:none}
  102. .toggle .track::after{content:"";position:absolute;top:2px;left:2px;width:12px;height:12px;border-radius:50%;background:var(--muted);transition:all .15s}
  103. .toggle input:checked+.track{background:var(--amber-dim)}
  104. .toggle input:checked+.track::after{left:16px;background:var(--amber)}
  105. .empty{border:1px dashed var(--line);border-radius:6px;padding:34px 20px;text-align:center;color:var(--muted)}
  106. .empty p{margin-bottom:14px}
  107. /* ---------- side column ---------- */
  108. .panelbox{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:16px;margin-bottom:22px}
  109. .panelbox h3{display:flex;align-items:center;gap:8px;font-size:11px;font-weight:700;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);margin-bottom:12px}
  110. .log{list-style:none;max-height:420px;overflow:auto;font-family:var(--mono);font-size:11.5px}
  111. .log li{display:flex;gap:8px;padding:6px 0;border-bottom:1px solid var(--line-soft);align-items:baseline}
  112. .log time{color:var(--faint);flex:none}
  113. .log .badge{flex:none;font-size:9px;letter-spacing:.12em;text-transform:uppercase;padding:1px 6px;border-radius:3px}
  114. .badge.relayed{color:var(--ok);border:1px solid var(--ok)}
  115. .badge.failed,.badge.rejected{color:var(--fail);border:1px solid var(--fail)}
  116. .badge.filtered{color:var(--warn);border:1px solid var(--warn)}
  117. .log .who{color:var(--text)} .log .what{color:var(--muted);overflow-wrap:anywhere}
  118. /* ---------- save bar ---------- */
  119. #savebar{position:fixed;left:0;right:0;bottom:0;z-index:5;display:none;justify-content:center;padding:12px;
  120. background:linear-gradient(180deg,transparent,rgba(10,11,15,.92) 40%)}
  121. #savebar.show{display:flex}
  122. #savebar .inner{display:flex;gap:10px;align-items:center;background:var(--panel);border:1px solid var(--amber);
  123. border-radius:6px;padding:10px 14px;box-shadow:0 8px 30px rgba(0,0,0,.5)}
  124. #savebar .msg{font-family:var(--mono);font-size:12px;color:var(--amber)}
  125. #toast{position:fixed;top:16px;right:16px;z-index:9;display:none;font-family:var(--mono);font-size:12.5px;
  126. background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--ok);border-radius:4px;padding:10px 14px;max-width:340px}
  127. #toast.err{border-left-color:var(--fail)}
  128. /* ---------- login ---------- */
  129. #login{position:fixed;inset:0;z-index:10;background:var(--ink);display:flex;align-items:center;justify-content:center;padding:20px}
  130. #login .plate2{width:100%;max-width:380px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:30px}
  131. #login h1{font-size:17px;font-weight:800;letter-spacing:.3em;text-transform:uppercase;text-align:center;margin-bottom:4px}
  132. #login h1 .two{color:var(--amber)}
  133. #login .sub{text-align:center;font-family:var(--mono);font-size:11px;color:var(--faint);margin-bottom:22px;letter-spacing:.1em}
  134. #login .err{color:var(--fail);font-family:var(--mono);font-size:12px;margin-top:10px;min-height:16px}
  135. .hidden{display:none!important}
  136. </style>
  137. </head>
  138. <body>
  139. <div id="login">
  140. <div class="plate2">
  141. <h1>DIS<span class="two">2</span>HOOK</h1>
  142. <div class="sub">RELAY CONSOLE · ADMIN KEY REQUIRED</div>
  143. <div class="field">
  144. <label for="keyInput">Admin key</label>
  145. <input id="keyInput" type="password" autocomplete="off" placeholder="from token.json / install output">
  146. </div>
  147. <button class="btn primary" style="width:100%" id="loginBtn">Unlock console</button>
  148. <div class="err" id="loginErr"></div>
  149. </div>
  150. </div>
  151. <header class="hidden" id="hdr">
  152. <div class="plate">
  153. <h1>DIS<span class="two">2</span>HOOK</h1>
  154. <span class="ver" id="ver"></span>
  155. </div>
  156. <div id="botLabel"><span class="lamp" id="botLamp"></span><span id="botText">checking bot…</span></div>
  157. <div class="meter">
  158. <div class="gauge"><b id="mRecv">0</b><span>received</span></div>
  159. <div class="gauge relayed"><b id="mRelay">0</b><span>relayed</span></div>
  160. <div class="gauge"><b id="mFilt">0</b><span>filtered</span></div>
  161. <div class="gauge failed"><b id="mFail">0</b><span>failed</span></div>
  162. <div class="gauge"><b id="mUp">—</b><span>uptime</span></div>
  163. </div>
  164. </header>
  165. <main class="hidden" id="mainEl">
  166. <section>
  167. <h2>Webhook sources <button class="btn tiny" id="addSrc" style="margin-left:auto">+ add source</button></h2>
  168. <div id="strips"></div>
  169. </section>
  170. <aside>
  171. <section>
  172. <h2>Relay settings</h2>
  173. <div class="panelbox">
  174. <h3><span class="lamp ok" style="width:7px;height:7px"></span> Heartbeat</h3>
  175. <label class="toggle" style="margin-bottom:10px"><input type="checkbox" id="hbOn"><span class="track"></span>Post a periodic pulse</label>
  176. <div class="field"><label for="hbChan">Channel ID</label><input id="hbChan" placeholder="e.g. 1123456789012345678"></div>
  177. <div class="field"><label for="hbInt">Interval (minutes)</label><input id="hbInt" type="number" min="1" max="10080" value="60"></div>
  178. </div>
  179. <div class="panelbox">
  180. <h3><span class="lamp" style="width:7px;height:7px;background:var(--blurple)"></span> Status updates</h3>
  181. <label class="toggle" style="margin-bottom:10px"><input type="checkbox" id="stOn"><span class="track"></span>Announce online / offline</label>
  182. <div class="field"><label for="stChan">Channel ID</label><input id="stChan" placeholder="e.g. 1123456789012345678"></div>
  183. </div>
  184. </section>
  185. <section>
  186. <h2>Recent activity</h2>
  187. <div class="panelbox"><ul class="log" id="log"><li style="color:var(--faint)">Nothing yet — waiting for signal.</li></ul></div>
  188. </section>
  189. </aside>
  190. </main>
  191. <div id="savebar"><div class="inner">
  192. <span class="msg">Unsaved changes</span>
  193. <button class="btn ghost" id="discardBtn">Discard</button>
  194. <button class="btn primary" id="saveBtn">Save changes</button>
  195. </div></div>
  196. <div id="toast" role="status"></div>
  197. <script>
  198. "use strict";
  199. const $ = s => document.querySelector(s);
  200. let ADMIN_KEY = "";
  201. let cfg = null; // working copy (edited by UI)
  202. let dirty = false;
  203. let statusTimer = null;
  204. /* ---------- storage guard (falls back to memory-only) ---------- */
  205. const store = {
  206. get(k){ try { return sessionStorage.getItem(k); } catch(e){ return null; } },
  207. set(k,v){ try { sessionStorage.setItem(k,v); } catch(e){} },
  208. del(k){ try { sessionStorage.removeItem(k); } catch(e){} }
  209. };
  210. /* ---------- api ---------- */
  211. async function api(path, opts={}){
  212. const r = await fetch(path, {...opts, headers:{
  213. "Content-Type":"application/json", "X-Admin-Key":ADMIN_KEY, ...(opts.headers||{})}});
  214. if (r.status === 401) { logout(); throw new Error("unauthorized"); }
  215. const data = await r.json().catch(()=>({}));
  216. if (!r.ok) throw new Error(data.error || ("HTTP "+r.status));
  217. return data;
  218. }
  219. /* ---------- toast ---------- */
  220. let toastT;
  221. function toast(msg, err=false){
  222. const t = $("#toast");
  223. t.textContent = msg; t.className = err ? "err" : ""; t.style.display = "block";
  224. clearTimeout(toastT); toastT = setTimeout(()=> t.style.display="none", 3500);
  225. }
  226. /* ---------- login ---------- */
  227. async function tryLogin(key){
  228. ADMIN_KEY = key;
  229. await api("/api/login", {method:"POST"});
  230. store.set("d2h_key", key);
  231. $("#login").classList.add("hidden");
  232. $("#hdr").classList.remove("hidden");
  233. $("#mainEl").classList.remove("hidden");
  234. await loadConfig();
  235. await refreshStatus();
  236. statusTimer = setInterval(refreshStatus, 10000);
  237. }
  238. function logout(){
  239. store.del("d2h_key"); ADMIN_KEY = "";
  240. clearInterval(statusTimer);
  241. $("#login").classList.remove("hidden");
  242. $("#hdr").classList.add("hidden");
  243. $("#mainEl").classList.add("hidden");
  244. }
  245. $("#loginBtn").addEventListener("click", async ()=>{
  246. $("#loginErr").textContent = "";
  247. try { await tryLogin($("#keyInput").value.trim()); }
  248. catch(e){ $("#loginErr").textContent = "That key was not accepted."; }
  249. });
  250. $("#keyInput").addEventListener("keydown", e=>{ if(e.key==="Enter") $("#loginBtn").click(); });
  251. /* ---------- status ---------- */
  252. function fmtUptime(s){
  253. const d=Math.floor(s/86400), h=Math.floor(s%86400/3600), m=Math.floor(s%3600/60);
  254. return (d?d+"d ":"")+(h||d?h+"h ":"")+m+"m";
  255. }
  256. async function refreshStatus(){
  257. try {
  258. const st = await api("/api/status");
  259. $("#ver").textContent = "v"+st.version;
  260. $("#mRecv").textContent = st.stats.received;
  261. $("#mRelay").textContent = st.stats.relayed;
  262. $("#mFilt").textContent = st.stats.filtered;
  263. $("#mFail").textContent = st.stats.failed;
  264. $("#mUp").textContent = fmtUptime(st.uptime_seconds);
  265. const lamp = $("#botLamp");
  266. if (st.bot.ok){ lamp.className="lamp ok"; $("#botText").textContent = "bot: "+st.bot.name; }
  267. else { lamp.className="lamp fail"; $("#botText").textContent = "bot token not verified"; }
  268. renderLog(st.activity);
  269. } catch(e){ /* transient; keep last values */ }
  270. }
  271. function renderLog(items){
  272. const ul = $("#log"); ul.innerHTML = "";
  273. if (!items || !items.length){
  274. ul.innerHTML = '<li style="color:var(--faint)">Nothing yet — waiting for signal.</li>'; return;
  275. }
  276. for (const it of items){
  277. const li = document.createElement("li");
  278. const t = document.createElement("time");
  279. t.textContent = new Date(it.time).toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"});
  280. const b = document.createElement("span"); b.className = "badge "+it.outcome; b.textContent = it.outcome;
  281. const who = document.createElement("span"); who.className="who"; who.textContent = it.source;
  282. const what = document.createElement("span"); what.className="what"; what.textContent = it.detail || "";
  283. li.append(t,b,who,what); ul.appendChild(li);
  284. }
  285. }
  286. /* ---------- config ---------- */
  287. async function loadConfig(){
  288. cfg = await api("/api/config");
  289. cfg.sources = cfg.sources || [];
  290. bindGlobals(); renderStrips(); setDirty(false);
  291. }
  292. function bindGlobals(){
  293. $("#hbOn").checked = !!cfg.heartbeat.enabled;
  294. $("#hbChan").value = cfg.heartbeat.channel_id || "";
  295. $("#hbInt").value = cfg.heartbeat.interval_minutes || 60;
  296. $("#stOn").checked = !!cfg.status.enabled;
  297. $("#stChan").value = cfg.status.channel_id || "";
  298. }
  299. function collectGlobals(){
  300. cfg.heartbeat.enabled = $("#hbOn").checked;
  301. cfg.heartbeat.channel_id = $("#hbChan").value.trim();
  302. cfg.heartbeat.interval_minutes = parseInt($("#hbInt").value, 10) || 60;
  303. cfg.status.enabled = $("#stOn").checked;
  304. cfg.status.channel_id = $("#stChan").value.trim();
  305. }
  306. for (const id of ["hbOn","hbChan","hbInt","stOn","stChan"])
  307. $("#"+id).addEventListener("input", ()=>{ collectGlobals(); setDirty(true); });
  308. function setDirty(v){ dirty = v; $("#savebar").classList.toggle("show", v); }
  309. window.addEventListener("beforeunload", e=>{ if(dirty){ e.preventDefault(); e.returnValue=""; }});
  310. $("#saveBtn").addEventListener("click", async ()=>{
  311. collectGlobals();
  312. try { await api("/api/config", {method:"PUT", body: JSON.stringify(cfg)});
  313. setDirty(false); toast("Configuration saved."); }
  314. catch(e){ toast("Save failed: "+e.message, true); }
  315. });
  316. $("#discardBtn").addEventListener("click", ()=> loadConfig().then(()=>toast("Changes discarded.")));
  317. /* ---------- sources ---------- */
  318. function randHex(n){
  319. const a = new Uint8Array(n); crypto.getRandomValues(a);
  320. return Array.from(a, x=>x.toString(16).padStart(2,"0")).join("");
  321. }
  322. function slugify(s){ return s.toLowerCase().replace(/[^a-z0-9-]+/g,"-").replace(/^-+|-+$/g,"").slice(0,64) || "source"; }
  323. function uniqueId(base){
  324. let id = base, n = 2;
  325. while (cfg.sources.some(s=>s.id===id)) id = base+"-"+(n++);
  326. return id;
  327. }
  328. $("#addSrc").addEventListener("click", ()=>{
  329. const id = uniqueId("source-"+(cfg.sources.length+1));
  330. cfg.sources.push({ id, name:"New source", secret: randHex(16), channel_id:"",
  331. enabled:true, template:"", filters:[] });
  332. renderStrips(id); setDirty(true);
  333. });
  334. function hookURL(id){ return location.origin + "/hook/" + id; }
  335. function copyText(text, btn){
  336. const done = ()=>{ const old=btn.textContent; btn.textContent="copied"; setTimeout(()=>btn.textContent=old,1200); };
  337. if (navigator.clipboard && navigator.clipboard.writeText)
  338. navigator.clipboard.writeText(text).then(done).catch(()=>fallbackCopy(text,done));
  339. else fallbackCopy(text,done);
  340. }
  341. function fallbackCopy(text, done){
  342. const ta=document.createElement("textarea"); ta.value=text; document.body.appendChild(ta);
  343. ta.select(); try{ document.execCommand("copy"); }catch(e){} ta.remove(); done();
  344. }
  345. function field(labelText, inputEl, hint){
  346. const w = document.createElement("div"); w.className = "field";
  347. const l = document.createElement("label"); l.textContent = labelText;
  348. w.append(l, inputEl);
  349. if (hint){ const h=document.createElement("div"); h.className="hint"; h.textContent=hint; w.appendChild(h); }
  350. return w;
  351. }
  352. function textInput(value, oninput, placeholder){
  353. const i = document.createElement("input"); i.value = value ?? ""; i.placeholder = placeholder||"";
  354. i.addEventListener("input", ()=>{ oninput(i.value); setDirty(true); });
  355. return i;
  356. }
  357. function renderStrips(openId){
  358. const wrap = $("#strips"); wrap.innerHTML = "";
  359. if (!cfg.sources.length){
  360. const e = document.createElement("div"); e.className="empty";
  361. e.innerHTML = "<p>No sources patched in yet.<br>Add one to get a webhook URL you can point anything at.</p>";
  362. const b = document.createElement("button"); b.className="btn primary"; b.textContent="+ add your first source";
  363. b.addEventListener("click", ()=> $("#addSrc").click());
  364. e.appendChild(b); wrap.appendChild(e); return;
  365. }
  366. cfg.sources.forEach((src, idx)=> wrap.appendChild(buildStrip(src, idx, src.id===openId)));
  367. }
  368. function buildStrip(src, idx, open){
  369. const strip = document.createElement("div");
  370. strip.className = "strip" + (open ? " open":"") + (src.enabled===false ? " disabled":"");
  371. /* head with the relay line */
  372. const head = document.createElement("div"); head.className = "strip-head";
  373. head.setAttribute("role","button"); head.tabIndex = 0;
  374. const jackS = document.createElement("span"); jackS.className = "jack src"; jackS.title = "webhook in";
  375. const name = document.createElement("span"); name.className = "strip-name"; name.textContent = src.name || src.id;
  376. const relay = document.createElement("span"); relay.className = "relay";
  377. const wire = document.createElement("span"); wire.className = "wire";
  378. const pulse = document.createElement("span"); pulse.className = "pulse";
  379. wire.appendChild(pulse);
  380. const jackD = document.createElement("span"); jackD.className = "jack dst"; jackD.title = "Discord out";
  381. relay.append(wire);
  382. const chan = document.createElement("span"); chan.className = "chan-tag";
  383. chan.textContent = src.channel_id ? "#"+src.channel_id : "no channel";
  384. const caret = document.createElement("span"); caret.className = "caret"; caret.textContent = "▸";
  385. head.append(jackS, name, relay, jackD, chan, caret);
  386. const toggleOpen = ()=> strip.classList.toggle("open");
  387. head.addEventListener("click", toggleOpen);
  388. head.addEventListener("keydown", e=>{ if(e.key==="Enter"||e.key===" "){ e.preventDefault(); toggleOpen(); }});
  389. /* body */
  390. const body = document.createElement("div"); body.className = "strip-body";
  391. const g1 = document.createElement("div"); g1.className = "grid2";
  392. g1.appendChild(field("Display name",
  393. textInput(src.name, v=>{ src.name=v; name.textContent=v||src.id; }, "e.g. Gitea CI")));
  394. const idInput = textInput(src.id, v=>{ src.id = slugify(v); urlCode.textContent = hookURL(src.id); },
  395. "gitea-ci");
  396. g1.appendChild(field("Endpoint ID", idInput, "Lowercase letters, digits, dashes. Sets the URL below."));
  397. body.appendChild(g1);
  398. /* webhook url */
  399. const urlBox = document.createElement("div"); urlBox.className = "hookurl";
  400. const urlCode = document.createElement("code"); urlCode.textContent = hookURL(src.id);
  401. const urlCopy = document.createElement("button"); urlCopy.className="btn tiny"; urlCopy.textContent="copy";
  402. urlCopy.addEventListener("click", ()=> copyText(urlCode.textContent, urlCopy));
  403. urlBox.append(urlCode, urlCopy);
  404. body.appendChild(field("Webhook URL — point your service here", urlBox,
  405. "Send POSTs with header X-Hook-Secret (or ?secret=) set to the secret below."));
  406. const g2 = document.createElement("div"); g2.className = "grid2";
  407. /* secret with regen + copy */
  408. const secretWrap = document.createElement("div"); secretWrap.className = "inline";
  409. const secretIn = textInput(src.secret, v=>{ src.secret=v; });
  410. const regen = document.createElement("button"); regen.className="btn tiny"; regen.textContent="new";
  411. regen.title = "Generate a new secret";
  412. regen.addEventListener("click", ()=>{ src.secret = randHex(16); secretIn.value = src.secret; setDirty(true); });
  413. const scopy = document.createElement("button"); scopy.className="btn tiny"; scopy.textContent="copy";
  414. scopy.addEventListener("click", ()=> copyText(secretIn.value, scopy));
  415. secretWrap.append(secretIn, regen, scopy);
  416. g2.appendChild(field("Shared secret", secretWrap));
  417. g2.appendChild(field("Discord channel ID",
  418. textInput(src.channel_id, v=>{ src.channel_id=v.trim(); chan.textContent = v.trim()? "#"+v.trim():"no channel"; },
  419. "1123456789012345678"),
  420. "Right-click a channel in Discord → Copy Channel ID (developer mode)."));
  421. body.appendChild(g2);
  422. /* template */
  423. const ta = document.createElement("textarea");
  424. ta.value = src.template || "";
  425. ta.placeholder = "**{repository.full_name}** — {pusher.name} pushed\n{commits[0].message}\n\nLeave empty to relay the full payload as a JSON block.";
  426. ta.addEventListener("input", ()=>{ src.template = ta.value; setDirty(true); });
  427. body.appendChild(field("Markdown template", ta,
  428. "Discord Markdown plus {dotted.paths[0]} placeholders resolved from the incoming JSON payload."));
  429. /* filters */
  430. const fwrap = document.createElement("div"); fwrap.className = "filters";
  431. const flabel = document.createElement("div"); flabel.className = "field";
  432. const fl = document.createElement("label"); fl.textContent = "Event filters — relay only when every rule matches";
  433. flabel.appendChild(fl); flabel.appendChild(fwrap);
  434. const renderFilters = ()=>{
  435. fwrap.innerHTML = "";
  436. (src.filters||[]).forEach((flt, fi)=>{
  437. const row = document.createElement("div"); row.className = "filter-row";
  438. const p = textInput(flt.path, v=>flt.path=v, "payload path, e.g. action");
  439. const sel = document.createElement("select");
  440. for (const m of ["equals","contains","exists"]){
  441. const o=document.createElement("option"); o.value=m; o.textContent=m;
  442. if ((flt.mode||"equals")===m) o.selected=true; sel.appendChild(o);
  443. }
  444. sel.addEventListener("change", ()=>{ flt.mode=sel.value; v.disabled = sel.value==="exists"; setDirty(true); });
  445. const v = textInput(flt.value, x=>flt.value=x, "expected value");
  446. v.disabled = (flt.mode||"equals")==="exists";
  447. const del = document.createElement("button"); del.className="btn tiny danger"; del.textContent="✕";
  448. del.title = "Remove rule";
  449. del.addEventListener("click", ()=>{ src.filters.splice(fi,1); renderFilters(); setDirty(true); });
  450. row.append(p, sel, v, del); fwrap.appendChild(row);
  451. });
  452. const add = document.createElement("button"); add.className="btn tiny ghost"; add.textContent="+ add rule";
  453. add.addEventListener("click", ()=>{ (src.filters ||= []).push({path:"",mode:"equals",value:""}); renderFilters(); setDirty(true); });
  454. fwrap.appendChild(add);
  455. };
  456. renderFilters();
  457. body.appendChild(flabel);
  458. /* actions */
  459. const actions = document.createElement("div"); actions.className = "strip-actions";
  460. const en = document.createElement("label"); en.className = "toggle";
  461. const enIn = document.createElement("input"); enIn.type="checkbox"; enIn.checked = src.enabled !== false;
  462. const track = document.createElement("span"); track.className="track";
  463. en.append(enIn, track, document.createTextNode("Enabled"));
  464. enIn.addEventListener("change", ()=>{ src.enabled = enIn.checked; strip.classList.toggle("disabled", !enIn.checked); setDirty(true); });
  465. const test = document.createElement("button"); test.className="btn"; test.textContent="Send test message";
  466. test.addEventListener("click", async ()=>{
  467. if (dirty){ toast("Save changes first, then test.", true); return; }
  468. test.disabled = true;
  469. strip.classList.remove("firing"); void strip.offsetWidth; strip.classList.add("firing");
  470. try { await api("/api/test/"+encodeURIComponent(src.id), {method:"POST"}); toast("Test message relayed."); }
  471. catch(e){ toast("Test failed: "+e.message, true); }
  472. test.disabled = false;
  473. });
  474. const del = document.createElement("button"); del.className="btn danger"; del.textContent="Delete source";
  475. del.addEventListener("click", ()=>{
  476. if (!confirm(`Delete "${src.name||src.id}"? Its webhook URL stops working when you save.`)) return;
  477. cfg.sources.splice(idx,1); renderStrips(); setDirty(true);
  478. });
  479. actions.append(en, test, del);
  480. body.appendChild(actions);
  481. strip.append(head, body);
  482. return strip;
  483. }
  484. /* ---------- boot ---------- */
  485. (async ()=>{
  486. const saved = store.get("d2h_key");
  487. if (saved){ try { await tryLogin(saved); return; } catch(e){ store.del("d2h_key"); } }
  488. $("#keyInput").focus();
  489. })();
  490. </script>
  491. </body>
  492. </html>