tribes_view.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. const { div, h2, p, section, button, form, a, input, img, label, select, option, br, textarea, h1 } = require("../server/node_modules/hyperaxe");
  2. const { template, i18n } = require('./main_views');
  3. const { config } = require('../server/SSB_server.js');
  4. const { renderUrl } = require('../backend/renderUrl');
  5. const userId = config.keys.id;
  6. const paginateFeedTribesView = (feed, page = 1, itemsPerPage = 5) => {
  7. const startIndex = (page - 1) * itemsPerPage;
  8. return feed.slice(startIndex, startIndex + itemsPerPage);
  9. };
  10. const renderPaginationTribesView = (page, totalPages, filter) => {
  11. if (totalPages <= 1) return '';
  12. return div({ class: 'pagination' },
  13. page > 1 ? form({ method: 'GET', action: '/tribes' },
  14. input({ type: 'hidden', name: 'filter', value: filter }),
  15. input({ type: 'hidden', name: 'page', value: page - 1 }),
  16. button({ type: 'submit' }, i18n.previousPage)
  17. ) : null,
  18. page < totalPages ? form({ method: 'GET', action: '/tribes' },
  19. input({ type: 'hidden', name: 'filter', value: filter }),
  20. input({ type: 'hidden', name: 'page', value: page + 1 }),
  21. button({ type: 'submit' }, i18n.nextPage)
  22. ) : null
  23. );
  24. };
  25. const renderFeedTribesView = (tribe, page, query, filter) => {
  26. const feed = Array.isArray(tribe.feed) ? tribe.feed : [];
  27. const feedFilter = (query.feedFilter || 'TOP').toUpperCase();
  28. let filteredFeed = feed;
  29. if (feedFilter === 'MINE') filteredFeed = feed.filter(m => m.author === userId);
  30. if (feedFilter === 'RECENT') {
  31. const last24h = Date.now() - 86400000;
  32. filteredFeed = [...feed]
  33. .filter(m => {
  34. const msgDate = typeof m.date === "string" ? Date.parse(m.date) : m.date;
  35. return msgDate >= last24h;
  36. })
  37. .sort((a, b) => {
  38. const dateA = typeof a.date === "string" ? Date.parse(a.date) : a.date;
  39. const dateB = typeof b.date === "string" ? Date.parse(b.date) : b.date;
  40. return dateB - dateA;
  41. });
  42. }
  43. if (feedFilter === 'ALL') filteredFeed = [...feed].sort((a, b) => b.date - a.date);
  44. if (feedFilter === 'TOP') filteredFeed = [...feed].sort((a, b) => (b.refeeds || 0) - (a.refeeds || 0));
  45. const totalPages = Math.ceil(filteredFeed.length / 5);
  46. const paginatedFeed = paginateFeedTribesView(filteredFeed, page);
  47. return div({ class: 'tribe-feed' },
  48. div({ class: 'feed-actions', style: 'margin-bottom:8px;' },
  49. ['TOP', 'MINE', 'ALL', 'RECENT'].map(f =>
  50. form({ method: 'GET', action: '/tribes' },
  51. input({ type: 'hidden', name: 'filter', value: filter }),
  52. input({ type: 'hidden', name: 'feedFilter', value: f }),
  53. button({ type: 'submit', class: feedFilter === f ? 'active' : '' }, i18n[`tribeFeedFilter${f}`])
  54. )
  55. )
  56. ),
  57. paginatedFeed.length === 0
  58. ? p(i18n.tribeFeedEmpty)
  59. : div({ class: 'feed-list' },
  60. paginatedFeed.map(m => div({ class: 'feed-item' },
  61. div({ class: 'feed-row' },
  62. div({ class: 'refeed-column' },
  63. h1(`${m.refeeds || 0}`),
  64. !m.refeeds_inhabitants.includes(userId)
  65. ? form({ method: 'POST', action: `/tribes/${encodeURIComponent(tribe.id)}/refeed/${encodeURIComponent(m.id)}` }, button({ class: 'refeed-btn' }, i18n.tribeFeedRefeed))
  66. : p(i18n.alreadyRefeeded)
  67. ),
  68. div({ class: 'feed-main' },
  69. p(`${new Date(m.date).toLocaleString()} — `, a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)),
  70. br,
  71. p(...renderUrl(m.message))
  72. )
  73. )
  74. ))
  75. ),
  76. tribe.members.includes(userId)
  77. ? form({ method: 'POST', action: `/tribes/${encodeURIComponent(tribe.id)}/message` },
  78. textarea({ name: 'message', rows: 4, cols: 50, maxlength: 280, placeholder: i18n.tribeFeedMessagePlaceholder }),
  79. button({ type: 'submit' }, i18n.tribeFeedSend)
  80. )
  81. : null,
  82. renderPaginationTribesView(page, totalPages, filter)
  83. );
  84. };
  85. const renderGallery = (sortedTribes) => {
  86. return div({ class: "gallery", style: 'display:grid; grid-template-columns: repeat(3, 1fr); gap:16px;' },
  87. sortedTribes.length
  88. ? sortedTribes.map(t =>
  89. a({ href: `#tribe-${encodeURIComponent(t.id)}`, class: "gallery-item" },
  90. img({ src: t.image ? `/blob/${encodeURIComponent(t.image)}` : '/assets/images/default-tribe.png', alt: t.title || "", class: "gallery-image" })
  91. )
  92. )
  93. : p(i18n.noTribes)
  94. );
  95. };
  96. const renderLightbox = (sortedTribes) => {
  97. return sortedTribes.map(t =>
  98. div(
  99. { id: `tribe-${encodeURIComponent(t.id)}`, class: "lightbox" },
  100. a({ href: "#", class: "lightbox-close" }, "×"),
  101. img({
  102. src: t.image ? `/blob/${encodeURIComponent(t.image)}` : '/assets/images/default-tribe.png',
  103. class: "lightbox-image",
  104. alt: t.title || ""
  105. })
  106. )
  107. );
  108. };
  109. exports.renderInvitePage = (inviteCode) => {
  110. const pageContent = div({ class: 'invite-page' },
  111. h2(i18n.tribeInviteCodeText, inviteCode),
  112. form({ method: "GET", action: `/tribes` },
  113. button({ type: "submit", class: "filter-btn" }, i18n.walletBack)
  114. ),
  115. );
  116. return template('Invite Page', section(pageContent));
  117. };
  118. exports.tribesView = async (tribes, filter, tribeId, query = {}) => {
  119. const now = Date.now();
  120. const search = (query.search || '').toLowerCase();
  121. const filtered = tribes.filter(t => {
  122. return (
  123. filter === 'all' ? t.isAnonymous === false :
  124. filter === 'mine' ? t.author === userId :
  125. filter === 'membership' ? t.members.includes(userId) :
  126. filter === 'recent' ? t.isAnonymous === false && ((typeof t.createdAt === 'string' ? Date.parse(t.createdAt) : t.createdAt) >= now - 86400000 ) :
  127. filter === 'top' ? t.isAnonymous === false :
  128. filter === 'gallery' ? t.isAnonymous === false :
  129. filter === 'larp' ? t.isAnonymous === false && t.isLARP === true :
  130. filter === 'create' ? true :
  131. filter === 'edit' ? true :
  132. false
  133. );
  134. });
  135. const searched = filter === 'create' || filter === 'edit' || !search
  136. ? filtered
  137. : filtered.filter(t =>
  138. (t.title && t.title.toLowerCase().includes(search)) ||
  139. (t.description && t.description.toLowerCase().includes(search))
  140. );
  141. const sorted = filter === 'top'
  142. ? [...searched].sort((a, b) => b.members.length - a.members.length)
  143. : [...searched].sort((a, b) => b.createdAt - a.createdAt);
  144. const title =
  145. filter === 'mine' ? i18n.tribeMineSectionTitle :
  146. filter === 'create' ? i18n.tribeCreateSectionTitle :
  147. filter === 'edit' ? i18n.tribeUpdateSectionTitle :
  148. filter === 'gallery' ? i18n.tribeGallerySectionTitle :
  149. filter === 'recent' ? i18n.tribeRecentSectionTitle :
  150. filter === 'top' ? i18n.tribeTopSectionTitle :
  151. filter === 'larp' ? i18n.tribeLarpSectionTitle :
  152. i18n.tribeAllSectionTitle;
  153. const header = div({ class: 'tags-header' }, h2(title), p(i18n.tribeDescription));
  154. const filters = div({ class: 'filters' },
  155. form({ method: 'GET', action: '/tribes' },
  156. input({ type: 'hidden', name: 'filter', value: filter }),
  157. input({ type: 'text', name: 'search', placeholder: i18n.searchTribesPlaceholder, value: query.search || '' }),
  158. br(),
  159. button({ type: 'submit' }, i18n.applyFilters),
  160. br()
  161. )
  162. );
  163. const modeButtons = div({ class: 'mode-buttons', style: 'display:flex; gap:8px; margin-top:16px;' },
  164. ['all','mine','membership','larp','recent','top','gallery'].map(f =>
  165. form({ method: 'GET', action: '/tribes' },
  166. input({ type: 'hidden', name: 'filter', value: f }),
  167. button({ type: 'submit', class: filter === f ? 'filter-btn active' : 'filter-btn' },
  168. i18n[`tribeFilter${f.charAt(0).toUpperCase()+f.slice(1)}`]
  169. )
  170. )
  171. ),
  172. form({ method: 'GET', action: '/tribes/create' },
  173. button({ type: 'submit', class: 'create-button' }, i18n.tribeCreateButton)
  174. )
  175. );
  176. const isEdit = filter === 'edit' && tribeId;
  177. const tribeToEdit = isEdit ? tribes.find(t => t.id === tribeId) : {};
  178. const createForm = (filter === 'create' || isEdit) ? div({ class: 'create-tribe-form' },
  179. h2(isEdit ? i18n.updateTribeTitle : i18n.createTribeTitle),
  180. form({
  181. method: 'POST',
  182. action: isEdit ? `/tribes/update/${encodeURIComponent(tribeToEdit.id)}` : '/tribes/create',
  183. enctype: 'multipart/form-data'
  184. },
  185. label({ for: 'title' }, i18n.tribeTitleLabel),
  186. br,
  187. input({ type: 'text', name: 'title', id: 'title', required: true, placeholder: i18n.tribeTitlePlaceholder, value: tribeToEdit.title || '' }),
  188. br(),
  189. label({ for: 'description' }, i18n.tribeDescriptionLabel),
  190. br,
  191. textarea({ name: 'description', id: 'description', required: true, rows: 4, cols: 50, placeholder: i18n.tribeDescriptionPlaceholder }, tribeToEdit.description || ''),
  192. br(),
  193. label({ for: 'location' }, i18n.tribeLocationLabel),
  194. br,
  195. input({ type: 'text', name: 'location', id: 'location', required: true, placeholder: i18n.tribeLocationPlaceholder, value: tribeToEdit.location || '' }),
  196. br(),
  197. label({ for: 'image' }, i18n.tribeImageLabel),
  198. br,
  199. input({ type: 'file', name: 'image', id: 'image', accept: 'image/*' }),
  200. br(), br(),
  201. label({ for: 'tags' }, i18n.tribeTagsLabel),
  202. br,
  203. input({ type: 'text', name: 'tags', id: 'tags', placeholder: i18n.tribeTagsPlaceholder, value: (tribeToEdit.tags || []).join(', ') }),
  204. br(),
  205. label({ for: 'isAnonymous' }, i18n.tribeIsAnonymousLabel),
  206. br,
  207. select({ name: 'isAnonymous', id: 'isAnonymous' },
  208. option({ value: 'true', selected: tribeToEdit.isAnonymous === true ? 'selected' : undefined }, i18n.tribePrivate),
  209. option({ value: 'false', selected: tribeToEdit.isAnonymous === false ? 'selected' : undefined }, i18n.tribePublic)
  210. ),
  211. br(), br(),
  212. label({ for: 'inviteMode' }, i18n.tribeModeLabel),
  213. br,
  214. select({ name: 'inviteMode', id: 'inviteMode' },
  215. option({ value: 'strict', selected: tribeToEdit.inviteMode === 'strict' ? 'selected' : undefined }, i18n.tribeStrict),
  216. option({ value: 'open', selected: tribeToEdit.inviteMode === 'open' ? 'selected' : undefined }, i18n.tribeOpen)
  217. ),
  218. br(), br(),
  219. // label({ for: 'isLARP' }, i18n.tribeIsLARPLabel),
  220. // br,
  221. // select({ name: 'isLARP', id: 'isLARP' },
  222. // option({ value: 'true', selected: tribeToEdit.isLARP === true ? 'selected' : undefined }, i18n.tribeYes),
  223. // option({ value: 'false', selected: tribeToEdit.isLARP === false ? 'selected' : undefined }, i18n.tribeNo)
  224. // ),
  225. // br(), br(),
  226. button({ type: 'submit' }, isEdit ? i18n.tribeUpdateButton : i18n.tribeCreateButton)
  227. )
  228. ) : null;
  229. const tribeCards = sorted.map(t => {
  230. const imageSrc = t.image
  231. ? `/blob/${encodeURIComponent(t.image)}`
  232. : '/assets/images/default-tribe.png';
  233. const infoCol = div({ class: 'tribe-card', style: 'width:50%' },
  234. filter === 'mine' ? div({ class: 'tribe-actions' },
  235. form({ method: 'GET', action: `/tribes/edit/${encodeURIComponent(t.id)}` }, button({ type: 'submit' }, i18n.tribeUpdateButton)),
  236. form({ method: 'POST', action: `/tribes/delete/${encodeURIComponent(t.id)}` }, button({ type: 'submit' }, i18n.tribeDeleteButton))
  237. ) : null,
  238. div({ style: 'display: flex; justify-content: space-between;' },
  239. form({ method: 'GET', action: `/tribe/${encodeURIComponent(t.id)}` },
  240. button({ type: 'submit', class: 'filter-btn' }, i18n.tribeviewTribeButton)
  241. ),
  242. h2(t.title)
  243. ),
  244. p(`${i18n.tribeIsAnonymousLabel}: ${t.isAnonymous ? i18n.tribePrivate : i18n.tribePublic}`),
  245. p(`${i18n.tribeModeLabel}: ${t.inviteMode.toUpperCase()}`),
  246. p(`${i18n.tribeLARPLabel}: ${t.isLARP ? i18n.tribeYes : i18n.tribeNo}`),
  247. p(`${i18n.tribeLocationLabel}: ${t.location}`),
  248. img({ src: imageSrc }),
  249. t.description ? p(...renderUrl(t.description)) : null,
  250. h2(`${i18n.tribeMembersCount}: ${t.members.length}`),
  251. t.tags && t.tags.filter(Boolean).length ? div(t.tags.filter(Boolean).map(tag =>
  252. a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link', style: 'margin-right:0.8em;margin-bottom:0.5em;' }, `#${tag}`)
  253. )) : null,
  254. p(`${i18n.tribeCreatedAt}: ${new Date(t.createdAt).toLocaleString()}`),
  255. p(a({ class: 'user-link', href: `/author/${encodeURIComponent(t.author)}` }, t.author)),
  256. t.members.includes(userId) ? div(
  257. form({ method: 'POST', action: '/tribes/generate-invite' },
  258. input({ type: 'hidden', name: 'tribeId', value: t.id }),
  259. button({ type: 'submit' }, i18n.tribeGenerateInvite)
  260. ),
  261. form({ method: 'POST', action: `/tribes/leave/${encodeURIComponent(t.id)}` }, button({ type: 'submit' }, i18n.tribeLeaveButton))
  262. ) : null
  263. );
  264. const feedCol = renderFeedTribesView(t, query.page || 1, query, filter);
  265. return div({ class: 'tribe-row', style: 'display:flex; gap:24px; margin-bottom:32px;' }, infoCol, feedCol);
  266. });
  267. return template(
  268. title,
  269. section(header),
  270. section(filters),
  271. section(modeButtons),
  272. section(
  273. (filter === 'create' || filter === 'edit')
  274. ? createForm
  275. : filter === 'gallery'
  276. ? renderGallery(sorted.filter(t => t.isAnonymous === false))
  277. : div({ class: 'tribe-grid', style: 'display:grid; grid-template-columns: repeat(3, 1fr); gap:16px;' },
  278. tribeCards.length > 0 ? tribeCards : p(i18n.noTribes)
  279. )
  280. ),
  281. ...renderLightbox(sorted.filter(t => t.isAnonymous === false))
  282. );
  283. };
  284. const renderFeedTribeView = async (tribe, query = {}, filter) => {
  285. const feed = Array.isArray(tribe.feed) ? tribe.feed : [];
  286. const feedFilter = (query.feedFilter || 'RECENT').toUpperCase();
  287. let filteredFeed = feed;
  288. if (feedFilter === 'MINE') {
  289. filteredFeed = feed.filter(m => m.author === userId);
  290. }
  291. if (feedFilter === 'RECENT') {
  292. const last24h = Date.now() - 86400000;
  293. filteredFeed = feed
  294. .filter(m => {
  295. const msgDate = typeof m.date === 'string' ? Date.parse(m.date) : m.date;
  296. return msgDate >= last24h;
  297. })
  298. .sort((a, b) => {
  299. const dateA = typeof a.date === 'string' ? Date.parse(a.date) : a.date;
  300. const dateB = typeof b.date === 'string' ? Date.parse(b.date) : b.date;
  301. return dateB - dateA;
  302. });
  303. }
  304. if (feedFilter === 'ALL') {
  305. filteredFeed = [...feed].sort((a, b) => b.date - a.date);
  306. }
  307. if (feedFilter === 'TOP') {
  308. filteredFeed = [...feed].sort((a, b) => (b.refeeds || 0) - (a.refeeds || 0));
  309. }
  310. return div({ class: 'tribe-feed-full' },
  311. div({ class: 'feed-actions', style: 'margin-bottom:8px;' },
  312. ['TOP', 'MINE', 'ALL', 'RECENT'].map(f =>
  313. form({ method: 'GET', action: `/tribe/${encodeURIComponent(tribe.id)}` },
  314. input({ type: 'hidden', name: 'filter', value: filter }),
  315. input({ type: 'hidden', name: 'feedFilter', value: f }),
  316. button({ type: 'submit', class: feedFilter === f ? 'active' : '' }, i18n[`tribeFeedFilter${f}`])
  317. )
  318. )
  319. ),
  320. filteredFeed.length === 0
  321. ? p(i18n.tribeFeedEmpty)
  322. : div({ class: 'feed-list' },
  323. filteredFeed.map(m => div({ class: 'feed-item' },
  324. div({ class: 'feed-row' },
  325. div({ class: 'refeed-column' },
  326. h1(`${m.refeeds || 0}`),
  327. !m.refeeds_inhabitants.includes(userId)
  328. ? form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/refeed/${encodeURIComponent(m.id)}` }, button({ class: 'refeed-btn' }, i18n.tribeFeedRefeed))
  329. : p(i18n.alreadyRefeeded)
  330. ),
  331. div({ class: 'feed-main' },
  332. p(`${new Date(m.date).toLocaleString()} — `, a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)),
  333. br,
  334. p(...renderUrl(m.message))
  335. )
  336. )
  337. ))
  338. )
  339. );
  340. };
  341. exports.tribeView = async (tribe, userId, query) => {
  342. if (!tribe) {
  343. return div({ class: 'error' }, 'Tribe not found!');
  344. }
  345. const feedFilter = (query.feedFilter || 'TOP').toUpperCase();
  346. const imageSrc = tribe.image
  347. ? `/blob/${encodeURIComponent(tribe.image)}`
  348. : '/assets/images/default-tribe.png';
  349. const pageTitle = tribe.title;
  350. const tribeDetails = div({ class: 'tribe-details' },
  351. h2(tribe.title),
  352. p(`${i18n.tribeIsAnonymousLabel}: ${tribe.isAnonymous ? i18n.tribePrivate : i18n.tribePublic}`),
  353. p(`${i18n.tribeModeLabel}: ${tribe.inviteMode.toUpperCase()}`),
  354. p(`${i18n.tribeLARPLabel}: ${tribe.isLARP ? i18n.tribeYes : i18n.tribeNo}`),
  355. p(`${i18n.tribeLocationLabel}: ${tribe.location}`),
  356. img({ src: imageSrc, alt: tribe.title }),
  357. tribe.description ? p(...renderUrl(tribe.description)) : null,
  358. h2(`${i18n.tribeMembersCount}: ${tribe.members.length}`),
  359. tribe.tags && tribe.tags.filter(Boolean).length ? div(tribe.tags.filter(Boolean).map(tag =>
  360. a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link', style: 'margin-right:0.8em;margin-bottom:0.5em;' }, `#${tag}`)
  361. )) : null,
  362. p(`${i18n.tribeCreatedAt}: ${new Date(tribe.createdAt).toLocaleString()}`),
  363. p(a({ class: 'user-link', href: `/author/${encodeURIComponent(tribe.author)}` }, tribe.author)),
  364. div({ class: 'tribe-feed-form' }, tribe.members.includes(config.keys.id)
  365. ? form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/message` },
  366. textarea({ name: 'message', rows: 4, cols: 50, maxlength: 280, placeholder: i18n.tribeFeedMessagePlaceholder }),
  367. br,
  368. button({ type: 'submit' }, i18n.tribeFeedSend)
  369. )
  370. : null
  371. ),
  372. div({ class: 'tribe-feed-full' }, await renderFeedTribeView(tribe, query, query.filter)),
  373. );
  374. return template(
  375. pageTitle,
  376. tribeDetails
  377. );
  378. };