| 1 | // Moderation console for user-published skills/plugins/MCP servers. Gated on the unified |
| 2 | // dashboard admin role; reads and writes the registry database. |
| 3 | import { esc, page } from "./shell"; |
| 4 | import { type User, userNav } from "./auth"; |
| 5 | import type { PackageRow } from "./registry/types"; |
| 6 | |
| 7 | const STATUS_TABS = [ |
| 8 | { key: "pending", label: "Pending" }, |
| 9 | { key: "active", label: "Active" }, |
| 10 | { key: "hidden", label: "Hidden" }, |
| 11 | { key: "rejected", label: "Rejected" }, |
| 12 | ]; |
| 13 | |
| 14 | function actionForm(pkg: PackageRow, action: string, label: string, cls: string, backStatus: string, confirm?: string): string { |
| 15 | const onsubmit = confirm ? ` onsubmit="return confirm('${esc(confirm)}')"` : ""; |
| 16 | return `<form method="post" action="/community/${esc(pkg.scope_handle)}/${esc(pkg.name)}/${action}" class="inline"${onsubmit}> |
| 17 | <input type="hidden" name="status" value="${esc(backStatus)}"> |
| 18 | <input type="hidden" name="expectedVersion" value="${esc(pkg.latest_version)}"> |
| 19 | <input type="hidden" name="expectedUpdatedAt" value="${esc(pkg.updated_at)}"> |
| 20 | <input type="hidden" name="expectedStatus" value="${esc(pkg.status)}"> |
| 21 | <button class="btn ${cls} sm" type="submit">${esc(label)}</button></form>`; |
| 22 | } |
| 23 | |
| 24 | function rowActions(pkg: PackageRow, backStatus: string): string { |
| 25 | if (pkg.status === "active") { |
| 26 | const verify = pkg.verified |
| 27 | ? actionForm(pkg, "unverify", "Unverify", "ghost", backStatus) |
| 28 | : actionForm(pkg, "verify", "Verify", "ghost", backStatus); |
| 29 | return `${verify}${actionForm(pkg, "hide", "Hide", "danger", backStatus, `Hide ${pkg.slug}?`)}`; |
| 30 | } |
| 31 | const approve = actionForm(pkg, "approve", "Approve", "", backStatus); |
| 32 | const reject = pkg.status === "rejected" ? "" : actionForm(pkg, "reject", "Reject", "danger", backStatus, `Reject ${pkg.slug}?`); |
| 33 | return `${approve}${reject}`; |
| 34 | } |
| 35 | |
| 36 | function sourceLinks(pkg: PackageRow): string { |
| 37 | const links: string[] = []; |
| 38 | if (pkg.repo_url) links.push(`<a class="navlink" href="${esc(pkg.repo_url)}" target="_blank" rel="noopener">repo</a>`); |
| 39 | if (pkg.homepage) links.push(`<a class="navlink" href="${esc(pkg.homepage)}" target="_blank" rel="noopener">home</a>`); |
| 40 | return links.join(" · "); |
| 41 | } |
| 42 | |
| 43 | // One paste-ready block of everything a reviewer needs: the install pointer, |
| 44 | // provenance links, and README. The manifest snapshot lives on package_versions, |
| 45 | // not here — source/repo_url are what actually carry the reviewable content. |
| 46 | function reviewBlob(pkg: PackageRow): string { |
| 47 | return [ |
| 48 | `Registry submission — ${pkg.slug}`, |
| 49 | `kind: ${pkg.kind}`, |
| 50 | `status: ${pkg.status}`, |
| 51 | `publisher: @${pkg.scope_handle}`, |
| 52 | `version: ${pkg.latest_version || "—"}`, |
| 53 | `submitted: ${pkg.created_at}`, |
| 54 | `install_kind: ${pkg.install_kind}`, |
| 55 | `source: ${pkg.source || "—"}`, |
| 56 | `repo_url: ${pkg.repo_url || "—"}`, |
| 57 | `homepage: ${pkg.homepage || "—"}`, |
| 58 | `tags: ${pkg.tags || "—"}`, |
| 59 | `summary: ${pkg.summary || "—"}`, |
| 60 | ``, |
| 61 | `--- description (README) ---`, |
| 62 | pkg.description || "(none)", |
| 63 | ].join("\n"); |
| 64 | } |
| 65 | |
| 66 | function copyButton(pkg: PackageRow): string { |
| 67 | return `<button type="button" class="btn ghost sm copy-btn" data-copy="${esc(reviewBlob(pkg))}"><span class="copy-label">Copy for review</span></button>`; |
| 68 | } |
| 69 | |
| 70 | export function renderCommunity(viewer: User, packages: PackageRow[], status: string): string { |
| 71 | const tabs = STATUS_TABS.map( |
| 72 | (t) => `<a class="filter-tab${t.key === status ? " active" : ""}" href="/community?status=${t.key}">${t.label}</a>`, |
| 73 | ).join(""); |
| 74 | const rows = packages.length |
| 75 | ? packages |
| 76 | .map((p) => { |
| 77 | const verified = p.verified ? ` <span class="badge admin">verified</span>` : ""; |
| 78 | const links = sourceLinks(p); |
| 79 | return `<tr> |
| 80 | <td><div class="crash-summary"><span>${esc(p.slug)}${verified}</span><small>${esc(p.summary || "—")}</small></div></td> |
| 81 | <td><span class="pill">${esc(p.kind)}</span></td> |
| 82 | <td>@${esc(p.scope_handle)}</td> |
| 83 | <td class="n">${esc(p.latest_version || "—")} · ${p.install_count} inst · ${p.star_count}★</td> |
| 84 | <td class="n">${esc(p.created_at.slice(0, 10))}</td> |
| 85 | <td><div class="actions">${rowActions(p, status)}</div><div class="rowlinks">${copyButton(p)}${links ? `<span class="muted">${links}</span>` : ""}</div></td> |
| 86 | </tr>`; |
| 87 | }) |
| 88 | .join("") |
| 89 | : `<tr><td colspan="6"><div class="empty">No ${esc(status)} packages</div></td></tr>`; |
| 90 | return page( |
| 91 | "Reasonix · Community", |
| 92 | "community", |
| 93 | `<h1>Community</h1><p class="sub">Review user-published skills, plugins, and MCP servers — approve to publish, verify to badge, hide to take down</p> |
| 94 | <div class="filter-tabs">${tabs}</div> |
| 95 | <div class="card full"><table class="reg-table"><colgroup><col class="c-pkg"><col class="c-kind"><col class="c-pub"><col class="c-ver"><col class="c-sub"><col class="c-act"></colgroup><thead><tr><th>package</th><th>kind</th><th>publisher</th><th>version · installs · stars</th><th>submitted</th><th></th></tr></thead> |
| 96 | <tbody>${rows}</tbody></table></div>`, |
| 97 | userNav(viewer), |
| 98 | ); |
| 99 | } |
| 100 |