trending_view.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  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 } = 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 userId = config.keys.id;
  7. const generateFilterButtons = (filters, currentFilter, action) =>
  8. div({ class: 'filter-buttons-container', style: 'display: flex; gap: 16px; flex-wrap: wrap;' },
  9. filters.map(mode =>
  10. form({ method: 'GET', action },
  11. input({ type: 'hidden', name: 'filter', value: mode }),
  12. button({ type: 'submit', class: currentFilter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
  13. )
  14. )
  15. );
  16. const renderTrendingCard = (item, votes, categories, seenTitles) => {
  17. const c = item.value.content;
  18. const created = new Date(item.value.timestamp).toLocaleString();
  19. let contentHtml;
  20. if (c.type === 'bookmark') {
  21. const { url, description, lastVisit } = c;
  22. contentHtml = div({ class: 'trending-bookmark' },
  23. div({ class: 'card-section bookmark' },
  24. form({ method: "GET", action: `/bookmarks/${encodeURIComponent(item.key)}` },
  25. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  26. ),
  27. br,
  28. url ? h2(p(a({ href: url, target: '_blank', class: "bookmark-url" }, url))) : "",
  29. lastVisit ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkLastVisit + ':'), span({ class: 'card-value' }, new Date(lastVisit).toLocaleString())) : "",
  30. description ? [span({ class: 'card-label' }, i18n.bookmarkDescriptionLabel + ":"), p(...renderUrl(description))] : null
  31. )
  32. );
  33. } else if (c.type === 'image') {
  34. const { url, title, description, meme } = c;
  35. contentHtml = div({ class: 'trending-image' },
  36. div({ class: 'card-section image' },
  37. form({ method: "GET", action: `/images/${encodeURIComponent(item.key)}` },
  38. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  39. ),
  40. br,
  41. title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageTitleLabel + ':'), span({ class: 'card-value' }, title)) : "",
  42. description ? [span({ class: 'card-label' }, i18n.imageDescriptionLabel + ":"), p(...renderUrl(description))] : null,
  43. meme ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.trendingCategory + ':'), span({ class: 'card-value' }, i18n.meme)) : "",
  44. div({ class: 'card-field' }, img({ src: `/blob/${encodeURIComponent(url)}`, class: 'feed-image' }))
  45. )
  46. );
  47. } else if (c.type === 'audio') {
  48. const { url, mimeType, title, description } = c;
  49. contentHtml = div({ class: 'trending-audio' },
  50. div({ class: 'card-section audio' },
  51. form({ method: "GET", action: `/audios/${encodeURIComponent(item.key)}` },
  52. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  53. ),
  54. br,
  55. title?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.audioTitleLabel + ':'), span({ class: 'card-value' }, title)) : "",
  56. description ? [span({ class: 'card-label' }, i18n.audioDescriptionLabel + ":"), p(...renderUrl(description))] : null,
  57. url
  58. ? div({ class: 'card-field audio-container' }, audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(url)}`, type: mimeType }))
  59. : div({ class: 'card-field' }, p(i18n.audioNoFile))
  60. )
  61. );
  62. } else if (c.type === 'video') {
  63. const { url, mimeType, title, description } = c;
  64. contentHtml = div({ class: 'trending-video' },
  65. div({ class: 'card-section video' },
  66. form({ method: "GET", action: `/videos/${encodeURIComponent(item.key)}` },
  67. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  68. ),
  69. br,
  70. title?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.videoTitleLabel + ':'), span({ class: 'card-value' }, title)) : "",
  71. description ? [span({ class: 'card-label' }, i18n.videoDescriptionLabel + ":"), p(...renderUrl(description))] : null,
  72. br,
  73. url
  74. ? div({ class: 'card-field video-container' }, videoHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(url)}`, type: mimeType, preload: 'metadata', width: '640', height: '360' }))
  75. : div({ class: 'card-field' }, p(i18n.videoNoFile))
  76. )
  77. );
  78. } else if (c.type === 'document') {
  79. const { url, title, description } = c;
  80. const t = title?.trim();
  81. if (t && seenTitles.has(t)) return null;
  82. if (t) seenTitles.add(t);
  83. contentHtml = div({ class: 'trending-document' },
  84. div({ class: 'card-section document' },
  85. form({ method: "GET", action: `/documents/${encodeURIComponent(item.key)}` },
  86. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  87. ),
  88. br,
  89. t ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.documentTitleLabel + ':'), span({ class: 'card-value' }, t)) : "",
  90. description ? [span({ class: 'card-label' }, i18n.documentDescriptionLabel + ":"), p(...renderUrl(description))] : null,
  91. div({ id: `pdf-container-${item.key}`, class: 'pdf-viewer-container', 'data-pdf-url': `/blob/${encodeURIComponent(url)}` })
  92. )
  93. );
  94. } else if (c.type === 'feed') {
  95. const { text, refeeds } = c;
  96. contentHtml = div({ class: 'trending-feed' },
  97. div({ class: 'card-section feed' },
  98. div({ class: 'feed-text', innerHTML: renderTextWithStyles(text) }),
  99. h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ': '), span({ class: 'card-value' }, refeeds))
  100. )
  101. );
  102. } else if (c.type === 'votes') {
  103. const { question, deadline, votes, totalVotes } = c;
  104. const votesList = votes && typeof votes === 'object' ? Object.entries(votes).map(([o, cnt]) => ({ option: o, count: cnt })) : [];
  105. contentHtml = div({ class: 'trending-votes' },
  106. div({ class: 'card-section votes' },
  107. form({ method: "GET", action: `/votes/${encodeURIComponent(item.key)}` },
  108. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  109. ),
  110. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.voteQuestionLabel + ':'), span({ class: 'card-value' }, question)),
  111. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.voteDeadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : '')),
  112. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.voteTotalVotes + ':'), span({ class: 'card-value' }, totalVotes)),
  113. table(
  114. tr(...votesList.map(v => th(i18n[v.option] || v.option))),
  115. tr(...votesList.map(v => td(v.count)))
  116. )
  117. )
  118. );
  119. } else if (c.type === 'transfer') {
  120. const { from, to, concept, amount, deadline, status, confirmedBy } = c;
  121. contentHtml = div({ class: 'trending-transfer' },
  122. div({ class: 'card-section transfer' },
  123. form({ method: "GET", action: `/transfers/${encodeURIComponent(item.key)}` },
  124. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  125. ),
  126. br,
  127. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.concept + ':'), span({ class: 'card-value' }, concept)),
  128. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.deadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : '')),
  129. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.status + ':'), span({ class: 'card-value' }, status)),
  130. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.amount + ':'), span({ class: 'card-value' }, amount)),
  131. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.from + ':'), span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(from)}`, target: '_blank' }, from))),
  132. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.to + ':'), span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(to)}`, target: '_blank' }, to))),
  133. h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersConfirmations + ': '), span({ class: 'card-value' }, `${confirmedBy.length}/2`))
  134. )
  135. );
  136. } else {
  137. contentHtml = div({ class: 'styled-text' },
  138. div({ class: 'card-section styled-text-content' },
  139. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.textContentLabel + ':'), span({ class: 'card-value', innerHTML: renderTextWithStyles(c.text || c.description || c.title || '[no content]') }))
  140. )
  141. );
  142. }
  143. return div({ class: 'trending-card', style: 'background-color:#2c2f33;border-radius:8px;padding:16px;border:1px solid #444;' },
  144. contentHtml,
  145. p({ class: 'card-footer' }, span({ class: 'date-link' }, `${created} ${i18n.performed} `), a({ href: `/author/${encodeURIComponent(item.value.author)}`, class: 'user-link' }, item.value.author)),
  146. h2(`${i18n.trendingTotalOpinions || i18n.trendingTotalCount}: ${votes}`),
  147. div({ class: 'voting-buttons' }, categories.map(cat => form({ method: 'POST', action: `/trending/${encodeURIComponent(item.key)}/${cat}` }, button({ class: 'vote-btn' }, `${i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)]} [${c.opinions?.[cat]||0}]`))))
  148. );
  149. };
  150. exports.trendingView = (items, filter, categories) => {
  151. const seenDocumentTitles = new Set();
  152. const title = i18n.trendingTitle;
  153. const baseFilters = ['RECENT', 'ALL', 'MINE', 'TOP'];
  154. const contentFilters = [
  155. ['votes', 'feed', 'transfer'],
  156. ['bookmark', 'image', 'video', 'audio', 'document']
  157. ];
  158. let filteredItems = items.filter(item => {
  159. const c = item.value?.content || item.content;
  160. return c && typeof c === 'object' && c.type !== 'tombstone';
  161. });
  162. if (filter === 'MINE') {
  163. filteredItems = filteredItems.filter(item => item.value.author === userId);
  164. } else if (filter === 'RECENT') {
  165. const now = Date.now();
  166. filteredItems = filteredItems.filter(item => now - item.value.timestamp < 24 * 60 * 60 * 1000);
  167. } else if (filter === 'TOP') {
  168. filteredItems.sort((a, b) => {
  169. const aVotes = (a.value.content.opinions_inhabitants || []).length;
  170. const bVotes = (b.value.content.opinions_inhabitants || []).length;
  171. return bVotes !== aVotes ? bVotes - aVotes : b.value.timestamp - a.value.timestamp;
  172. });
  173. } else if (contentFilters.flat().includes(filter)) {
  174. filteredItems = filteredItems.filter(item => item.value.content.type === filter);
  175. } else if (filter !== 'ALL') {
  176. filteredItems = filteredItems.filter(item => (item.value.content.opinions_inhabitants || []).length > 0);
  177. }
  178. filteredItems.sort((a, b) => b.value.timestamp - a.value.timestamp);
  179. const header = div({ class: 'tags-header' }, h2(title), p(i18n.exploreTrending));
  180. const cards = filteredItems
  181. .map(item => renderTrendingCard(item, Object.values(item.value.content.opinions || {}).reduce((s, n) => s + n, 0), categories, seenDocumentTitles))
  182. .filter(Boolean);
  183. const hasDocument = filteredItems.some(item => item.value.content.type === 'document');
  184. let html = template(
  185. title,
  186. section(
  187. header,
  188. div({ class: 'mode-buttons', style: 'display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:16px;margin-bottom:24px;' },
  189. generateFilterButtons(baseFilters, filter, '/trending'),
  190. ...contentFilters.map(row =>
  191. div({ style: 'display:flex;flex-direction:column;gap:8px;' },
  192. row.map(mode =>
  193. form({ method: 'GET', action: '/trending' },
  194. input({ type: 'hidden', name: 'filter', value: mode }),
  195. button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
  196. )
  197. )
  198. )
  199. )
  200. ),
  201. section(
  202. cards.length
  203. ? div({ class: 'trending-container', style: 'display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:20px;' }, ...cards)
  204. : div({ class: 'no-results' }, p(i18n.trendingNoContentMessage))
  205. )
  206. )
  207. );
  208. if (hasDocument) {
  209. html += `
  210. <script type="module" src="/js/pdf.min.mjs"></script>
  211. <script src="/js/pdf-viewer.js"></script>
  212. `;
  213. }
  214. return html;
  215. };