opinions_view.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. const { div, h2, p, section, button, form, a, img, video: videoHyperaxe, audio: audioHyperaxe, input, table, tr, th, td, br, span } = require("../server/node_modules/hyperaxe");
  2. const { template, i18n } = require('./main_views');
  3. const { config } = require('../server/SSB_server.js');
  4. const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
  5. const { renderUrl } = require('../backend/renderUrl');
  6. const opinionCategories = require('../backend/opinion_categories');
  7. const { sanitizeHtml } = require('../backend/sanitizeHtml');
  8. const seenDocumentTitles = new Set();
  9. const renderContentHtml = (content, key) => {
  10. switch (content.type) {
  11. case 'bookmark':
  12. return div({ class: 'opinion-bookmark' },
  13. div({ class: 'card-section bookmark' },
  14. form({ method: "GET", action: `/bookmarks/${encodeURIComponent(key)}` },
  15. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  16. ),
  17. br(),
  18. h2(content.url ? div({ class: 'card-field' },
  19. span({ class: 'card-label' }, p(a({ href: content.url, target: '_blank', class: "bookmark-url" }, content.url)))
  20. ) : ""),
  21. content.lastVisit ? div({ class: 'card-field' },
  22. span({ class: 'card-label' }, i18n.bookmarkLastVisit + ':'),
  23. span({ class: 'card-value' }, new Date(content.lastVisit).toLocaleString())
  24. ) : "",
  25. content.description
  26. ? [
  27. span({ class: 'card-label' }, i18n.bookmarkDescriptionLabel + ":"),
  28. p(...renderUrl(content.description))
  29. ]
  30. : null
  31. )
  32. );
  33. case 'image':
  34. return div({ class: 'opinion-image' },
  35. div({ class: 'card-section image' },
  36. form({ method: "GET", action: `/images/${encodeURIComponent(key)}` },
  37. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  38. ),
  39. br(),
  40. content.title ? div({ class: 'card-field' },
  41. span({ class: 'card-label' }, i18n.imageTitleLabel + ':'),
  42. span({ class: 'card-value' }, content.title)
  43. ) : "",
  44. content.description
  45. ? [
  46. span({ class: 'card-label' }, i18n.imageDescriptionLabel + ":"),
  47. p(...renderUrl(content.description))
  48. ]
  49. : null,
  50. content.meme ? div({ class: 'card-field' },
  51. span({ class: 'card-label' }, i18n.trendingCategory + ':'),
  52. span({ class: 'card-value' }, i18n.meme)
  53. ) : "",
  54. br(),
  55. div({ class: 'card-field' },
  56. img({ src: `/blob/${encodeURIComponent(content.url)}`, class: 'feed-image' })
  57. )
  58. )
  59. );
  60. case 'video':
  61. return div({ class: 'opinion-video' },
  62. div({ class: 'card-section video' },
  63. form({ method: "GET", action: `/videos/${encodeURIComponent(key)}` },
  64. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  65. ),
  66. br(),
  67. content.title ? div({ class: 'card-field' },
  68. span({ class: 'card-label' }, i18n.videoTitleLabel + ':'),
  69. span({ class: 'card-value' }, content.title)
  70. ) : "",
  71. content.description
  72. ? [
  73. span({ class: 'card-label' }, i18n.videoDescriptionLabel + ":"),
  74. p(...renderUrl(content.description))
  75. ]
  76. : null,
  77. div({ class: 'card-field' },
  78. videoHyperaxe({
  79. controls: true,
  80. src: `/blob/${encodeURIComponent(content.url)}`,
  81. type: content.mimeType || 'video/mp4',
  82. width: '640',
  83. height: '360'
  84. })
  85. )
  86. )
  87. );
  88. case 'audio':
  89. return div({ class: 'opinion-audio' },
  90. div({ class: 'card-section audio' },
  91. form({ method: "GET", action: `/audios/${encodeURIComponent(key)}` },
  92. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  93. ),
  94. br(),
  95. content.title ? div({ class: 'card-field' },
  96. span({ class: 'card-label' }, i18n.audioTitleLabel + ':'),
  97. span({ class: 'card-value' }, content.title)
  98. ) : "",
  99. content.description
  100. ? [
  101. span({ class: 'card-label' }, i18n.audioDescriptionLabel + ":"),
  102. p(...renderUrl(content.description))
  103. ]
  104. : null,
  105. div({ class: 'card-field' },
  106. audioHyperaxe({
  107. controls: true,
  108. src: `/blob/${encodeURIComponent(content.url)}`,
  109. type: content.mimeType,
  110. preload: 'metadata'
  111. })
  112. )
  113. )
  114. );
  115. case 'document': {
  116. const t = content.title?.trim();
  117. if (t && seenDocumentTitles.has(t)) return null;
  118. if (t) seenDocumentTitles.add(t);
  119. return div({ class: 'opinion-document' },
  120. div({ class: 'card-section document' },
  121. form({ method: "GET", action: `/documents/${encodeURIComponent(key)}` },
  122. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  123. ),
  124. br(),
  125. t ? div({ class: 'card-field' },
  126. span({ class: 'card-label' }, i18n.documentTitleLabel + ':'),
  127. span({ class: 'card-value' }, t)
  128. ) : "",
  129. content.description
  130. ? [
  131. span({ class: 'card-label' }, i18n.documentDescriptionLabel + ":"),
  132. p(...renderUrl(content.description))
  133. ]
  134. : null,
  135. div({ class: 'card-field' },
  136. div({ class: 'pdf-viewer-container', 'data-pdf-url': `/blob/${encodeURIComponent(content.url)}` })
  137. )
  138. )
  139. );
  140. }
  141. case 'feed':
  142. return div({ class: 'opinion-feed' },
  143. div({ class: 'card-section feed' },
  144. div({ class: 'feed-text', innerHTML: sanitizeHtml(renderTextWithStyles(content.text)) }),
  145. h2({ class: 'card-field' },
  146. span({ class: 'card-label' }, `${i18n.tribeFeedRefeeds}: `),
  147. span({ class: 'card-value' }, content.refeeds)
  148. )
  149. )
  150. );
  151. case 'votes': {
  152. const votesList = content.votes && typeof content.votes === 'object'
  153. ? Object.entries(content.votes).map(([option, count]) => ({ option, count }))
  154. : [];
  155. return div({ class: 'opinion-votes' },
  156. div({ class: 'card-section votes' },
  157. form({ method: "GET", action: `/votes/${encodeURIComponent(key)}` },
  158. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  159. ),
  160. div({ class: 'card-field' },
  161. span({ class: 'card-label' }, i18n.voteQuestionLabel + ':'),
  162. span({ class: 'card-value' }, content.question)
  163. ),
  164. div({ class: 'card-field' },
  165. span({ class: 'card-label' }, i18n.voteDeadline + ':'),
  166. span({ class: 'card-value' }, content.deadline ? new Date(content.deadline).toLocaleString() : '')
  167. ),
  168. div({ class: 'card-field' },
  169. span({ class: 'card-label' }, i18n.voteTotalVotes + ':'),
  170. span({ class: 'card-value' }, content.totalVotes)
  171. ),
  172. table(
  173. tr(...votesList.map(({ option }) => th(i18n[option] || option))),
  174. tr(...votesList.map(({ count }) => td(count)))
  175. )
  176. )
  177. );
  178. }
  179. case 'transfer':
  180. return div({ class: 'opinion-transfer' },
  181. div({ class: 'card-section transfer' },
  182. form({ method: "GET", action: `/transfers/${encodeURIComponent(key)}` },
  183. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  184. ),
  185. br(),
  186. div({ class: 'card-field' },
  187. span({ class: 'card-label' }, i18n.concept + ':'),
  188. span({ class: 'card-value' }, content.concept)
  189. ),
  190. div({ class: 'card-field' },
  191. span({ class: 'card-label' }, i18n.deadline + ':'),
  192. span({ class: 'card-value' }, content.deadline ? new Date(content.deadline).toLocaleString() : '')
  193. ),
  194. div({ class: 'card-field' },
  195. span({ class: 'card-label' }, i18n.status + ':'),
  196. span({ class: 'card-value' }, content.status)
  197. ),
  198. div({ class: 'card-field' },
  199. span({ class: 'card-label' }, i18n.amount + ':'),
  200. span({ class: 'card-value' }, content.amount)
  201. ),
  202. div({ class: 'card-field' },
  203. span({ class: 'card-label' }, i18n.from + ':'),
  204. span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(content.from)}`, target: "_blank" }, content.from))
  205. ),
  206. div({ class: 'card-field' },
  207. span({ class: 'card-label' }, i18n.to + ':'),
  208. span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(content.to)}`, target: "_blank" }, content.to))
  209. ),
  210. h2({ class: 'card-field' },
  211. span({ class: 'card-label' }, `${i18n.transfersConfirmations}: `),
  212. span({ class: 'card-value' }, `${content.confirmedBy.length}/2`)
  213. )
  214. )
  215. );
  216. default:
  217. return div({ class: 'styled-text' },
  218. div({ class: 'card-section styled-text-content' },
  219. div({ class: 'card-field' },
  220. span({ class: 'card-label' }, i18n.textContentLabel + ':'),
  221. span({ class: 'card-value', innerHTML: sanitizeHtml(content.text || content.description || content.title || '[no content]') })
  222. )
  223. )
  224. );
  225. }
  226. };
  227. exports.opinionsView = (items, filter) => {
  228. seenDocumentTitles.clear();
  229. items = items
  230. .filter(item => {
  231. const c = item.value?.content || item.content;
  232. return c && typeof c === 'object' && c.type !== 'tombstone';
  233. })
  234. .sort((a, b) => {
  235. if (filter === 'TOP') {
  236. const aVotes = (a.value.content.opinions_inhabitants || []).length;
  237. const bVotes = (b.value.content.opinions_inhabitants || []).length;
  238. return bVotes !== aVotes ? bVotes - aVotes : b.value.timestamp - a.value.timestamp;
  239. }
  240. return b.value.timestamp - a.value.timestamp;
  241. });
  242. const title = i18n.opinionsTitle;
  243. const baseFilters = ['RECENT', 'ALL', 'MINE', 'TOP'];
  244. const cards = items
  245. .map(item => {
  246. const c = item.value.content;
  247. const key = item.key;
  248. const contentHtml = renderContentHtml(c, key);
  249. if (!contentHtml) return null;
  250. const voteEntries = Object.entries(c.opinions || {});
  251. const total = voteEntries.reduce((sum, [, v]) => sum + v, 0);
  252. const voted = c.opinions_inhabitants?.includes(config.keys.id);
  253. const created = new Date(item.value.timestamp).toLocaleString();
  254. const allCats = opinionCategories;
  255. return div(
  256. contentHtml,
  257. p({ class: 'card-footer' },
  258. span({ class: 'date-link' }, `${created} ${i18n.performed} `),
  259. a({ href: `/author/${encodeURIComponent(item.value.author)}`, class: 'user-link' }, item.value.author)
  260. ),
  261. (() => {
  262. const entries = voteEntries.filter(([, v]) => v > 0);
  263. const dominantPart = (() => {
  264. if (!entries.length) return null;
  265. const maxVal = Math.max(...entries.map(([, v]) => v));
  266. const dominant = entries.filter(([, v]) => v === maxVal).map(([k]) => i18n['vote' + k.charAt(0).toUpperCase() + k.slice(1)] || k);
  267. return [
  268. span({ style: 'margin:0 8px;opacity:0.5;' }, '|'),
  269. span({ style: 'font-weight:700;' }, `${i18n.moreVoted || 'More Voted'}: ${dominant.join(' + ')}`)
  270. ];
  271. })();
  272. return h2(
  273. `${i18n.totalOpinions || i18n.opinionsTotalCount}: `,
  274. span({ style: 'font-weight:700;' }, String(total)),
  275. ...(dominantPart || [])
  276. );
  277. })(),
  278. div({ class: 'voting-buttons' },
  279. allCats.map(cat => {
  280. const label = `${i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${c.opinions?.[cat] || 0}]`;
  281. if (voted) {
  282. return button({ class: 'vote-btn', type: 'button' }, label);
  283. }
  284. return form({ method: 'POST', action: `/opinions/${encodeURIComponent(key)}/${cat}` },
  285. button({ class: 'vote-btn' }, label)
  286. );
  287. })
  288. )
  289. );
  290. })
  291. .filter(Boolean);
  292. const hasDocuments = items.some(item => item.value.content?.type === 'document');
  293. const header = div({ class: 'tags-header' },
  294. h2(title),
  295. p(i18n.shareYourOpinions)
  296. );
  297. const html = template(
  298. title,
  299. section(
  300. header,
  301. div({ class: 'mode-buttons' },
  302. div({ class: 'column' },
  303. baseFilters.map(mode =>
  304. form({ method: 'GET', action: '/opinions' },
  305. input({ type: 'hidden', name: 'filter', value: mode }),
  306. button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
  307. )
  308. )
  309. ),
  310. div({ class: 'column' },
  311. opinionCategories.positive.slice(0, 5).map(mode =>
  312. form({ method: 'GET', action: '/opinions' },
  313. input({ type: 'hidden', name: 'filter', value: mode }),
  314. button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
  315. )
  316. )
  317. ),
  318. div({ class: 'column' },
  319. opinionCategories.positive.slice(5, 10).map(mode =>
  320. form({ method: 'GET', action: '/opinions' },
  321. input({ type: 'hidden', name: 'filter', value: mode }),
  322. button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
  323. )
  324. )
  325. ),
  326. div({ class: 'column' },
  327. opinionCategories.positive.slice(10, 15).map(mode =>
  328. form({ method: 'GET', action: '/opinions' },
  329. input({ type: 'hidden', name: 'filter', value: mode }),
  330. button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
  331. )
  332. )
  333. )
  334. ),
  335. div({ class: 'mode-buttons' },
  336. div({ class: 'column' },
  337. opinionCategories.constructive.slice(0, 5).map(mode =>
  338. form({ method: 'GET', action: '/opinions' },
  339. input({ type: 'hidden', name: 'filter', value: mode }),
  340. button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
  341. )
  342. )
  343. ),
  344. div({ class: 'column' },
  345. opinionCategories.constructive.slice(5, 11).map(mode =>
  346. form({ method: 'GET', action: '/opinions' },
  347. input({ type: 'hidden', name: 'filter', value: mode }),
  348. button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
  349. )
  350. )
  351. ),
  352. div({ class: 'column' },
  353. opinionCategories.moderation.slice(0, 5).map(mode =>
  354. form({ method: 'GET', action: '/opinions' },
  355. input({ type: 'hidden', name: 'filter', value: mode }),
  356. button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
  357. )
  358. )
  359. ),
  360. div({ class: 'column' },
  361. opinionCategories.moderation.slice(5, 10).map(mode =>
  362. form({ method: 'GET', action: '/opinions' },
  363. input({ type: 'hidden', name: 'filter', value: mode }),
  364. button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
  365. )
  366. )
  367. )
  368. ),
  369. section(
  370. cards.length
  371. ? div({ class: 'opinions-container' }, ...cards)
  372. : div({ class: 'no-results' }, p(i18n.noOpinionsFound))
  373. )
  374. )
  375. );
  376. return `${html}${hasDocuments
  377. ? `<script type="module" src="/js/pdf.min.mjs"></script>
  378. <script src="/js/pdf-viewer.js"></script>`
  379. : ''}`;
  380. };