forum_view.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. const {
  2. div, a, span, form, button, section, p,
  3. input, label, br, select, option, h2, textarea
  4. } = require("../server/node_modules/hyperaxe");
  5. const moment = require("../server/node_modules/moment");
  6. const { template, i18n } = require('./main_views');
  7. const { config } = require('../server/SSB_server.js');
  8. const { renderUrl } = require('../backend/renderUrl');
  9. const userId = config.keys.id;
  10. const BASE_FILTERS = ['hot','all','mine','recent','top'];
  11. const CAT_BLOCK1 = ['GENERAL','OASIS','L.A.R.P.','POLITICS','TECH'];
  12. const CAT_BLOCK2 = ['SCIENCE','MUSIC','ART','GAMING','BOOKS','FILMS'];
  13. const CAT_BLOCK3 = ['PHILOSOPHY','SOCIETY','PRIVACY','CYBERWARFARE','SURVIVALISM'];
  14. const ALL_CATS = [...CAT_BLOCK1, ...CAT_BLOCK2, ...CAT_BLOCK3];
  15. const catKey = (c) => 'forumCat' + String(c || '').replace(/\./g,'').replace(/[\s-]/g,'').toUpperCase();
  16. const catLabel = (c) => i18n[catKey(c)] || c;
  17. const Z = 1.96;
  18. function wilsonScore(pos, neg) {
  19. const n = (pos||0)+(neg||0);
  20. if (n === 0) return 0;
  21. const phat = pos / n, z2 = Z * Z;
  22. return (phat + z2/(2*n) - Z*Math.sqrt((phat*(1-phat)+z2/(4*n))/n)) / (1+z2/n);
  23. }
  24. function getFilteredForums(filter, forums) {
  25. const now = Date.now();
  26. if (filter === 'mine') return forums.filter(f => f.author === userId);
  27. if (filter === 'recent') return forums.filter(f => new Date(f.createdAt).getTime() >= now - 86400000);
  28. if (filter === 'top') return forums.slice().sort((a,b) => b.score - a.score);
  29. if (filter === 'hot') return forums
  30. .filter(f => new Date(f.createdAt).getTime() >= now - 86400000)
  31. .sort((a,b) => b.score - a.score);
  32. if (ALL_CATS.includes(filter))
  33. return forums.filter(f => f.category === filter);
  34. return forums;
  35. }
  36. const generateFilterButtons = (filters, currentFilter, action, i18nMap = {}) =>
  37. div({ class: 'filter-group' },
  38. filters.map(mode =>
  39. form({ method: 'GET', action },
  40. input({ type: 'hidden', name: 'filter', value: mode }),
  41. button({ type: 'submit', class: currentFilter === mode ? 'filter-btn active' : 'filter-btn' },
  42. String(i18nMap[mode] || mode).toUpperCase()
  43. )
  44. )
  45. )
  46. );
  47. const renderCreateForumButton = () =>
  48. div({ class: 'forum-create-col' },
  49. form({ method: 'GET', action: '/forum' },
  50. button({ type: 'submit', name: 'filter', value: 'create', class: 'create-button' },
  51. i18n.forumCreateButton
  52. )
  53. )
  54. );
  55. const renderVotes = (target, score, forumId) =>
  56. div({ class: 'forum-score-box' },
  57. form({ method: 'POST', action: `/forum/${encodeURIComponent(forumId)}/vote`, class: 'forum-score-form' },
  58. button({ name: 'value', value: 1, class: 'score-btn' }, '▲'),
  59. div({ class: 'score-total' }, String(score || 0)),
  60. button({ name: 'value', value: -1, class: 'score-btn' }, '▼'),
  61. input({ type: 'hidden', name: 'target', value: target }),
  62. input({ type: 'hidden', name: 'forumId', value: forumId })
  63. )
  64. );
  65. const renderForumForm = () =>
  66. div({ class: 'forum-form' },
  67. form({ action: '/forum/create', method: 'POST' },
  68. label(i18n.forumCategoryLabel), br(),
  69. select({ name: 'category', required: true },
  70. ALL_CATS.map(cat => option({ value: cat }, catLabel(cat)))
  71. ), br(), br(),
  72. label(i18n.forumTitleLabel), br(),
  73. input({
  74. type: 'text',
  75. name: 'title',
  76. required: true,
  77. placeholder: i18n.forumTitlePlaceholder
  78. }), br(), br(),
  79. label(i18n.forumMessageLabel), br(),
  80. textarea({
  81. name: 'text',
  82. required: true,
  83. rows: 4,
  84. placeholder: i18n.forumMessagePlaceholder
  85. }), br(), br(),
  86. button({ type: 'submit' }, i18n.forumCreateButton)
  87. )
  88. );
  89. const renderThread = (nodes, level = 0, forumId) => {
  90. if (!Array.isArray(nodes)) return [];
  91. return [...nodes]
  92. .sort((a, b) =>
  93. wilsonScore(b.positiveVotes, b.negativeVotes)
  94. - wilsonScore(a.positiveVotes, a.negativeVotes)
  95. )
  96. .flatMap((m, i) => {
  97. const isTopLevelWinner = level === 0 && i === 0;
  98. const classList = [
  99. 'forum-comment',
  100. `level-${level}`,
  101. isTopLevelWinner ? 'highlighted-reply' : ''
  102. ].filter(Boolean).join(' ');
  103. const commentBox = div(
  104. { class: classList },
  105. div({ class: 'comment-header' },
  106. span({ class: 'date-link' },
  107. `${moment(m.timestamp).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
  108. a({
  109. href: `/author/${encodeURIComponent(m.author)}`,
  110. class: 'user-link',
  111. style: 'margin-left:12px;'
  112. }, m.author),
  113. div({ class: 'comment-votes' },
  114. span({ class: 'votes-count' }, `▲: ${m.positiveVotes || 0}`),
  115. span({ class: 'votes-count', style: 'margin-left:12px;' },
  116. `▼: ${m.negativeVotes || 0}`)
  117. )
  118. ),
  119. div({ class: 'comment-body-row' },
  120. div({ class: 'comment-vote-col' },
  121. renderVotes(m.key, m.score, forumId)
  122. ),
  123. div({ class: 'comment-text-col' },
  124. div(
  125. ...(m.text || '').split('\n')
  126. .map(l => l.trim())
  127. .filter(l => l)
  128. .map(l => p(...renderUrl(l)))
  129. )
  130. )
  131. ),
  132. div({ class: 'new-reply' },
  133. form({
  134. method: 'POST',
  135. action: `/forum/${encodeURIComponent(forumId)}/message`,
  136. class: 'comment-form'
  137. },
  138. input({ type: 'hidden', name: 'parentId', value: m.key }),
  139. textarea({
  140. name: 'message',
  141. rows: 2,
  142. required: true,
  143. placeholder: i18n.forumMessagePlaceholder,
  144. class: 'comment-textarea'
  145. }),
  146. button({ type: 'submit', class: 'forum-send-btn' }, 'Reply')
  147. )
  148. )
  149. );
  150. return [ commentBox, ...renderThread(m.children || [], level + 1, forumId) ];
  151. });
  152. };
  153. const renderForumList = (forums, currentFilter) =>
  154. div({ class: 'forum-list' },
  155. Array.isArray(forums) && forums.length
  156. ? forums.map(f =>
  157. div({ class: 'forum-card' },
  158. div({ class: 'forum-score-col' },
  159. renderVotes(f.key, f.score, f.key)
  160. ),
  161. div({ class: 'forum-main-col' },
  162. div({ class: 'forum-header-row' },
  163. a({
  164. class: 'forum-category',
  165. href: `/forum?filter=${encodeURIComponent(f.category)}`
  166. }, `[${catLabel(f.category)}]`),
  167. a({
  168. class: 'forum-title',
  169. href: `/forum/${encodeURIComponent(f.key)}`
  170. }, f.title)
  171. ),
  172. div({ class: 'forum-body' }, ...renderUrl(f.text || '')),
  173. div({ class: 'forum-meta' },
  174. span({ class: 'forum-positive-votes' },
  175. `▲: ${f.positiveVotes || 0}`),
  176. span({ class: 'forum-negative-votes', style: 'margin-left:12px;' },
  177. `▼: ${f.negativeVotes || 0}`),
  178. span({ class: 'forum-participants' },
  179. `${i18n.forumParticipants.toUpperCase()}: ${f.participants?.length || 1}`),
  180. span({ class: 'forum-messages' },
  181. `${i18n.forumMessages.toUpperCase()}: ${f.messagesCount - 1}`),
  182. form({ method: 'GET', action: `/forum/${encodeURIComponent(f.key)}`, style: 'visit-forum-form' },
  183. button({ type: 'submit', class: 'filter-btn' }, i18n.forumVisitButton)
  184. )
  185. ),
  186. div({ class: 'forum-footer' },
  187. span({ class: 'date-link' },
  188. `${moment(f.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
  189. a({
  190. href: `/author/${encodeURIComponent(f.author)}`,
  191. class: 'user-link',
  192. style: 'margin-left:12px;'
  193. }, f.author)
  194. ),
  195. currentFilter === 'mine' && f.author === userId
  196. ? div({ class: 'forum-owner-actions' },
  197. form({
  198. method: 'POST',
  199. action: `/forum/delete/${f.key}`,
  200. class: 'forum-delete-form'
  201. },
  202. button({ type: 'submit', class: 'delete-btn' },
  203. i18n.forumDeleteButton)
  204. )
  205. )
  206. : null
  207. )
  208. )
  209. )
  210. : p(i18n.noForums)
  211. );
  212. exports.forumView = async (forums, currentFilter) => {
  213. const CAT_I18N_MAP_UP = ALL_CATS.reduce((m,c)=>{ m[c]=(catLabel(c)||c).toUpperCase(); return m; },{});
  214. return template(i18n.forumTitle,
  215. section(
  216. div({ class: 'tags-header' },
  217. h2(currentFilter === 'create'
  218. ? i18n.forumCreateSectionTitle
  219. : i18n.forumTitle),
  220. p(i18n.forumDescription)
  221. ),
  222. div({ class: 'mode-buttons-cols' },
  223. generateFilterButtons(BASE_FILTERS, currentFilter, '/forum', {
  224. hot: i18n.forumFilterHot,
  225. all: i18n.forumFilterAll,
  226. mine: i18n.forumFilterMine,
  227. recent: i18n.forumFilterRecent,
  228. top: i18n.forumFilterTop
  229. }),
  230. generateFilterButtons(CAT_BLOCK1, currentFilter, '/forum', CAT_I18N_MAP_UP),
  231. generateFilterButtons(CAT_BLOCK2, currentFilter, '/forum', CAT_I18N_MAP_UP),
  232. generateFilterButtons(CAT_BLOCK3, currentFilter, '/forum', CAT_I18N_MAP_UP),
  233. renderCreateForumButton()
  234. ),
  235. currentFilter === 'create'
  236. ? renderForumForm()
  237. : renderForumList(
  238. getFilteredForums(currentFilter || 'hot', forums),
  239. currentFilter
  240. )
  241. )
  242. );
  243. };
  244. exports.singleForumView = async (forum, messagesData, currentFilter) => {
  245. const CAT_I18N_MAP_UP = ALL_CATS.reduce((m,c)=>{ m[c]=(catLabel(c)||c).toUpperCase(); return m; },{});
  246. return template(forum.title,
  247. section(
  248. div({ class: 'tags-header' },
  249. h2(i18n.forumTitle),
  250. p(i18n.forumDescription)
  251. ),
  252. div({ class: 'mode-buttons' },
  253. generateFilterButtons(BASE_FILTERS, currentFilter, '/forum', {
  254. hot: i18n.forumFilterHot,
  255. all: i18n.forumFilterAll,
  256. mine: i18n.forumFilterMine,
  257. recent: i18n.forumFilterRecent,
  258. top: i18n.forumFilterTop
  259. }),
  260. generateFilterButtons(CAT_BLOCK1, currentFilter, '/forum', CAT_I18N_MAP_UP),
  261. generateFilterButtons(CAT_BLOCK2, currentFilter, '/forum', CAT_I18N_MAP_UP),
  262. generateFilterButtons(CAT_BLOCK3, currentFilter, '/forum', CAT_I18N_MAP_UP),
  263. renderCreateForumButton()
  264. )
  265. ),
  266. div({ class: 'forum-thread-container' },
  267. div({
  268. class: 'forum-card forum-thread-header',
  269. style: 'display:flex;align-items:flex-start;'
  270. },
  271. div({
  272. class: 'root-vote-col',
  273. style: 'width:60px;text-align:center;'
  274. }, renderVotes(
  275. forum.key,
  276. messagesData.totalScore,
  277. forum.key
  278. )),
  279. div({
  280. class: 'forum-main-col',
  281. style: 'flex:1;padding-left:10px;'
  282. },
  283. div({ class: 'forum-header-row' },
  284. a({
  285. class: 'forum-category',
  286. href: `/forum?filter=${encodeURIComponent(forum.category)}`
  287. }, `[${catLabel(forum.category)}]`),
  288. a({
  289. class: 'forum-title',
  290. href: `/forum/${encodeURIComponent(forum.key)}`
  291. }, forum.title)
  292. ),
  293. div({ class: 'forum-footer' },
  294. span({ class: 'date-link' },
  295. `${moment(forum.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
  296. a({
  297. href: `/author/${encodeURIComponent(forum.author)}`,
  298. class: 'user-link',
  299. style: 'margin-left:12px;'
  300. }, forum.author)
  301. ),
  302. div(
  303. ...(forum.text || '').split('\n')
  304. .map(l => l.trim())
  305. .filter(l => l)
  306. .map(l => p(...renderUrl(l)))
  307. ),
  308. div({ class: 'forum-meta' },
  309. span({ class: 'votes-count' },
  310. `▲: ${messagesData.positiveVotes}`),
  311. span({
  312. class: 'votes-count',
  313. style: 'margin-left:12px;'
  314. }, `▼: ${messagesData.negativeVotes}`),
  315. span({ class: 'forum-participants' },
  316. `${i18n.forumParticipants.toUpperCase()}: ${forum.participants?.length || 1}`),
  317. span({ class: 'forum-messages' },
  318. `${i18n.forumMessages.toUpperCase()}: ${messagesData.total}`)
  319. )
  320. )
  321. ),
  322. div({
  323. class: 'new-message-wrapper',
  324. style: 'margin-top:12px;'
  325. },
  326. form({
  327. method: 'POST',
  328. action: `/forum/${encodeURIComponent(forum.key)}/message`,
  329. class: 'new-message-form'
  330. },
  331. textarea({
  332. name: 'message',
  333. rows: 4,
  334. required: true,
  335. placeholder: i18n.forumMessagePlaceholder,
  336. style: 'width:100%;'
  337. }), br(),
  338. button({
  339. type: 'submit',
  340. class: 'forum-send-btn',
  341. style: 'margin-top:4px;'
  342. }, i18n.forumSendButton)
  343. )
  344. ),
  345. ...renderThread(messagesData.messages, 0, forum.key)
  346. )
  347. );
  348. };