1import type { Context, Hono } from "hono";2import { MAX_CHARS } from "../issues/spam.ts";3import {4 addEntry,5 ISSUE_TYPES,6 type IssueType,7 type OperatorFilter,8 type OperatorIssue,9 operatorIssue,10 operatorIssues,11 setClosed,12 setHidden,13 setType,14 threadEntries,15} from "../issues/store.ts";16import { saveByPrefix } from "../mirror/queries.ts";17import { openMirror } from "../mirror/store.ts";18import { html } from "../pages/html.ts";19import { formatDate, relativeTime } from "../pages/layout.ts";20import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";2122const FILTERS: OperatorFilter[] = ["open", "closed", "hidden"];23const SAVE_PREFIX = /^[0-9a-f]{6,12}$/;24const ISSUE_PATH = "/admin/issues/:repo/:n{[0-9]{1,9}}";25const unixNow = () => Math.floor(Date.now() / 1000);262728export function registerIssues(app: Hono, ctx: AdminContext): void {29 app.get("/admin/issues", (c) => {30 const filter = FILTERS.find((f) => f === c.req.query("filter")) ?? "open";31 const tab = (f: OperatorFilter) =>32 html`<a class="issues-tab${f === filter ? " active" : ""}" href="/admin/issues?filter=${f}">${f}</a>`;33 const issues = operatorIssues(ctx.kepr, filter);34 const body = html`<p class="issues-bar mono">${FILTERS.map(tab)}</p>35<section class="repo-section">36${issues.length > 0 ? issues.map((issue) => html`<a class="issue-row" href="/admin/issues/${issue.repo}/${issue.number}"><span class="issue-title">${issue.title}</span><span class="issue-meta mono">${issue.repo} #${issue.number} · ${issue.type} · ${issue.status} · ${relativeTime(issue.filed_at)}</span></a>`) : html`<p class="muted mono save-empty">no ${filter} issues</p>`}37</section>`;38 return c.html(adminPage(c, ctx, "issues", body));39 });4041 app.get(ISSUE_PATH, (c) => {42 const issue = operatorIssue(ctx.kepr, c.req.param("repo"), Number(c.req.param("n")));43 return issue ? c.html(issuePage(c, ctx, issue, "")) : c.notFound();44 });4546 app.post(ISSUE_PATH, async (c) => {47 const issue = operatorIssue(ctx.kepr, c.req.param("repo"), Number(c.req.param("n")));48 if (!issue) return c.notFound();49 const fields = await textFields(c);50 const refuse = (error: string) => c.html(issuePage(c, ctx, issue, error), 400);51 switch (fields.action) {52 case "reply": {53 const body = (fields.body ?? "").trim();54 if (body === "" || body.length > MAX_CHARS) {55 return refuse(`A reply is 1 to ${MAX_CHARS.toLocaleString("en-US")} characters.`);56 }57 addEntry(ctx.kepr, issue.id, "operator", body, unixNow());58 break;59 }60 case "close": {61 const prefix = (fields.fixed_in ?? "").trim();62 if (prefix === "") {63 setClosed(ctx.kepr, issue.id, unixNow());64 break;65 }66 const save = SAVE_PREFIX.test(prefix)67 ? saveByPrefix(openMirror(ctx.mirrorDir, issue.repo), prefix)68 : null;69 if (!save) return refuse(`No save ${prefix} in ${issue.repo}.`);70 setClosed(ctx.kepr, issue.id, unixNow(), save.id);71 break;72 }73 case "reopen":74 setClosed(ctx.kepr, issue.id, null);75 break;76 case "hide":77 case "unhide":78 setHidden(ctx.kepr, issue.id, fields.action === "hide");79 break;80 case "type":81 if (!ISSUE_TYPES.includes(fields.type as IssueType)) return refuse("Pick a type.");82 setType(ctx.kepr, issue.id, fields.type as IssueType);83 break;84 default:85 return refuse("Unknown action.");86 }87 return c.redirect(`/admin/issues/${issue.repo}/${issue.number}`, 303);88 });89}9091function issuePage(c: Context, ctx: AdminContext, issue: OperatorIssue, error: string): string {92 const action = `/admin/issues/${issue.repo}/${issue.number}`;93 const button = (name: string, label: string) =>94 html`<form method="post" action="${action}">${formField(c)}<input type="hidden" name="action" value="${name}"><button class="issues-submit" type="submit">${label}</button></form>`;95 const post = (operator: boolean, at: number, text: string) =>96 html`<article class="issue-post${operator ? " issue-post--operator" : ""}">97<p class="issue-post-head mono">${operator ? html`<span class="issue-badge">operator</span>` : html`<span class="issue-author">${issue.reporter_name || "anonymous"}</span>`}<span class="history-when">${formatDate(at)}</span></p>98<div class="issue-text">${text}</div>99</article>`;100 const typeOption = (type: string) =>101 html`<option value="${type}"${issue.type === type ? html` selected` : ""}>${type}</option>`;102 const body = html`${notice(error)}103<p class="issue-meta mono">${issue.repo} #${issue.number} · ${issue.status}${issue.fixed_in && ` · fixed in ${issue.fixed_in}`}${issue.hidden ? " · hidden" : ""} · ${issue.version && `v${issue.version} · `}filed ${formatDate(issue.filed_at)}</p>104<p class="issue-meta mono">reporter: ${issue.reporter_name || "anonymous"} · contact: ${issue.contact_email || "none given"}</p>105${post(false, issue.filed_at, issue.body)}106${threadEntries(ctx.kepr, issue.id).map((entry) => post(entry.author === "operator", entry.created_at, entry.body))}107<form class="issues-form" method="post" action="${action}">${formField(c)}108<input type="hidden" name="action" value="reply">109<label class="issues-label" for="reply">reply</label>110<textarea class="issues-textarea" id="reply" name="body" rows="5" required maxlength="${MAX_CHARS}"></textarea>111<button class="issues-submit" type="submit">reply</button>112</form>113${114 issue.status === "open"115 ? html`<form class="issues-form" method="post" action="${action}">${formField(c)}116<input type="hidden" name="action" value="close">117<label class="issues-label" for="fixed-in">fixed in save <span class="muted">(optional)</span></label>118<input class="issues-input" id="fixed-in" name="fixed_in" maxlength="12">119<button class="issues-submit" type="submit">close</button>120</form>`121 : button("reopen", "reopen")122}123<form class="issues-form" method="post" action="${action}">${formField(c)}124<input type="hidden" name="action" value="type">125<label class="issues-label" for="type">type</label>126<select class="issues-input" id="type" name="type">${ISSUE_TYPES.map(typeOption)}</select>127<button class="issues-submit" type="submit">set type</button>128</form>129${issue.hidden ? button("unhide", "unhide") : button("hide", "hide (spam)")}`;130 return adminPage(c, ctx, issue.title, body);131}