Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <link rel="icon" type="image/png" href="/templates/logo.png" /> | |
| <title>{{ app_title }}</title> | |
| <style> | |
| :root { | |
| --bg: #0b1020; | |
| --panel: #121a2f; | |
| --panel-2: #1a2440; | |
| --line: #2d3a5f; | |
| --text: #edf2ff; | |
| --muted: #9aa7c7; | |
| --accent: #ff875b; | |
| --accent-soft: #40211a; | |
| --good: #7ad79a; | |
| --good-soft: #163326; | |
| --warn: #ffcb6b; | |
| --warn-soft: #3a2f14; | |
| --bad: #ff8f8f; | |
| --shadow: 0 22px 48px rgba(0, 0, 0, 0.38); | |
| --radius: 18px; | |
| --font: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; | |
| --mono: Consolas, "SFMono-Regular", monospace; | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { margin: 0; padding: 0; min-height: 100%; } | |
| body { | |
| font-family: var(--font); | |
| color: var(--text); | |
| background: | |
| radial-gradient(circle at top right, rgba(255, 135, 91, 0.16), transparent 22%), | |
| radial-gradient(circle at top left, rgba(89, 122, 255, 0.16), transparent 18%), | |
| linear-gradient(180deg, #11182c 0%, var(--bg) 100%); | |
| } | |
| a { color: inherit; } | |
| button, input, textarea, select { font: inherit; } | |
| .shell { | |
| max-width: 1180px; | |
| margin: 0 auto; | |
| padding: 24px 18px 40px; | |
| } | |
| .topbar { | |
| display: flex; | |
| justify-content: space-between; | |
| gap: 16px; | |
| align-items: center; | |
| padding: 18px 20px; | |
| border: 1px solid var(--line); | |
| border-radius: 24px; | |
| background: rgba(18, 26, 47, 0.88); | |
| backdrop-filter: blur(12px); | |
| box-shadow: var(--shadow); | |
| } | |
| .brand { | |
| display: flex; | |
| gap: 14px; | |
| align-items: center; | |
| min-width: 0; | |
| } | |
| .brand img { | |
| width: 42px; | |
| height: 42px; | |
| border-radius: 12px; | |
| border: 1px solid var(--line); | |
| object-fit: cover; | |
| background: #0f1628; | |
| } | |
| .brand h1 { | |
| margin: 0; | |
| font-size: 1.15rem; | |
| letter-spacing: -0.02em; | |
| } | |
| .brand p { | |
| margin: 4px 0 0; | |
| color: var(--muted); | |
| font-size: 0.93rem; | |
| } | |
| .badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| border: 1px solid var(--line); | |
| border-radius: 999px; | |
| padding: 7px 12px; | |
| font-size: 0.82rem; | |
| color: var(--muted); | |
| background: rgba(26, 36, 64, 0.78); | |
| white-space: nowrap; | |
| } | |
| .view { | |
| margin-top: 20px; | |
| display: none; | |
| } | |
| .view.active { display: block; } | |
| .panel { | |
| border: 1px solid var(--line); | |
| border-radius: var(--radius); | |
| background: var(--panel); | |
| box-shadow: var(--shadow); | |
| } | |
| .list-layout { | |
| display: grid; | |
| gap: 18px; | |
| } | |
| .toolbar { | |
| display: grid; | |
| gap: 16px; | |
| padding: 18px; | |
| } | |
| .toolbar-row { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| align-items: center; | |
| justify-content: space-between; | |
| } | |
| .filters { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| } | |
| .word-filter-row { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| align-items: center; | |
| } | |
| .word-input { | |
| border: 1px solid var(--line); | |
| border-radius: 14px; | |
| padding: 9px 12px; | |
| background: #0e1528; | |
| color: var(--text); | |
| width: 80px; | |
| } | |
| .word-input::placeholder { color: #7381a6; } | |
| .pill { | |
| border: 1px solid var(--line); | |
| background: #18233d; | |
| color: var(--muted); | |
| border-radius: 999px; | |
| padding: 9px 14px; | |
| cursor: pointer; | |
| } | |
| .pill.active { | |
| background: var(--accent); | |
| color: #fff; | |
| border-color: var(--accent); | |
| } | |
| .search-form { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| width: 100%; | |
| } | |
| .search-form input { | |
| flex: 1 1 280px; | |
| border: 1px solid var(--line); | |
| border-radius: 14px; | |
| padding: 12px 14px; | |
| background: #0e1528; | |
| color: var(--text); | |
| min-width: 0; | |
| } | |
| .search-form input::placeholder, | |
| .answer-form textarea::placeholder, | |
| .proposal-form textarea::placeholder { | |
| color: #7381a6; | |
| } | |
| .btn { | |
| border: 1px solid var(--line); | |
| background: #18233d; | |
| color: var(--text); | |
| border-radius: 14px; | |
| padding: 11px 16px; | |
| cursor: pointer; | |
| transition: transform 120ms ease, background 120ms ease; | |
| } | |
| .btn:hover { transform: translateY(-1px); } | |
| .btn.primary { | |
| background: var(--accent); | |
| border-color: var(--accent); | |
| color: #fff; | |
| } | |
| .meta-line { | |
| color: var(--muted); | |
| font-size: 0.9rem; | |
| } | |
| .queue-list { | |
| display: grid; | |
| gap: 14px; | |
| padding: 18px; | |
| } | |
| .queue-item { | |
| display: grid; | |
| gap: 10px; | |
| padding: 16px; | |
| border: 1px solid var(--line); | |
| border-radius: 16px; | |
| background: #161f37; | |
| text-decoration: none; | |
| transition: border-color 120ms ease, transform 120ms ease; | |
| } | |
| .queue-item:hover { | |
| border-color: var(--accent); | |
| transform: translateY(-1px); | |
| } | |
| .queue-head { | |
| display: flex; | |
| justify-content: space-between; | |
| gap: 12px; | |
| align-items: flex-start; | |
| } | |
| .queue-head h2 { | |
| margin: 0; | |
| font-size: 1.06rem; | |
| line-height: 1.4; | |
| } | |
| .queue-tags { | |
| display: flex; | |
| gap: 8px; | |
| flex-wrap: wrap; | |
| } | |
| .tag { | |
| display: inline-flex; | |
| align-items: center; | |
| border-radius: 999px; | |
| padding: 5px 10px; | |
| font-size: 0.78rem; | |
| border: 1px solid var(--line); | |
| color: var(--muted); | |
| background: #18233d; | |
| } | |
| .tag.unanswered { | |
| border-color: #7c6321; | |
| background: var(--warn-soft); | |
| color: var(--warn); | |
| } | |
| .tag.answered { | |
| border-color: #2e6846; | |
| background: var(--good-soft); | |
| color: var(--good); | |
| } | |
| .preview { | |
| color: var(--muted); | |
| line-height: 1.55; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| } | |
| .pager { | |
| display: flex; | |
| justify-content: space-between; | |
| gap: 12px; | |
| align-items: center; | |
| padding: 0 18px 18px; | |
| flex-wrap: wrap; | |
| } | |
| .pager-actions { | |
| display: flex; | |
| gap: 10px; | |
| } | |
| .empty { | |
| padding: 28px 18px 34px; | |
| text-align: center; | |
| color: var(--muted); | |
| } | |
| .detail-layout { | |
| display: grid; | |
| gap: 18px; | |
| } | |
| .detail-header { | |
| padding: 18px; | |
| display: grid; | |
| gap: 12px; | |
| } | |
| .back-link { | |
| color: var(--accent); | |
| text-decoration: none; | |
| font-weight: 600; | |
| } | |
| .question-box { | |
| padding: 20px; | |
| border: 1px solid var(--line); | |
| border-radius: 18px; | |
| background: #161f37; | |
| } | |
| .question-box h2 { | |
| margin: 0 0 10px; | |
| font-size: 1.35rem; | |
| line-height: 1.35; | |
| } | |
| .answer-form, | |
| .answer-card, | |
| .version-card { | |
| padding: 18px; | |
| border: 1px solid var(--line); | |
| border-radius: 18px; | |
| background: #161f37; | |
| } | |
| .answer-form textarea, | |
| .proposal-form textarea { | |
| width: 100%; | |
| min-height: 120px; | |
| border: 1px solid var(--line); | |
| border-radius: 14px; | |
| padding: 12px 14px; | |
| resize: vertical; | |
| background: #0e1528; | |
| color: var(--text); | |
| } | |
| .section-title { | |
| margin: 0; | |
| font-size: 1.05rem; | |
| } | |
| .section-subtitle { | |
| margin: 4px 0 0; | |
| color: var(--muted); | |
| font-size: 0.9rem; | |
| } | |
| .stack { | |
| display: grid; | |
| gap: 14px; | |
| } | |
| .answer-card { | |
| display: grid; | |
| gap: 14px; | |
| } | |
| .answer-top { | |
| display: flex; | |
| justify-content: space-between; | |
| gap: 12px; | |
| align-items: flex-start; | |
| flex-wrap: wrap; | |
| } | |
| .answer-text, | |
| .version-text { | |
| line-height: 1.65; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| } | |
| .vote-row { | |
| display: flex; | |
| gap: 8px; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| } | |
| .vote-btn { | |
| border: 1px solid var(--line); | |
| background: #18233d; | |
| border-radius: 12px; | |
| padding: 8px 12px; | |
| cursor: pointer; | |
| color: var(--text); | |
| } | |
| .vote-btn.active-up { | |
| border-color: #469966; | |
| background: var(--good-soft); | |
| color: var(--good); | |
| } | |
| .vote-btn.active-down { | |
| border-color: #a45454; | |
| background: #3e2023; | |
| color: var(--bad); | |
| } | |
| .proposal-form { | |
| display: grid; | |
| gap: 10px; | |
| padding-top: 6px; | |
| } | |
| .proposal-actions, | |
| .form-actions { | |
| display: flex; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| } | |
| .muted { | |
| color: var(--muted); | |
| font-size: 0.9rem; | |
| } | |
| .toast { | |
| position: fixed; | |
| right: 18px; | |
| bottom: 18px; | |
| padding: 12px 14px; | |
| border-radius: 14px; | |
| color: #fff; | |
| background: rgba(10, 14, 26, 0.95); | |
| box-shadow: var(--shadow); | |
| opacity: 0; | |
| transform: translateY(10px); | |
| pointer-events: none; | |
| transition: opacity 150ms ease, transform 150ms ease; | |
| max-width: min(340px, calc(100vw - 36px)); | |
| } | |
| .toast.show { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| @media (max-width: 760px) { | |
| .shell { padding: 16px 12px 28px; } | |
| .topbar { padding: 14px; } | |
| .queue-head, | |
| .answer-top, | |
| .toolbar-row, | |
| .pager { | |
| display: grid; | |
| } | |
| .search-form { grid-template-columns: 1fr; } | |
| .btn, .pill { width: 100%; text-align: center; } | |
| .pager-actions { width: 100%; } | |
| .pager-actions .btn { flex: 1; } | |
| .word-filter-row { flex-direction: column; align-items: stretch; } | |
| .word-input { width: 100%; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="shell"> | |
| <header class="topbar"> | |
| <div class="brand"> | |
| <img src="/templates/logo.png" alt="Logo" /> | |
| <div> | |
| <h1>{{ app_title }} Queue</h1> | |
| <p>For unanswered questions, answer versions, and voting.</p> | |
| </div> | |
| </div> | |
| <div class="badge" id="statusBadge">Queue workspace</div> | |
| </header> | |
| <main> | |
| <section id="listView" class="view"> | |
| <div class="list-layout"> | |
| <div class="panel toolbar"> | |
| <div class="toolbar-row"> | |
| <div> | |
| <h2 class="section-title">Question queue</h2> | |
| <p class="section-subtitle">Search stored questions and human answers with lightweight text matching.</p> | |
| </div> | |
| <div class="meta-line" id="listSummary"></div> | |
| </div> | |
| <div class="filters" id="statusFilters"></div> | |
| <div class="filters" id="sortFilters"></div> | |
| <div class="word-filter-row"> | |
| <span class="muted" style="align-self:center;white-space:nowrap">Question words:</span> | |
| <input id="minWordsInput" type="number" class="word-input" placeholder="Min" min="0" /> | |
| <span class="muted" style="align-self:center">–</span> | |
| <input id="maxWordsInput" type="number" class="word-input" placeholder="Max" min="0" /> | |
| <button class="btn" type="button" id="applyWordsBtn">Apply</button> | |
| <button class="btn" type="button" id="clearWordsBtn">Clear</button> | |
| </div> | |
| <form id="searchForm" class="search-form"> | |
| <input id="searchInput" type="search" placeholder="Search questions, answers, or versions" /> | |
| <button class="btn primary" type="submit">Search</button> | |
| <button class="btn" type="button" id="clearSearchBtn">Clear</button> | |
| </form> | |
| </div> | |
| <div class="panel"> | |
| <div id="queueList" class="queue-list"></div> | |
| <div id="emptyState" class="empty" hidden>No questions match this filter yet.</div> | |
| <div class="pager"> | |
| <div class="meta-line" id="pagerInfo"></div> | |
| <div class="pager-actions"> | |
| <button class="btn" id="prevPageBtn" type="button">Previous</button> | |
| <button class="btn" id="nextPageBtn" type="button">Next</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <section id="detailView" class="view"> | |
| <div class="detail-layout"> | |
| <div class="panel detail-header"> | |
| <a id="backLink" class="back-link" href="/">← Back to question list</a> | |
| <div class="question-box"> | |
| <div class="queue-tags" id="detailTags"></div> | |
| <h2 id="detailQuestion"></h2> | |
| <div class="meta-line" id="detailMeta"></div> | |
| </div> | |
| </div> | |
| <div class="panel" style="padding:18px;"> | |
| <div class="answer-form stack"> | |
| <div> | |
| <h3 class="section-title">Add an answer</h3> | |
| <p class="section-subtitle">Write the first answer or add another answer version path for the community to vote on.</p> | |
| </div> | |
| <textarea id="newAnswerText" placeholder="Write a human answer for this question"></textarea> | |
| <div class="form-actions"> | |
| <button class="btn primary" id="submitAnswerBtn" type="button">Save answer</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="stack" id="answersMount"></div> | |
| </div> | |
| </section> | |
| </main> | |
| </div> | |
| <div id="toast" class="toast" role="status" aria-live="polite"></div> | |
| <script> | |
| </script> | |
| <script> | |
| (() => { | |
| const S = { | |
| clientId: null, | |
| filters: { status: "unanswered", q: "", page: 1, page_size: 20, sort: "newest", min_words: 0, max_words: 0 }, | |
| list: null, | |
| detail: null, | |
| }; | |
| const $ = (id) => document.getElementById(id); | |
| const esc = (value) => String(value ?? "") | |
| .replace(/&/g, "&") | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">") | |
| .replace(/"/g, """) | |
| .replace(/'/g, "'"); | |
| function nl2br(value) { | |
| return esc(value).replace(/\n/g, "<br>"); | |
| } | |
| function trimText(value) { | |
| return String(value || "").trim(); | |
| } | |
| function fmtDate(value) { | |
| if (!value) return "unknown time"; | |
| const d = new Date(value); | |
| if (Number.isNaN(d.getTime())) return value; | |
| return d.toLocaleString(); | |
| } | |
| function getClientId() { | |
| const key = "hi_queue_client_id"; | |
| let id = localStorage.getItem(key); | |
| if (!id) { | |
| id = Math.random().toString(36).slice(2) + Date.now().toString(36); | |
| localStorage.setItem(key, id); | |
| } | |
| return id; | |
| } | |
| function showToast(message) { | |
| const toast = $("toast"); | |
| toast.textContent = message; | |
| toast.classList.add("show"); | |
| clearTimeout(showToast._timer); | |
| showToast._timer = setTimeout(() => toast.classList.remove("show"), 2400); | |
| } | |
| function buildQuery(extra = {}) { | |
| const params = new URLSearchParams(); | |
| const merged = { | |
| status: S.filters.status, | |
| q: S.filters.q, | |
| page: S.filters.page, | |
| sort: S.filters.sort, | |
| min_words: S.filters.min_words, | |
| max_words: S.filters.max_words, | |
| ...extra, | |
| }; | |
| if (merged.status) params.set("status", merged.status); | |
| if (merged.q) params.set("q", merged.q); | |
| if (merged.page && Number(merged.page) > 1) params.set("page", String(merged.page)); | |
| if (merged.conversation_id) params.set("conversation_id", merged.conversation_id); | |
| if (merged.sort && merged.sort !== "newest") params.set("sort", merged.sort); | |
| if (Number(merged.min_words) > 0) params.set("min_words", String(merged.min_words)); | |
| if (Number(merged.max_words) > 0) params.set("max_words", String(merged.max_words)); | |
| return params.toString(); | |
| } | |
| function pushListUrl() { | |
| const qs = buildQuery(); | |
| history.replaceState({}, "", qs ? `/?${qs}` : "/"); | |
| } | |
| function pushDetailUrl(conversationId) { | |
| const qs = buildQuery({ conversation_id: conversationId }); | |
| history.pushState({}, "", `/?${qs}`); | |
| } | |
| async function callAPI(action, payload = {}) { | |
| const resp = await fetch("/api", { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| "X-Client-Id": S.clientId, | |
| }, | |
| body: JSON.stringify({ action, client_id: S.clientId, ...payload }), | |
| }); | |
| return resp.json(); | |
| } | |
| function activeVersion(answer) { | |
| const versions = Array.isArray(answer?.versions) ? answer.versions : []; | |
| if (!versions.length) return null; | |
| const byId = versions.find((v) => v.id === answer.active_version); | |
| if (byId) return byId; | |
| return [...versions].sort((a, b) => { | |
| const voteDiff = Number(b.votes || 0) - Number(a.votes || 0); | |
| if (voteDiff !== 0) return voteDiff; | |
| return String(b.created_at || "").localeCompare(String(a.created_at || "")); | |
| })[0]; | |
| } | |
| function sortedAnswers(conversation) { | |
| return [...(conversation?.answers || [])].sort((a, b) => { | |
| const av = activeVersion(a); | |
| const bv = activeVersion(b); | |
| const voteDiff = Number(bv?.votes || 0) - Number(av?.votes || 0); | |
| if (voteDiff !== 0) return voteDiff; | |
| return String(b.created_at || "").localeCompare(String(a.created_at || "")); | |
| }); | |
| } | |
| function renderStatusFilters() { | |
| const filters = [ | |
| ["unanswered", "Unanswered"], | |
| ["answered", "Answered"], | |
| ["all", "All"], | |
| ]; | |
| $("statusFilters").innerHTML = filters.map(([value, label]) => ` | |
| <button class="pill ${S.filters.status === value ? "active" : ""}" type="button" data-status="${value}">${label}</button> | |
| `).join(""); | |
| document.querySelectorAll("[data-status]").forEach((button) => { | |
| button.onclick = () => { | |
| S.filters.status = button.getAttribute("data-status"); | |
| S.filters.page = 1; | |
| loadList(); | |
| }; | |
| }); | |
| } | |
| function renderSortFilters() { | |
| const sorts = [ | |
| ["newest", "Newest"], | |
| ["oldest", "Oldest"], | |
| ["most_votes", "Most Votes"], | |
| ["most_answers", "Most Answers"], | |
| ["longest_answer", "Longest Answer"], | |
| ["shortest_answer", "Shortest Answer"], | |
| ]; | |
| $("sortFilters").innerHTML = sorts.map(([value, label]) => ` | |
| <button class="pill ${S.filters.sort === value ? "active" : ""}" type="button" data-sort="${value}">${label}</button> | |
| `).join(""); | |
| document.querySelectorAll("[data-sort]").forEach((button) => { | |
| button.onclick = () => { | |
| S.filters.sort = button.getAttribute("data-sort"); | |
| S.filters.page = 1; | |
| loadList(); | |
| }; | |
| }); | |
| } | |
| function renderList() { | |
| const list = S.list || { items: [], total: 0, page: 1, page_size: 20, has_next: false, has_prev: false }; | |
| $("listSummary").textContent = `${list.total} question${list.total === 1 ? "" : "s"} in queue`; | |
| $("searchInput").value = S.filters.q || ""; | |
| $("minWordsInput").value = S.filters.min_words > 0 ? S.filters.min_words : ""; | |
| $("maxWordsInput").value = S.filters.max_words > 0 ? S.filters.max_words : ""; | |
| const items = Array.isArray(list.items) ? list.items : []; | |
| $("queueList").innerHTML = items.map((item) => { | |
| const answered = item.has_answers; | |
| const detailHref = `/?${buildQuery({ conversation_id: item.id })}`; | |
| const preview = answered ? (item.best_answer_preview || "") : "No answer yet. Open this question to write the first answer."; | |
| return ` | |
| <a class="queue-item" href="${detailHref}" data-open-detail="${item.id}"> | |
| <div class="queue-head"> | |
| <h2>${esc(item.question || "Untitled question")}</h2> | |
| <div class="queue-tags"> | |
| <span class="tag ${answered ? "answered" : "unanswered"}">${answered ? "Answered" : "Unanswered"}</span> | |
| <span class="tag">${Number(item.answer_count || 0)} answer${Number(item.answer_count || 0) === 1 ? "" : "s"}</span> | |
| </div> | |
| </div> | |
| <div class="preview">${nl2br(preview)}</div> | |
| <div class="meta-line">Updated ${fmtDate(item.updated_at || item.created_at)}${answered && item.best_answer ? ` · best answer votes ${Number(item.best_answer.votes || 0)}` : ""}</div> | |
| </a> | |
| `; | |
| }).join(""); | |
| $("emptyState").hidden = items.length > 0; | |
| $("pagerInfo").textContent = `Page ${list.page} · showing ${items.length} of ${list.total}`; | |
| $("prevPageBtn").disabled = !list.has_prev; | |
| $("nextPageBtn").disabled = !list.has_next; | |
| document.querySelectorAll("[data-open-detail]").forEach((link) => { | |
| link.onclick = async (event) => { | |
| event.preventDefault(); | |
| await openDetail(link.getAttribute("data-open-detail")); | |
| }; | |
| }); | |
| } | |
| function renderDetail() { | |
| const conversation = S.detail; | |
| if (!conversation) return; | |
| const answers = sortedAnswers(conversation); | |
| const hasAnswers = answers.length > 0; | |
| $("detailQuestion").textContent = conversation.question || "Untitled question"; | |
| $("detailMeta").textContent = `Created ${fmtDate(conversation.created_at)} · Updated ${fmtDate(conversation.updated_at)} · ${answers.length} answer${answers.length === 1 ? "" : "s"}`; | |
| $("detailTags").innerHTML = ` | |
| <span class="tag ${hasAnswers ? "answered" : "unanswered"}">${hasAnswers ? "Answered" : "Unanswered"}</span> | |
| <span class="tag">Question ID ${esc(conversation.id)}</span> | |
| `; | |
| $("backLink").href = `/?${buildQuery()}`; | |
| $("newAnswerText").value = ""; | |
| $("answersMount").innerHTML = answers.length | |
| ? answers.map((answer, index) => renderAnswerCard(answer, index)).join("") | |
| : `<div class="panel empty">No answers yet. This question is ready for its first human answer.</div>`; | |
| bindDetailHandlers(); | |
| } | |
| function renderAnswerCard(answer, index) { | |
| const current = activeVersion(answer); | |
| const versions = [...(answer.versions || [])].sort((a, b) => { | |
| const voteDiff = Number(b.votes || 0) - Number(a.votes || 0); | |
| if (voteDiff !== 0) return voteDiff; | |
| return String(b.created_at || "").localeCompare(String(a.created_at || "")); | |
| }); | |
| const currentHtml = current ? ` | |
| <div class="answer-text">${nl2br(current.text || "")}</div> | |
| <div class="vote-row"> | |
| ${renderVoteButton(answer.id, current.id, 1, `▲ ${Number(current.votes || 0)}`, current)} | |
| ${renderVoteButton(answer.id, current.id, -1, "▼", current)} | |
| </div> | |
| ` : `<div class="muted">This answer has no active version yet.</div>`; | |
| const versionCards = versions.map((version) => ` | |
| <div class="version-card"> | |
| <div class="answer-top"> | |
| <div> | |
| <strong>${esc(version.author || "Anonymous")}</strong> | |
| <div class="muted">${fmtDate(version.created_at)}${version.id === answer.active_version ? " · active version" : ""}</div> | |
| </div> | |
| <div class="queue-tags"> | |
| <span class="tag">Votes ${Number(version.votes || 0)}</span> | |
| ${version.id === answer.active_version ? '<span class="tag answered">Active</span>' : ""} | |
| </div> | |
| </div> | |
| <div class="version-text">${nl2br(version.text || "")}</div> | |
| <div class="vote-row"> | |
| ${renderVoteButton(answer.id, version.id, 1, `▲ ${Number(version.votes || 0)}`, version)} | |
| ${renderVoteButton(answer.id, version.id, -1, "▼", version)} | |
| </div> | |
| </div> | |
| `).join(""); | |
| return ` | |
| <div class="panel" id="answer-${answer.id}"> | |
| <div class="answer-card"> | |
| <div class="answer-top"> | |
| <div> | |
| <h3 class="section-title">Answer ${index + 1}</h3> | |
| <p class="section-subtitle">Current community-selected version shown first. Historical versions stay searchable and voteable.</p> | |
| </div> | |
| <div class="queue-tags"> | |
| <span class="tag">${Number(answer.versions?.length || 0)} version${Number(answer.versions?.length || 0) === 1 ? "" : "s"}</span> | |
| </div> | |
| </div> | |
| ${currentHtml} | |
| <div class="proposal-form"> | |
| <label for="proposal-${answer.id}"><strong>Propose a new version</strong></label> | |
| <textarea id="proposal-${answer.id}" placeholder="Write a better version for this answer"></textarea> | |
| <div class="proposal-actions"> | |
| <button class="btn primary" type="button" data-propose="${answer.id}">Save version</button> | |
| </div> | |
| </div> | |
| <div class="stack">${versionCards}</div> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| function renderVoteButton(answerId, versionId, delta, label, version) { | |
| const voteMap = version?.votes_by_client || {}; | |
| const current = Number(voteMap[S.clientId] || 0); | |
| const cls = delta === 1 ? (current === 1 ? "active-up" : "") : (current === -1 ? "active-down" : ""); | |
| return `<button class="vote-btn ${cls}" type="button" data-vote="${answerId}|${versionId}|${delta}">${label}</button>`; | |
| } | |
| function showView(name) { | |
| $("listView").classList.toggle("active", name === "list"); | |
| $("detailView").classList.toggle("active", name === "detail"); | |
| $("statusBadge").textContent = name === "detail" ? "Question detail" : "Queue workspace"; | |
| } | |
| async function loadList() { | |
| const res = await callAPI("list_questions", { | |
| status: S.filters.status, | |
| q: S.filters.q, | |
| page: S.filters.page, | |
| sort: S.filters.sort, | |
| min_words: S.filters.min_words || 0, | |
| max_words: S.filters.max_words || 0, | |
| }); | |
| if (!res.ok) { | |
| showToast(res.error || "Could not load question list"); | |
| return; | |
| } | |
| S.list = res; | |
| renderStatusFilters(); | |
| renderSortFilters(); | |
| renderList(); | |
| showView("list"); | |
| pushListUrl(); | |
| } | |
| async function loadDetail(conversationId, pushUrl = false) { | |
| const res = await callAPI("get_question_detail", { conversation_id: conversationId }); | |
| if (!res.ok) { | |
| showToast(res.error || "Question not found"); | |
| return false; | |
| } | |
| S.detail = res.conversation; | |
| renderDetail(); | |
| showView("detail"); | |
| if (pushUrl) pushDetailUrl(conversationId); | |
| return true; | |
| } | |
| async function openDetail(conversationId) { | |
| await loadDetail(conversationId, true); | |
| } | |
| function bindListHandlers() { | |
| $("searchForm").addEventListener("submit", async (event) => { | |
| event.preventDefault(); | |
| S.filters.q = trimText($("searchInput").value); | |
| S.filters.page = 1; | |
| await loadList(); | |
| }); | |
| $("clearSearchBtn").onclick = async () => { | |
| $("searchInput").value = ""; | |
| S.filters.q = ""; | |
| S.filters.page = 1; | |
| await loadList(); | |
| }; | |
| $("applyWordsBtn").onclick = async () => { | |
| S.filters.min_words = Number($("minWordsInput").value) || 0; | |
| S.filters.max_words = Number($("maxWordsInput").value) || 0; | |
| S.filters.page = 1; | |
| await loadList(); | |
| }; | |
| $("clearWordsBtn").onclick = async () => { | |
| $("minWordsInput").value = ""; | |
| $("maxWordsInput").value = ""; | |
| S.filters.min_words = 0; | |
| S.filters.max_words = 0; | |
| S.filters.page = 1; | |
| await loadList(); | |
| }; | |
| $("prevPageBtn").onclick = async () => { | |
| if (!S.list?.has_prev) return; | |
| S.filters.page = Math.max(1, Number(S.filters.page || 1) - 1); | |
| await loadList(); | |
| }; | |
| $("nextPageBtn").onclick = async () => { | |
| if (!S.list?.has_next) return; | |
| S.filters.page = Number(S.filters.page || 1) + 1; | |
| await loadList(); | |
| }; | |
| } | |
| function bindDetailHandlers() { | |
| $("submitAnswerBtn").onclick = async () => { | |
| const text = trimText($("newAnswerText").value); | |
| if (!text) { | |
| showToast("Write an answer first"); | |
| return; | |
| } | |
| const res = await callAPI("add_answer", { | |
| conversation_id: S.detail.id, | |
| text, | |
| }); | |
| if (!res.ok) { | |
| showToast(res.error || "Could not save answer"); | |
| return; | |
| } | |
| S.detail = res.conversation; | |
| renderDetail(); | |
| showToast("Answer saved"); | |
| if (S.filters.status === "unanswered") { | |
| await loadList(); | |
| showView("detail"); | |
| } | |
| }; | |
| document.querySelectorAll("[data-propose]").forEach((button) => { | |
| button.onclick = async () => { | |
| const answerId = button.getAttribute("data-propose"); | |
| const box = $("proposal-" + answerId); | |
| const text = trimText(box?.value); | |
| if (!text) { | |
| showToast("Write a version first"); | |
| return; | |
| } | |
| const res = await callAPI("propose_version", { | |
| conversation_id: S.detail.id, | |
| answer_id: answerId, | |
| text, | |
| }); | |
| if (!res.ok) { | |
| showToast(res.error || "Could not save version"); | |
| return; | |
| } | |
| S.detail = res.conversation; | |
| renderDetail(); | |
| showToast("Version saved"); | |
| }; | |
| }); | |
| document.querySelectorAll("[data-vote]").forEach((button) => { | |
| button.onclick = async () => { | |
| const [answerId, versionId, delta] = button.getAttribute("data-vote").split("|"); | |
| const res = await callAPI("vote_version", { | |
| conversation_id: S.detail.id, | |
| answer_id: answerId, | |
| version_id: versionId, | |
| delta: Number(delta), | |
| }); | |
| if (!res.ok) { | |
| showToast(res.error || "Vote failed"); | |
| return; | |
| } | |
| S.detail = res.conversation; | |
| renderDetail(); | |
| showToast("Vote saved"); | |
| }; | |
| }); | |
| } | |
| function initFromServer() { | |
| const init = window.__INIT__ || {}; | |
| S.clientId = init.client_id || getClientId(); | |
| S.filters = { | |
| ...S.filters, | |
| ...(init.filters || {}), | |
| sort: (init.filters || {}).sort || "newest", | |
| min_words: Number((init.filters || {}).min_words || 0), | |
| max_words: Number((init.filters || {}).max_words || 0), | |
| }; | |
| S.list = init.list || null; | |
| S.detail = init.detail || null; | |
| renderStatusFilters(); | |
| renderSortFilters(); | |
| renderList(); | |
| bindListHandlers(); | |
| if (S.detail) { | |
| renderDetail(); | |
| showView("detail"); | |
| } else { | |
| showView("list"); | |
| } | |
| } | |
| window.addEventListener("popstate", async () => { | |
| const params = new URLSearchParams(window.location.search); | |
| S.filters.status = params.get("status") || "unanswered"; | |
| S.filters.q = params.get("q") || ""; | |
| S.filters.page = Number(params.get("page") || 1); | |
| S.filters.sort = params.get("sort") || "newest"; | |
| S.filters.min_words = Number(params.get("min_words") || 0); | |
| S.filters.max_words = Number(params.get("max_words") || 0); | |
| const conversationId = params.get("conversation_id"); | |
| await loadList(); | |
| if (conversationId) { | |
| await loadDetail(conversationId, false); | |
| } | |
| }); | |
| initFromServer(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |