/** * — Discourse instance provisioner dashboard. * * Lists user's forum instances, shows provisioning status, and allows * creating new instances. */ class FolkForumDashboard extends HTMLElement { private shadow: ShadowRoot; private instances: any[] = []; private selectedInstance: any = null; private selectedLogs: any[] = []; private view: "list" | "detail" | "create" = "list"; private loading = false; private pollTimer: number | null = null; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.render(); this.loadInstances(); } disconnectedCallback() { if (this.pollTimer) clearInterval(this.pollTimer); } private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^\/([^/]+)\/forum/); return match ? `/${match[1]}/forum` : ""; } private getAuthHeaders(): Record { const token = localStorage.getItem("encryptid_session"); if (token) { try { const parsed = JSON.parse(token); return { "X-User-DID": parsed.did || "" }; } catch {} } return {}; } private async loadInstances() { this.loading = true; this.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/instances`, { headers: this.getAuthHeaders() }); if (res.ok) { const data = await res.json(); this.instances = data.instances || []; } } catch (e) { console.error("[ForumDashboard] Error:", e); } this.loading = false; this.render(); } private async loadInstanceDetail(id: string) { try { const base = this.getApiBase(); const res = await fetch(`${base}/api/instances/${id}`, { headers: this.getAuthHeaders() }); if (res.ok) { const data = await res.json(); this.selectedInstance = data.instance; this.selectedLogs = data.logs || []; this.view = "detail"; this.render(); // Poll if provisioning const active = ["pending", "provisioning", "installing", "configuring"]; if (active.includes(this.selectedInstance.status)) { if (this.pollTimer) clearInterval(this.pollTimer); this.pollTimer = setInterval(() => this.loadInstanceDetail(id), 5000) as any; } else { if (this.pollTimer) clearInterval(this.pollTimer); } } } catch {} } private async handleCreate(e: Event) { e.preventDefault(); const form = this.shadow.querySelector("#create-form") as HTMLFormElement; if (!form) return; const name = (form.querySelector('[name="name"]') as HTMLInputElement)?.value; const subdomain = (form.querySelector('[name="subdomain"]') as HTMLInputElement)?.value; const adminEmail = (form.querySelector('[name="admin_email"]') as HTMLInputElement)?.value; const region = (form.querySelector('[name="region"]') as HTMLSelectElement)?.value; const size = (form.querySelector('[name="size"]') as HTMLSelectElement)?.value; if (!name || !subdomain || !adminEmail) return; try { const base = this.getApiBase(); const res = await fetch(`${base}/api/instances`, { method: "POST", headers: { "Content-Type": "application/json", ...this.getAuthHeaders() }, body: JSON.stringify({ name, subdomain, admin_email: adminEmail, region, size }), }); if (res.ok) { const data = await res.json(); this.view = "detail"; this.loadInstanceDetail(data.instance.id); } else { const err = await res.json(); alert(err.error || "Failed to create instance"); } } catch { alert("Network error"); } } private async handleDestroy(id: string) { if (!confirm("Are you sure you want to destroy this forum instance? This cannot be undone.")) return; try { const base = this.getApiBase(); await fetch(`${base}/api/instances/${id}`, { method: "DELETE", headers: this.getAuthHeaders(), }); this.view = "list"; this.loadInstances(); } catch {} } private statusBadge(status: string): string { const colors: Record = { pending: "#ffa726", provisioning: "#42a5f5", installing: "#42a5f5", configuring: "#42a5f5", active: "#66bb6a", error: "#ef5350", destroying: "#ffa726", destroyed: "#888", }; const color = colors[status] || "#888"; const pulse = ["provisioning", "installing", "configuring"].includes(status) ? "animation: pulse 1.5s ease-in-out infinite;" : ""; return `${status}`; } private logStepIcon(status: string): string { if (status === "success") return "\u2705"; if (status === "error") return "\u274C"; if (status === "running") return "\u23F3"; return "\u23ED\uFE0F"; } private render() { this.shadow.innerHTML = ` ${this.view === "list" ? this.renderList() : ""} ${this.view === "detail" ? this.renderDetail() : ""} ${this.view === "create" ? this.renderCreate() : ""} `; this.attachEvents(); } private renderList(): string { return `

\uD83D\uDCAC Forum Instances

${this.loading ? '
Loading...
' : ""} ${!this.loading && this.instances.length === 0 ? '
No forum instances yet. Deploy your first Discourse forum!
' : ""}
${this.instances.map((inst) => `
${this.esc(inst.name)} ${this.statusBadge(inst.status)}
${inst.domain} · ${inst.region} · ${inst.size} ${inst.vps_ip ? ` · ${inst.vps_ip}` : ""}
`).join("")}
`; } private renderDetail(): string { const inst = this.selectedInstance; if (!inst) return ""; return `
${inst.status !== "destroyed" ? `` : ""}
${this.esc(inst.name)}
${this.statusBadge(inst.status)}
${inst.status === "active" ? `\u2197 Open Forum` : ""}
${inst.error_message ? `
${this.esc(inst.error_message)}
` : ""}
${inst.domain}
${inst.vps_ip || "—"}
${inst.region}
${inst.size}
${inst.admin_email || "—"}
${inst.ssl_provisioned ? "\u2705 Active" : "\u23F3 Pending"}

Provision Log

${this.selectedLogs.length === 0 ? '
No logs yet
' : ""} ${this.selectedLogs.map((log) => `
${this.logStepIcon(log.status)}
${this.formatStep(log.step)}
${this.esc(log.message || "")}
`).join("")}
`; } private renderCreate(): string { return `

Deploy New Forum

Starter
\u20AC3.79/mo
2 vCPU · 4 GB · ~500 users
Standard
\u20AC6.80/mo
4 vCPU · 8 GB · ~2000 users
Performance
\u20AC13.80/mo
8 vCPU · 16 GB · ~10k users
.rforum.online
`; } private attachEvents() { this.shadow.querySelectorAll("[data-action]").forEach((el) => { const action = (el as HTMLElement).dataset.action!; const id = (el as HTMLElement).dataset.id; el.addEventListener("click", () => { if (action === "show-create") { this.view = "create"; this.render(); } else if (action === "back") { if (this.pollTimer) clearInterval(this.pollTimer); this.view = "list"; this.loadInstances(); } else if (action === "detail" && id) { this.loadInstanceDetail(id); } else if (action === "destroy" && id) { this.handleDestroy(id); } }); }); this.shadow.querySelectorAll(".price-card").forEach((card) => { card.addEventListener("click", () => { this.shadow.querySelectorAll(".price-card").forEach((c) => c.classList.remove("selected")); card.classList.add("selected"); const sizeInput = this.shadow.querySelector('[name="size"]') as HTMLInputElement; if (sizeInput) sizeInput.value = (card as HTMLElement).dataset.size || "cx22"; }); }); const form = this.shadow.querySelector("#create-form"); if (form) form.addEventListener("submit", (e) => this.handleCreate(e)); } private formatStep(step: string): string { const labels: Record = { create_vps: "Create Server", wait_ready: "Wait for Boot", configure_dns: "Configure DNS", install_discourse: "Install Discourse", verify_live: "Verify Live", }; return labels[step] || step; } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-forum-dashboard", FolkForumDashboard);