diff --git a/src/uwudash_mk3/static/app.js b/src/uwudash_mk3/static/app.js new file mode 100644 index 0000000..f32bc0f --- /dev/null +++ b/src/uwudash_mk3/static/app.js @@ -0,0 +1,272 @@ +/* UwuDash MK3 - dashboard front end. */ +"use strict"; + +const el = { + serviceList: document.getElementById("service-list"), + viewer: document.getElementById("viewer"), + viewerTitle: document.getElementById("viewer-title"), + viewerUrl: document.getElementById("viewer-url"), + viewerFrame: document.getElementById("viewer-frame"), + emptyState: document.getElementById("empty-state"), + emptyText: document.getElementById("empty-text"), + reloadBtn: document.getElementById("reload-btn"), + settings: document.getElementById("settings"), + settingsToggle: document.getElementById("settings-toggle"), + settingsClose: document.getElementById("settings-close"), + serviceTable: document.getElementById("service-table"), + form: document.getElementById("service-form"), + formTitle: document.getElementById("form-title"), + name: document.getElementById("f-name"), + url: document.getElementById("f-url"), + desc: document.getElementById("f-desc"), + icon: document.getElementById("f-icon"), + open: document.getElementById("f-open"), + headers: document.getElementById("f-headers"), + enabled: document.getElementById("f-enabled"), + formCancel: document.getElementById("form-cancel"), + toast: document.getElementById("toast"), +}; + +const state = { services: [], selected: null, editing: null }; + +/* ---------------------------------------------------------------- helpers */ +const ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; + +function esc(value) { + return String(value ?? "").replace(/[&<>"']/g, (char) => ESCAPES[char]); +} + +function toast(message, kind = "ok") { + el.toast.textContent = message; + el.toast.className = `toast ${kind}`; + el.toast.hidden = false; + clearTimeout(toast._timer); + toast._timer = setTimeout(() => { el.toast.hidden = true; }, 4000); +} + +async function api(path, options = {}) { + const response = await fetch(path, { + ...options, + headers: { "Content-Type": "application/json", ...(options.headers || {}) }, + }); + if (!response.ok) { + let detail = response.statusText; + try { + const body = await response.json(); + if (body.detail) detail = body.detail; + } catch (_) { /* keep statusText */ } + throw new Error(detail); + } + return response.status === 204 ? null : response.json(); +} + +function headersToText(headers) { + return Object.entries(headers || {}).map(([k, v]) => `${k}: ${v}`).join("\n"); +} + +function textToHeaders(text) { + const out = {}; + for (const line of (text || "").split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const index = trimmed.indexOf(":"); + if (index === 0) continue; + out[trimmed.slice(0, index).trim()] = trimmed.slice(index + 1).trim(); + } + return out; +} + +/* ---------------------------------------------------------------- rendering */ +function renderSidebar() { + el.serviceList.innerHTML = ""; + if (!state.services.length) { + el.serviceList.innerHTML = '

No services configured yet.

'; + return; + } + for (const service of state.services) { + const button = document.createElement("button"); + button.className = + "service-btn" + + (state.selected === service.id ? " active" : "") + + (service.enabled ? "" : " disabled"); + button.title = service.url; + button.innerHTML = + `${esc(service.icon || (service.name[0] || "?").toUpperCase())}` + + `${esc(service.name)}` + + `${esc(service.description || service.origin)}`; + button.addEventListener("click", () => openService(service)); + el.serviceList.appendChild(button); + } +} + +function renderTable() { + el.serviceTable.innerHTML = ""; + if (!state.services.length) { + el.serviceTable.innerHTML = '

No services yet - add one on the left.

'; + return; + } + for (const service of state.services) { + const row = document.createElement("div"); + row.className = "service-row"; + row.innerHTML = + `
${esc(service.name)}` + + `${esc(service.url)}` + + `${esc(service.description || "")}
` + + `${service.enabled ? "enabled" : "disabled"}` + + `` + + `` + + ``; + el.serviceTable.appendChild(row); + } +} + +function findService(id) { + return state.services.find((s) => s.id === id); +} + +/* ---------------------------------------------------------------- actions */ +function openService(service) { + state.selected = service.id; + renderSidebar(); + + if (!service.enabled) { + toast(`${service.name} is disabled`, "error"); + return; + } + + if (service.open_in === "newtab") { + window.open(service.proxy_path, "_blank"); + el.settings.hidden = true; + el.viewerFrame.hidden = true; + el.viewerFrame.src = ""; + el.reloadBtn.hidden = true; + el.emptyState.hidden = false; + el.emptyText.textContent = `${service.name} opened in a new tab (${service.proxy_path})`; + return; + } + + el.settings.hidden = true; + el.viewer.hidden = false; + el.emptyState.hidden = true; + el.viewerFrame.hidden = false; + el.reloadBtn.hidden = false; + el.viewerTitle.textContent = service.name; + el.viewerUrl.textContent = service.proxy_path; + el.viewerFrame.src = service.proxy_path; +} + +function showSettings() { + el.settings.hidden = false; + el.viewer.hidden = true; + el.viewerFrame.src = ""; + renderTable(); +} + +function resetForm() { + state.editing = null; + el.formTitle.textContent = "Add service"; + el.name.value = ""; + el.url.value = ""; + el.desc.value = ""; + el.icon.value = ""; + el.open.value = "iframe"; + el.headers.value = ""; + el.enabled.checked = true; +} + +function fillForm(service) { + state.editing = service.id; + el.formTitle.textContent = `Edit ${service.name}`; + el.name.value = service.name; + el.url.value = service.url; + el.desc.value = service.description || ""; + el.icon.value = service.icon || ""; + el.open.value = service.open_in; + el.headers.value = headersToText(service.headers); + el.enabled.checked = service.enabled; +} + +async function refresh() { + state.services = await api("/api/services"); + renderSidebar(); + renderTable(); +} + +async function saveService(event) { + event.preventDefault(); + const payload = { + name: el.name.value.trim(), + url: el.url.value.trim(), + description: el.desc.value.trim(), + icon: el.icon.value.trim(), + open_in: el.open.value, + enabled: el.enabled.checked, + headers: textToHeaders(el.headers.value), + }; + try { + const saved = state.editing + ? await api(`/api/services/${state.editing}`, { method: "PATCH", body: JSON.stringify(payload) }) + : await api("/api/services", { method: "POST", body: JSON.stringify(payload) }); + toast(state.editing ? `Updated ${saved.name}` : `Added ${saved.name}`); + resetForm(); + await refresh(); + } catch (error) { + toast(error.message, "error"); + } +} + +async function deleteService(id) { + const service = findService(id); + if (!service || !confirm(`Delete ${service.name}?`)) return; + try { + await api(`/api/services/${id}`, { method: "DELETE" }); + if (state.selected === id) state.selected = null; + toast(`Deleted ${service.name}`); + await refresh(); + } catch (error) { + toast(error.message, "error"); + } +} + +async function testService(id, statusEl) { + statusEl.textContent = "testing..."; + try { + const health = await api(`/api/services/${id}/health`); + statusEl.textContent = health.reachable + ? `reachable (${health.status})` + : `unreachable: ${health.error || health.status}`; + statusEl.className = `status ${health.reachable ? "ok" : "bad"}`; + } catch (error) { + statusEl.textContent = error.message; + statusEl.className = "status bad"; + } +} + +/* ---------------------------------------------------------------- wiring */ +el.serviceTable.addEventListener("click", (event) => { + const button = event.target.closest("button[data-action]"); + if (!button) return; + const id = button.dataset.id; + if (button.dataset.action === "edit") { + const service = findService(id); + if (service) fillForm(service); + } else if (button.dataset.action === "delete") { + deleteService(id); + } else if (button.dataset.action === "test") { + const statusEl = el.serviceTable.querySelector(`[data-status="${id}"]`); + testService(id, statusEl); + } +}); + +el.form.addEventListener("submit", saveService); +el.formCancel.addEventListener("click", resetForm); +el.settingsToggle.addEventListener("click", showSettings); +el.settingsClose.addEventListener("click", () => { + el.settings.hidden = true; + el.viewer.hidden = false; +}); +el.reloadBtn.addEventListener("click", () => { + if (el.viewerFrame.src) el.viewerFrame.src = el.viewerFrame.src; +}); + +refresh().catch((error) => toast(error.message, "error")); diff --git a/src/uwudash_mk3/static/styles.css b/src/uwudash_mk3/static/styles.css new file mode 100644 index 0000000..456c88c --- /dev/null +++ b/src/uwudash_mk3/static/styles.css @@ -0,0 +1,191 @@ +:root { + --bg: #10131a; + --panel: #171c26; + --panel-2: #1f2532; + --border: #2a3242; + --fg: #e8edf5; + --muted: #8b96a8; + --accent: #4f8cff; + --success: #3fbf7f; + --error: #e0556b; + --warning: #d9a441; +} + +@media (prefers-color-scheme: light) { + :root { + --bg: #f4f6fa; + --panel: #ffffff; + --panel-2: #eef2f7; + --border: #d5dde8; + --fg: #16202e; + --muted: #61708a; + --accent: #2f6fd0; + } +} + +* { box-sizing: border-box; } + +[hidden] { display: none !important; } + +body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--fg); + display: flex; + min-height: 100vh; +} + +/* ---------------------------------------------------------------- sidebar */ +.sidebar { + width: 220px; + flex: 0 0 220px; + background: var(--panel); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + padding: 16px 12px; +} + +.brand { display: flex; align-items: baseline; gap: 8px; margin-bottom: 16px; } +.brand-name { font-size: 18px; font-weight: 700; } +.brand-sub { color: var(--muted); font-size: 12px; } + +.service-list { display: flex; flex-direction: column; gap: 8px; flex: 1; } + +.service-btn { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel-2); + color: var(--fg); + cursor: pointer; + text-align: left; + font: inherit; +} +.service-btn:hover { border-color: var(--accent); } +.service-btn.active { border-color: var(--accent); background: var(--accent); color: #fff; } +.service-btn .icon { font-size: 16px; } +.service-btn .label { display: block; font-weight: 600; } +.service-btn .sub { display: block; font-size: 11px; color: var(--muted); } +.service-btn.disabled { opacity: 0.5; } + +.sidebar-foot { margin-top: 12px; } + +/* ------------------------------------------------------------------- main */ +.main { flex: 1; display: flex; flex-direction: column; min-width: 0; } + +.viewer { flex: 1; display: flex; flex-direction: column; min-height: 0; } +.viewer-bar { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + border-bottom: 1px solid var(--border); + background: var(--panel); +} +.viewer-title { font-weight: 600; } +.viewer-url { color: var(--muted); font-size: 12px; } +.viewer-bar .btn { margin-left: auto; } + +#viewer-frame { flex: 1; width: 100%; border: 0; background: var(--bg); } + +.empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + padding: 40px; +} + +/* --------------------------------------------------------------- settings */ +.settings { padding: 20px; } +.settings-head { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; } +.settings-head h1 { margin: 0; font-size: 20px; } +.settings-head .btn { margin-left: auto; } + +.settings-grid { + display: grid; + grid-template-columns: 320px 1fr; + gap: 16px; + align-items: start; +} + +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; +} + +.form { display: flex; flex-direction: column; gap: 10px; } +.form h2 { margin: 0 0 4px; font-size: 16px; } +.form label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--muted); } +.form input, .form select, .form textarea { + font: inherit; + color: var(--fg); + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 10px; +} +.form .checkbox { flex-direction: row; align-items: center; } +.form-actions { display: flex; gap: 8px; margin-top: 6px; } + +.service-table { display: flex; flex-direction: column; gap: 10px; } +.service-row { + display: grid; + grid-template-columns: 1fr auto auto auto; + gap: 12px; + align-items: center; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px 14px; +} +.service-row .meta { min-width: 0; } +.service-row .meta strong { display: block; } +.service-row .meta span { color: var(--muted); font-size: 12px; display: block; } +.service-row .meta code { color: var(--muted); font-size: 12px; } + +.status { font-size: 12px; color: var(--muted); } +.status.ok { color: var(--success); } +.status.bad { color: var(--error); } + +/* ------------------------------------------------------------------ btns */ +.btn { + font: inherit; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--accent); + background: var(--accent); + color: #fff; + cursor: pointer; +} +.btn.ghost { background: transparent; color: var(--fg); border-color: var(--border); } +.btn:hover { filter: brightness(1.1); } + +/* ----------------------------------------------------------------- toast */ +.toast { + position: fixed; + bottom: 16px; + right: 16px; + background: var(--panel); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: 8px; + padding: 10px 14px; + max-width: 360px; + font-size: 13px; +} +.toast.error { border-left-color: var(--error); } +.toast.ok { border-left-color: var(--success); } + +@media (max-width: 860px) { + .settings-grid { grid-template-columns: 1fr; } + .sidebar { width: 180px; flex-basis: 180px; } +} diff --git a/src/uwudash_mk3/templates/index.html b/src/uwudash_mk3/templates/index.html new file mode 100644 index 0000000..dd84cc6 --- /dev/null +++ b/src/uwudash_mk3/templates/index.html @@ -0,0 +1,89 @@ + + + + + + UwuDash MK3 + + + + + +
+
+
+ Nothing selected + + +
+ +
+

Pick a service from the sidebar, or add one in Settings.

+
+
+ + +
+ + + + +