forum_view.js 12 KB

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