返回 DeepSeek-Reasonix
community.js
根目录 / site / src / scripts / community.js
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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
336 lines JAVASCRIPT