tribes_view.js 76 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528
  1. const { div, h2, p, section, button, form, a, input, img, label, select, option, br, textarea, h1, span, nav, ul, li, video, audio, table, tr, td } = 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 { renderMapLocationUrl, renderMapLocationGrid, renderMapLocationVisitLabel } = require("./maps_view");
  6. const opinion_categories = require('../backend/opinion_categories.js');
  7. const userId = config.keys.id;
  8. const DEFAULT_HASH_ENC = "%260000000000000000000000000000000000000000000%3D.sha256";
  9. const DEFAULT_HASH_PATH_RE = /\/image\/\d+\/%260000000000000000000000000000000000000000000%3D\.sha256$/;
  10. const isDefaultImageId = (v) => {
  11. if (!v) return true;
  12. if (typeof v === 'string') {
  13. if (v === DEFAULT_HASH_ENC) return true;
  14. if (DEFAULT_HASH_PATH_RE.test(v)) return true;
  15. }
  16. return false;
  17. };
  18. const resolvePhoto = (photoField) => {
  19. if (!photoField) return '/assets/images/default-avatar.png';
  20. if (typeof photoField === 'string') {
  21. if (photoField.startsWith('/assets/')) return photoField;
  22. if (photoField.startsWith('/blob/')) return photoField;
  23. if (photoField.startsWith('/image/')) {
  24. if (isDefaultImageId(photoField)) return '/assets/images/default-avatar.png';
  25. return photoField;
  26. }
  27. }
  28. return '/assets/images/default-avatar.png';
  29. };
  30. const MS_PER_DAY = 86400000;
  31. const FEED_ITEMS_PER_PAGE = 5;
  32. const MAX_MESSAGE_LENGTH = 280;
  33. const renderMediaBlob = (value, fallbackSrc = null, attrs = {}) => {
  34. if (!value) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
  35. const s = String(value).trim()
  36. if (!s) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
  37. if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}`, ...attrs })
  38. const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
  39. if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` })
  40. const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
  41. if (mAudio) return audio({ controls: true, class: attrs.class || 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` })
  42. const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
  43. if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, ...attrs })
  44. return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
  45. }
  46. const toBlobUrl = (raw) => {
  47. if (!raw) return null
  48. const s = String(raw).trim()
  49. if (s.startsWith('&')) return `/blob/${encodeURIComponent(s)}`
  50. const m = s.match(/\((&[^)\s]+\.sha256)\s*\)/)
  51. return m ? `/blob/${encodeURIComponent(m[1])}` : null
  52. }
  53. const toImageUrl = (raw, fallback) => {
  54. if (!raw) return fallback
  55. const s = String(raw).trim()
  56. if (/^\[video:|^\[audio:/.test(s)) return fallback
  57. const url = toBlobUrl(raw)
  58. return url || fallback
  59. }
  60. const filterAndSortFeed = (feed, feedFilter) => {
  61. const parseDate = (d) => typeof d === 'string' ? Date.parse(d) : d;
  62. if (feedFilter === 'MINE') return feed.filter(m => m.author === userId);
  63. if (feedFilter === 'RECENT') {
  64. const last24h = Date.now() - MS_PER_DAY;
  65. return [...feed].filter(m => parseDate(m.createdAt) >= last24h).sort((a, b) => parseDate(b.createdAt) - parseDate(a.createdAt));
  66. }
  67. if (feedFilter === 'ALL') return [...feed].sort((a, b) => parseDate(b.createdAt) - parseDate(a.createdAt));
  68. if (feedFilter === 'TOP') return [...feed].sort((a, b) => (b.refeeds || 0) - (a.refeeds || 0));
  69. return feed;
  70. };
  71. const renderGallery = (sortedTribes) => {
  72. return div({ class: "gallery" },
  73. sortedTribes.length
  74. ? sortedTribes.map(t =>
  75. a({ href: `#tribe-${encodeURIComponent(t.id)}`, class: "gallery-item" },
  76. img({ src: toImageUrl(t.image, '/assets/images/default-tribe.png'), alt: t.title || "", class: "gallery-image" })
  77. )
  78. )
  79. : p(i18n.noTribes)
  80. );
  81. };
  82. const renderLightbox = (sortedTribes) => {
  83. return sortedTribes.map(t =>
  84. div(
  85. { id: `tribe-${encodeURIComponent(t.id)}`, class: "lightbox" },
  86. a({ href: "#", class: "lightbox-close" }, "×"),
  87. img({ src: toImageUrl(t.image, '/assets/images/default-tribe.png'), class: "lightbox-image", alt: t.title || "" })
  88. )
  89. );
  90. };
  91. exports.renderInvitePage = (inviteCode) => {
  92. const pageContent = div({ class: 'invite-page' },
  93. h2(i18n.tribeInviteCodeText, inviteCode),
  94. form({ method: "GET", action: `/tribes` },
  95. button({ type: "submit", class: "filter-btn" }, i18n.walletBack)
  96. ),
  97. );
  98. return template(i18n.tribeInviteMode, section(pageContent));
  99. };
  100. exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = null) => {
  101. const now = Date.now();
  102. const search = (query.search || '').toLowerCase();
  103. const visible = (t) => !t.isAnonymous || t.members.includes(userId);
  104. const isMainTribe = (t) => !t.parentTribeId;
  105. const filtered = tribes.filter(t => {
  106. return (
  107. filter === 'all' ? visible(t) && isMainTribe(t) :
  108. filter === 'mine' ? t.author === userId :
  109. filter === 'membership' ? t.members.includes(userId) :
  110. filter === 'subtribes' ? visible(t) && !!t.parentTribeId :
  111. filter === 'recent' ? visible(t) && isMainTribe(t) && ((typeof t.createdAt === 'string' ? Date.parse(t.createdAt) : t.createdAt) >= now - MS_PER_DAY ) :
  112. filter === 'top' ? visible(t) && isMainTribe(t) :
  113. filter === 'gallery' ? visible(t) && isMainTribe(t) :
  114. filter === 'larp' ? visible(t) && isMainTribe(t) && t.isLARP === true :
  115. filter === 'create' ? true :
  116. filter === 'edit' ? true :
  117. false
  118. );
  119. });
  120. const searched = filter === 'create' || filter === 'edit' || !search
  121. ? filtered
  122. : filtered.filter(t =>
  123. (t.title && t.title.toLowerCase().includes(search)) ||
  124. (t.description && t.description.toLowerCase().includes(search))
  125. );
  126. const sorted = filter === 'top'
  127. ? [...searched].sort((a, b) => b.members.length - a.members.length)
  128. : [...searched].sort((a, b) => {
  129. const ca = typeof a.createdAt === 'string' ? Date.parse(a.createdAt) : a.createdAt;
  130. const cb = typeof b.createdAt === 'string' ? Date.parse(b.createdAt) : b.createdAt;
  131. return cb - ca;
  132. });
  133. const title =
  134. filter === 'recent' ? i18n.tribeRecentSectionTitle :
  135. filter === 'mine' ? i18n.tribeMineSectionTitle :
  136. filter === 'create' ? i18n.tribeCreateSectionTitle :
  137. filter === 'edit' ? i18n.tribeUpdateSectionTitle :
  138. filter === 'gallery' ? i18n.tribeGallerySectionTitle :
  139. filter === 'top' ? i18n.tribeTopSectionTitle :
  140. filter === 'larp' ? i18n.tribeLarpSectionTitle :
  141. filter === 'subtribes' ? (i18n.tribeSubTribes || 'SUB-TRIBES') :
  142. i18n.tribeAllSectionTitle;
  143. const header = div({ class: 'tags-header' }, h2(title), p(i18n.tribeDescription));
  144. const filters = div({ class: 'filters' },
  145. form({ method: 'GET', action: '/tribes' },
  146. input({ type: 'hidden', name: 'filter', value: filter }),
  147. input({ type: 'text', name: 'search', placeholder: i18n.searchTribesPlaceholder, value: query.search || '' }),
  148. br(),
  149. button({ type: 'submit' }, i18n.applyFilters),
  150. br()
  151. )
  152. );
  153. const modeButtons = div({ class: 'tribe-mode-buttons' },
  154. ['all','recent','mine','membership','subtribes','larp','top','gallery'].map(f =>
  155. form({ method: 'GET', action: '/tribes' },
  156. input({ type: 'hidden', name: 'filter', value: f }),
  157. button({ type: 'submit', class: filter === f ? 'filter-btn active' : 'filter-btn' },
  158. i18n[`tribeFilter${f.charAt(0).toUpperCase()+f.slice(1)}`]
  159. )
  160. )
  161. ),
  162. form({ method: 'GET', action: '/tribes/create' },
  163. button({ type: 'submit', class: 'create-button' }, i18n.tribeCreateButton)
  164. )
  165. );
  166. const isEdit = filter === 'edit' && tribeId;
  167. const tribeToEdit = isEdit ? tribes.find(t => t.id === tribeId) : {};
  168. const createForm = (filter === 'create' || isEdit) ? div({ class: 'create-tribe-form' },
  169. h2(isEdit ? i18n.updateTribeTitle : i18n.createTribeTitle),
  170. form({
  171. method: 'POST',
  172. action: isEdit ? `/tribes/update/${encodeURIComponent(tribeToEdit.id)}` : '/tribes/create',
  173. enctype: 'multipart/form-data'
  174. },
  175. label({ for: 'title' }, i18n.tribeTitleLabel),
  176. br,
  177. input({ type: 'text', name: 'title', id: 'title', required: true, placeholder: i18n.tribeTitlePlaceholder, value: tribeToEdit.title || '' }),
  178. br(),
  179. label({ for: 'description' }, i18n.tribeDescriptionLabel),
  180. br,
  181. textarea({ name: 'description', id: 'description', required: true, rows: 4, cols: 50, placeholder: i18n.tribeDescriptionPlaceholder }, tribeToEdit.description || ''),
  182. br,
  183. label({ for: 'location' }, i18n.tribeLocationLabel),
  184. br,
  185. input({ type: 'text', name: 'location', id: 'location', placeholder: i18n.tribeLocationPlaceholder, value: tribeToEdit.location || '' }),
  186. br,
  187. label(i18n.mapLocationTitle || 'Map Location'),
  188. br,
  189. input({ type: 'text', name: 'mapUrl', placeholder: i18n.mapUrlPlaceholder || '/maps/MAP_ID', value: tribeToEdit.mapUrl || '' }),
  190. br,
  191. label({ for: 'image' }, i18n.tribeImageLabel),
  192. br,
  193. input({ type: 'file', name: 'image', id: 'image' }),
  194. br(), br(),
  195. label({ for: 'tags' }, i18n.tribeTagsLabel),
  196. br,
  197. input({ type: 'text', name: 'tags', id: 'tags', placeholder: i18n.tribeTagsPlaceholder, value: (tribeToEdit.tags || []).join(', ') }),
  198. br,
  199. label({ for: 'isAnonymous' }, i18n.tribeIsAnonymousLabel),
  200. br,
  201. select({ name: 'isAnonymous', id: 'isAnonymous' },
  202. option({ value: 'true', selected: tribeToEdit.isAnonymous === true ? 'selected' : undefined }, i18n.tribePrivate),
  203. option({ value: 'false', selected: tribeToEdit.isAnonymous === false ? 'selected' : undefined }, i18n.tribePublic)
  204. ),
  205. br(), br(),
  206. label({ for: 'inviteMode' }, i18n.tribeModeLabel),
  207. br,
  208. select({ name: 'inviteMode', id: 'inviteMode' },
  209. option({ value: 'strict', selected: tribeToEdit.inviteMode === 'strict' ? 'selected' : undefined }, i18n.tribeStrict),
  210. option({ value: 'open', selected: tribeToEdit.inviteMode === 'open' ? 'selected' : undefined }, i18n.tribeOpen)
  211. ),
  212. br(), br(),
  213. label({ for: 'isLARP' }, i18n.tribeIsLARPLabel),
  214. br,
  215. select({ name: 'isLARP', id: 'isLARP' },
  216. option({ value: 'false', selected: tribeToEdit.isLARP !== true ? 'selected' : undefined }, i18n.tribeNo),
  217. option({ value: 'true', selected: tribeToEdit.isLARP === true ? 'selected' : undefined }, i18n.tribeYes)
  218. ),
  219. br(), br(),
  220. button({ type: 'submit' }, isEdit ? i18n.tribeUpdateButton : i18n.tribeCreateButton)
  221. )
  222. ) : null;
  223. const allT = allTribes || tribes;
  224. const tribeCards = sorted.map(t => {
  225. const isMember = t.members.includes(userId);
  226. const subtribes = allT.filter(st => st.parentTribeId === t.id);
  227. const parentTribe = t.parentTribeId ? allT.find(p => p.id === t.parentTribeId) : null;
  228. return div({ class: 'tribe-card' },
  229. parentTribe
  230. ? div({ class: 'tribe-card-parent' },
  231. span({ class: 'tribe-info-label' }, i18n.tribeMainTribeLabel || 'MAIN TRIBE'),
  232. a({ href: `/tribe/${encodeURIComponent(parentTribe.id)}`, class: 'tribe-parent-card-link' }, parentTribe.title)
  233. )
  234. : null,
  235. div({ class: 'tribe-card-image-wrapper' },
  236. a({ href: `/tribe/${encodeURIComponent(t.id)}` },
  237. renderMediaBlob(t.image, '/assets/images/default-tribe.png', { class: 'tribe-card-hero-image' })
  238. ),
  239. isMember
  240. ? form({ method: 'GET', action: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-visit-btn-wrapper' },
  241. button({ type: 'submit', class: 'filter-btn' }, String(i18n.tribeviewTribeButton || '').toUpperCase())
  242. )
  243. : null
  244. ),
  245. div({ class: 'tribe-card-body' },
  246. h2({ class: 'tribe-card-title' }, a({ href: `/tribe/${encodeURIComponent(t.id)}` }, t.isAnonymous ? "\uD83D\uDD12 " : "", t.title)),
  247. t.description ? p({ class: 'tribe-card-description' }, ...renderUrl(t.description)) : null,
  248. renderMapLocationVisitLabel(t.mapUrl),
  249. table({ class: 'tribe-info-table' },
  250. t.location ? tr(
  251. td({ class: 'tribe-info-label' }, i18n.tribeLocationLabel || 'LOCATION'),
  252. td({ class: 'tribe-info-value', colspan: '3' }, ...renderUrl(t.location))
  253. ) : null,
  254. tr(
  255. td({ class: 'tribe-info-label' }, i18n.tribeIsAnonymousLabel || 'STATUS'),
  256. td({ class: 'tribe-info-value', colspan: '3' }, t.isAnonymous ? i18n.tribePrivate : i18n.tribePublic)
  257. ),
  258. tr(
  259. td({ class: 'tribe-info-label' }, i18n.tribeModeLabel || 'MODE'),
  260. td({ class: 'tribe-info-value', colspan: '3' }, String(inviteModeI18n()[t.inviteMode] || t.inviteMode).toUpperCase())
  261. ),
  262. tr(
  263. td({ class: 'tribe-info-label' }, i18n.tribeLARPLabel || 'L.A.R.P.'),
  264. td({ class: 'tribe-info-value', colspan: '3' }, t.isLARP ? i18n.tribeYes : i18n.tribeNo)
  265. )
  266. ),
  267. div({ class: 'tribe-card-subtribes' },
  268. span({ class: 'tribe-info-label' }, i18n.tribeSubTribes || 'SUB-TRIBES'),
  269. subtribes.length > 0
  270. ? subtribes.map(st =>
  271. form({ method: 'GET', action: `/tribe/${encodeURIComponent(st.id)}` },
  272. button({ type: 'submit', class: 'tribe-subtribe-link' }, st.title)
  273. )
  274. )
  275. : span({ class: 'tribe-info-empty' }, '—')
  276. ),
  277. div({ class: 'tribe-card-members' },
  278. span({ class: 'tribe-members-count' }, `${i18n.tribeMembersCount}: ${t.members.length}`)
  279. ),
  280. isMember ? div({ class: 'tribe-card-actions' },
  281. form({ method: 'POST', action: '/tribes/generate-invite' },
  282. input({ type: 'hidden', name: 'tribeId', value: t.id }),
  283. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeGenerateInvite)
  284. ),
  285. form({ method: 'POST', action: `/tribes/leave/${encodeURIComponent(t.id)}` },
  286. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeLeaveButton)
  287. )
  288. ) : null,
  289. filter === 'mine' ? div({ class: 'tribe-actions' },
  290. form({ method: 'GET', action: `/tribes/edit/${encodeURIComponent(t.id)}` }, button({ type: 'submit' }, i18n.tribeUpdateButton)),
  291. form({ method: 'POST', action: `/tribes/delete/${encodeURIComponent(t.id)}` }, button({ type: 'submit' }, i18n.tribeDeleteButton))
  292. ) : null
  293. )
  294. );
  295. });
  296. return template(
  297. title,
  298. section(header),
  299. section(filters),
  300. section(modeButtons),
  301. section(
  302. (filter === 'create' || filter === 'edit')
  303. ? createForm
  304. : filter === 'gallery'
  305. ? renderGallery(sorted.filter(t => t.isAnonymous === false))
  306. : div({ class: 'tribe-grid' },
  307. tribeCards.length > 0 ? tribeCards : p(i18n.noTribes)
  308. )
  309. ),
  310. ...renderLightbox(sorted.filter(t => t.isAnonymous === false))
  311. );
  312. };
  313. const renderFeedTribeView = async (feedItems, tribe, query = {}, filter) => {
  314. const feed = Array.isArray(feedItems) ? feedItems : [];
  315. const feedFilter = (query.feedFilter || 'RECENT').toUpperCase();
  316. const filteredFeed = filterAndSortFeed(feed, feedFilter);
  317. return div({ class: 'tribe-feed-full' },
  318. div({ class: 'feed-actions' },
  319. ['TOP', 'MINE', 'ALL', 'RECENT'].map(f =>
  320. form({ method: 'GET', action: `/tribe/${encodeURIComponent(tribe.id)}` },
  321. input({ type: 'hidden', name: 'section', value: 'feed' }),
  322. input({ type: 'hidden', name: 'feedFilter', value: f }),
  323. button({ type: 'submit', class: feedFilter === f ? 'filter-btn active' : 'filter-btn' }, i18n[`tribeFeedFilter${f}`])
  324. )
  325. )
  326. ),
  327. filteredFeed.length === 0
  328. ? p(i18n.tribeFeedEmpty)
  329. : div({ class: 'feed-list' },
  330. filteredFeed.map(m => div({ class: 'feed-item' },
  331. div({ class: 'feed-row' },
  332. div({ class: 'refeed-column' },
  333. h1(`${m.refeeds || 0}`),
  334. !(m.refeeds_inhabitants || []).includes(userId)
  335. ? form(
  336. { method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/refeed/${encodeURIComponent(m.id)}` },
  337. button({ class: 'refeed-btn' }, i18n.tribeFeedRefeed)
  338. )
  339. : null
  340. ),
  341. div({ class: 'feed-main' },
  342. p(`${new Date(m.createdAt).toLocaleString()} — `, a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)),
  343. br,
  344. p(...renderUrl(m.description))
  345. )
  346. )
  347. ))
  348. )
  349. );
  350. };
  351. const sectionLink = (tribe, sectionKey, label, currentSection) =>
  352. form({ method: 'GET', action: `/tribe/${encodeURIComponent(tribe.id)}` },
  353. input({ type: 'hidden', name: 'section', value: sectionKey }),
  354. button({ type: 'submit', class: currentSection === sectionKey ? 'filter-btn active' : 'filter-btn' }, label)
  355. );
  356. const renderSectionNav = (tribe, section) => {
  357. const firstGroup = [{ key: 'activity', label: i18n.tribeSectionActivity }, { key: 'inhabitants', label: i18n.tribeSectionInhabitants }];
  358. if (!tribe.parentTribeId) firstGroup.push({ key: 'subtribes', label: i18n.tribeSubTribes });
  359. const sections = [
  360. { items: firstGroup },
  361. { items: [{ key: 'votations', label: i18n.tribeSectionVotations }, { key: 'events', label: i18n.tribeSectionEvents }, { key: 'tasks', label: i18n.tribeSectionTasks }] },
  362. { items: [{ key: 'feed', label: i18n.tribeSectionFeed }, { key: 'forum', label: i18n.tribeSectionForum }] },
  363. { items: [{ key: 'images', label: i18n.tribeSectionImages || 'IMAGES' }, { key: 'audios', label: i18n.tribeSectionAudios || 'AUDIOS' }, { key: 'videos', label: i18n.tribeSectionVideos || 'VIDEOS' }, { key: 'documents', label: i18n.tribeSectionDocuments || 'DOCUMENTS' }, { key: 'bookmarks', label: i18n.tribeSectionBookmarks || 'BOOKMARKS' }, { key: 'maps', label: i18n.tribeSectionMaps || 'MAPS' }] },
  364. { items: [{ key: 'pads', label: i18n.tribeSectionPads || 'PADS' }, { key: 'chats', label: i18n.tribeSectionChats || 'CHATS' }, { key: 'calendars', label: i18n.tribeSectionCalendars || 'CALENDARS' }] },
  365. { items: [{ key: 'search', label: i18n.tribeSectionSearch }] },
  366. ];
  367. return div({ class: 'tribe-section-nav', style: 'border: none;' },
  368. sections.map(g =>
  369. div({ class: 'tribe-section-group', style: 'border: none;' },
  370. g.items.map(s => s.href
  371. ? form({ method: 'GET', action: s.href },
  372. button({ type: 'submit', class: 'filter-btn' }, s.label)
  373. )
  374. : sectionLink(tribe, s.key, s.label, section))
  375. )
  376. )
  377. );
  378. };
  379. const statusI18n = () => ({
  380. 'OPEN': i18n.tribeStatusOpen,
  381. 'CLOSED': i18n.tribeStatusClosed,
  382. 'IN-PROGRESS': i18n.tribeStatusInProgress,
  383. });
  384. const priorityI18n = () => ({
  385. 'LOW': i18n.tribePriorityLow,
  386. 'MEDIUM': i18n.tribePriorityMedium,
  387. 'HIGH': i18n.tribePriorityHigh,
  388. 'CRITICAL': i18n.tribePriorityCritical,
  389. });
  390. const inviteModeI18n = () => ({
  391. 'strict': i18n.tribeStrict,
  392. 'open': i18n.tribeOpen,
  393. });
  394. const forumCatI18n = () => ({
  395. 'GENERAL': i18n.tribeForumCatGeneral,
  396. 'PROPOSAL': i18n.tribeForumCatProposal,
  397. 'QUESTION': i18n.tribeForumCatQuestion,
  398. 'ANNOUNCEMENT': i18n.tribeForumCatAnnouncement,
  399. });
  400. const mediaTypeI18n = () => ({
  401. 'all': i18n.tribeMediaFilterAll,
  402. 'image': i18n.tribeMediaTypeImage,
  403. 'video': i18n.tribeMediaTypeVideo,
  404. 'audio': i18n.tribeMediaTypeAudio,
  405. 'document': i18n.tribeMediaTypeDocument,
  406. 'bookmark': i18n.tribeMediaTypeBookmark,
  407. });
  408. const taskFilterI18n = () => ({
  409. 'all': i18n.tribeTaskFilterAll,
  410. 'open': i18n.tribeStatusOpen,
  411. 'in-progress': i18n.tribeStatusInProgress,
  412. 'closed': i18n.tribeStatusClosed,
  413. });
  414. const statusBadge = (status) => {
  415. const cls = status === 'OPEN' ? 'tribe-content-status-open'
  416. : status === 'CLOSED' ? 'tribe-content-status-closed'
  417. : 'tribe-content-status-in-progress';
  418. return span({ class: `tribe-content-status ${cls}` }, statusI18n()[status] || status);
  419. };
  420. const priorityLabel = (priority) => {
  421. const cls = `tribe-priority-${(priority || 'low').toLowerCase()}`;
  422. return span({ class: cls }, priorityI18n()[priority] || (priority || '').toUpperCase());
  423. };
  424. const contentTypeVerb = (ct) => {
  425. const map = { event: i18n.tribeActivityCreated, task: i18n.tribeActivityCreated, votation: i18n.tribeActivityCreated, forum: i18n.tribeActivityPosted, 'forum-reply': i18n.tribeActivityReplied, media: i18n.tribeActivityCreated, feed: i18n.tribeActivityPosted };
  426. return map[ct] || i18n.tribeActivityCreated;
  427. };
  428. const contentTypeName = (ct) => {
  429. const map = { event: i18n.tribeSectionEvents, task: i18n.tribeSectionTasks, votation: i18n.tribeSectionVotations, forum: i18n.tribeSectionForum, 'forum-reply': i18n.tribeSectionForum, media: i18n.tribeSectionMedia, feed: i18n.tribeSectionFeed, pad: i18n.tribeSectionPads || 'PADS', chat: i18n.tribeSectionChats || 'CHATS', calendar: i18n.tribeSectionCalendars || 'CALENDARS', map: i18n.tribeSectionMaps || 'MAPS' };
  430. return map[ct] || ct;
  431. };
  432. const activitySectionMap = {
  433. event: 'events', task: 'tasks', votation: 'votations',
  434. forum: 'forum', 'forum-reply': 'forum',
  435. feed: 'feed'
  436. };
  437. const activitySectionForItem = (item) => {
  438. if (item.contentType === 'media' && item.mediaType) {
  439. const map = { image: 'images', audio: 'audios', video: 'videos', document: 'documents', bookmark: 'bookmarks' };
  440. return map[item.mediaType] || 'images';
  441. }
  442. return activitySectionMap[item.contentType] || 'activity';
  443. };
  444. const activityMediaTypeName = (mt) => {
  445. const map = { image: i18n.tribeSectionImages, audio: i18n.tribeSectionAudios, video: i18n.tribeSectionVideos, document: i18n.tribeSectionDocuments, bookmark: i18n.tribeSectionBookmarks };
  446. return map[mt] || i18n.tribeSectionMedia || 'MEDIA';
  447. };
  448. const renderTribeActivitySection = (tribe, sectionData) => {
  449. const { activities } = sectionData || { activities: [] };
  450. if (activities.length === 0) return div({ class: 'tribe-content-list' }, p(i18n.tribeActivityEmpty));
  451. const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
  452. return div({ class: 'tribe-content-list', style: 'gap: 16px; display: flex; flex-direction: column;' },
  453. activities.slice(0, 50).map(item => {
  454. if (item.encrypted) return div({ class: 'card card-rpg' }, div({ class: 'tribe-card-body' }, p({ class: 'tribe-meta-label' }, i18n.tribeContentEncrypted || 'Encrypted content')));
  455. const date = item.timestamp ? new Date(item.timestamp).toLocaleString() : '';
  456. const typeLabel = item.contentType === 'media' && item.mediaType
  457. ? activityMediaTypeName(item.mediaType)
  458. : contentTypeName(item.contentType);
  459. const headerText = item.tribeName
  460. ? `[${String(typeLabel).toUpperCase()} · ${item.tribeName}]`
  461. : `[${String(typeLabel).toUpperCase()}]`;
  462. const targetSection = activitySectionForItem(item);
  463. const blobUrl = item.contentType === 'media' ? toBlobUrl(item.image) : null;
  464. const mediaContent =
  465. item.contentType === 'media' && item.mediaType === 'image' && blobUrl
  466. ? a({ href: blobUrl, target: '_blank' }, img({ src: blobUrl, alt: item.title || '', class: 'tribe-media-thumb' }))
  467. : item.contentType === 'media' && item.mediaType === 'audio' && blobUrl
  468. ? audio({ src: blobUrl, controls: true, class: 'tribe-media-audio' })
  469. : item.contentType === 'media' && item.mediaType === 'video' && blobUrl
  470. ? video({ src: blobUrl, controls: true, class: 'tribe-media-thumb' })
  471. : item.contentType === 'media' && item.mediaType === 'document' && blobUrl
  472. ? a({ href: blobUrl, target: '_blank', class: 'tribe-action-btn' }, i18n.readDocument || 'Read Document')
  473. : item.contentType === 'media' && item.mediaType === 'bookmark' && (item.url || item.description)
  474. ? a({ href: item.url || item.description, target: '_blank', class: 'tribe-action-btn' }, item.url || item.description)
  475. : null;
  476. return div({ class: 'card card-rpg', style: 'padding: 12px 16px;' },
  477. div({ class: 'card-header' },
  478. h2({ class: 'card-label' }, headerText),
  479. item.directUrl
  480. ? a({ href: item.directUrl, class: 'filter-btn' }, i18n.viewDetails || 'View Details')
  481. : form({ method: 'GET', action: tribeUrl },
  482. input({ type: 'hidden', name: 'section', value: targetSection }),
  483. button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details')
  484. )
  485. ),
  486. div({ class: 'tribe-card-body' },
  487. item.title ? div({ class: 'card-field' },
  488. span({ class: 'card-label' }, (i18n.title || 'Title') + ':'),
  489. span({ class: 'card-value' }, item.title)
  490. ) : null,
  491. mediaContent,
  492. item.description && !(item.contentType === 'media' && item.mediaType === 'bookmark' && item.description === item.url) ? p(item.description.substring(0, 200)) : null
  493. ),
  494. p({ class: 'card-footer' },
  495. span({ class: 'date-link' }, `${date} ${i18n.performed || ''} `),
  496. a({ href: `/author/${encodeURIComponent(item.author)}`, class: 'user-link' }, item.authorName || item.author)
  497. )
  498. );
  499. })
  500. );
  501. };
  502. const engagementScore = (item) => (item.refeeds || 0) + (Array.isArray(item.attendees) ? item.attendees.length : 0) + Object.values(item.votes || {}).reduce((s, arr) => s + (Array.isArray(arr) ? arr.length : 0), 0) + (Array.isArray(item.assignees) ? item.assignees.length : 0) + (Array.isArray(item.opinions_inhabitants) ? item.opinions_inhabitants.length : 0);
  503. const renderTribeTrendingSection = (tribe, sectionData, query) => {
  504. const { items, period } = sectionData || { items: [], period: 'all' };
  505. const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
  506. const periodBtn = (key, label) => form({ method: 'GET', action: tribeUrl }, input({ type: 'hidden', name: 'section', value: 'trending' }), input({ type: 'hidden', name: 'period', value: key }), button({ type: 'submit', class: period === key ? 'filter-btn active' : 'filter-btn' }, label));
  507. return div({ class: 'tribe-content-list' },
  508. div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionTrending)),
  509. div({ class: 'tribe-filter-bar' }, periodBtn('day', i18n.tribeTrendingPeriodDay), periodBtn('week', i18n.tribeTrendingPeriodWeek), periodBtn('all', i18n.tribeTrendingPeriodAll)),
  510. items.length === 0 ? p(i18n.tribeTrendingEmpty) :
  511. items.slice(0, 30).map((item, idx) => {
  512. if (item.encrypted) return div({ class: 'tribe-content-card' }, div({ class: 'tribe-content-meta' }, span(`#${idx + 1}`)), p({ class: 'tribe-meta-label' }, i18n.tribeContentEncrypted || 'Encrypted content'));
  513. return div({ class: 'tribe-content-card' },
  514. div({ class: 'tribe-content-meta' },
  515. span(`#${idx + 1}`),
  516. span({ class: 'tribe-badge' }, contentTypeName(item.contentType)),
  517. span(`${i18n.tribeTrendingEngagement}: ${engagementScore(item)}`),
  518. (() => { const entries = Object.entries(item.opinions || {}); if (!entries.length) return null; const top = entries.reduce((a, b) => b[1] > a[1] ? b : a); return span(`| ${i18n.tribeTopCategory}: ${i18n['opinionCat' + top[0].charAt(0).toUpperCase() + top[0].slice(1)] || top[0]}`); })()
  519. ),
  520. item.title ? h2(item.title) : item.description ? h2(item.description) : null,
  521. div({ class: 'tribe-content-meta' },
  522. item.refeeds ? span(`${i18n.tribeActivityRefeed}: ${item.refeeds}`) : null,
  523. Array.isArray(item.attendees) && item.attendees.length ? span(`${i18n.tribeEventAttendees}: ${item.attendees.length}`) : null
  524. ),
  525. p({ class: 'tribe-meta-label' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(item.author)}` }, item.author))
  526. ); })
  527. );
  528. };
  529. const renderTribeSearchSection = (tribe, sectionData, query) => {
  530. const { results } = sectionData || { results: [] };
  531. const sq = sectionData?.query || '';
  532. const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
  533. return div({ class: 'tribe-content-list' },
  534. div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionSearch)),
  535. form({ method: 'GET', action: tribeUrl, class: 'tribe-search-form' },
  536. input({ type: 'hidden', name: 'section', value: 'search' }),
  537. input({ type: 'text', name: 'q', value: sq, placeholder: i18n.tribeSearchPlaceholder, minlength: 2 }),
  538. button({ type: 'submit', class: 'create-button' }, i18n.tribeSectionSearch)
  539. ),
  540. sq.length > 0 && sq.length < 2 ? p(i18n.tribeSearchMinChars) : null,
  541. sq.length >= 2 ? div(
  542. h2(`${i18n.tribeSearchResults}: ${results.length}`),
  543. results.length === 0 ? p(i18n.tribeSearchEmpty) :
  544. results.map(item => div({ class: 'card card-rpg' },
  545. div({ class: 'card-header' },
  546. h2({ class: 'card-label' }, `[${String(contentTypeName(item.contentType)).toUpperCase()}]`)
  547. ),
  548. div({ class: 'card-body' },
  549. item.title ? div({ class: 'card-field' },
  550. span({ class: 'card-label' }, (i18n.title || 'Title') + ':'),
  551. span({ class: 'card-value' }, item.title)
  552. ) : null,
  553. item.description ? p(item.description.substring(0, 200)) : null
  554. ),
  555. p({ class: 'card-footer' },
  556. span({ class: 'date-link' }, new Date(item.createdAt).toLocaleDateString()),
  557. a({ class: 'user-link', href: `/author/${encodeURIComponent(item.author)}` }, item.author)
  558. )
  559. ))
  560. ) : null
  561. );
  562. };
  563. const renderOverviewSection = (tribe, query, sectionData) => {
  564. const feed = Array.isArray(sectionData?.feed) ? sectionData.feed : [];
  565. const recentFeed = [...feed]
  566. .sort((a, b) => (Date.parse(b.createdAt) || b._ts || 0) - (Date.parse(a.createdAt) || a._ts || 0))
  567. .slice(0, 5);
  568. const events = Array.isArray(sectionData?.events) ? sectionData.events.slice(0, 3) : [];
  569. const tasks = Array.isArray(sectionData?.tasks) ? sectionData.tasks.slice(0, 3) : [];
  570. return div({ class: 'tribe-overview-grid' },
  571. div({ class: 'tribe-overview-section' },
  572. h2(i18n.tribeSectionFeed),
  573. recentFeed.length === 0 ? p(i18n.tribeFeedEmpty) :
  574. recentFeed.map(m =>
  575. div({ class: 'feed-item' },
  576. p(`${new Date(m.createdAt).toLocaleString()} — `, a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)),
  577. p(...renderUrl(m.description))
  578. )
  579. )
  580. ),
  581. div({ class: 'tribe-overview-section' },
  582. h2(i18n.tribeSectionEvents),
  583. events.length === 0 ? p(i18n.tribeEventsEmpty) :
  584. events.map(e =>
  585. div({ class: 'tribe-content-card' },
  586. h2(e.title),
  587. div({ class: 'tribe-content-meta' },
  588. e.date ? span(e.date) : null,
  589. e.location ? span(e.location) : null,
  590. statusBadge(e.status)
  591. )
  592. )
  593. )
  594. ),
  595. div({ class: 'tribe-overview-section' },
  596. h2(i18n.tribeSectionTasks),
  597. tasks.length === 0 ? p(i18n.tribeTasksEmpty) :
  598. tasks.map(t =>
  599. div({ class: 'tribe-content-card' },
  600. h2(t.title),
  601. div({ class: 'tribe-content-meta' },
  602. priorityLabel(t.priority),
  603. statusBadge(t.status)
  604. )
  605. )
  606. )
  607. ),
  608. div({ class: 'tribe-overview-section' },
  609. h2(i18n.tribeSectionInhabitants),
  610. p(`${i18n.tribeMembersCount}: ${tribe.members.length}`),
  611. tribe.members.slice(0, 6).map(m =>
  612. a({ class: 'user-link', href: `/author/${encodeURIComponent(m)}` }, m),
  613. )
  614. )
  615. );
  616. };
  617. const renderInhabitantsSection = (tribe, members) => {
  618. const resolved = Array.isArray(members) ? members : [];
  619. if (resolved.length === 0) return p(i18n.tribeInhabitantsEmpty);
  620. return div({ class: 'tribe-thumb-grid' },
  621. resolved.map(m =>
  622. a({ href: `/author/${encodeURIComponent(m.id)}`, class: 'tribe-thumb-link', title: m.name || m.id },
  623. img({ src: resolvePhoto(m.photo), class: 'tribe-thumb-img', alt: m.name || m.id })
  624. )
  625. )
  626. );
  627. };
  628. const createButtonI18n = {
  629. events: () => i18n.tribeEventCreate || 'Create Event',
  630. tasks: () => i18n.tribeTaskCreate || 'Create Task',
  631. votations: () => i18n.tribeVotationCreate || 'Create Votation',
  632. forum: () => i18n.tribeForumCreate || 'Create Forum',
  633. subtribes: () => i18n.tribeSubTribesCreate || 'Create Sub-Tribe',
  634. };
  635. const renderCreateForm = (tribe, contentType, fields) => {
  636. const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
  637. const hasFile = fields.some(f => f.type === 'file');
  638. const formAttrs = { method: 'POST', action: `${tribeUrl}/${contentType}/create` };
  639. if (hasFile) formAttrs.enctype = 'multipart/form-data';
  640. const btnLabel = createButtonI18n[contentType] ? createButtonI18n[contentType]() : i18n.tribeCreateButton;
  641. return div({ class: 'create-tribe-form' },
  642. form(formAttrs,
  643. ...fields.map(f => {
  644. const prefix = f.spaceBefore ? [br()] : [];
  645. if (f.type === 'textarea') return [...prefix, label({ for: f.name }, f.label), br, textarea({ name: f.name, id: f.name, rows: f.rows || 4, required: f.required, placeholder: f.placeholder }, ''), br()];
  646. if (f.type === 'select') return [...prefix, label({ for: f.name }, f.label), br, select({ name: f.name, id: f.name }, ...f.options.map(o => option({ value: o.value }, o.label))), br()];
  647. if (f.type === 'file') return [...prefix, label({ for: f.name }, f.label), br, input({ type: 'file', name: f.name, id: f.name, accept: f.accept || '*/*' }), br()];
  648. const attrs = { type: f.type || 'text', name: f.name, id: f.name, required: f.required, placeholder: f.placeholder };
  649. if (f.min) attrs.min = f.min;
  650. return [...prefix, br, label({ for: f.name }, f.label), br, input(attrs), br()];
  651. }).flat(),br(),
  652. button({ type: 'submit', class: 'create-button' }, btnLabel)
  653. )
  654. );
  655. };
  656. const renderEventsSection = (tribe, items, query) => {
  657. const events = Array.isArray(items) ? items : [];
  658. const action = query.action;
  659. const today = new Date().toISOString().split('T')[0];
  660. if (action === 'create') {
  661. return renderCreateForm(tribe, 'events', [
  662. { name: 'title', label: i18n.tribeEventTitle, required: true, placeholder: i18n.tribeEventTitle },
  663. { name: 'description', type: 'textarea', label: i18n.tribeEventDescription, required: true, placeholder: i18n.tribeEventDescription },
  664. { name: 'date', type: 'date', label: i18n.tribeEventDate, required: true, min: today },
  665. { name: 'location', label: i18n.tribeEventLocation, placeholder: i18n.tribeEventLocation },
  666. ]);
  667. }
  668. const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
  669. return div({ class: 'tribe-content-list' },
  670. div({ class: 'tribe-content-header' },
  671. h2(i18n.tribeSectionEvents),
  672. form({ method: 'GET', action: tribeUrl },
  673. input({ type: 'hidden', name: 'section', value: 'events' }),
  674. input({ type: 'hidden', name: 'action', value: 'create' }),
  675. button({ type: 'submit', class: 'create-button' }, i18n.tribeEventCreate)
  676. )
  677. ),
  678. events.length === 0 ? p(i18n.tribeEventsEmpty) :
  679. events.map(e => div({ class: 'tribe-content-card' },
  680. h2(e.title),
  681. e.description ? p(...renderUrl(e.description)) : null,
  682. e.date ? div({ class: 'card-field' },
  683. span({ class: 'card-label' }, (i18n.tribeEventDate || 'Date') + ':'),
  684. span({ class: 'card-value' }, e.date)
  685. ) : null,
  686. e.location ? div({ class: 'card-field' },
  687. span({ class: 'card-label' }, (i18n.tribeEventLocation || 'Location') + ':'),
  688. span({ class: 'card-value' }, ...renderUrl(e.location))
  689. ) : null,
  690. div({ class: 'card-field' },
  691. span({ class: 'card-label' }, (i18n.tribeEventAttendees || 'Attendees') + ':'),
  692. span({ class: 'card-value' }, String((e.attendees || []).length))
  693. ),
  694. statusBadge(e.status),
  695. div({ class: 'tribe-content-actions' },
  696. form({ method: 'POST', action: `${tribeUrl}/events/attend/${encodeURIComponent(e.id)}` },
  697. button({ type: 'submit', class: 'filter-btn' },
  698. (e.attendees || []).includes(userId) ? i18n.tribeEventUnattend : i18n.tribeEventAttend
  699. )
  700. ),
  701. e.author === userId ? form({ method: 'POST', action: `${tribeUrl}/content/delete/${encodeURIComponent(e.id)}` },
  702. button({ type: 'submit', class: 'filter-btn' }, i18n.tribeContentDelete)
  703. ) : null
  704. )
  705. ))
  706. );
  707. };
  708. const renderTasksSection = (tribe, items, query) => {
  709. const tasks = Array.isArray(items) ? items : [];
  710. const action = query.action;
  711. const today = new Date().toISOString().split('T')[0];
  712. if (action === 'create') {
  713. return renderCreateForm(tribe, 'tasks', [
  714. { name: 'title', label: i18n.tribeTaskTitle, required: true, placeholder: i18n.tribeTaskTitle },
  715. { name: 'description', type: 'textarea', label: i18n.tribeTaskDescription, required: true, placeholder: i18n.tribeTaskDescription },
  716. { name: 'priority', type: 'select', label: i18n.tribeTaskPriority, options: [
  717. { value: 'LOW', label: i18n.tribePriorityLow }, { value: 'MEDIUM', label: i18n.tribePriorityMedium },
  718. { value: 'HIGH', label: i18n.tribePriorityHigh }, { value: 'CRITICAL', label: i18n.tribePriorityCritical }
  719. ]},
  720. { name: 'deadline', type: 'date', label: i18n.tribeTaskDeadline, min: today },
  721. ]);
  722. }
  723. const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
  724. return div({ class: 'tribe-content-list' },
  725. div({ class: 'tribe-content-header' },
  726. h2(i18n.tribeSectionTasks),
  727. form({ method: 'GET', action: tribeUrl },
  728. input({ type: 'hidden', name: 'section', value: 'tasks' }),
  729. input({ type: 'hidden', name: 'action', value: 'create' }),
  730. button({ type: 'submit', class: 'create-button' }, i18n.tribeTaskCreate)
  731. )
  732. ),
  733. tasks.length === 0 ? p(i18n.tribeTasksEmpty) :
  734. tasks.map(t => div({ class: 'tribe-content-card' },
  735. h2(t.title),
  736. t.description ? p(...renderUrl(t.description)) : null,
  737. div({ class: 'card-field' },
  738. span({ class: 'card-label' }, (i18n.tribeTaskPriority || 'Priority') + ':'),
  739. priorityLabel(t.priority)
  740. ),
  741. div({ class: 'card-field' },
  742. span({ class: 'card-label' }, (i18n.tribeStatusLabel || 'Status') + ':'),
  743. statusBadge(t.status)
  744. ),
  745. div({ class: 'card-field' },
  746. span({ class: 'card-label' }, (i18n.tribeTaskAssignees || 'Assignees') + ':'),
  747. span({ class: 'card-value' }, String((t.assignees || []).length))
  748. ),
  749. br(),
  750. t.deadline ? div({ class: 'card-field' },
  751. span({ class: 'card-label' }, (i18n.tribeTaskDeadline || 'Deadline') + ':'),
  752. span({ class: 'card-value' }, t.deadline)
  753. ) : null,
  754. div({ class: 'tribe-content-actions' },
  755. form({ method: 'POST', action: `${tribeUrl}/tasks/assign/${encodeURIComponent(t.id)}` },
  756. button({ type: 'submit', class: 'filter-btn' },
  757. (t.assignees || []).includes(userId) ? i18n.tribeTaskUnassign : i18n.tribeTaskAssign
  758. )
  759. ),
  760. t.status !== 'IN-PROGRESS' && t.author === userId ? form({ method: 'POST', action: `${tribeUrl}/tasks/status/${encodeURIComponent(t.id)}` },
  761. input({ type: 'hidden', name: 'status', value: 'IN-PROGRESS' }),
  762. button({ type: 'submit', class: 'filter-btn' }, i18n.tribeTaskStatusInProgress)
  763. ) : null,
  764. t.status !== 'CLOSED' && t.author === userId ? form({ method: 'POST', action: `${tribeUrl}/tasks/status/${encodeURIComponent(t.id)}` },
  765. input({ type: 'hidden', name: 'status', value: 'CLOSED' }),
  766. button({ type: 'submit', class: 'filter-btn' }, i18n.tribeTaskStatusClosed)
  767. ) : null,
  768. t.author === userId ? form({ method: 'POST', action: `${tribeUrl}/content/delete/${encodeURIComponent(t.id)}` },
  769. button({ type: 'submit', class: 'filter-btn' }, i18n.tribeContentDelete)
  770. ) : null
  771. )
  772. ))
  773. );
  774. };
  775. const renderVotationsSection = (tribe, items, query) => {
  776. const votations = Array.isArray(items) ? items : [];
  777. const action = query.action;
  778. const today = new Date().toISOString().split('T')[0];
  779. if (action === 'create') {
  780. return div({ class: 'create-tribe-form' },
  781. form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/votations/create` },
  782. label({ for: 'title' }, i18n.tribeVotationTitle), br,
  783. input({ type: 'text', name: 'title', id: 'title', required: true, placeholder: i18n.tribeVotationTitle }), br(),
  784. label({ for: 'description' }, i18n.tribeVotationDescription), br,
  785. textarea({ name: 'description', id: 'description', rows: 3, placeholder: i18n.tribeVotationDescription }, ''), br(),
  786. label({ for: 'deadline' }, i18n.tribeVotationDeadline), br,
  787. input({ type: 'date', name: 'deadline', id: 'deadline', min: today }), br(),
  788. br(),
  789. label(i18n.tribeVotationOptions), br,
  790. input({ type: 'text', name: 'option1', placeholder: `${i18n.tribeVotationOptionPlaceholder} 1`, required: true }), br(),
  791. input({ type: 'text', name: 'option2', placeholder: `${i18n.tribeVotationOptionPlaceholder} 2`, required: true }), br(),
  792. input({ type: 'text', name: 'option3', placeholder: `${i18n.tribeVotationOptionPlaceholder} 3` }), br(),
  793. input({ type: 'text', name: 'option4', placeholder: `${i18n.tribeVotationOptionPlaceholder} 4` }), br(),
  794. button({ type: 'submit', class: 'create-button' }, i18n.tribeVotationCreate)
  795. )
  796. );
  797. }
  798. const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
  799. return div({ class: 'tribe-content-list' },
  800. div({ class: 'tribe-content-header' },
  801. h2(i18n.tribeSectionVotations),
  802. form({ method: 'GET', action: tribeUrl },
  803. input({ type: 'hidden', name: 'section', value: 'votations' }),
  804. input({ type: 'hidden', name: 'action', value: 'create' }),
  805. button({ type: 'submit', class: 'create-button' }, i18n.tribeVotationCreate)
  806. )
  807. ),
  808. votations.length === 0 ? p(i18n.tribeVotationsEmpty) :
  809. votations.map(v => {
  810. const opts = Array.isArray(v.options) ? v.options : [];
  811. const votes = v.votes || {};
  812. const totalVotes = Object.values(votes).reduce((sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0), 0);
  813. const hasVoted = Object.values(votes).some(arr => Array.isArray(arr) && arr.includes(userId));
  814. const isOpen = v.status === 'OPEN';
  815. return div({ class: 'tribe-content-card' },
  816. h2(v.title),
  817. v.description ? p(...renderUrl(v.description)) : null,
  818. statusBadge(v.status),
  819. v.deadline ? div({ class: 'card-field' },
  820. span({ class: 'card-label' }, (i18n.tribeVotationDeadline || 'Deadline') + ':'),
  821. span({ class: 'card-value' }, v.deadline)
  822. ) : null,
  823. div({ class: 'card-field' },
  824. span({ class: 'card-label' }, (i18n.tribeVotationResults || 'Votes') + ':'),
  825. span({ class: 'card-value' }, String(totalVotes))
  826. ),
  827. br(),
  828. div({ class: 'tribe-votation-options' },
  829. opts.map((opt, idx) => {
  830. const count = Array.isArray(votes[String(idx)]) ? votes[String(idx)].length : 0;
  831. const pct = totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0;
  832. const roundedPct = Math.round(pct / 5) * 5;
  833. return div({ class: 'tribe-votation-option' },
  834. span({ class: 'tribe-votation-label' }, opt),
  835. div({ class: 'tribe-votation-bar' },
  836. div({ class: `tribe-votation-fill tribe-fill-${roundedPct}` })
  837. ),
  838. span({ class: 'tribe-votation-count' }, `${count} (${pct}%)`),
  839. isOpen && !hasVoted ? form({ method: 'POST', action: `${tribeUrl}/votations/${encodeURIComponent(v.id)}/vote` },
  840. input({ type: 'hidden', name: 'optionIndex', value: String(idx) }),
  841. button({ type: 'submit', class: 'filter-btn' }, i18n.tribeVotationVote)
  842. ) : null
  843. );
  844. })
  845. ),
  846. v.author === userId ? div({ class: 'tribe-content-actions' },
  847. isOpen ? form({ method: 'POST', action: `${tribeUrl}/votations/close/${encodeURIComponent(v.id)}` },
  848. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeVotationClose)
  849. ) : null,
  850. form({ method: 'POST', action: `${tribeUrl}/content/delete/${encodeURIComponent(v.id)}` },
  851. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeContentDelete)
  852. )
  853. ) : null
  854. );
  855. })
  856. );
  857. };
  858. const renderForumSection = (tribe, items, query) => {
  859. const allItems = Array.isArray(items) ? items : [];
  860. const threads = allItems.filter(i => i.contentType === 'forum');
  861. const action = query.action;
  862. const threadId = query.thread;
  863. const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
  864. if (action === 'create') {
  865. return renderCreateForm(tribe, 'forum', [
  866. { name: 'title', label: i18n.tribeForumTitle, required: true, placeholder: i18n.tribeForumTitle },
  867. { name: 'description', type: 'textarea', label: i18n.tribeForumText, required: true, placeholder: i18n.tribeForumText, rows: 6 },
  868. { name: 'category', type: 'select', label: i18n.tribeForumCategory, options: [
  869. { value: 'GENERAL', label: i18n.tribeForumCatGeneral }, { value: 'PROPOSAL', label: i18n.tribeForumCatProposal },
  870. { value: 'QUESTION', label: i18n.tribeForumCatQuestion }, { value: 'ANNOUNCEMENT', label: i18n.tribeForumCatAnnouncement }
  871. ]}
  872. ]);
  873. }
  874. if (threadId) {
  875. const thread = allItems.find(i => i.id === threadId);
  876. const replies = allItems.filter(i => i.contentType === 'forum-reply' && i.parentId === threadId)
  877. .sort((a, b) => (a.refeeds || 0) - (b.refeeds || 0) !== 0 ? (b.refeeds || 0) - (a.refeeds || 0) : (Date.parse(a.createdAt) || 0) - (Date.parse(b.createdAt) || 0));
  878. if (!thread) return p(i18n.tribeForumEmpty);
  879. const replyCount = replies.length;
  880. return div({ class: 'tribe-content-list' },
  881. form({ method: 'GET', action: tribeUrl },
  882. input({ type: 'hidden', name: 'section', value: 'forum' }),
  883. button({ type: 'submit', class: 'filter-btn' }, i18n.walletBack)
  884. ),
  885. div({ class: 'forum-card forum-thread-header' },
  886. div({ class: 'forum-score-col' },
  887. div({ class: 'forum-score-box' },
  888. form({ method: 'POST', action: `${tribeUrl}/forum/${encodeURIComponent(thread.id)}/refeed` },
  889. button({ type: 'submit', class: 'score-btn' }, '▲')
  890. ),
  891. div({ class: 'score-total' }, String(thread.refeeds || 0)),
  892. )
  893. ),
  894. div({ class: 'forum-main-col' },
  895. div({ class: 'forum-header-row' },
  896. thread.category ? span({ class: 'forum-category' }, `[${forumCatI18n()[thread.category] || thread.category}]`) : null,
  897. span({ class: 'forum-title' }, thread.title)
  898. ),
  899. div({ class: 'forum-footer' },
  900. span({ class: 'date-link' }, `${new Date(thread.createdAt).toLocaleString()} ${i18n.performed || ''}`),
  901. a({ href: `/author/${encodeURIComponent(thread.author)}`, class: 'user-link' }, thread.author)
  902. ),
  903. div({ class: 'forum-body' }, ...renderUrl(thread.description || '')),
  904. div({ class: 'forum-meta' },
  905. span({ class: 'forum-positive-votes' }, `▲: ${thread.refeeds || 0}`),
  906. span({ class: 'forum-messages' }, `${(i18n.forumMessages || i18n.tribeForumReplies || 'MESSAGES').toUpperCase()}: ${replyCount}`)
  907. )
  908. )
  909. ),
  910. div({ class: 'tribe-forum-reply-form' },
  911. form({ method: 'POST', action: `${tribeUrl}/forum/${encodeURIComponent(threadId)}/reply` },
  912. textarea({ name: 'description', rows: 3, required: true, placeholder: i18n.tribeForumReply }),
  913. br(),
  914. button({ type: 'submit', class: 'forum-send-btn' }, i18n.tribeForumReply)
  915. )
  916. ),
  917. replies.length > 0
  918. ? replies.map((r, idx) =>
  919. div({ class: `forum-comment${idx === 0 ? ' highlighted-reply' : ''}` },
  920. div({ class: 'comment-header' },
  921. span({ class: 'date-link' }, `${new Date(r.createdAt).toLocaleString()} ${i18n.performed || ''}`),
  922. a({ href: `/author/${encodeURIComponent(r.author)}`, class: 'user-link' }, r.author),
  923. div({ class: 'comment-votes' },
  924. span({ class: 'forum-positive-votes' }, `▲: ${r.refeeds || 0}`)
  925. )
  926. ),
  927. div({ class: 'comment-body-row' },
  928. div({ class: 'comment-vote-col' },
  929. div({ class: 'forum-score-box' },
  930. form({ method: 'POST', action: `${tribeUrl}/forum/${encodeURIComponent(r.id)}/refeed` },
  931. button({ type: 'submit', class: 'score-btn' }, '▲')
  932. ),
  933. div({ class: 'score-total' }, String(r.refeeds || 0))
  934. )
  935. ),
  936. div({ class: 'comment-text-col' },
  937. ...(r.description || '').split('\n').map(l => l.trim()).filter(l => l).map(l => p(...renderUrl(l)))
  938. )
  939. ),
  940. r.author === userId ? div({ class: 'tribe-content-actions' },
  941. form({ method: 'POST', action: `${tribeUrl}/content/delete/${encodeURIComponent(r.id)}` },
  942. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeContentDelete)
  943. )
  944. ) : null
  945. )
  946. )
  947. : null
  948. );
  949. }
  950. const replyMap = new Map();
  951. allItems.filter(i => i.contentType === 'forum-reply').forEach(r => { replyMap.set(r.parentId, (replyMap.get(r.parentId) || 0) + 1); });
  952. const sortedThreads = [...threads].sort((a, b) => ((b.refeeds || 0) + (replyMap.get(b.id) || 0)) - ((a.refeeds || 0) + (replyMap.get(a.id) || 0)));
  953. return div({ class: 'tribe-content-list' },
  954. div({ class: 'tribe-content-header' },
  955. h2(i18n.tribeSectionForum),
  956. form({ method: 'GET', action: tribeUrl },
  957. input({ type: 'hidden', name: 'section', value: 'forum' }),
  958. input({ type: 'hidden', name: 'action', value: 'create' }),
  959. button({ type: 'submit', class: 'create-button' }, i18n.tribeForumCreate)
  960. )
  961. ),
  962. sortedThreads.length === 0 ? p(i18n.tribeForumEmpty) :
  963. div({ class: 'forum-list' },
  964. sortedThreads.map(t => {
  965. const replyCount = allItems.filter(i => i.contentType === 'forum-reply' && i.parentId === t.id).length;
  966. return div({ class: 'forum-card' },
  967. div({ class: 'forum-score-col' },
  968. div({ class: 'forum-score-box' },
  969. form({ method: 'POST', action: `${tribeUrl}/forum/${encodeURIComponent(t.id)}/refeed` },
  970. button({ type: 'submit', class: 'score-btn' }, '▲')
  971. ),
  972. div({ class: 'score-total' }, String(t.refeeds || 0))
  973. )
  974. ),
  975. div({ class: 'forum-main-col' },
  976. div({ class: 'forum-header-row' },
  977. t.category ? span({ class: 'forum-category' }, `[${forumCatI18n()[t.category] || t.category}]`) : null,
  978. form({ method: 'GET', action: tribeUrl, class: 'forum-title-form' },
  979. input({ type: 'hidden', name: 'section', value: 'forum' }),
  980. input({ type: 'hidden', name: 'thread', value: t.id }),
  981. button({ type: 'submit', class: 'forum-title' }, t.title)
  982. )
  983. ),
  984. t.description ? div({ class: 'forum-body' }, ...renderUrl((t.description || '').substring(0, 200))) : null,
  985. div({ class: 'forum-meta' },
  986. span({ class: 'forum-positive-votes' }, `▲: ${t.refeeds || 0}`),
  987. span({ class: 'forum-messages' }, `${(i18n.forumMessages || i18n.tribeForumReplies || 'MESSAGES').toUpperCase()}: ${replyCount}`),
  988. form({ method: 'GET', action: tribeUrl, class: 'visit-forum-form' },
  989. input({ type: 'hidden', name: 'section', value: 'forum' }),
  990. input({ type: 'hidden', name: 'thread', value: t.id }),
  991. button({ type: 'submit', class: 'filter-btn' }, i18n.forumVisitButton || 'VISIT')
  992. )
  993. ),
  994. div({ class: 'forum-footer' },
  995. span({ class: 'date-link' }, `${new Date(t.createdAt).toLocaleString()} ${i18n.performed || ''}`),
  996. a({ href: `/author/${encodeURIComponent(t.author)}`, class: 'user-link' }, t.author)
  997. ),
  998. t.author === userId ? div({ class: 'forum-owner-actions' },
  999. form({ method: 'POST', action: `${tribeUrl}/content/delete/${encodeURIComponent(t.id)}`, class: 'forum-delete-form' },
  1000. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeContentDelete)
  1001. )
  1002. ) : null
  1003. )
  1004. );
  1005. })
  1006. )
  1007. );
  1008. };
  1009. const sectionKeyForMediaType = { image: 'images', audio: 'audios', video: 'videos', document: 'documents', bookmark: 'bookmarks' };
  1010. const acceptForMediaType = { image: 'image/*', audio: 'audio/*', video: 'video/*', document: 'application/pdf,.pdf,.doc,.docx,.txt,.odt', bookmark: null };
  1011. const sectionTitleForMediaType = (mt) => {
  1012. const map = { image: i18n.tribeSectionImages, audio: i18n.tribeSectionAudios, video: i18n.tribeSectionVideos, document: i18n.tribeSectionDocuments, bookmark: i18n.tribeSectionBookmarks };
  1013. return map[mt] || mt;
  1014. };
  1015. const renderTribeMediaTypeSection = (tribe, items, query, mediaType) => {
  1016. const allMedia = Array.isArray(items) ? items : [];
  1017. const media = allMedia.filter(m => m.mediaType === mediaType);
  1018. const action = query.action;
  1019. const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
  1020. const sectionKey = sectionKeyForMediaType[mediaType] || 'media';
  1021. const sTitle = sectionTitleForMediaType(mediaType);
  1022. const createMediaLabel = {
  1023. image: () => i18n.tribeCreateImage || 'Create Image',
  1024. audio: () => i18n.tribeCreateAudio || 'Create Audio',
  1025. video: () => i18n.tribeCreateVideo || 'Create Video',
  1026. document: () => i18n.tribeCreateDocument || 'Create Document',
  1027. bookmark: () => i18n.tribeCreateBookmark || 'Create Bookmark',
  1028. };
  1029. const mediaBtnLabel = createMediaLabel[mediaType] ? createMediaLabel[mediaType]() : i18n.tribeCreateButton;
  1030. if (action === 'create') {
  1031. if (mediaType === 'bookmark') {
  1032. return div({ class: 'create-tribe-form' },
  1033. form({ method: 'POST', action: `${tribeUrl}/media/upload` },
  1034. input({ type: 'hidden', name: 'mediaType', value: 'bookmark' }),
  1035. input({ type: 'hidden', name: 'returnSection', value: sectionKey }),
  1036. label({ for: 'title' }, i18n.tribeMediaTitle), br,
  1037. input({ type: 'text', name: 'title', id: 'title', required: true, placeholder: i18n.tribeMediaTitle }), br(),
  1038. label({ for: 'url' }, i18n.bookmarkUrlLabel || 'URL'), br,
  1039. input({ type: 'url', name: 'url', id: 'url', required: true, placeholder: 'https://' }), br(),br(),
  1040. label({ for: 'description' }, i18n.tribeMediaDescription), br,
  1041. textarea({ name: 'description', id: 'description', rows: 3, placeholder: i18n.tribeMediaDescription }, ''), br(),
  1042. button({ type: 'submit', class: 'create-button' }, mediaBtnLabel)
  1043. )
  1044. );
  1045. }
  1046. return div({ class: 'create-tribe-form' },
  1047. form({ method: 'POST', action: `${tribeUrl}/media/upload`, enctype: 'multipart/form-data' },
  1048. input({ type: 'hidden', name: 'mediaType', value: mediaType }),
  1049. input({ type: 'hidden', name: 'returnSection', value: sectionKey }),
  1050. label({ for: 'title' }, i18n.tribeMediaTitle), br,
  1051. input({ type: 'text', name: 'title', id: 'title', required: true, placeholder: i18n.tribeMediaTitle }), br(),
  1052. label({ for: 'description' }, i18n.tribeMediaDescription), br,
  1053. textarea({ name: 'description', id: 'description', rows: 3, placeholder: i18n.tribeMediaDescription }, ''), br(),
  1054. label({ for: 'media' }, i18n.tribeMediaUpload), br,
  1055. input({ type: 'file', name: 'media', id: 'media', accept: acceptForMediaType[mediaType] || '*/*', required: true }), br(), br(),
  1056. button({ type: 'submit', class: 'create-button' }, mediaBtnLabel)
  1057. )
  1058. );
  1059. }
  1060. const mediaFooter = (m) => [
  1061. p({ class: 'tribe-media-date' }, span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString())),
  1062. p({ class: 'tribe-media-author' }, a({ href: `/author/${encodeURIComponent(m.author)}`, class: 'user-link' }, m.author)),
  1063. m.author === userId ? form({ method: 'POST', action: `${tribeUrl}/content/delete/${encodeURIComponent(m.id)}` },
  1064. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeContentDelete)
  1065. ) : null
  1066. ];
  1067. const renderMediaItem = (m) => {
  1068. const blobUrl = toBlobUrl(m.image);
  1069. if (mediaType === 'image') {
  1070. return div({ class: 'tribe-media-item' },
  1071. blobUrl ? a({ href: blobUrl, target: '_blank' }, img({ src: blobUrl, alt: m.title || '', class: 'tribe-media-thumb' })) : null,
  1072. div({ class: 'tribe-media-item-info' },
  1073. m.title ? h2(m.title) : null,
  1074. m.description ? p(...renderUrl(m.description)) : null,
  1075. ...mediaFooter(m)
  1076. )
  1077. );
  1078. }
  1079. if (mediaType === 'audio') {
  1080. return div({ class: 'tribe-media-item' },
  1081. blobUrl ? audio({ src: blobUrl, controls: true, class: 'tribe-media-audio' }) : p(i18n.tribeMediaEmpty),
  1082. div({ class: 'tribe-media-item-info' },
  1083. m.title ? h2(m.title) : null,
  1084. m.description ? p(...renderUrl(m.description)) : null,
  1085. ...mediaFooter(m)
  1086. )
  1087. );
  1088. }
  1089. if (mediaType === 'video') {
  1090. return div({ class: 'tribe-media-item' },
  1091. blobUrl ? video({ src: blobUrl, controls: true, class: 'tribe-media-thumb' }) : p(i18n.tribeMediaEmpty),
  1092. div({ class: 'tribe-media-item-info' },
  1093. m.title ? h2(m.title) : null,
  1094. m.description ? p(...renderUrl(m.description)) : null,
  1095. ...mediaFooter(m)
  1096. )
  1097. );
  1098. }
  1099. if (mediaType === 'document') {
  1100. return div({ class: 'tribe-media-item' },
  1101. blobUrl ? a({ href: blobUrl, target: '_blank', class: 'tribe-action-btn' }, i18n.readDocument || 'Read Document') : p(i18n.tribeMediaEmpty),
  1102. div({ class: 'tribe-media-item-info' },
  1103. m.title ? h2(m.title) : null,
  1104. m.description ? p(...renderUrl(m.description)) : null,
  1105. ...mediaFooter(m)
  1106. )
  1107. );
  1108. }
  1109. if (mediaType === 'bookmark') {
  1110. const url = m.url || m.description || '';
  1111. return div({ class: 'tribe-media-item' },
  1112. div({ class: 'tribe-media-item-info' },
  1113. m.title ? h2(m.title) : null,
  1114. url ? div({ class: 'card-field' },
  1115. span({ class: 'card-label' }, 'URL:'),
  1116. a({ href: url, target: '_blank', class: 'card-value' }, url)
  1117. ) : null,
  1118. m.description && m.description !== url ? p(...renderUrl(m.description)) : null,
  1119. ...mediaFooter(m)
  1120. )
  1121. );
  1122. }
  1123. return null;
  1124. };
  1125. return div({ class: 'tribe-content-list' },
  1126. div({ class: 'tribe-content-header' },
  1127. h2(sTitle),
  1128. form({ method: 'GET', action: tribeUrl },
  1129. input({ type: 'hidden', name: 'section', value: sectionKey }),
  1130. input({ type: 'hidden', name: 'action', value: 'create' }),
  1131. button({ type: 'submit', class: 'create-button' }, mediaBtnLabel)
  1132. )
  1133. ),
  1134. media.length === 0 ? p(i18n.tribeMediaEmpty) :
  1135. div({ class: 'tribe-media-grid' }, media.map(renderMediaItem))
  1136. );
  1137. };
  1138. const renderSubTribesSection = (tribe, items, query) => {
  1139. const action = query.action;
  1140. const canCreate = tribe.inviteMode === 'open'
  1141. ? tribe.members.includes(userId)
  1142. : tribe.author === userId;
  1143. if (action === 'create' && canCreate) {
  1144. return renderCreateForm(tribe, 'subtribes', [
  1145. { name: 'title', label: i18n.tribeTitleLabel, required: true, placeholder: 'Name of the sub-tribe' },
  1146. { name: 'description', type: 'textarea', label: i18n.tribeDescriptionLabel, required: true, placeholder: 'Description of the sub-tribe' },
  1147. { name: 'location', label: i18n.tribeLocationLabel, placeholder: 'Where is this sub-tribe located?' },
  1148. { name: 'mapUrl', label: i18n.mapLocationTitle || 'Map Location', placeholder: i18n.mapUrlPlaceholder || '/maps/MAP_ID', spaceBefore: true },
  1149. { name: 'image', type: 'file', label: i18n.tribeImageLabel },
  1150. { name: 'tags', label: i18n.tribeTagsLabel, placeholder: i18n.tribeTagsPlaceholder, spaceBefore: true },
  1151. { name: 'inviteMode', type: 'select', label: i18n.tribeModeLabel, options: [
  1152. { value: 'open', label: i18n.tribeOpen }, { value: 'strict', label: i18n.tribeStrict }
  1153. ], spaceBefore: true },
  1154. { name: 'isAnonymous', type: 'select', label: i18n.tribeIsAnonymousLabel, options: [
  1155. { value: 'true', label: i18n.tribePrivate }, { value: 'false', label: i18n.tribePublic }
  1156. ], spaceBefore: true },
  1157. { name: 'isLARP', type: 'select', label: 'L.A.R.P.?', options: [
  1158. { value: 'false', label: i18n.tribeNo }, { value: 'true', label: i18n.tribeYes }
  1159. ], spaceBefore: true },
  1160. ]);
  1161. }
  1162. const subTribes = Array.isArray(items) ? items : [];
  1163. const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
  1164. return div({ class: 'tribe-content-list' },
  1165. canCreate ? form({ method: 'GET', action: tribeUrl },
  1166. input({ type: 'hidden', name: 'section', value: 'subtribes' }),
  1167. input({ type: 'hidden', name: 'action', value: 'create' }),
  1168. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeSubTribesCreate)
  1169. ) : null,
  1170. subTribes.length === 0
  1171. ? null
  1172. : div({ class: 'tribe-thumb-grid' },
  1173. subTribes.map(st => {
  1174. return a({ href: `/tribe/${encodeURIComponent(st.id)}`, class: 'tribe-thumb-link', title: st.title },
  1175. img({ src: toImageUrl(st.image, '/assets/images/default-tribe.png'), class: 'tribe-thumb-img', alt: st.title })
  1176. );
  1177. })
  1178. )
  1179. );
  1180. };
  1181. const renderTribeMapsSection = (tribe, maps) => {
  1182. const items = Array.isArray(maps) ? maps : [];
  1183. const createBtn = form({ method: 'GET', action: '/maps' },
  1184. input({ type: 'hidden', name: 'filter', value: 'create' }),
  1185. input({ type: 'hidden', name: 'tribeId', value: tribe.id }),
  1186. button({ type: 'submit', class: 'create-button' }, i18n.mapUploadButton || 'Create Map'));
  1187. if (items.length === 0) return div({ class: 'tribe-content-list' }, createBtn, p(i18n.noMaps || 'No maps yet'));
  1188. return div({ class: 'tribe-content-list' },
  1189. div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionMaps || 'MAPS'), createBtn),
  1190. items.map(m =>
  1191. div({ class: 'card card-rpg', style: 'padding: 12px 16px;' },
  1192. div({ class: 'card-header' },
  1193. h2({ class: 'card-label' }, `[${(i18n.typeMap || 'MAP').toUpperCase()}]`),
  1194. form({ method: 'GET', action: `/maps/${encodeURIComponent(m.key)}` },
  1195. button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details'))
  1196. ),
  1197. div({ class: 'tribe-card-body' },
  1198. m.title ? div({ class: 'card-field' },
  1199. span({ class: 'card-label' }, (i18n.title || 'Title') + ':'),
  1200. span({ class: 'card-value' }, a({ href: `/maps/${encodeURIComponent(m.key)}` }, m.title))
  1201. ) : null,
  1202. m.description ? p(m.description.substring(0, 200)) : null,
  1203. m.lat && m.lng ? span({ class: 'map-coords' }, `📍 ${m.lat.toFixed(4)}, ${m.lng.toFixed(4)}`) : null
  1204. ),
  1205. p({ class: 'card-footer' },
  1206. span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()),
  1207. a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)
  1208. )
  1209. )
  1210. )
  1211. );
  1212. };
  1213. const renderTribePadsSection = (tribe, pads) => {
  1214. const items = Array.isArray(pads) ? pads : [];
  1215. const createBtn = form({ method: 'GET', action: '/pads' },
  1216. input({ type: 'hidden', name: 'filter', value: 'create' }),
  1217. input({ type: 'hidden', name: 'tribeId', value: tribe.id }),
  1218. button({ type: 'submit', class: 'create-button' }, i18n.tribePadCreate || 'Create Pad'));
  1219. if (items.length === 0) return div({ class: 'tribe-content-list' }, createBtn, p(i18n.tribePadsEmpty || 'No pads, yet.'));
  1220. return div({ class: 'tribe-content-list' },
  1221. div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionPads || 'PADS'), createBtn),
  1222. items.map(m =>
  1223. div({ class: 'card card-rpg', style: 'padding: 12px 16px;' },
  1224. div({ class: 'card-header' },
  1225. h2({ class: 'card-label' }, `[${(i18n.typePad || 'PAD').toUpperCase()}]`),
  1226. form({ method: 'GET', action: `/pads/${encodeURIComponent(m.rootId)}` },
  1227. button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details'))
  1228. ),
  1229. div({ class: 'tribe-card-body' },
  1230. m.title ? div({ class: 'card-field' },
  1231. span({ class: 'card-label' }, (i18n.title || 'Title') + ':'),
  1232. span({ class: 'card-value' }, a({ href: `/pads/${encodeURIComponent(m.rootId)}` }, m.title))
  1233. ) : null
  1234. ),
  1235. p({ class: 'card-footer' },
  1236. span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()),
  1237. a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)
  1238. )
  1239. )
  1240. )
  1241. );
  1242. };
  1243. const renderTribeChatsSection = (tribe, chats) => {
  1244. const items = Array.isArray(chats) ? chats : [];
  1245. const createBtn = form({ method: 'GET', action: '/chats' },
  1246. input({ type: 'hidden', name: 'filter', value: 'create' }),
  1247. input({ type: 'hidden', name: 'tribeId', value: tribe.id }),
  1248. button({ type: 'submit', class: 'create-button' }, i18n.tribeChatCreate || 'Create Chat'));
  1249. if (items.length === 0) return div({ class: 'tribe-content-list' }, createBtn, p(i18n.tribeChatsEmpty || 'No chats, yet.'));
  1250. return div({ class: 'tribe-content-list' },
  1251. div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionChats || 'CHATS'), createBtn),
  1252. items.map(m =>
  1253. div({ class: 'card card-rpg', style: 'padding: 12px 16px;' },
  1254. div({ class: 'card-header' },
  1255. h2({ class: 'card-label' }, `[${(i18n.typeChat || 'CHAT').toUpperCase()}]`),
  1256. form({ method: 'GET', action: `/chats/${encodeURIComponent(m.key)}` },
  1257. button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details'))
  1258. ),
  1259. div({ class: 'tribe-card-body' },
  1260. m.title ? div({ class: 'card-field' },
  1261. span({ class: 'card-label' }, (i18n.title || 'Title') + ':'),
  1262. span({ class: 'card-value' }, a({ href: `/chats/${encodeURIComponent(m.key)}` }, m.title))
  1263. ) : null,
  1264. m.description ? p(m.description.substring(0, 200)) : null
  1265. ),
  1266. p({ class: 'card-footer' },
  1267. span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()),
  1268. a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)
  1269. )
  1270. )
  1271. )
  1272. );
  1273. };
  1274. const renderTribeCalendarsSection = (tribe, calendars) => {
  1275. const items = Array.isArray(calendars) ? calendars : [];
  1276. const createBtn = form({ method: 'GET', action: '/calendars' },
  1277. input({ type: 'hidden', name: 'filter', value: 'create' }),
  1278. input({ type: 'hidden', name: 'tribeId', value: tribe.id }),
  1279. button({ type: 'submit', class: 'create-button' }, i18n.tribeCalendarCreate || 'Create Calendar'));
  1280. if (items.length === 0) return div({ class: 'tribe-content-list' }, createBtn, p(i18n.tribeCalendarsEmpty || 'No calendars, yet.'));
  1281. return div({ class: 'tribe-content-list' },
  1282. div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionCalendars || 'CALENDARS'), createBtn),
  1283. items.map(m =>
  1284. div({ class: 'card card-rpg', style: 'padding: 12px 16px;' },
  1285. div({ class: 'card-header' },
  1286. h2({ class: 'card-label' }, `[${(i18n.typeCalendar || 'CALENDAR').toUpperCase()}]`),
  1287. form({ method: 'GET', action: `/calendars/${encodeURIComponent(m.rootId)}` },
  1288. button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details'))
  1289. ),
  1290. div({ class: 'tribe-card-body' },
  1291. m.title ? div({ class: 'card-field' },
  1292. span({ class: 'card-label' }, (i18n.title || 'Title') + ':'),
  1293. span({ class: 'card-value' }, a({ href: `/calendars/${encodeURIComponent(m.rootId)}` }, m.title))
  1294. ) : null,
  1295. m.deadline ? div({ class: 'card-field' },
  1296. span({ class: 'card-label' }, (i18n.calendarDeadlineLabel || 'Deadline') + ':'),
  1297. span({ class: 'card-value' }, new Date(m.deadline).toLocaleDateString())
  1298. ) : null
  1299. ),
  1300. p({ class: 'card-footer' },
  1301. span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()),
  1302. a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)
  1303. )
  1304. )
  1305. )
  1306. );
  1307. };
  1308. exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => {
  1309. if (!tribe) {
  1310. return div({ class: 'error' }, i18n.tribeNotFound);
  1311. }
  1312. section = section || 'activity';
  1313. sectionData = sectionData || {};
  1314. const imageSrc = tribe.image;
  1315. const pageTitle = tribe.title;
  1316. let sectionContent;
  1317. switch (section) {
  1318. case 'inhabitants': sectionContent = renderInhabitantsSection(tribe, sectionData); break;
  1319. case 'feed':
  1320. sectionContent = div(
  1321. query.sent ? div({ class: 'card card-rpg', style: 'padding: 12px 16px; margin-bottom: 16px; text-align: center;' },
  1322. p({ style: 'font-weight: bold;' }, i18n.tribeFeedSent || 'Message sent successfully!')
  1323. ) : null,
  1324. tribe.members.includes(config.keys.id)
  1325. ? form({ class: 'tribe-feed-compose', method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/message` },
  1326. textarea({ name: 'message', rows: 4, maxlength: MAX_MESSAGE_LENGTH, placeholder: i18n.tribeFeedMessagePlaceholder }),
  1327. button({ type: 'submit', class: 'tribe-feed-send' }, i18n.tribeFeedSend)
  1328. )
  1329. : null,
  1330. await renderFeedTribeView(sectionData, tribe, query, query.filter)
  1331. );
  1332. break;
  1333. case 'events': sectionContent = renderEventsSection(tribe, sectionData, query); break;
  1334. case 'tasks': sectionContent = renderTasksSection(tribe, sectionData, query); break;
  1335. case 'votations': sectionContent = renderVotationsSection(tribe, sectionData, query); break;
  1336. case 'forum': sectionContent = renderForumSection(tribe, sectionData, query); break;
  1337. case 'subtribes': sectionContent = renderSubTribesSection(tribe, sectionData, query); break;
  1338. case 'search': sectionContent = renderTribeSearchSection(tribe, sectionData, query); break;
  1339. case 'images': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'image'); break;
  1340. case 'audios': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'audio'); break;
  1341. case 'videos': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'video'); break;
  1342. case 'documents': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'document'); break;
  1343. case 'bookmarks': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'bookmark'); break;
  1344. case 'maps': sectionContent = renderTribeMapsSection(tribe, sectionData); break;
  1345. case 'pads': sectionContent = renderTribePadsSection(tribe, sectionData); break;
  1346. case 'chats': sectionContent = renderTribeChatsSection(tribe, sectionData); break;
  1347. case 'calendars': sectionContent = renderTribeCalendarsSection(tribe, sectionData); break;
  1348. case 'activity':
  1349. default: sectionContent = renderTribeActivitySection(tribe, sectionData); break;
  1350. }
  1351. const subTribes = Array.isArray(tribe.subTribes) ? tribe.subTribes : [];
  1352. const tribeDetails = div({ class: 'tribe-details' },
  1353. div({ class: 'tribe-side' },
  1354. tribe.parentTribe
  1355. ? div({ class: 'tribe-parent-box' },
  1356. h2({ class: 'tribe-info-label' }, i18n.tribeMainTribeLabel || 'MAIN TRIBE'),
  1357. a({ href: `/tribe/${encodeURIComponent(tribe.parentTribe.id)}`, class: 'tribe-parent-link' },
  1358. img({ src: toBlobUrl(tribe.parentTribe.image) || '/assets/images/default-tribe.png', alt: tribe.parentTribe.title, class: 'tribe-parent-image' })
  1359. )
  1360. )
  1361. : null,
  1362. h2(tribe.isAnonymous ? "\uD83D\uDD12 " : "", tribe.title),
  1363. renderMediaBlob(imageSrc, '/assets/images/default-tribe.png', { alt: tribe.title, class: 'tribe-detail-image' }),
  1364. table({ class: 'tribe-info-table' },
  1365. tr(
  1366. td({ class: 'tribe-info-label' }, i18n.tribeCreatedAt || 'CREATED'),
  1367. td({ class: 'tribe-info-value', colspan: '3' }, new Date(tribe.createdAt).toLocaleString())
  1368. ),
  1369. tr(
  1370. td({ class: 'tribe-info-value', colspan: '4' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(tribe.author)}` }, tribe.author))
  1371. ),
  1372. tribe.location ? tr(
  1373. td({ class: 'tribe-info-label' }, i18n.tribeLocationLabel || 'LOCATION'),
  1374. td({ class: 'tribe-info-value', colspan: '3' }, ...renderUrl(tribe.location))
  1375. ) : null,
  1376. tr(
  1377. td({ class: 'tribe-info-label' }, i18n.tribeStatusLabel || 'STATUS'),
  1378. td({ class: 'tribe-info-value', colspan: '3' }, String(statusI18n()[tribe.status] || i18n.tribeStatusOpen).toUpperCase())
  1379. ),
  1380. tr(
  1381. td({ class: 'tribe-info-label' }, i18n.tribeModeLabel || 'MODE'),
  1382. td({ class: 'tribe-info-value', colspan: '3' }, String(inviteModeI18n()[tribe.inviteMode] || tribe.inviteMode).toUpperCase())
  1383. ),
  1384. tr(
  1385. td({ class: 'tribe-info-label' }, i18n.tribeLARPLabel || 'L.A.R.P.'),
  1386. td({ class: 'tribe-info-value', colspan: '3' }, tribe.isLARP ? i18n.tribeYes : i18n.tribeNo)
  1387. )
  1388. ),
  1389. h2({ class: 'tribe-members-count' }, `${i18n.tribeMembersCount}: ${tribe.members.length}`),
  1390. !tribe.parentTribeId ? div({ class: 'tribe-side-subtribes' },
  1391. (tribe.inviteMode === 'open' || tribe.author === userId)
  1392. ? form({ method: 'GET', action: `/tribe/${encodeURIComponent(tribe.id)}` },
  1393. input({ type: 'hidden', name: 'section', value: 'subtribes' }),
  1394. input({ type: 'hidden', name: 'action', value: 'create' }),
  1395. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeSubTribesCreate)
  1396. )
  1397. : null,
  1398. subTribes.length > 0
  1399. ? div({ class: 'tribe-subtribes-list' },
  1400. subTribes.map(st =>
  1401. form({ method: 'GET', action: `/tribe/${encodeURIComponent(st.id)}` },
  1402. button({ type: 'submit', class: 'tribe-subtribe-link' }, st.isAnonymous ? "\uD83D\uDD12 " : "", st.title)
  1403. )
  1404. )
  1405. )
  1406. : null
  1407. ) : null,
  1408. tribe.description ? p({ class: 'tribe-side-description' }, ...renderUrl(tribe.description)) : null,
  1409. renderMapLocationVisitLabel(tribe.mapUrl),
  1410. div({ class: 'tribe-side-actions' },
  1411. form({ method: 'POST', action: '/tribes/generate-invite' },
  1412. input({ type: 'hidden', name: 'tribeId', value: tribe.id }),
  1413. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeGenerateInvite)
  1414. ),
  1415. tribe.author === userId
  1416. ? form({ method: 'GET', action: `/tribes/edit/${encodeURIComponent(tribe.id)}` },
  1417. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeUpdateButton)
  1418. )
  1419. : null,
  1420. tribe.author === userId
  1421. ? form({ method: 'POST', action: `/tribes/delete/${encodeURIComponent(tribe.id)}` },
  1422. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeDeleteButton)
  1423. )
  1424. : null,
  1425. tribe.author !== userId
  1426. ? form({ method: 'POST', action: `/tribes/leave/${encodeURIComponent(tribe.id)}` },
  1427. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeLeaveButton)
  1428. )
  1429. : null
  1430. ),
  1431. tribe.tags && tribe.tags.filter(Boolean).length ? div({ class: 'tribe-side-tags' }, tribe.tags.filter(Boolean).map(tag =>
  1432. a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
  1433. )) : null,
  1434. ),
  1435. div({ class: 'tribe-main' },
  1436. query.inviteCode ? div({ class: 'card card-rpg', style: 'padding: 12px 16px; margin-bottom: 16px; text-align: center;' },
  1437. p({ style: 'font-weight: bold;' }, i18n.tribeInviteCodeText, query.inviteCode)
  1438. ) : null,
  1439. renderSectionNav(tribe, section),
  1440. sectionContent
  1441. )
  1442. );
  1443. return template(
  1444. pageTitle,
  1445. tribeDetails
  1446. );
  1447. };