opinions_view.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  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, userLink} = 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.bookmarkLastVisitLabel + ':'),
  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 'torrent':
  116. return div({ class: 'opinion-torrent' },
  117. div({ class: 'card-section' },
  118. form({ method: "GET", action: `/torrents/${encodeURIComponent(key)}` },
  119. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)),
  120. br(),
  121. content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || 'Title') + ':'), span({ class: 'card-value' }, content.title)) : ""
  122. )
  123. );
  124. case 'document': {
  125. const t = content.title?.trim();
  126. if (t && seenDocumentTitles.has(t)) return null;
  127. if (t) seenDocumentTitles.add(t);
  128. return div({ class: 'opinion-document' },
  129. div({ class: 'card-section document' },
  130. form({ method: "GET", action: `/documents/${encodeURIComponent(key)}` },
  131. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  132. ),
  133. br(),
  134. t ? div({ class: 'card-field' },
  135. span({ class: 'card-label' }, i18n.documentTitleLabel + ':'),
  136. span({ class: 'card-value' }, t)
  137. ) : "",
  138. content.description
  139. ? [
  140. span({ class: 'card-label' }, i18n.documentDescriptionLabel + ":"),
  141. p(...renderUrl(content.description))
  142. ]
  143. : null,
  144. div({ class: 'card-field' },
  145. div({ class: 'pdf-viewer-container', 'data-pdf-url': `/blob/${encodeURIComponent(content.url)}` })
  146. )
  147. )
  148. );
  149. }
  150. case 'feed':
  151. return div({ class: 'opinion-feed' },
  152. div({ class: 'card-section feed' },
  153. form({ method: "GET", action: `/feed/${encodeURIComponent(key)}` },
  154. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  155. ),
  156. br,
  157. div({ class: 'feed-text', innerHTML: sanitizeHtml(renderTextWithStyles(content.text)) }),
  158. content.refeeds
  159. ? h2({ class: 'card-field' }, span({ class: 'card-label' }, `${i18n.tribeFeedRefeeds}: `), span({ class: 'card-value' }, content.refeeds))
  160. : ""
  161. )
  162. );
  163. case 'votes': {
  164. const votesList = content.votes && typeof content.votes === 'object'
  165. ? Object.entries(content.votes).map(([option, count]) => ({ option, count }))
  166. : [];
  167. return div({ class: 'opinion-votes' },
  168. div({ class: 'card-section votes' },
  169. form({ method: "GET", action: `/votes/${encodeURIComponent(key)}` },
  170. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  171. ),
  172. div({ class: 'card-field' },
  173. span({ class: 'card-label' }, i18n.voteQuestionLabel + ':'),
  174. span({ class: 'card-value' }, content.question)
  175. ),
  176. div({ class: 'card-field' },
  177. span({ class: 'card-label' }, i18n.voteDeadline + ':'),
  178. span({ class: 'card-value' }, content.deadline ? new Date(content.deadline).toLocaleString() : '')
  179. ),
  180. div({ class: 'card-field' },
  181. span({ class: 'card-label' }, i18n.voteTotalVotes + ':'),
  182. span({ class: 'card-value' }, content.totalVotes)
  183. ),
  184. table(
  185. tr(...votesList.map(({ option }) => th(i18n[option] || option))),
  186. tr(...votesList.map(({ count }) => td(count)))
  187. )
  188. )
  189. );
  190. }
  191. case 'transfer':
  192. return div({ class: 'opinion-transfer' },
  193. div({ class: 'card-section transfer' },
  194. form({ method: "GET", action: `/transfers/${encodeURIComponent(key)}` },
  195. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  196. ),
  197. br(),
  198. div({ class: 'card-field' },
  199. span({ class: 'card-label' }, i18n.concept + ':'),
  200. span({ class: 'card-value' }, content.concept)
  201. ),
  202. div({ class: 'card-field' },
  203. span({ class: 'card-label' }, i18n.deadline + ':'),
  204. span({ class: 'card-value' }, content.deadline ? new Date(content.deadline).toLocaleString() : '')
  205. ),
  206. div({ class: 'card-field' },
  207. span({ class: 'card-label' }, i18n.status + ':'),
  208. span({ class: 'card-value' }, content.status)
  209. ),
  210. div({ class: 'card-field' },
  211. span({ class: 'card-label' }, i18n.amount + ':'),
  212. span({ class: 'card-value' }, content.amount)
  213. ),
  214. div({ class: 'card-field' },
  215. span({ class: 'card-label' }, i18n.from + ':'),
  216. span({ class: 'card-value' }, userLink(content.from))
  217. ),
  218. div({ class: 'card-field' },
  219. span({ class: 'card-label' }, i18n.to + ':'),
  220. span({ class: 'card-value' }, userLink(content.to))
  221. ),
  222. h2({ class: 'card-field' },
  223. span({ class: 'card-label' }, `${i18n.transfersConfirmations}: `),
  224. span({ class: 'card-value' }, `${content.confirmedBy.length}/2`)
  225. )
  226. )
  227. );
  228. default:
  229. return div({ class: 'styled-text' },
  230. div({ class: 'card-section styled-text-content' },
  231. div({ class: 'card-field' },
  232. span({ class: 'card-value', innerHTML: sanitizeHtml(content.text || content.description || content.title || '[no content]') })
  233. )
  234. )
  235. );
  236. }
  237. };
  238. exports.opinionsView = (items, filter) => {
  239. seenDocumentTitles.clear();
  240. items = items
  241. .filter(item => {
  242. const c = item.value?.content || item.content;
  243. return c && typeof c === 'object' && c.type !== 'tombstone';
  244. })
  245. .sort((a, b) => {
  246. if (filter === 'TOP') {
  247. const aVotes = (a.value.content.opinions_inhabitants || []).length;
  248. const bVotes = (b.value.content.opinions_inhabitants || []).length;
  249. return bVotes !== aVotes ? bVotes - aVotes : b.value.timestamp - a.value.timestamp;
  250. }
  251. return b.value.timestamp - a.value.timestamp;
  252. });
  253. const title = i18n.opinionsTitle;
  254. const baseFilters = ['RECENT', 'ALL', 'MINE', 'TOP'];
  255. const cards = items
  256. .map(item => {
  257. const c = item.value.content;
  258. const key = item.key;
  259. const contentHtml = renderContentHtml(c, key);
  260. if (!contentHtml) return null;
  261. const voteEntries = Object.entries(c.opinions || {});
  262. const total = voteEntries.reduce((sum, [, v]) => sum + v, 0);
  263. const voted = c.opinions_inhabitants?.includes(config.keys.id);
  264. const created = new Date(item.value.timestamp).toLocaleString();
  265. const allCats = opinionCategories;
  266. return div(
  267. contentHtml,
  268. p({ class: 'card-footer' },
  269. span({ class: 'date-link' }, `${created} ${i18n.performed} `),
  270. userLink(item.value.author)
  271. ),
  272. (() => {
  273. const entries = voteEntries.filter(([, v]) => v > 0);
  274. const dominantPart = (() => {
  275. if (!entries.length) return null;
  276. const maxVal = Math.max(...entries.map(([, v]) => v));
  277. const dominant = entries.filter(([, v]) => v === maxVal).map(([k]) => i18n['vote' + k.charAt(0).toUpperCase() + k.slice(1)] || k);
  278. return [
  279. span({ style: 'margin:0 8px;opacity:0.5;' }, '|'),
  280. span({ style: 'font-weight:700;' }, `${i18n.moreVoted || 'More Voted'}: ${dominant.join(' + ')}`)
  281. ];
  282. })();
  283. return h2(
  284. `${i18n.totalOpinions || i18n.opinionsTotalCount}: `,
  285. span({ style: 'font-weight:700;' }, String(total)),
  286. ...(dominantPart || [])
  287. );
  288. })(),
  289. div({ class: 'voting-buttons' },
  290. allCats.map(cat => {
  291. const label = `${i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${c.opinions?.[cat] || 0}]`;
  292. if (voted) {
  293. return button({ class: 'vote-btn', type: 'button' }, label);
  294. }
  295. return form({ method: 'POST', action: `/opinions/${encodeURIComponent(key)}/${cat}` },
  296. button({ class: 'vote-btn' }, label)
  297. );
  298. })
  299. )
  300. );
  301. })
  302. .filter(Boolean);
  303. const hasDocuments = items.some(item => item.value.content?.type === 'document');
  304. const header = div({ class: 'tags-header' },
  305. h2(title),
  306. p(i18n.shareYourOpinions)
  307. );
  308. const html = template(
  309. title,
  310. section(
  311. header,
  312. div({ class: 'mode-buttons' },
  313. div({ class: 'column' },
  314. baseFilters.map(mode =>
  315. form({ method: 'GET', action: '/opinions' },
  316. input({ type: 'hidden', name: 'filter', value: mode }),
  317. button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
  318. )
  319. )
  320. ),
  321. div({ class: 'column' },
  322. opinionCategories.positive.slice(0, 5).map(mode =>
  323. form({ method: 'GET', action: '/opinions' },
  324. input({ type: 'hidden', name: 'filter', value: mode }),
  325. button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
  326. )
  327. )
  328. ),
  329. div({ class: 'column' },
  330. opinionCategories.positive.slice(5, 10).map(mode =>
  331. form({ method: 'GET', action: '/opinions' },
  332. input({ type: 'hidden', name: 'filter', value: mode }),
  333. button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
  334. )
  335. )
  336. ),
  337. div({ class: 'column' },
  338. opinionCategories.positive.slice(10, 15).map(mode =>
  339. form({ method: 'GET', action: '/opinions' },
  340. input({ type: 'hidden', name: 'filter', value: mode }),
  341. button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
  342. )
  343. )
  344. )
  345. ),
  346. div({ class: 'mode-buttons' },
  347. div({ class: 'column' },
  348. opinionCategories.constructive.slice(0, 5).map(mode =>
  349. form({ method: 'GET', action: '/opinions' },
  350. input({ type: 'hidden', name: 'filter', value: mode }),
  351. button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
  352. )
  353. )
  354. ),
  355. div({ class: 'column' },
  356. opinionCategories.constructive.slice(5, 11).map(mode =>
  357. form({ method: 'GET', action: '/opinions' },
  358. input({ type: 'hidden', name: 'filter', value: mode }),
  359. button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
  360. )
  361. )
  362. ),
  363. div({ class: 'column' },
  364. opinionCategories.moderation.slice(0, 5).map(mode =>
  365. form({ method: 'GET', action: '/opinions' },
  366. input({ type: 'hidden', name: 'filter', value: mode }),
  367. button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
  368. )
  369. )
  370. ),
  371. div({ class: 'column' },
  372. opinionCategories.moderation.slice(5, 10).map(mode =>
  373. form({ method: 'GET', action: '/opinions' },
  374. input({ type: 'hidden', name: 'filter', value: mode }),
  375. button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
  376. )
  377. )
  378. )
  379. ),
  380. section(
  381. cards.length
  382. ? div({ class: 'opinions-container' }, ...cards)
  383. : div({ class: 'no-results' }, p(i18n.noOpinionsFound))
  384. )
  385. )
  386. );
  387. return `${html}${hasDocuments
  388. ? `<script type="module" src="/js/pdf.min.mjs"></script>
  389. <script src="/js/pdf-viewer.js"></script>`
  390. : ''}`;
  391. };