| 1 | // Reasonix Community client. Renders the forum from the forum.reasonix.io API and |
| 2 | // gates posting on the shared id.reasonix.io session (cookie sent cross-subdomain). |
| 3 | // Bilingual like the rest of the site: static labels via .l-en/.l-zh spans that the |
| 4 | // shared `reasonix-lang` choice toggles; plain-text strings pick the current lang. |
| 5 | import { initTheme } from "./theme.js"; |
| 6 | |
| 7 | const FORUM = (import.meta.env.PUBLIC_FORUM_API || "https://forum.reasonix.io").replace(/\/$/, ""); |
| 8 | const ACCOUNTS = (import.meta.env.PUBLIC_ACCOUNTS_API || "https://id.reasonix.io").replace(/\/$/, ""); |
| 9 | |
| 10 | const el = (id) => document.getElementById(id); |
| 11 | const qp = new URLSearchParams(location.search); |
| 12 | |
| 13 | let lang = "en"; |
| 14 | const L = (en, zh) => `<span class="l-en">${en}</span><span class="l-zh">${zh}</span>`; |
| 15 | const t = (en, zh) => (lang === "zh" ? zh : en); |
| 16 | |
| 17 | function applyLangText() { |
| 18 | const attr = lang === "zh" ? "zh" : "en"; |
| 19 | document.querySelectorAll("[data-ph-en]").forEach((n) => { n.placeholder = n.getAttribute(`data-ph-${attr}`); }); |
| 20 | document.querySelectorAll("[data-l-en]").forEach((n) => { n.textContent = n.getAttribute(`data-l-${attr}`); }); |
| 21 | } |
| 22 | function setLang(l) { |
| 23 | lang = l; |
| 24 | document.body.dataset.lang = l; |
| 25 | document.documentElement.lang = l === "zh" ? "zh-CN" : "en"; |
| 26 | document.querySelectorAll(".lang-switch button").forEach((b) => b.classList.toggle("active", b.dataset.lang === l)); |
| 27 | try { localStorage.setItem("reasonix-lang", l); } catch {} |
| 28 | applyLangText(); |
| 29 | } |
| 30 | function initLang() { |
| 31 | let saved = ""; |
| 32 | try { saved = localStorage.getItem("reasonix-lang") || ""; } catch {} |
| 33 | setLang(saved || ((navigator.language || "").toLowerCase().startsWith("zh") ? "zh" : "en")); |
| 34 | document.querySelectorAll(".lang-switch button").forEach((b) => b.addEventListener("click", () => setLang(b.dataset.lang))); |
| 35 | } |
| 36 | |
| 37 | async function api(base, path, opts = {}) { |
| 38 | const res = await fetch(base + path, { |
| 39 | method: opts.method || "GET", |
| 40 | credentials: "include", |
| 41 | headers: opts.body ? { "content-type": "application/json" } : undefined, |
| 42 | body: opts.body ? JSON.stringify(opts.body) : undefined, |
| 43 | }); |
| 44 | let data = null; |
| 45 | try { data = await res.json(); } catch {} |
| 46 | if (!res.ok) { |
| 47 | const err = new Error(data?.error?.message || "Something went wrong."); |
| 48 | err.code = data?.error?.code; |
| 49 | err.status = res.status; |
| 50 | throw err; |
| 51 | } |
| 52 | return data; |
| 53 | } |
| 54 | const forum = (p, o) => api(FORUM, p, o); |
| 55 | |
| 56 | // Anti-spam / gate errors are localized by code; unknown codes fall back to the |
| 57 | // server message. |
| 58 | const ERR = { |
| 59 | email_unverified: ["Confirm your email address before posting.", "发帖前请先验证你的邮箱。"], |
| 60 | links_restricted: ["New members can't post links yet — participate a little to unlock.", "新成员暂时不能发链接——参与一段时间后解锁。"], |
| 61 | insufficient_trust: ["You don't have access to post in this category yet.", "你还没有在此分区发帖的权限。"], |
| 62 | silenced: ["Your account is temporarily restricted from posting.", "你的账号暂时被限制发帖。"], |
| 63 | rate_limited: ["You're posting too fast — take a short break.", "发帖太快了——稍微歇一会儿。"], |
| 64 | daily_limit: ["You've hit today's posting limit for your trust level.", "你已达到当前信任等级的每日发帖上限。"], |
| 65 | closed: ["This topic is closed to new replies.", "该话题已关闭,不能再回复。"], |
| 66 | self_flag: ["You can't report your own post.", "不能举报自己的帖子。"], |
| 67 | unauthorized: ["Sign in to continue.", "请先登录。"], |
| 68 | }; |
| 69 | const errText = (err) => (ERR[err.code] ? t(ERR[err.code][0], ERR[err.code][1]) : err.message); |
| 70 | |
| 71 | const CATS = { |
| 72 | announcements: ["Announcements", "公告"], |
| 73 | help: ["Help & Support", "帮助与支持"], |
| 74 | skills: ["Skills & Plugins", "技能与插件"], |
| 75 | show: ["Show & Tell", "作品展示"], |
| 76 | feedback: ["Feedback & Ideas", "反馈与建议"], |
| 77 | }; |
| 78 | const CATDESC = { |
| 79 | announcements: ["Releases, roadmap, and community news.", "版本发布、路线图与社区动态。"], |
| 80 | help: ["Stuck on setup, config, or cache behavior? Ask here.", "安装、配置或缓存问题?在这里提问。"], |
| 81 | skills: ["Share, request, and review community skills and MCP servers.", "分享、求助、评审社区技能与 MCP 服务。"], |
| 82 | show: ["Built something with Reasonix? Show the community.", "用 Reasonix 做了东西?来给社区看看。"], |
| 83 | feedback: ["Feature requests and product feedback.", "功能建议与产品反馈。"], |
| 84 | }; |
| 85 | const catName = (slug, apiName) => (CATS[slug] ? L(CATS[slug][0], CATS[slug][1]) : esc(apiName)); |
| 86 | const catText = (slug, apiName) => (CATS[slug] ? t(CATS[slug][0], CATS[slug][1]) : apiName); |
| 87 | const ROLES = { admin: ["admin", "管理员"], moderator: ["moderator", "版主"] }; |
| 88 | |
| 89 | function esc(s) { |
| 90 | return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); |
| 91 | } |
| 92 | const AV_GRAD = [ |
| 93 | "var(--accent),var(--violet)", |
| 94 | "var(--ok),oklch(0.62 0.15 200)", |
| 95 | "var(--warm),var(--rose)", |
| 96 | "var(--violet),var(--rose)", |
| 97 | "oklch(0.6 0.15 200),var(--accent)", |
| 98 | ]; |
| 99 | function avatar(handle, size = "") { |
| 100 | const h = handle || "?"; |
| 101 | let n = 0; |
| 102 | for (const ch of h) n = (n + ch.charCodeAt(0)) % AV_GRAD.length; |
| 103 | const initials = h.replace(/[^a-zA-Z0-9]/g, "").slice(0, 2).toUpperCase() || "?"; |
| 104 | return `<span class="av ${size}" style="background:linear-gradient(140deg,${AV_GRAD[n]})">${esc(initials)}</span>`; |
| 105 | } |
| 106 | function ago(iso) { |
| 107 | if (!iso) return ""; |
| 108 | const s = Math.max(1, (Date.now() - new Date(iso).getTime()) / 1000); |
| 109 | const u = [[86400, "d", "天"], [3600, "h", "小时"], [60, "m", "分钟"]]; |
| 110 | for (const [sec, en, zh] of u) if (s >= sec) { const v = Math.floor(s / sec); return L(`${v}${en} ago`, `${v}${zh}前`); } |
| 111 | return L("just now", "刚刚"); |
| 112 | } |
| 113 | function md(body) { |
| 114 | const parts = esc(body).split(/```/); |
| 115 | let out = ""; |
| 116 | parts.forEach((chunk, i) => { |
| 117 | if (i % 2 === 1) { out += `<pre>${chunk.replace(/^\n/, "")}</pre>`; return; } |
| 118 | const paras = chunk.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean); |
| 119 | out += paras.map((p) => `<p>${p.replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\n/g, "<br>")}</p>`).join(""); |
| 120 | }); |
| 121 | return out || "<p></p>"; |
| 122 | } |
| 123 | |
| 124 | const CAT_ICONS = { announcements: "📣", help: "🛟", skills: "🧩", show: "✨", feedback: "💡" }; |
| 125 | const loginUrl = () => `/login/?next=${encodeURIComponent(location.pathname + location.search)}`; |
| 126 | |
| 127 | let account = null; |
| 128 | async function loadAccount() { |
| 129 | try { account = (await api(ACCOUNTS, "/me")).user; } catch { account = null; } |
| 130 | const slot = el("nav-account"); |
| 131 | if (slot) { |
| 132 | slot.innerHTML = account |
| 133 | ? `<a href="/account/" title="${esc(account.email)}">${avatar(account.handle)}</a>` |
| 134 | : `<a class="btn btn-ghost sm" href="${loginUrl()}">${L("Sign in", "登录")}</a>`; |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | /* ── home ─────────────────────────────────────────── */ |
| 139 | function renderHome() { |
| 140 | const catBox = el("cat-list"); |
| 141 | const topicBox = el("topic-list"); |
| 142 | const category = qp.get("category") || ""; |
| 143 | |
| 144 | forum("/categories").then((d) => { |
| 145 | const cats = d.categories; |
| 146 | if (el("s-cats")) el("s-cats").textContent = cats.length; |
| 147 | if (el("s-topics")) el("s-topics").textContent = cats.reduce((a, c) => a + (c.topicCount || 0), 0); |
| 148 | catBox.innerHTML = cats.map((c) => ` |
| 149 | <a class="cat" href="/community/?category=${esc(c.slug)}"> |
| 150 | <span class="ico">${CAT_ICONS[c.slug] || "💬"}</span> |
| 151 | <div><h3>${catName(c.slug, c.name)}</h3><p>${CATDESC[c.slug] ? L(CATDESC[c.slug][0], CATDESC[c.slug][1]) : esc(c.description)}</p> |
| 152 | <div class="meta">${c.topicCount || 0} ${L("topics", "话题")}${c.lastActivity ? " · " + ago(c.lastActivity) : ""}</div></div> |
| 153 | </a>`).join(""); |
| 154 | }).catch(() => { catBox.innerHTML = `<div class="empty">${L("Couldn't load categories.", "无法加载分区。")}</div>`; }); |
| 155 | |
| 156 | const loadTopics = (sort) => { |
| 157 | topicBox.innerHTML = `<div class="skeleton"><div class="bar"></div><div class="bar short"></div></div>`.repeat(3); |
| 158 | const q = new URLSearchParams(); |
| 159 | if (category) q.set("category", category); |
| 160 | if (sort) q.set("sort", sort); |
| 161 | forum("/topics?" + q).then((d) => { |
| 162 | if (!d.topics.length) { topicBox.innerHTML = `<div class="empty">${L("No topics yet —", "还没有话题 ——")} <a class="tag" href="/community/new/">${L("start the first one", "来发第一个")}</a>.</div>`; return; } |
| 163 | topicBox.innerHTML = d.topics.map((tp) => ` |
| 164 | <div class="topic"> |
| 165 | ${avatar(tp.author)} |
| 166 | <div class="main"> |
| 167 | <div class="title"> |
| 168 | ${tp.pinned ? `<span class="badge pinned">📌 ${L("Pinned", "置顶")}</span>` : ""} |
| 169 | ${tp.status === "solved" ? `<span class="badge solved">✓ ${L("Solved", "已解决")}</span>` : ""} |
| 170 | <a href="/community/topic/?id=${tp.id}">${esc(tp.title)}</a> |
| 171 | </div> |
| 172 | <div class="sub"><span class="cat-tag">${catName(tp.category, tp.categoryName)}</span> <span class="who">${esc(tp.author)}</span> · ${ago(tp.createdAt)}</div> |
| 173 | </div> |
| 174 | <div class="stat"><div class="n">${tp.replyCount}</div><div class="l">${L("replies", "回复")}</div></div> |
| 175 | <div class="last">${ago(tp.lastPostAt)}</div> |
| 176 | </div>`).join(""); |
| 177 | }).catch(() => { topicBox.innerHTML = `<div class="empty">${L("Couldn't load discussions.", "无法加载讨论。")}</div>`; }); |
| 178 | }; |
| 179 | loadTopics("latest"); |
| 180 | |
| 181 | el("sort-tabs")?.addEventListener("click", (e) => { |
| 182 | const b = e.target.closest("button[data-sort]"); |
| 183 | if (!b) return; |
| 184 | el("sort-tabs").querySelectorAll("button").forEach((x) => x.classList.toggle("on", x === b)); |
| 185 | loadTopics(b.dataset.sort); |
| 186 | }); |
| 187 | } |
| 188 | |
| 189 | /* ── thread ───────────────────────────────────────── */ |
| 190 | let firstPostId = 0; |
| 191 | function postHtml(p, topic) { |
| 192 | const answer = topic.acceptedPostId && topic.acceptedPostId === p.id; |
| 193 | const cls = answer ? "post answer" : p.id === firstPostId ? "post op" : "post"; |
| 194 | const roleWord = ROLES[p.role] ? L(ROLES[p.role][0], ROLES[p.role][1]) : ""; |
| 195 | const role = roleWord ? `<span class="badge role ${esc(p.role)}">${roleWord}</span>` : ""; |
| 196 | return `<article class="${cls}"> |
| 197 | ${avatar(p.handle || p.author, "lg")} |
| 198 | <div> |
| 199 | ${answer ? `<div class="answer-flag">✓ ${L("Accepted answer", "已采纳回答")}</div>` : ""} |
| 200 | <div class="who"><span class="name">${esc(p.handle || p.author)}</span>${role}<span class="when">${ago(p.createdAt)}</span></div> |
| 201 | <div class="body">${md(p.body)}</div> |
| 202 | <div class="actions"> |
| 203 | <button class="react${p.liked ? " on" : ""}" data-like="${p.id}" data-liked="${p.liked ? "1" : "0"}" aria-pressed="${p.liked ? "true" : "false"}">👍 <span>${p.likeCount || 0}</span></button> |
| 204 | <button class="link-act" data-flag="${p.id}">${L("Report", "举报")}</button> |
| 205 | </div> |
| 206 | </div> |
| 207 | </article>`; |
| 208 | } |
| 209 | |
| 210 | async function renderThread() { |
| 211 | const id = Number(qp.get("id")); |
| 212 | if (!id) { location.href = "/community/"; return; } |
| 213 | let data; |
| 214 | try { data = await forum(`/topics/${id}`); } |
| 215 | catch { el("posts").innerHTML = `<div class="empty">${L("That discussion doesn't exist or was removed.", "该讨论不存在或已被移除。")}</div>`; return; } |
| 216 | const { topic, posts } = data; |
| 217 | firstPostId = posts[0]?.id || 0; |
| 218 | |
| 219 | document.title = `${topic.title} — ${t("Reasonix Community", "Reasonix 社区")}`; |
| 220 | el("crumb-cat").textContent = catText(topic.category, topic.category); |
| 221 | el("crumb-title").textContent = topic.title; |
| 222 | el("t-title").textContent = topic.title; |
| 223 | el("t-meta").innerHTML = |
| 224 | `${topic.status === "solved" ? `<span class="badge solved">✓ ${L("Solved", "已解决")}</span>` : ""} |
| 225 | <span>${topic.replyCount} ${L("replies", "回复")} · ${topic.viewCount} ${L("views", "浏览")} · ${L("started", "发起于")} ${ago(topic.createdAt)}</span>`; |
| 226 | el("posts").innerHTML = posts.map((p) => postHtml(p, topic)).join(""); |
| 227 | |
| 228 | const seen = new Set(); |
| 229 | el("parti").innerHTML = posts.filter((p) => !seen.has(p.author) && seen.add(p.author)).slice(0, 8).map((p) => avatar(p.handle || p.author)).join(""); |
| 230 | |
| 231 | el("posts").addEventListener("click", async (e) => { |
| 232 | const like = e.target.closest("[data-like]"); |
| 233 | if (like && account) { |
| 234 | const wasLiked = like.dataset.liked === "1"; |
| 235 | like.disabled = true; |
| 236 | try { |
| 237 | const out = await forum(`/posts/${like.dataset.like}/likes`, { method: wasLiked ? "DELETE" : "POST" }); |
| 238 | like.dataset.liked = out.liked ? "1" : "0"; |
| 239 | like.setAttribute("aria-pressed", out.liked ? "true" : "false"); |
| 240 | like.classList.toggle("on", out.liked); |
| 241 | like.querySelector("span").textContent = out.likeCount; |
| 242 | } catch (err) { alert(errText(err)); } |
| 243 | finally { like.disabled = false; } |
| 244 | return; |
| 245 | } else if (like) { |
| 246 | location.href = loginUrl(); |
| 247 | return; |
| 248 | } |
| 249 | const flag = e.target.closest("[data-flag]"); |
| 250 | if (flag && account) { |
| 251 | if (!confirm(t("Report this post as spam or abuse?", "举报该帖为垃圾/滥用内容?"))) return; |
| 252 | try { await forum(`/posts/${flag.dataset.flag}/flags`, { method: "POST", body: { reason: "spam" } }); flag.innerHTML = L("Reported ✓", "已举报 ✓"); flag.disabled = true; } |
| 253 | catch (err) { alert(errText(err)); } |
| 254 | } else if (flag) { location.href = loginUrl(); } |
| 255 | }); |
| 256 | |
| 257 | const zone = el("reply-zone"); |
| 258 | if (!account) { |
| 259 | zone.innerHTML = `<div class="composer"><div class="gate"><p>${L("Sign in with your Reasonix account to reply.", "用你的 Reasonix 账号登录后回复。")}</p><a class="btn btn-primary" href="${loginUrl()}">${L("Sign in", "登录")}</a></div></div>`; |
| 260 | return; |
| 261 | } |
| 262 | zone.innerHTML = ` |
| 263 | <div class="msg error" id="reply-msg" hidden></div> |
| 264 | <div class="composer"> |
| 265 | <textarea id="reply-body" data-ph-en="Write a reply… Markdown and \`\`\` code blocks supported." data-ph-zh="写下回复… 支持 Markdown 和 \`\`\` 代码块。"></textarea> |
| 266 | <div class="foot"><span class="hint">${L("Signed in as", "已登录")} <b>${esc(account.handle)}</b></span><button class="btn btn-primary" id="reply-submit">${L("Post reply", "发布回复")}</button></div> |
| 267 | </div>`; |
| 268 | applyLangText(); |
| 269 | el("reply-submit").addEventListener("click", async () => { |
| 270 | const body = el("reply-body").value.trim(); |
| 271 | const msg = el("reply-msg"); |
| 272 | msg.hidden = true; |
| 273 | if (body.length < 2) return; |
| 274 | el("reply-submit").disabled = true; |
| 275 | try { |
| 276 | await forum(`/topics/${id}/posts`, { method: "POST", body: { body } }); |
| 277 | location.reload(); |
| 278 | } catch (err) { |
| 279 | msg.textContent = errText(err); msg.hidden = false; |
| 280 | el("reply-submit").disabled = false; |
| 281 | } |
| 282 | }); |
| 283 | } |
| 284 | |
| 285 | /* ── new topic ────────────────────────────────────── */ |
| 286 | async function renderNew() { |
| 287 | if (!account) { |
| 288 | el("new-gate").hidden = false; |
| 289 | el("gate-login").href = loginUrl(); |
| 290 | return; |
| 291 | } |
| 292 | el("new-form").hidden = false; |
| 293 | const sel = el("f-category"); |
| 294 | try { |
| 295 | const { categories } = await forum("/categories"); |
| 296 | for (const c of categories) { |
| 297 | const o = document.createElement("option"); |
| 298 | o.value = c.id; |
| 299 | o.setAttribute("data-l-en", CATS[c.slug] ? CATS[c.slug][0] : c.name); |
| 300 | o.setAttribute("data-l-zh", CATS[c.slug] ? CATS[c.slug][1] : c.name); |
| 301 | o.textContent = catText(c.slug, c.name); |
| 302 | sel.appendChild(o); |
| 303 | } |
| 304 | const pre = qp.get("category"); |
| 305 | if (pre) { const m = categories.find((c) => c.slug === pre); if (m) sel.value = m.id; } |
| 306 | } catch {} |
| 307 | applyLangText(); |
| 308 | |
| 309 | el("f-submit").addEventListener("click", async () => { |
| 310 | const msg = el("new-msg"); |
| 311 | msg.hidden = true; |
| 312 | const categoryId = Number(sel.value); |
| 313 | const title = el("f-title").value.trim(); |
| 314 | const body = el("f-body").value.trim(); |
| 315 | if (!categoryId) { msg.textContent = t("Choose a category.", "请选择一个分区。"); msg.hidden = false; return; } |
| 316 | if (title.length < 6 || body.length < 10) { msg.textContent = t("Add a title (6+ chars) and a bit more detail (10+ chars).", "标题至少 6 个字符,正文至少 10 个字符。"); msg.hidden = false; return; } |
| 317 | el("f-submit").disabled = true; |
| 318 | try { |
| 319 | const { topic } = await forum("/topics", { method: "POST", body: { categoryId, title, body } }); |
| 320 | location.href = `/community/topic/?id=${topic.id}`; |
| 321 | } catch (err) { |
| 322 | msg.textContent = errText(err); msg.hidden = false; |
| 323 | el("f-submit").disabled = false; |
| 324 | } |
| 325 | }); |
| 326 | } |
| 327 | |
| 328 | (async function () { |
| 329 | initTheme(); |
| 330 | initLang(); |
| 331 | await loadAccount(); |
| 332 | if (el("topic-list")) renderHome(); |
| 333 | else if (el("posts")) renderThread(); |
| 334 | else if (el("new-form")) renderNew(); |
| 335 | })(); |
| 336 |