tribes_view.js 17 KB

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