trending_view.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. const { div, h2, p, section, button, form, a, textarea, br, input, table, tr, th, td, img, video: videoHyperaxe, audio: audioHyperaxe, span} = require("../server/node_modules/hyperaxe");
  2. const { template, i18n, userLink} = require('./main_views');
  3. const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
  4. const { config } = require('../server/SSB_server.js');
  5. const { renderUrl } = require('../backend/renderUrl');
  6. const opinionCategories = require('../backend/opinion_categories');
  7. const { sanitizeHtml } = require('../backend/sanitizeHtml');
  8. const userId = config.keys.id;
  9. const generateFilterButtons = (filters, currentFilter, action) =>
  10. div({ class: 'filter-buttons-container', style: 'display: flex; gap: 16px; flex-wrap: wrap;' },
  11. filters.map(mode =>
  12. form({ method: 'GET', action },
  13. input({ type: 'hidden', name: 'filter', value: mode }),
  14. button(
  15. { type: 'submit', class: currentFilter === mode ? 'filter-btn active' : 'filter-btn' },
  16. i18n[mode + 'Button'] || mode
  17. )
  18. )
  19. )
  20. );
  21. const voteLabelFor = (cat) =>
  22. i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat;
  23. const renderTrendingCard = (item, votes, categories, seenTitles) => {
  24. const c = item.value.content;
  25. const created = new Date(item.value.timestamp).toLocaleString();
  26. let contentHtml;
  27. if (c.type === 'bookmark') {
  28. const { url, description, lastVisit } = c;
  29. contentHtml = div({ class: 'trending-bookmark' },
  30. div({ class: 'card-section bookmark' },
  31. form({ method: "GET", action: `/bookmarks/${encodeURIComponent(item.key)}` },
  32. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  33. ),
  34. br(),
  35. url ? h2(p(a({ href: url, target: '_blank', class: "bookmark-url" }, url))) : "",
  36. lastVisit
  37. ? div(
  38. { class: 'card-field' },
  39. span({ class: 'card-label' }, i18n.bookmarkLastVisitLabel + ':'),
  40. span({ class: 'card-value' }, new Date(lastVisit).toLocaleString())
  41. )
  42. : "",
  43. description ? [span({ class: 'card-label' }, i18n.bookmarkDescriptionLabel + ":"), p(...renderUrl(description))] : null
  44. )
  45. );
  46. } else if (c.type === 'image') {
  47. const { url, title, description, meme } = c;
  48. contentHtml = div({ class: 'trending-image' },
  49. div({ class: 'card-section image' },
  50. form({ method: "GET", action: `/images/${encodeURIComponent(item.key)}` },
  51. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  52. ),
  53. br(),
  54. title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageTitleLabel + ':'), span({ class: 'card-value' }, title)) : "",
  55. description ? [span({ class: 'card-label' }, i18n.imageDescriptionLabel + ":"), p(...renderUrl(description))] : null,
  56. meme ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.trendingCategory + ':'), span({ class: 'card-value' }, i18n.meme)) : "",
  57. div({ class: 'card-field' }, img({ src: `/blob/${encodeURIComponent(url)}`, class: 'feed-image' }))
  58. )
  59. );
  60. } else if (c.type === 'audio') {
  61. const { url, mimeType, title, description } = c;
  62. contentHtml = div({ class: 'trending-audio' },
  63. div({ class: 'card-section audio' },
  64. form({ method: "GET", action: `/audios/${encodeURIComponent(item.key)}` },
  65. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  66. ),
  67. br(),
  68. title?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.audioTitleLabel + ':'), span({ class: 'card-value' }, title)) : "",
  69. description ? [span({ class: 'card-label' }, i18n.audioDescriptionLabel + ":"), p(...renderUrl(description))] : null,
  70. url
  71. ? div({ class: 'card-field audio-container' }, audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(url)}`, type: mimeType }))
  72. : div({ class: 'card-field' }, p(i18n.audioNoFile))
  73. )
  74. );
  75. } else if (c.type === 'video') {
  76. const { url, mimeType, title, description } = c;
  77. contentHtml = div({ class: 'trending-video' },
  78. div({ class: 'card-section video' },
  79. form({ method: "GET", action: `/videos/${encodeURIComponent(item.key)}` },
  80. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  81. ),
  82. br(),
  83. title?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.videoTitleLabel + ':'), span({ class: 'card-value' }, title)) : "",
  84. description ? [span({ class: 'card-label' }, i18n.videoDescriptionLabel + ":"), p(...renderUrl(description))] : null,
  85. br(),
  86. url
  87. ? div({ class: 'card-field video-container' }, videoHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(url)}`, type: mimeType, preload: 'metadata', width: '640', height: '360' }))
  88. : div({ class: 'card-field' }, p(i18n.videoNoFile))
  89. )
  90. );
  91. } else if (c.type === 'torrent') {
  92. const { url, title, description } = c;
  93. contentHtml = div({ class: 'trending-torrent' },
  94. div({ class: 'card-section torrent' },
  95. form({ method: "GET", action: `/torrents/${encodeURIComponent(item.key)}` },
  96. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  97. ),
  98. br(),
  99. title?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || 'Title') + ':'), span({ class: 'card-value' }, title)) : "",
  100. description ? [span({ class: 'card-label' }, (i18n.torrentDescriptionLabel || 'Description') + ":"), p(...renderUrl(description))] : null,
  101. url && url.startsWith("&")
  102. ? div({ class: 'card-field' }, a({ href: `/blob/${encodeURIComponent(url)}`, class: 'filter-btn' }, i18n.torrentDownload || 'Download'))
  103. : div({ class: 'card-field' }, p(i18n.torrentNoFile || 'No file'))
  104. )
  105. );
  106. } else if (c.type === 'document') {
  107. const { url, title, description } = c;
  108. const t = title?.trim();
  109. if (t && seenTitles.has(t)) return null;
  110. if (t) seenTitles.add(t);
  111. contentHtml = div({ class: 'trending-document' },
  112. div({ class: 'card-section document' },
  113. form({ method: "GET", action: `/documents/${encodeURIComponent(item.key)}` },
  114. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  115. ),
  116. br(),
  117. t ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.documentTitleLabel + ':'), span({ class: 'card-value' }, t)) : "",
  118. description ? [span({ class: 'card-label' }, i18n.documentDescriptionLabel + ":"), p(...renderUrl(description))] : null,
  119. div({ id: `pdf-container-${item.key}`, class: 'pdf-viewer-container', 'data-pdf-url': `/blob/${encodeURIComponent(url)}` })
  120. )
  121. );
  122. } else if (c.type === 'feed') {
  123. const { text, refeeds } = c;
  124. contentHtml = div({ class: 'trending-feed' },
  125. div({ class: 'card-section feed' },
  126. form({ method: "GET", action: `/feed/${encodeURIComponent(item.key)}` },
  127. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  128. ),
  129. br,
  130. div({ class: 'feed-text', innerHTML: sanitizeHtml(renderTextWithStyles(text)) }),
  131. refeeds
  132. ? h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ': '), span({ class: 'card-value' }, refeeds))
  133. : ""
  134. )
  135. );
  136. } else if (c.type === 'votes') {
  137. const { question, deadline, votes: vmap, totalVotes } = c;
  138. const votesList = vmap && typeof vmap === 'object'
  139. ? Object.entries(vmap).map(([o, cnt]) => ({ option: o, count: cnt }))
  140. : [];
  141. contentHtml = div({ class: 'trending-votes' },
  142. div({ class: 'card-section votes' },
  143. form({ method: "GET", action: `/votes/${encodeURIComponent(item.key)}` },
  144. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  145. ),
  146. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.voteQuestionLabel + ':'), span({ class: 'card-value' }, question)),
  147. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.voteDeadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : '')),
  148. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.voteTotalVotes + ':'), span({ class: 'card-value' }, totalVotes)),
  149. table(
  150. tr(...votesList.map(v => th(i18n[v.option] || v.option))),
  151. tr(...votesList.map(v => td(v.count)))
  152. )
  153. )
  154. );
  155. } else if (c.type === 'transfer') {
  156. const { from, to, concept, amount, deadline, status, confirmedBy = [] } = c;
  157. contentHtml = div({ class: 'trending-transfer' },
  158. div({ class: 'card-section transfer' },
  159. form({ method: "GET", action: `/transfers/${encodeURIComponent(item.key)}` },
  160. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  161. ),
  162. br(),
  163. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.concept + ':'), span({ class: 'card-value' }, concept)),
  164. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.deadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : '')),
  165. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.status + ':'), span({ class: 'card-value' }, status)),
  166. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.amount + ':'), span({ class: 'card-value' }, amount)),
  167. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.from + ':'), span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(from)}`, target: '_blank' }, from))),
  168. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.to + ':'), span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(to)}`, target: '_blank' }, to))),
  169. h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersConfirmations + ': '), span({ class: 'card-value' }, `${confirmedBy.length}/2`))
  170. )
  171. );
  172. } else {
  173. contentHtml = div({ class: 'styled-text' },
  174. div({ class: 'card-section styled-text-content' },
  175. div(
  176. { class: 'card-field' },
  177. span({ class: 'card-value', innerHTML: sanitizeHtml(renderTextWithStyles(c.text || c.description || c.title || '[no content]')) })
  178. )
  179. )
  180. );
  181. }
  182. return div(
  183. { class: 'trending-card', style: 'background-color:#2c2f33;border-radius:8px;padding:16px;border:1px solid #444;' },
  184. contentHtml,
  185. p(
  186. { class: 'card-footer' },
  187. span({ class: 'date-link' }, `${created} ${i18n.performed} `),
  188. userLink(item.value.author)
  189. ),
  190. (() => {
  191. const ops = c.opinions || {};
  192. const entries = Object.entries(ops).filter(([, v]) => v > 0);
  193. const dominantPart = (() => {
  194. if (!entries.length) return null;
  195. const maxVal = Math.max(...entries.map(([, v]) => v));
  196. const dominant = entries.filter(([, v]) => v === maxVal).map(([k]) => voteLabelFor(k));
  197. return [
  198. span({ style: 'margin:0 8px;opacity:0.5;' }, '|'),
  199. span({ style: 'font-weight:700;' }, `${i18n.moreVoted || 'More Voted'}: ${dominant.join(' + ')}`)
  200. ];
  201. })();
  202. return h2(
  203. `${i18n.trendingTotalOpinions || i18n.trendingTotalCount}: `,
  204. span({ style: 'font-weight:700;' }, String(votes)),
  205. ...(dominantPart || [])
  206. );
  207. })(),
  208. div(
  209. { class: 'voting-buttons' },
  210. categories.map(cat =>
  211. form({ method: 'POST', action: `/trending/${encodeURIComponent(item.key)}/${cat}` },
  212. button(
  213. { class: 'vote-btn' },
  214. `${voteLabelFor(cat)} [${c.opinions?.[cat] || 0}]`
  215. )
  216. )
  217. )
  218. )
  219. );
  220. };
  221. exports.trendingView = (items, filter, categories = opinionCategories) => {
  222. const seenDocumentTitles = new Set();
  223. const title = i18n.trendingTitle;
  224. const baseFilters = ['RECENT', 'ALL', 'MINE', 'TOP'];
  225. const contentFilters = [
  226. ['votes', 'feed', 'transfer'],
  227. ['bookmark', 'image', 'video', 'audio', 'document', 'torrent']
  228. ];
  229. let filteredItems = items.filter(item => {
  230. const c = item.value?.content || item.content;
  231. return c && typeof c === 'object' && c.type !== 'tombstone';
  232. });
  233. if (filter === 'MINE') {
  234. filteredItems = filteredItems.filter(item => item.value.author === userId);
  235. } else if (filter === 'RECENT') {
  236. const now = Date.now();
  237. filteredItems = filteredItems.filter(item => now - item.value.timestamp < 24 * 60 * 60 * 1000);
  238. } else if (filter === 'TOP') {
  239. filteredItems.sort((a, b) => {
  240. const aVotes = (a.value.content.opinions_inhabitants || []).length;
  241. const bVotes = (b.value.content.opinions_inhabitants || []).length;
  242. return bVotes !== aVotes ? bVotes - aVotes : b.value.timestamp - a.value.timestamp;
  243. });
  244. } else if (contentFilters.flat().includes(filter)) {
  245. filteredItems = filteredItems.filter(item => item.value.content.type === filter);
  246. } else if (filter !== 'ALL') {
  247. filteredItems = filteredItems.filter(item => (item.value.content.opinions_inhabitants || []).length > 0);
  248. }
  249. if (filter !== 'TOP') {
  250. filteredItems.sort((a, b) => b.value.timestamp - a.value.timestamp);
  251. }
  252. const header = div({ class: 'tags-header' }, h2(title), p(i18n.exploreTrending));
  253. const cards = filteredItems
  254. .map(item =>
  255. renderTrendingCard(
  256. item,
  257. Object.values(item.value.content.opinions || {}).reduce((s, n) => s + (n || 0), 0),
  258. categories,
  259. seenDocumentTitles
  260. )
  261. )
  262. .filter(Boolean);
  263. const hasDocument = filteredItems.some(item => item.value.content.type === 'document');
  264. let html = template(
  265. title,
  266. section(
  267. header,
  268. div(
  269. { class: 'mode-buttons', style: 'display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:16px;margin-bottom:24px;' },
  270. generateFilterButtons(baseFilters, filter, '/trending'),
  271. ...contentFilters.map(row =>
  272. div({ style: 'display:flex;flex-direction:column;gap:8px;' },
  273. row.map(mode =>
  274. form({ method: 'GET', action: '/trending' },
  275. input({ type: 'hidden', name: 'filter', value: mode }),
  276. button(
  277. { type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' },
  278. i18n[mode + 'Button'] || mode
  279. )
  280. )
  281. )
  282. )
  283. )
  284. ),
  285. section(
  286. cards.length
  287. ? div({ class: 'trending-container', style: 'display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:20px;' }, ...cards)
  288. : div({ class: 'no-results' }, p(i18n.trendingNoContentMessage))
  289. )
  290. )
  291. );
  292. if (hasDocument) {
  293. html += `
  294. <script type="module" src="/js/pdf.min.mjs"></script>
  295. <script src="/js/pdf-viewer.js"></script>
  296. `;
  297. }
  298. return html;
  299. };