tribes_view.js 91 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832
  1. const { div, h2, h3, p, section, button, form, a, input, img, label, select, option, br, textarea, h1, span, nav, ul, li, video, audio, table, tr, td, thead, tbody, th } = require("../server/node_modules/hyperaxe");
  2. const moment = require("../server/node_modules/moment");
  3. const { template, i18n } = require('./main_views');
  4. const { config } = require('../server/SSB_server.js');
  5. const { renderUrl } = require('../backend/renderUrl');
  6. const { renderMapLocationUrl, renderMapLocationGrid, renderMapLocationVisitLabel } = require("./maps_view");
  7. const opinion_categories = require('../backend/opinion_categories.js');
  8. const userId = config.keys.id;
  9. const DEFAULT_HASH_ENC = "%260000000000000000000000000000000000000000000%3D.sha256";
  10. const DEFAULT_HASH_PATH_RE = /\/image\/\d+\/%260000000000000000000000000000000000000000000%3D\.sha256$/;
  11. const isDefaultImageId = (v) => {
  12. if (!v) return true;
  13. if (typeof v === 'string') {
  14. if (v === DEFAULT_HASH_ENC) return true;
  15. if (DEFAULT_HASH_PATH_RE.test(v)) return true;
  16. }
  17. return false;
  18. };
  19. const resolvePhoto = (photoField) => {
  20. if (!photoField) return '/assets/images/default-avatar.png';
  21. if (typeof photoField === 'string') {
  22. if (photoField.startsWith('/assets/')) return photoField;
  23. if (photoField.startsWith('/blob/')) return photoField;
  24. if (photoField.startsWith('/image/')) {
  25. if (isDefaultImageId(photoField)) return '/assets/images/default-avatar.png';
  26. return photoField;
  27. }
  28. }
  29. return '/assets/images/default-avatar.png';
  30. };
  31. const MS_PER_DAY = 86400000;
  32. const FEED_ITEMS_PER_PAGE = 5;
  33. const MAX_MESSAGE_LENGTH = 280;
  34. const renderMediaBlob = (value, fallbackSrc = null, attrs = {}) => {
  35. if (!value) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
  36. const s = String(value).trim()
  37. if (!s) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
  38. if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}`, ...attrs })
  39. const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
  40. if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` })
  41. const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
  42. if (mAudio) return audio({ controls: true, class: attrs.class || 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` })
  43. const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
  44. if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, ...attrs })
  45. return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
  46. }
  47. const toBlobUrl = (raw) => {
  48. if (!raw) return null
  49. const s = String(raw).trim()
  50. if (s.startsWith('&')) return `/blob/${encodeURIComponent(s)}`
  51. const m = s.match(/\((&[^)\s]+\.sha256)\s*\)/)
  52. return m ? `/blob/${encodeURIComponent(m[1])}` : null
  53. }
  54. const toImageUrl = (raw, fallback) => {
  55. if (!raw) return fallback
  56. const s = String(raw).trim()
  57. if (/^\[video:|^\[audio:/.test(s)) return fallback
  58. const url = toBlobUrl(raw)
  59. return url || fallback
  60. }
  61. const filterAndSortFeed = (feed, feedFilter) => {
  62. const parseDate = (d) => typeof d === 'string' ? Date.parse(d) : d;
  63. if (feedFilter === 'MINE') return feed.filter(m => m.author === userId);
  64. if (feedFilter === 'RECENT') {
  65. const last24h = Date.now() - MS_PER_DAY;
  66. return [...feed].filter(m => parseDate(m.createdAt) >= last24h).sort((a, b) => parseDate(b.createdAt) - parseDate(a.createdAt));
  67. }
  68. if (feedFilter === 'ALL') return [...feed].sort((a, b) => parseDate(b.createdAt) - parseDate(a.createdAt));
  69. if (feedFilter === 'TOP') return [...feed].sort((a, b) => (b.refeeds || 0) - (a.refeeds || 0));
  70. return feed;
  71. };
  72. const renderGallery = (sortedTribes) => {
  73. return div({ class: "gallery" },
  74. sortedTribes.length
  75. ? sortedTribes.map(t =>
  76. a({ href: `#tribe-${encodeURIComponent(t.id)}`, class: "gallery-item" },
  77. img({ src: toImageUrl(t.image, '/assets/images/default-tribe.png'), alt: t.title || "", class: "gallery-image" })
  78. )
  79. )
  80. : p(i18n.noTribes)
  81. );
  82. };
  83. const renderLightbox = (sortedTribes) => {
  84. return sortedTribes.map(t =>
  85. div(
  86. { id: `tribe-${encodeURIComponent(t.id)}`, class: "lightbox" },
  87. a({ href: "#", class: "lightbox-close" }, "×"),
  88. img({ src: toImageUrl(t.image, '/assets/images/default-tribe.png'), class: "lightbox-image", alt: t.title || "" })
  89. )
  90. );
  91. };
  92. exports.renderInvitePage = (inviteCode) => {
  93. const pageContent = div({ class: 'invite-page' },
  94. h2(i18n.tribeInviteCodeText, inviteCode),
  95. form({ method: "GET", action: `/tribes` },
  96. button({ type: "submit", class: "filter-btn" }, i18n.walletBack)
  97. ),
  98. );
  99. return template(i18n.tribeInviteMode, section(pageContent));
  100. };
  101. exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = null) => {
  102. const now = Date.now();
  103. const search = (query.search || '').toLowerCase();
  104. const allT0 = allTribes || tribes;
  105. const isAncestorPrivate = (t) => {
  106. let cur = t;
  107. const seen = new Set();
  108. while (cur && cur.parentTribeId && !seen.has(cur.parentTribeId)) {
  109. seen.add(cur.parentTribeId);
  110. const p = allT0.find(x => x.id === cur.parentTribeId);
  111. if (!p) break;
  112. if (p.isAnonymous) return true;
  113. cur = p;
  114. }
  115. return false;
  116. };
  117. const effectivePrivate = (t) => !!t.isAnonymous || isAncestorPrivate(t);
  118. const visible = (t) => !effectivePrivate(t) || t.author === userId || (Array.isArray(t.members) && t.members.includes(userId));
  119. const isMainTribe = (t) => !t.parentTribeId;
  120. const filtered = tribes.filter(t => {
  121. return (
  122. filter === 'all' ? visible(t) && isMainTribe(t) :
  123. filter === 'mine' ? t.author === userId :
  124. filter === 'membership' ? t.members.includes(userId) :
  125. filter === 'subtribes' ? visible(t) && !!t.parentTribeId :
  126. filter === 'recent' ? visible(t) && isMainTribe(t) && ((typeof t.createdAt === 'string' ? Date.parse(t.createdAt) : t.createdAt) >= now - MS_PER_DAY ) :
  127. filter === 'top' ? visible(t) && isMainTribe(t) :
  128. filter === 'gallery' ? visible(t) && isMainTribe(t) :
  129. filter === 'larp' ? visible(t) && isMainTribe(t) && t.isLARP === true :
  130. filter === 'create' ? true :
  131. filter === 'edit' ? true :
  132. false
  133. );
  134. });
  135. const searched = filter === 'create' || filter === 'edit' || !search
  136. ? filtered
  137. : filtered.filter(t =>
  138. (t.title && t.title.toLowerCase().includes(search)) ||
  139. (t.description && t.description.toLowerCase().includes(search))
  140. );
  141. const sorted = filter === 'top'
  142. ? [...searched].sort((a, b) => b.members.length - a.members.length)
  143. : [...searched].sort((a, b) => {
  144. const ca = typeof a.createdAt === 'string' ? Date.parse(a.createdAt) : a.createdAt;
  145. const cb = typeof b.createdAt === 'string' ? Date.parse(b.createdAt) : b.createdAt;
  146. return cb - ca;
  147. });
  148. const title =
  149. filter === 'recent' ? i18n.tribeRecentSectionTitle :
  150. filter === 'mine' ? i18n.tribeMineSectionTitle :
  151. filter === 'create' ? i18n.tribeCreateSectionTitle :
  152. filter === 'edit' ? i18n.tribeUpdateSectionTitle :
  153. filter === 'gallery' ? i18n.tribeGallerySectionTitle :
  154. filter === 'top' ? i18n.tribeTopSectionTitle :
  155. filter === 'larp' ? i18n.tribeLarpSectionTitle :
  156. filter === 'subtribes' ? (i18n.tribeSubTribes || 'SUB-TRIBES') :
  157. i18n.tribeAllSectionTitle;
  158. const header = div({ class: 'tags-header' }, h2(title), p(i18n.tribeDescription));
  159. const filters = div({ class: 'filters' },
  160. form({ method: 'GET', action: '/tribes' },
  161. input({ type: 'hidden', name: 'filter', value: filter }),
  162. input({ type: 'text', name: 'search', placeholder: i18n.searchTribesPlaceholder, value: query.search || '' }),
  163. br(),
  164. button({ type: 'submit' }, i18n.applyFilters),
  165. br()
  166. )
  167. );
  168. const modeButtons = div({ class: 'tribe-mode-buttons' },
  169. ['all','recent','mine','membership','subtribes','larp','top','gallery'].map(f =>
  170. form({ method: 'GET', action: '/tribes' },
  171. input({ type: 'hidden', name: 'filter', value: f }),
  172. button({ type: 'submit', class: filter === f ? 'filter-btn active' : 'filter-btn' },
  173. i18n[`tribeFilter${f.charAt(0).toUpperCase()+f.slice(1)}`]
  174. )
  175. )
  176. ),
  177. form({ method: 'GET', action: '/tribes/create' },
  178. button({ type: 'submit', class: 'create-button' }, i18n.tribeCreateButton)
  179. )
  180. );
  181. const isEdit = filter === 'edit' && tribeId;
  182. const tribeToEdit = (isEdit ? tribes.find(t => t.id === tribeId) : null) || {};
  183. const isSubEdit = isEdit && !!tribeToEdit.parentTribeId;
  184. const createForm = (filter === 'create' || isEdit) ? div({ class: 'create-tribe-form' },
  185. h2(isEdit ? i18n.updateTribeTitle : i18n.createTribeTitle),
  186. form({
  187. method: 'POST',
  188. action: isEdit ? `/tribes/update/${encodeURIComponent(tribeToEdit.id)}` : '/tribes/create',
  189. enctype: 'multipart/form-data'
  190. },
  191. label({ for: 'title' }, i18n.tribeTitleLabel),
  192. br,
  193. input({ type: 'text', name: 'title', id: 'title', required: true, placeholder: i18n.tribeTitlePlaceholder, value: tribeToEdit.title || '' }),
  194. br(),
  195. label({ for: 'description' }, i18n.tribeDescriptionLabel),
  196. br,
  197. textarea({ name: 'description', id: 'description', required: true, rows: 4, cols: 50, placeholder: i18n.tribeDescriptionPlaceholder }, tribeToEdit.description || ''),
  198. br,
  199. label({ for: 'location' }, i18n.tribeLocationLabel),
  200. br,
  201. input({ type: 'text', name: 'location', id: 'location', placeholder: i18n.tribeLocationPlaceholder, value: tribeToEdit.location || '' }),
  202. br,
  203. label(i18n.mapLocationTitle || 'Map Location'),
  204. br,
  205. input({ type: 'text', name: 'mapUrl', placeholder: i18n.mapUrlPlaceholder || '/maps/MAP_ID', value: tribeToEdit.mapUrl || '' }),
  206. br,
  207. label({ for: 'image' }, i18n.tribeImageLabel),
  208. br,
  209. input({ type: 'file', name: 'image', id: 'image' }),
  210. br(), br(),
  211. label({ for: 'tags' }, i18n.tribeTagsLabel),
  212. br,
  213. input({ type: 'text', name: 'tags', id: 'tags', placeholder: i18n.tribeTagsPlaceholder, value: (tribeToEdit.tags || []).join(', ') }),
  214. br,
  215. isSubEdit ? null : label({ for: 'isAnonymous' }, i18n.tribeIsAnonymousLabel),
  216. isSubEdit ? null : br,
  217. isSubEdit ? null : select({ name: 'isAnonymous', id: 'isAnonymous' },
  218. option({ value: 'true', selected: tribeToEdit.isAnonymous === true ? 'selected' : undefined }, i18n.tribePrivate),
  219. option({ value: 'false', selected: tribeToEdit.isAnonymous === false ? 'selected' : undefined }, i18n.tribePublic)
  220. ),
  221. isSubEdit ? null : br(),
  222. isSubEdit ? null : br(),
  223. label({ for: 'inviteMode' }, i18n.tribeModeLabel),
  224. br,
  225. select({ name: 'inviteMode', id: 'inviteMode' },
  226. option({ value: 'strict', selected: tribeToEdit.inviteMode === 'strict' ? 'selected' : undefined }, i18n.tribeStrict),
  227. option({ value: 'open', selected: tribeToEdit.inviteMode === 'open' ? 'selected' : undefined }, i18n.tribeOpen)
  228. ),
  229. br(), br(),
  230. isSubEdit ? null : label({ for: 'isLARP' }, i18n.tribeIsLARPLabel),
  231. isSubEdit ? null : br,
  232. isSubEdit ? null : select({ name: 'isLARP', id: 'isLARP' },
  233. option({ value: 'false', selected: tribeToEdit.isLARP !== true ? 'selected' : undefined }, i18n.tribeNo),
  234. option({ value: 'true', selected: tribeToEdit.isLARP === true ? 'selected' : undefined }, i18n.tribeYes)
  235. ),
  236. isSubEdit ? null : br(),
  237. isSubEdit ? null : br(),
  238. button({ type: 'submit' }, isEdit ? i18n.tribeUpdateButton : i18n.tribeCreateButton)
  239. )
  240. ) : null;
  241. const allT = allTribes || tribes;
  242. const tribeCards = sorted.map(t => {
  243. const isMember = t.members.includes(userId);
  244. const parentTribe = t.parentTribeId ? allT.find(p => p.id === t.parentTribeId) : null;
  245. return div({ class: 'tribe-card' },
  246. div({ class: 'tribe-card-image-wrapper' },
  247. a({ href: `/tribe/${encodeURIComponent(t.id)}` },
  248. renderMediaBlob(t.image, '/assets/images/default-tribe.png', { class: 'tribe-card-hero-image' })
  249. ),
  250. isMember
  251. ? form({ method: 'GET', action: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-visit-btn-wrapper' },
  252. button({ type: 'submit', class: 'filter-btn' }, t.parentTribeId ? String(i18n.tribeviewSubTribeButton || 'VISIT SUB-TRIBE').toUpperCase() : String(i18n.tribeviewTribeButton || '').toUpperCase())
  253. )
  254. : null
  255. ),
  256. div({ class: 'tribe-card-body' },
  257. h2({ class: 'tribe-card-title' }, a({ href: `/tribe/${encodeURIComponent(t.id)}` }, t.isAnonymous ? "\uD83D\uDD12 " : "", t.title)),
  258. t.description ? p({ class: 'tribe-card-description' }, ...renderUrl(t.description)) : null,
  259. renderMapLocationVisitLabel(t.mapUrl),
  260. table({ class: 'tribe-info-table' },
  261. parentTribe ? tr(
  262. td({ class: 'tribe-info-label' }, i18n.tribeRootLabel || 'ROOT'),
  263. td({ class: 'tribe-info-value', colspan: '3' }, a({ href: `/tribe/${encodeURIComponent(parentTribe.id)}` }, parentTribe.title))
  264. ) : null,
  265. t.location ? tr(
  266. td({ class: 'tribe-info-label' }, i18n.tribeLocationLabel || 'LOCATION'),
  267. td({ class: 'tribe-info-value', colspan: '3' }, ...renderUrl(t.location))
  268. ) : null,
  269. !t.parentTribeId ? tr(
  270. td({ class: 'tribe-info-label' }, i18n.tribeIsAnonymousLabel || 'STATUS'),
  271. td({ class: 'tribe-info-value', colspan: '3' }, t.isAnonymous ? i18n.tribePrivate : i18n.tribePublic)
  272. ) : null,
  273. tr(
  274. td({ class: 'tribe-info-label' }, i18n.tribeModeLabel || 'MODE'),
  275. td({ class: 'tribe-info-value', colspan: '3' }, String(inviteModeI18n()[t.inviteMode] || t.inviteMode).toUpperCase())
  276. ),
  277. !t.parentTribeId ? tr(
  278. td({ class: 'tribe-info-label' }, i18n.tribeLARPLabel || 'L.A.R.P.'),
  279. td({ class: 'tribe-info-value', colspan: '3' }, t.isLARP ? i18n.tribeYes : i18n.tribeNo)
  280. ) : null
  281. ),
  282. div({ class: 'tribe-card-members' },
  283. span({ class: 'tribe-members-count' }, `${i18n.tribeMembersCount}: ${t.members.length}`)
  284. ),
  285. isMember ? div({ class: 'tribe-card-actions' },
  286. form({ method: 'POST', action: '/tribes/generate-invite' },
  287. input({ type: 'hidden', name: 'tribeId', value: t.id }),
  288. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeGenerateInvite)
  289. ),
  290. form({ method: 'POST', action: `/tribes/leave/${encodeURIComponent(t.id)}` },
  291. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeLeaveButton)
  292. )
  293. ) : null,
  294. filter === 'mine' ? div({ class: 'tribe-actions' },
  295. form({ method: 'GET', action: `/tribes/edit/${encodeURIComponent(t.id)}` }, button({ type: 'submit' }, i18n.tribeUpdateButton)),
  296. form({ method: 'POST', action: `/tribes/delete/${encodeURIComponent(t.id)}` }, button({ type: 'submit' }, i18n.tribeDeleteButton))
  297. ) : null
  298. )
  299. );
  300. });
  301. return template(
  302. title,
  303. section(header),
  304. section(filters),
  305. section(modeButtons),
  306. section(
  307. (filter === 'create' || filter === 'edit')
  308. ? createForm
  309. : filter === 'gallery'
  310. ? renderGallery(sorted.filter(t => t.isAnonymous === false))
  311. : div({ class: 'tribe-grid' },
  312. tribeCards.length > 0 ? tribeCards : p(i18n.noTribes)
  313. )
  314. ),
  315. ...renderLightbox(sorted.filter(t => t.isAnonymous === false))
  316. );
  317. };
  318. const renderFeedTribeView = async (feedItems, tribe, query = {}, filter) => {
  319. const feed = Array.isArray(feedItems) ? feedItems : [];
  320. const feedFilter = (query.feedFilter || 'RECENT').toUpperCase();
  321. const filteredFeed = filterAndSortFeed(feed, feedFilter);
  322. return div({ class: 'tribe-feed-full' },
  323. div({ class: 'feed-actions' },
  324. ['TOP', 'MINE', 'ALL', 'RECENT'].map(f =>
  325. form({ method: 'GET', action: `/tribe/${encodeURIComponent(tribe.id)}` },
  326. input({ type: 'hidden', name: 'section', value: 'feed' }),
  327. input({ type: 'hidden', name: 'feedFilter', value: f }),
  328. button({ type: 'submit', class: feedFilter === f ? 'filter-btn active' : 'filter-btn' }, i18n[`tribeFeedFilter${f}`])
  329. )
  330. )
  331. ),
  332. filteredFeed.length === 0
  333. ? p(i18n.tribeFeedEmpty)
  334. : div({ class: 'feed-list' },
  335. filteredFeed.map(m => div({ class: 'feed-item' },
  336. div({ class: 'feed-row' },
  337. div({ class: 'refeed-column' },
  338. h1(`${m.refeeds || 0}`),
  339. !(m.refeeds_inhabitants || []).includes(userId)
  340. ? form(
  341. { method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/refeed/${encodeURIComponent(m.id)}` },
  342. button({ class: 'refeed-btn' }, i18n.tribeFeedRefeed)
  343. )
  344. : null
  345. ),
  346. div({ class: 'feed-main' },
  347. p(`${new Date(m.createdAt).toLocaleString()} — `, a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)),
  348. br,
  349. p(...renderUrl(m.description))
  350. )
  351. )
  352. ))
  353. )
  354. );
  355. };
  356. const sectionLink = (tribe, sectionKey, label, currentSection) =>
  357. form({ method: 'GET', action: `/tribe/${encodeURIComponent(tribe.id)}` },
  358. input({ type: 'hidden', name: 'section', value: sectionKey }),
  359. button({ type: 'submit', class: currentSection === sectionKey ? 'filter-btn active' : 'filter-btn' }, label)
  360. );
  361. const renderSectionNav = (tribe, section) => {
  362. const firstGroup = [{ key: 'activity', label: i18n.tribeSectionActivity }, { key: 'inhabitants', label: i18n.tribeSectionInhabitants }];
  363. if (!tribe.parentTribeId) firstGroup.push({ key: 'subtribes', label: i18n.tribeSubTribes });
  364. if (!tribe.parentTribeId) firstGroup.push({ key: 'governance', label: i18n.tribeSectionGovernance || 'GOVERNANCE' });
  365. const sections = [
  366. { items: firstGroup },
  367. { items: [{ key: 'votations', label: i18n.tribeSectionVotations }, { key: 'events', label: i18n.tribeSectionEvents }, { key: 'tasks', label: i18n.tribeSectionTasks }] },
  368. { items: [{ key: 'feed', label: i18n.tribeSectionFeed }, { key: 'forum', label: i18n.tribeSectionForum }, { key: 'maps', label: i18n.tribeSectionMaps || 'MAPS' }, { key: 'torrents', label: i18n.tribeSectionTorrents || 'TORRENTS' }, { key: 'pads', label: i18n.tribeSectionPads || 'PADS' }, { key: 'chats', label: i18n.tribeSectionChats || 'CHATS' }, { key: 'calendars', label: i18n.tribeSectionCalendars || 'CALENDARS' }] },
  369. { 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' }] },
  370. { items: [{ key: 'tags', label: i18n.tribeSectionTags || 'TAGS' }, { key: 'search', label: i18n.tribeSectionSearch }] },
  371. ];
  372. return div({ class: 'tribe-section-nav no-border' },
  373. sections.map(g =>
  374. div({ class: 'tribe-section-group no-border' },
  375. g.items.map(s => s.href
  376. ? form({ method: 'GET', action: s.href },
  377. button({ type: 'submit', class: 'filter-btn' }, s.label)
  378. )
  379. : sectionLink(tribe, s.key, s.label, section))
  380. )
  381. )
  382. );
  383. };
  384. const statusI18n = () => ({
  385. 'OPEN': i18n.tribeStatusOpen,
  386. 'CLOSED': i18n.tribeStatusClosed,
  387. 'IN-PROGRESS': i18n.tribeStatusInProgress,
  388. });
  389. const priorityI18n = () => ({
  390. 'LOW': i18n.tribePriorityLow,
  391. 'MEDIUM': i18n.tribePriorityMedium,
  392. 'HIGH': i18n.tribePriorityHigh,
  393. 'CRITICAL': i18n.tribePriorityCritical,
  394. });
  395. const inviteModeI18n = () => ({
  396. 'strict': i18n.tribeStrict,
  397. 'open': i18n.tribeOpen,
  398. });
  399. const forumCatI18n = () => ({
  400. 'GENERAL': i18n.tribeForumCatGeneral,
  401. 'PROPOSAL': i18n.tribeForumCatProposal,
  402. 'QUESTION': i18n.tribeForumCatQuestion,
  403. 'ANNOUNCEMENT': i18n.tribeForumCatAnnouncement,
  404. });
  405. const mediaTypeI18n = () => ({
  406. 'all': i18n.tribeMediaFilterAll,
  407. 'image': i18n.tribeMediaTypeImage,
  408. 'video': i18n.tribeMediaTypeVideo,
  409. 'audio': i18n.tribeMediaTypeAudio,
  410. 'document': i18n.tribeMediaTypeDocument,
  411. 'bookmark': i18n.tribeMediaTypeBookmark,
  412. });
  413. const taskFilterI18n = () => ({
  414. 'all': i18n.tribeTaskFilterAll,
  415. 'open': i18n.tribeStatusOpen,
  416. 'in-progress': i18n.tribeStatusInProgress,
  417. 'closed': i18n.tribeStatusClosed,
  418. });
  419. const statusBadge = (status) => {
  420. const cls = status === 'OPEN' ? 'tribe-content-status-open'
  421. : status === 'CLOSED' ? 'tribe-content-status-closed'
  422. : 'tribe-content-status-in-progress';
  423. return span({ class: `tribe-content-status ${cls}` }, statusI18n()[status] || status);
  424. };
  425. const priorityLabel = (priority) => {
  426. const cls = `tribe-priority-${(priority || 'low').toLowerCase()}`;
  427. return span({ class: cls }, priorityI18n()[priority] || (priority || '').toUpperCase());
  428. };
  429. const contentTypeVerb = (ct) => {
  430. const map = { event: i18n.tribeActivityCreated, task: i18n.tribeActivityCreated, votation: i18n.tribeActivityCreated, forum: i18n.tribeActivityPosted, 'forum-reply': i18n.tribeActivityReplied, media: i18n.tribeActivityCreated, feed: i18n.tribeActivityPosted };
  431. return map[ct] || i18n.tribeActivityCreated;
  432. };
  433. const contentTypeName = (ct) => {
  434. 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' };
  435. return map[ct] || ct;
  436. };
  437. const activitySectionMap = {
  438. event: 'events', task: 'tasks', votation: 'votations',
  439. forum: 'forum', 'forum-reply': 'forum',
  440. feed: 'feed',
  441. pad: 'pads', chat: 'chats', calendar: 'calendars', map: 'maps', torrent: 'torrents'
  442. };
  443. const activitySectionForItem = (item) => {
  444. if (item.contentType === 'media' && item.mediaType) {
  445. const map = { image: 'images', audio: 'audios', video: 'videos', document: 'documents', bookmark: 'bookmarks', torrent: 'torrents' };
  446. return map[item.mediaType] || 'media';
  447. }
  448. return activitySectionMap[item.contentType] || 'activity';
  449. };
  450. const activityMediaTypeName = (mt) => {
  451. const map = { image: i18n.tribeSectionImages, audio: i18n.tribeSectionAudios, video: i18n.tribeSectionVideos, document: i18n.tribeSectionDocuments, bookmark: i18n.tribeSectionBookmarks, torrent: i18n.tribeSectionTorrents };
  452. return map[mt] || i18n.tribeSectionMedia || 'MEDIA';
  453. };
  454. const renderTribeActivitySection = (tribe, sectionData) => {
  455. const { activities } = sectionData || { activities: [] };
  456. if (activities.length === 0) return div({ class: 'tribe-content-list' }, p(i18n.tribeActivityEmpty));
  457. const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
  458. return div({ class: 'tribe-content-list tribe-content-list-spaced' },
  459. activities.slice(0, 50).map(item => {
  460. if (item.encrypted) return div({ class: 'card card-rpg' }, div({ class: 'tribe-card-body' }, p({ class: 'tribe-meta-label' }, i18n.tribeContentEncrypted || 'Encrypted content')));
  461. const date = item.timestamp ? new Date(item.timestamp).toLocaleString() : '';
  462. const typeLabel = item.contentType === 'media' && item.mediaType
  463. ? activityMediaTypeName(item.mediaType)
  464. : contentTypeName(item.contentType);
  465. const headerText = item.tribeName
  466. ? `[${String(typeLabel).toUpperCase()} · ${item.tribeName}]`
  467. : `[${String(typeLabel).toUpperCase()}]`;
  468. const targetSection = activitySectionForItem(item);
  469. const blobUrl = item.contentType === 'media' ? toBlobUrl(item.image) : null;
  470. const mediaContent =
  471. item.contentType === 'media' && item.mediaType === 'image' && blobUrl
  472. ? a({ href: blobUrl, target: '_blank' }, img({ src: blobUrl, alt: item.title || '', class: 'tribe-media-thumb' }))
  473. : item.contentType === 'media' && item.mediaType === 'audio' && blobUrl
  474. ? audio({ src: blobUrl, controls: true, class: 'tribe-media-audio' })
  475. : item.contentType === 'media' && item.mediaType === 'video' && blobUrl
  476. ? video({ src: blobUrl, controls: true, class: 'tribe-media-thumb' })
  477. : item.contentType === 'media' && item.mediaType === 'document' && blobUrl
  478. ? a({ href: blobUrl, target: '_blank', class: 'tribe-action-btn' }, i18n.readDocument || 'Read Document')
  479. : item.contentType === 'media' && item.mediaType === 'bookmark' && (item.url || item.description)
  480. ? a({ href: item.url || item.description, target: '_blank', class: 'tribe-action-btn' }, item.url || item.description)
  481. : null;
  482. return div({ class: 'card card-rpg tribe-card-padded' },
  483. div({ class: 'card-header' },
  484. h2({ class: 'card-label' }, headerText),
  485. item.directUrl
  486. ? a({ href: item.directUrl, class: 'filter-btn' }, i18n.viewDetails || 'View Details')
  487. : form({ method: 'GET', action: tribeUrl },
  488. input({ type: 'hidden', name: 'section', value: targetSection }),
  489. button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details')
  490. )
  491. ),
  492. div({ class: 'tribe-card-body' },
  493. item.title ? div({ class: 'card-field' },
  494. span({ class: 'card-value' }, item.title)
  495. ) : null,
  496. mediaContent,
  497. item.description && !(item.contentType === 'media' && item.mediaType === 'bookmark' && item.description === item.url) ? p(item.description.substring(0, 200)) : null
  498. ),
  499. p({ class: 'card-footer' },
  500. span({ class: 'date-link' }, `${date} ${i18n.performed || ''} `),
  501. a({ href: `/author/${encodeURIComponent(item.author)}`, class: 'user-link' }, item.authorName || item.author)
  502. )
  503. );
  504. })
  505. );
  506. };
  507. 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);
  508. const renderTribeTrendingSection = (tribe, sectionData, query) => {
  509. const { items, period } = sectionData || { items: [], period: 'all' };
  510. const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
  511. 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));
  512. return div({ class: 'tribe-content-list' },
  513. div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionTrending)),
  514. div({ class: 'tribe-filter-bar' }, periodBtn('day', i18n.tribeTrendingPeriodDay), periodBtn('week', i18n.tribeTrendingPeriodWeek), periodBtn('all', i18n.tribeTrendingPeriodAll)),
  515. items.length === 0 ? p(i18n.tribeTrendingEmpty) :
  516. items.slice(0, 30).map((item, idx) => {
  517. 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'));
  518. return div({ class: 'tribe-content-card' },
  519. div({ class: 'tribe-content-meta' },
  520. span(`#${idx + 1}`),
  521. span({ class: 'tribe-badge' }, contentTypeName(item.contentType)),
  522. span(`${i18n.tribeTrendingEngagement}: ${engagementScore(item)}`),
  523. (() => { 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]}`); })()
  524. ),
  525. item.title ? h2(item.title) : item.description ? h2(item.description) : null,
  526. div({ class: 'tribe-content-meta' },
  527. item.refeeds ? span(`${i18n.tribeActivityRefeed}: ${item.refeeds}`) : null,
  528. Array.isArray(item.attendees) && item.attendees.length ? span(`${i18n.tribeEventAttendees}: ${item.attendees.length}`) : null
  529. ),
  530. p({ class: 'tribe-meta-label' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(item.author)}` }, item.author))
  531. ); })
  532. );
  533. };
  534. const renderTribeTagsSection = (tribe, sectionData, query) => {
  535. const tagsList = Array.isArray(sectionData?.tags) ? sectionData.tags : [];
  536. const selectedTag = sectionData?.selectedTag || '';
  537. const filteredItems = Array.isArray(sectionData?.filteredItems) ? sectionData.filteredItems : [];
  538. const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
  539. const sortedTags = tagsList.slice().sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
  540. return section(
  541. div({ class: 'tags-header' },
  542. h2(i18n.tribeSectionTags || i18n.tagsTitle || 'TAGS'),
  543. p(i18n.tagsDescription || '')
  544. ),
  545. div({ class: 'tags-list' },
  546. tagsList.length === 0
  547. ? p(i18n.tagsNoItems || i18n.tribeTagsEmpty || 'No tags yet')
  548. : table({ class: 'tag-table' },
  549. thead(tr(
  550. th(i18n.tagsTableHeaderTag || 'Tag'),
  551. th(i18n.tagsTableHeaderCount || 'Count')
  552. )),
  553. tbody(
  554. sortedTags.map(t => tr(
  555. td(a({ href: `${tribeUrl}?section=tags&tag=${encodeURIComponent(t.tag)}` }, t.tag)),
  556. td(`${t.count}`)
  557. ))
  558. )
  559. )
  560. ),
  561. selectedTag ? div({ class: 'tribe-content-list' },
  562. h2(`#${selectedTag} (${filteredItems.length})`),
  563. filteredItems.length === 0 ? p(i18n.tribeTagsEmpty || 'No items') :
  564. filteredItems.slice(0, 50).map(item => div({ class: 'card card-rpg' },
  565. div({ class: 'card-header' }, h2({ class: 'card-label' }, `[${String(contentTypeName(item.contentType)).toUpperCase()}]`)),
  566. div({ class: 'card-body' },
  567. item.title ? div({ class: 'card-field' }, span({ class: 'card-value' }, item.title)) : null,
  568. item.description ? p(item.description.substring(0, 200)) : null
  569. ),
  570. p({ class: 'card-footer' },
  571. span({ class: 'date-link' }, new Date(item.createdAt).toLocaleString()),
  572. a({ class: 'user-link', href: `/author/${encodeURIComponent(item.author)}` }, item.author)
  573. )
  574. ))
  575. ) : null
  576. );
  577. };
  578. const renderTribeSearchSection = (tribe, sectionData, query) => {
  579. const { results } = sectionData || { results: [] };
  580. const sq = sectionData?.query || '';
  581. const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
  582. return div({ class: 'tribe-content-list' },
  583. div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionSearch)),
  584. form({ method: 'GET', action: tribeUrl, class: 'tribe-search-form' },
  585. input({ type: 'hidden', name: 'section', value: 'search' }),
  586. input({ type: 'text', name: 'q', value: sq, placeholder: i18n.tribeSearchPlaceholder, minlength: 2 }),
  587. button({ type: 'submit', class: 'create-button' }, i18n.tribeSectionSearch)
  588. ),
  589. sq.length > 0 && sq.length < 2 ? p(i18n.tribeSearchMinChars) : null,
  590. sq.length >= 2 ? div(
  591. h2(`${i18n.tribeSearchResults}: ${results.length}`),
  592. results.length === 0 ? p(i18n.tribeSearchEmpty) :
  593. results.map(item => div({ class: 'card card-rpg' },
  594. div({ class: 'card-header' },
  595. h2({ class: 'card-label' }, `[${String(contentTypeName(item.contentType)).toUpperCase()}]`)
  596. ),
  597. div({ class: 'card-body' },
  598. item.title ? div({ class: 'card-field' },
  599. span({ class: 'card-label' }, (i18n.title || 'Title') + ':'),
  600. span({ class: 'card-value' }, item.title)
  601. ) : null,
  602. item.description ? p(item.description.substring(0, 200)) : null
  603. ),
  604. p({ class: 'card-footer' },
  605. span({ class: 'date-link' }, new Date(item.createdAt).toLocaleDateString()),
  606. a({ class: 'user-link', href: `/author/${encodeURIComponent(item.author)}` }, item.author)
  607. )
  608. ))
  609. ) : null
  610. );
  611. };
  612. const renderOverviewSection = (tribe, query, sectionData) => {
  613. const feed = Array.isArray(sectionData?.feed) ? sectionData.feed : [];
  614. const recentFeed = [...feed]
  615. .sort((a, b) => (Date.parse(b.createdAt) || b._ts || 0) - (Date.parse(a.createdAt) || a._ts || 0))
  616. .slice(0, 5);
  617. const events = Array.isArray(sectionData?.events) ? sectionData.events.slice(0, 3) : [];
  618. const tasks = Array.isArray(sectionData?.tasks) ? sectionData.tasks.slice(0, 3) : [];
  619. return div({ class: 'tribe-overview-grid' },
  620. div({ class: 'tribe-overview-section' },
  621. h2(i18n.tribeSectionFeed),
  622. recentFeed.length === 0 ? p(i18n.tribeFeedEmpty) :
  623. recentFeed.map(m =>
  624. div({ class: 'feed-item' },
  625. p(`${new Date(m.createdAt).toLocaleString()} — `, a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)),
  626. p(...renderUrl(m.description))
  627. )
  628. )
  629. ),
  630. div({ class: 'tribe-overview-section' },
  631. h2(i18n.tribeSectionEvents),
  632. events.length === 0 ? p(i18n.tribeEventsEmpty) :
  633. events.map(e =>
  634. div({ class: 'tribe-content-card' },
  635. h2(e.title),
  636. div({ class: 'tribe-content-meta' },
  637. e.date ? span(e.date) : null,
  638. e.location ? span(e.location) : null,
  639. statusBadge(e.status)
  640. )
  641. )
  642. )
  643. ),
  644. div({ class: 'tribe-overview-section' },
  645. h2(i18n.tribeSectionTasks),
  646. tasks.length === 0 ? p(i18n.tribeTasksEmpty) :
  647. tasks.map(t =>
  648. div({ class: 'tribe-content-card' },
  649. h2(t.title),
  650. div({ class: 'tribe-content-meta' },
  651. priorityLabel(t.priority),
  652. statusBadge(t.status)
  653. )
  654. )
  655. )
  656. ),
  657. div({ class: 'tribe-overview-section' },
  658. h2(i18n.tribeSectionInhabitants),
  659. p(`${i18n.tribeMembersCount}: ${tribe.members.length}`),
  660. tribe.members.slice(0, 6).map(m =>
  661. a({ class: 'user-link', href: `/author/${encodeURIComponent(m)}` }, m),
  662. )
  663. )
  664. );
  665. };
  666. const renderInhabitantsSection = (tribe, members) => {
  667. const resolved = Array.isArray(members) ? members : [];
  668. if (resolved.length === 0) return p(i18n.tribeInhabitantsEmpty);
  669. return div({ class: 'tribe-thumb-grid' },
  670. resolved.map(m =>
  671. a({ href: `/author/${encodeURIComponent(m.id)}`, class: 'tribe-thumb-link', title: m.name || m.id },
  672. img({ src: resolvePhoto(m.photo), class: 'tribe-thumb-img', alt: m.name || m.id })
  673. )
  674. )
  675. );
  676. };
  677. const createButtonI18n = {
  678. events: () => i18n.tribeEventCreate || 'Create Event',
  679. tasks: () => i18n.tribeTaskCreate || 'Create Task',
  680. votations: () => i18n.tribeVotationCreate || 'Create Votation',
  681. forum: () => i18n.tribeForumCreate || 'Create Forum',
  682. subtribes: () => i18n.tribeSubTribesCreate || 'Create Sub-Tribe',
  683. };
  684. const renderCreateForm = (tribe, contentType, fields) => {
  685. const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
  686. const hasFile = fields.some(f => f.type === 'file');
  687. const formAttrs = { method: 'POST', action: `${tribeUrl}/${contentType}/create` };
  688. if (hasFile) formAttrs.enctype = 'multipart/form-data';
  689. const btnLabel = createButtonI18n[contentType] ? createButtonI18n[contentType]() : i18n.tribeCreateButton;
  690. return div({ class: 'create-tribe-form' },
  691. form(formAttrs,
  692. ...fields.map(f => {
  693. const prefix = f.spaceBefore ? [br()] : [];
  694. 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()];
  695. 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()];
  696. 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()];
  697. const attrs = { type: f.type || 'text', name: f.name, id: f.name, required: f.required, placeholder: f.placeholder };
  698. if (f.min) attrs.min = f.min;
  699. return [...prefix, br, label({ for: f.name }, f.label), br, input(attrs), br()];
  700. }).flat(),br(),
  701. button({ type: 'submit', class: 'create-button' }, btnLabel)
  702. )
  703. );
  704. };
  705. const renderEventsSection = (tribe, items, query) => {
  706. const events = Array.isArray(items) ? items : [];
  707. const action = query.action;
  708. const today = new Date().toISOString().split('T')[0];
  709. if (action === 'create') {
  710. return renderCreateForm(tribe, 'events', [
  711. { name: 'title', label: i18n.tribeEventTitle, required: true, placeholder: i18n.tribeEventTitle },
  712. { name: 'description', type: 'textarea', label: i18n.tribeEventDescription, required: true, placeholder: i18n.tribeEventDescription },
  713. { name: 'date', type: 'date', label: i18n.tribeEventDate, required: true, min: today },
  714. { name: 'location', label: i18n.tribeEventLocation, placeholder: i18n.tribeEventLocation },
  715. ]);
  716. }
  717. const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
  718. return div({ class: 'tribe-content-list' },
  719. div({ class: 'tribe-content-header' },
  720. h2(i18n.tribeSectionEvents),
  721. form({ method: 'GET', action: tribeUrl },
  722. input({ type: 'hidden', name: 'section', value: 'events' }),
  723. input({ type: 'hidden', name: 'action', value: 'create' }),
  724. button({ type: 'submit', class: 'create-button' }, i18n.tribeEventCreate)
  725. )
  726. ),
  727. events.length === 0 ? p(i18n.tribeEventsEmpty) :
  728. events.map(e => div({ class: 'tribe-content-card' },
  729. h2(e.title),
  730. e.description ? p(...renderUrl(e.description)) : null,
  731. e.date ? div({ class: 'card-field' },
  732. span({ class: 'card-label' }, (i18n.tribeEventDate || 'Date') + ':'),
  733. span({ class: 'card-value' }, e.date)
  734. ) : null,
  735. e.location ? div({ class: 'card-field' },
  736. span({ class: 'card-label' }, (i18n.tribeEventLocation || 'Location') + ':'),
  737. span({ class: 'card-value' }, ...renderUrl(e.location))
  738. ) : null,
  739. div({ class: 'card-field' },
  740. span({ class: 'card-label' }, (i18n.tribeEventAttendees || 'Attendees') + ':'),
  741. span({ class: 'card-value' }, String((e.attendees || []).length))
  742. ),
  743. statusBadge(e.status),
  744. div({ class: 'tribe-content-actions' },
  745. form({ method: 'POST', action: `${tribeUrl}/events/attend/${encodeURIComponent(e.id)}` },
  746. button({ type: 'submit', class: 'filter-btn' },
  747. (e.attendees || []).includes(userId) ? i18n.tribeEventUnattend : i18n.tribeEventAttend
  748. )
  749. ),
  750. e.author === userId ? form({ method: 'POST', action: `${tribeUrl}/content/delete/${encodeURIComponent(e.id)}` },
  751. button({ type: 'submit', class: 'filter-btn' }, i18n.tribeContentDelete)
  752. ) : null
  753. )
  754. ))
  755. );
  756. };
  757. const renderTasksSection = (tribe, items, query) => {
  758. const tasks = Array.isArray(items) ? items : [];
  759. const action = query.action;
  760. const today = new Date().toISOString().split('T')[0];
  761. if (action === 'create') {
  762. return renderCreateForm(tribe, 'tasks', [
  763. { name: 'title', label: i18n.tribeTaskTitle, required: true, placeholder: i18n.tribeTaskTitle },
  764. { name: 'description', type: 'textarea', label: i18n.tribeTaskDescription, required: true, placeholder: i18n.tribeTaskDescription },
  765. { name: 'priority', type: 'select', label: i18n.tribeTaskPriority, options: [
  766. { value: 'LOW', label: i18n.tribePriorityLow }, { value: 'MEDIUM', label: i18n.tribePriorityMedium },
  767. { value: 'HIGH', label: i18n.tribePriorityHigh }, { value: 'CRITICAL', label: i18n.tribePriorityCritical }
  768. ]},
  769. { name: 'deadline', type: 'date', label: i18n.tribeTaskDeadline, min: today },
  770. ]);
  771. }
  772. const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
  773. return div({ class: 'tribe-content-list' },
  774. div({ class: 'tribe-content-header' },
  775. h2(i18n.tribeSectionTasks),
  776. form({ method: 'GET', action: tribeUrl },
  777. input({ type: 'hidden', name: 'section', value: 'tasks' }),
  778. input({ type: 'hidden', name: 'action', value: 'create' }),
  779. button({ type: 'submit', class: 'create-button' }, i18n.tribeTaskCreate)
  780. )
  781. ),
  782. tasks.length === 0 ? p(i18n.tribeTasksEmpty) :
  783. tasks.map(t => div({ class: 'tribe-content-card' },
  784. h2(t.title),
  785. t.description ? p(...renderUrl(t.description)) : null,
  786. div({ class: 'card-field' },
  787. span({ class: 'card-label' }, (i18n.tribeTaskPriority || 'Priority') + ':'),
  788. priorityLabel(t.priority)
  789. ),
  790. div({ class: 'card-field' },
  791. span({ class: 'card-label' }, (i18n.tribeStatusLabel || 'Status') + ':'),
  792. statusBadge(t.status)
  793. ),
  794. div({ class: 'card-field' },
  795. span({ class: 'card-label' }, (i18n.tribeTaskAssignees || 'Assignees') + ':'),
  796. span({ class: 'card-value' }, String((t.assignees || []).length))
  797. ),
  798. br(),
  799. t.deadline ? div({ class: 'card-field' },
  800. span({ class: 'card-label' }, (i18n.tribeTaskDeadline || 'Deadline') + ':'),
  801. span({ class: 'card-value' }, t.deadline)
  802. ) : null,
  803. div({ class: 'tribe-content-actions' },
  804. form({ method: 'POST', action: `${tribeUrl}/tasks/assign/${encodeURIComponent(t.id)}` },
  805. button({ type: 'submit', class: 'filter-btn' },
  806. (t.assignees || []).includes(userId) ? i18n.tribeTaskUnassign : i18n.tribeTaskAssign
  807. )
  808. ),
  809. t.status !== 'IN-PROGRESS' && t.author === userId ? form({ method: 'POST', action: `${tribeUrl}/tasks/status/${encodeURIComponent(t.id)}` },
  810. input({ type: 'hidden', name: 'status', value: 'IN-PROGRESS' }),
  811. button({ type: 'submit', class: 'filter-btn' }, i18n.tribeTaskStatusInProgress)
  812. ) : null,
  813. t.status !== 'CLOSED' && t.author === userId ? form({ method: 'POST', action: `${tribeUrl}/tasks/status/${encodeURIComponent(t.id)}` },
  814. input({ type: 'hidden', name: 'status', value: 'CLOSED' }),
  815. button({ type: 'submit', class: 'filter-btn' }, i18n.tribeTaskStatusClosed)
  816. ) : null,
  817. t.author === userId ? form({ method: 'POST', action: `${tribeUrl}/content/delete/${encodeURIComponent(t.id)}` },
  818. button({ type: 'submit', class: 'filter-btn' }, i18n.tribeContentDelete)
  819. ) : null
  820. )
  821. ))
  822. );
  823. };
  824. const renderVotationsSection = (tribe, items, query) => {
  825. const votations = Array.isArray(items) ? items : [];
  826. const action = query.action;
  827. const today = new Date().toISOString().split('T')[0];
  828. if (action === 'create') {
  829. return div({ class: 'create-tribe-form' },
  830. form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/votations/create` },
  831. label({ for: 'title' }, i18n.tribeVotationTitle), br,
  832. input({ type: 'text', name: 'title', id: 'title', required: true, placeholder: i18n.tribeVotationTitle }), br(),
  833. label({ for: 'description' }, i18n.tribeVotationDescription), br,
  834. textarea({ name: 'description', id: 'description', rows: 3, placeholder: i18n.tribeVotationDescription }, ''), br(),
  835. label({ for: 'deadline' }, i18n.tribeVotationDeadline), br,
  836. input({ type: 'date', name: 'deadline', id: 'deadline', min: today }), br(),
  837. br(),
  838. label(i18n.tribeVotationOptions), br,
  839. input({ type: 'text', name: 'option1', placeholder: `${i18n.tribeVotationOptionPlaceholder} 1`, required: true }), br(),
  840. input({ type: 'text', name: 'option2', placeholder: `${i18n.tribeVotationOptionPlaceholder} 2`, required: true }), br(),
  841. input({ type: 'text', name: 'option3', placeholder: `${i18n.tribeVotationOptionPlaceholder} 3` }), br(),
  842. input({ type: 'text', name: 'option4', placeholder: `${i18n.tribeVotationOptionPlaceholder} 4` }), br(),
  843. button({ type: 'submit', class: 'create-button' }, i18n.tribeVotationCreate)
  844. )
  845. );
  846. }
  847. const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
  848. return div({ class: 'tribe-content-list' },
  849. div({ class: 'tribe-content-header' },
  850. h2(i18n.tribeSectionVotations),
  851. form({ method: 'GET', action: tribeUrl },
  852. input({ type: 'hidden', name: 'section', value: 'votations' }),
  853. input({ type: 'hidden', name: 'action', value: 'create' }),
  854. button({ type: 'submit', class: 'create-button' }, i18n.tribeVotationCreate)
  855. )
  856. ),
  857. votations.length === 0 ? p(i18n.tribeVotationsEmpty) :
  858. votations.map(v => {
  859. const opts = Array.isArray(v.options) ? v.options : [];
  860. const votes = v.votes || {};
  861. const totalVotes = Object.values(votes).reduce((sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0), 0);
  862. const hasVoted = Object.values(votes).some(arr => Array.isArray(arr) && arr.includes(userId));
  863. const isOpen = v.status === 'OPEN';
  864. return div({ class: 'tribe-content-card' },
  865. h2(v.title),
  866. v.description ? p(...renderUrl(v.description)) : null,
  867. statusBadge(v.status),
  868. v.deadline ? div({ class: 'card-field' },
  869. span({ class: 'card-label' }, (i18n.tribeVotationDeadline || 'Deadline') + ':'),
  870. span({ class: 'card-value' }, v.deadline)
  871. ) : null,
  872. div({ class: 'card-field' },
  873. span({ class: 'card-label' }, (i18n.tribeVotationResults || 'Votes') + ':'),
  874. span({ class: 'card-value' }, String(totalVotes))
  875. ),
  876. br(),
  877. div({ class: 'tribe-votation-options' },
  878. opts.map((opt, idx) => {
  879. const count = Array.isArray(votes[String(idx)]) ? votes[String(idx)].length : 0;
  880. const pct = totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0;
  881. const roundedPct = Math.round(pct / 5) * 5;
  882. return div({ class: 'tribe-votation-option' },
  883. span({ class: 'tribe-votation-label' }, opt),
  884. div({ class: 'tribe-votation-bar' },
  885. div({ class: `tribe-votation-fill tribe-fill-${roundedPct}` })
  886. ),
  887. span({ class: 'tribe-votation-count' }, `${count} (${pct}%)`),
  888. isOpen && !hasVoted ? form({ method: 'POST', action: `${tribeUrl}/votations/${encodeURIComponent(v.id)}/vote` },
  889. input({ type: 'hidden', name: 'optionIndex', value: String(idx) }),
  890. button({ type: 'submit', class: 'filter-btn' }, i18n.tribeVotationVote)
  891. ) : null
  892. );
  893. })
  894. ),
  895. v.author === userId ? div({ class: 'tribe-content-actions' },
  896. isOpen ? form({ method: 'POST', action: `${tribeUrl}/votations/close/${encodeURIComponent(v.id)}` },
  897. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeVotationClose)
  898. ) : null,
  899. form({ method: 'POST', action: `${tribeUrl}/content/delete/${encodeURIComponent(v.id)}` },
  900. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeContentDelete)
  901. )
  902. ) : null
  903. );
  904. })
  905. );
  906. };
  907. const renderForumSection = (tribe, items, query) => {
  908. const allItems = Array.isArray(items) ? items : [];
  909. const threads = allItems.filter(i => i.contentType === 'forum');
  910. const action = query.action;
  911. const threadId = query.thread;
  912. const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
  913. if (action === 'create') {
  914. return renderCreateForm(tribe, 'forum', [
  915. { name: 'title', label: i18n.tribeForumTitle, required: true, placeholder: i18n.tribeForumTitle },
  916. { name: 'description', type: 'textarea', label: i18n.tribeForumText, required: true, placeholder: i18n.tribeForumText, rows: 6 },
  917. { name: 'category', type: 'select', label: i18n.tribeForumCategory, options: [
  918. { value: 'GENERAL', label: i18n.tribeForumCatGeneral }, { value: 'PROPOSAL', label: i18n.tribeForumCatProposal },
  919. { value: 'QUESTION', label: i18n.tribeForumCatQuestion }, { value: 'ANNOUNCEMENT', label: i18n.tribeForumCatAnnouncement }
  920. ]}
  921. ]);
  922. }
  923. if (threadId) {
  924. const thread = allItems.find(i => i.id === threadId);
  925. const replies = allItems.filter(i => i.contentType === 'forum-reply' && i.parentId === threadId)
  926. .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));
  927. if (!thread) return p(i18n.tribeForumEmpty);
  928. const replyCount = replies.length;
  929. return div({ class: 'tribe-content-list' },
  930. form({ method: 'GET', action: tribeUrl },
  931. input({ type: 'hidden', name: 'section', value: 'forum' }),
  932. button({ type: 'submit', class: 'filter-btn' }, i18n.walletBack)
  933. ),
  934. div({ class: 'forum-card forum-thread-header' },
  935. div({ class: 'forum-score-col' },
  936. div({ class: 'forum-score-box' },
  937. form({ method: 'POST', action: `${tribeUrl}/forum/${encodeURIComponent(thread.id)}/refeed` },
  938. button({ type: 'submit', class: 'score-btn' }, '▲')
  939. ),
  940. div({ class: 'score-total' }, String(thread.refeeds || 0)),
  941. )
  942. ),
  943. div({ class: 'forum-main-col' },
  944. div({ class: 'forum-header-row' },
  945. thread.category ? span({ class: 'forum-category' }, `[${forumCatI18n()[thread.category] || thread.category}]`) : null,
  946. span({ class: 'forum-title' }, thread.title)
  947. ),
  948. div({ class: 'forum-footer' },
  949. span({ class: 'date-link' }, `${new Date(thread.createdAt).toLocaleString()} ${i18n.performed || ''}`),
  950. a({ href: `/author/${encodeURIComponent(thread.author)}`, class: 'user-link' }, thread.author)
  951. ),
  952. div({ class: 'forum-body' }, ...renderUrl(thread.description || '')),
  953. div({ class: 'forum-meta' },
  954. span({ class: 'forum-positive-votes' }, `▲: ${thread.refeeds || 0}`),
  955. span({ class: 'forum-messages' }, `${(i18n.forumMessages || i18n.tribeForumReplies || 'MESSAGES').toUpperCase()}: ${replyCount}`)
  956. )
  957. )
  958. ),
  959. div({ class: 'tribe-forum-reply-form' },
  960. form({ method: 'POST', action: `${tribeUrl}/forum/${encodeURIComponent(threadId)}/reply` },
  961. textarea({ name: 'description', rows: 3, required: true, placeholder: i18n.tribeForumReply }),
  962. br(),
  963. button({ type: 'submit', class: 'forum-send-btn' }, i18n.tribeForumReply)
  964. )
  965. ),
  966. replies.length > 0
  967. ? replies.map((r, idx) =>
  968. div({ class: `forum-comment${idx === 0 ? ' highlighted-reply' : ''}` },
  969. div({ class: 'comment-header' },
  970. span({ class: 'date-link' }, `${new Date(r.createdAt).toLocaleString()} ${i18n.performed || ''}`),
  971. a({ href: `/author/${encodeURIComponent(r.author)}`, class: 'user-link' }, r.author),
  972. div({ class: 'comment-votes' },
  973. span({ class: 'forum-positive-votes' }, `▲: ${r.refeeds || 0}`)
  974. )
  975. ),
  976. div({ class: 'comment-body-row' },
  977. div({ class: 'comment-vote-col' },
  978. div({ class: 'forum-score-box' },
  979. form({ method: 'POST', action: `${tribeUrl}/forum/${encodeURIComponent(r.id)}/refeed` },
  980. button({ type: 'submit', class: 'score-btn' }, '▲')
  981. ),
  982. div({ class: 'score-total' }, String(r.refeeds || 0))
  983. )
  984. ),
  985. div({ class: 'comment-text-col' },
  986. ...(r.description || '').split('\n').map(l => l.trim()).filter(l => l).map(l => p(...renderUrl(l)))
  987. )
  988. ),
  989. r.author === userId ? div({ class: 'tribe-content-actions' },
  990. form({ method: 'POST', action: `${tribeUrl}/content/delete/${encodeURIComponent(r.id)}` },
  991. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeContentDelete)
  992. )
  993. ) : null
  994. )
  995. )
  996. : null
  997. );
  998. }
  999. const replyMap = new Map();
  1000. allItems.filter(i => i.contentType === 'forum-reply').forEach(r => { replyMap.set(r.parentId, (replyMap.get(r.parentId) || 0) + 1); });
  1001. const sortedThreads = [...threads].sort((a, b) => ((b.refeeds || 0) + (replyMap.get(b.id) || 0)) - ((a.refeeds || 0) + (replyMap.get(a.id) || 0)));
  1002. return div({ class: 'tribe-content-list' },
  1003. div({ class: 'tribe-content-header' },
  1004. h2(i18n.tribeSectionForum),
  1005. form({ method: 'GET', action: tribeUrl },
  1006. input({ type: 'hidden', name: 'section', value: 'forum' }),
  1007. input({ type: 'hidden', name: 'action', value: 'create' }),
  1008. button({ type: 'submit', class: 'create-button' }, i18n.tribeForumCreate)
  1009. )
  1010. ),
  1011. sortedThreads.length === 0 ? p(i18n.tribeForumEmpty) :
  1012. div({ class: 'forum-list' },
  1013. sortedThreads.map(t => {
  1014. const replyCount = allItems.filter(i => i.contentType === 'forum-reply' && i.parentId === t.id).length;
  1015. return div({ class: 'forum-card' },
  1016. div({ class: 'forum-score-col' },
  1017. div({ class: 'forum-score-box' },
  1018. form({ method: 'POST', action: `${tribeUrl}/forum/${encodeURIComponent(t.id)}/refeed` },
  1019. button({ type: 'submit', class: 'score-btn' }, '▲')
  1020. ),
  1021. div({ class: 'score-total' }, String(t.refeeds || 0))
  1022. )
  1023. ),
  1024. div({ class: 'forum-main-col' },
  1025. div({ class: 'forum-header-row' },
  1026. t.category ? span({ class: 'forum-category' }, `[${forumCatI18n()[t.category] || t.category}]`) : null,
  1027. form({ method: 'GET', action: tribeUrl, class: 'forum-title-form' },
  1028. input({ type: 'hidden', name: 'section', value: 'forum' }),
  1029. input({ type: 'hidden', name: 'thread', value: t.id }),
  1030. button({ type: 'submit', class: 'forum-title' }, t.title)
  1031. )
  1032. ),
  1033. t.description ? div({ class: 'forum-body' }, ...renderUrl((t.description || '').substring(0, 200))) : null,
  1034. div({ class: 'forum-meta' },
  1035. span({ class: 'forum-positive-votes' }, `▲: ${t.refeeds || 0}`),
  1036. span({ class: 'forum-messages' }, `${(i18n.forumMessages || i18n.tribeForumReplies || 'MESSAGES').toUpperCase()}: ${replyCount}`),
  1037. form({ method: 'GET', action: tribeUrl, class: 'visit-forum-form' },
  1038. input({ type: 'hidden', name: 'section', value: 'forum' }),
  1039. input({ type: 'hidden', name: 'thread', value: t.id }),
  1040. button({ type: 'submit', class: 'filter-btn' }, i18n.forumVisitButton || 'VISIT')
  1041. )
  1042. ),
  1043. div({ class: 'forum-footer' },
  1044. span({ class: 'date-link' }, `${new Date(t.createdAt).toLocaleString()} ${i18n.performed || ''}`),
  1045. a({ href: `/author/${encodeURIComponent(t.author)}`, class: 'user-link' }, t.author)
  1046. ),
  1047. t.author === userId ? div({ class: 'forum-owner-actions' },
  1048. form({ method: 'POST', action: `${tribeUrl}/content/delete/${encodeURIComponent(t.id)}`, class: 'forum-delete-form' },
  1049. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeContentDelete)
  1050. )
  1051. ) : null
  1052. )
  1053. );
  1054. })
  1055. )
  1056. );
  1057. };
  1058. const sectionKeyForMediaType = { image: 'images', audio: 'audios', video: 'videos', document: 'documents', bookmark: 'bookmarks', torrent: 'torrents' };
  1059. const acceptForMediaType = { image: 'image/*', audio: 'audio/*', video: 'video/*', document: 'application/pdf,.pdf,.doc,.docx,.txt,.odt', bookmark: null, torrent: '.torrent' };
  1060. const sectionTitleForMediaType = (mt) => {
  1061. const map = { image: i18n.tribeSectionImages, audio: i18n.tribeSectionAudios, video: i18n.tribeSectionVideos, document: i18n.tribeSectionDocuments, bookmark: i18n.tribeSectionBookmarks, torrent: i18n.tribeSectionTorrents };
  1062. return map[mt] || mt;
  1063. };
  1064. const renderTribeMediaTypeSection = (tribe, items, query, mediaType) => {
  1065. const allMedia = Array.isArray(items) ? items : [];
  1066. const media = allMedia.filter(m => m.mediaType === mediaType);
  1067. const action = query.action;
  1068. const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
  1069. const sectionKey = sectionKeyForMediaType[mediaType] || 'media';
  1070. const sTitle = sectionTitleForMediaType(mediaType);
  1071. const createMediaLabel = {
  1072. image: () => i18n.tribeCreateImage || 'Create Image',
  1073. audio: () => i18n.tribeCreateAudio || 'Create Audio',
  1074. video: () => i18n.tribeCreateVideo || 'Create Video',
  1075. document: () => i18n.tribeCreateDocument || 'Create Document',
  1076. bookmark: () => i18n.tribeCreateBookmark || 'Create Bookmark',
  1077. torrent: () => i18n.tribeCreateTorrent || 'Upload Torrent',
  1078. };
  1079. const mediaBtnLabel = createMediaLabel[mediaType] ? createMediaLabel[mediaType]() : i18n.tribeCreateButton;
  1080. if (action === 'create') {
  1081. if (mediaType === 'bookmark') {
  1082. return div({ class: 'create-tribe-form' },
  1083. form({ method: 'POST', action: `${tribeUrl}/media/upload` },
  1084. input({ type: 'hidden', name: 'mediaType', value: 'bookmark' }),
  1085. input({ type: 'hidden', name: 'returnSection', value: sectionKey }),
  1086. label({ for: 'title' }, i18n.tribeMediaTitle), br,
  1087. input({ type: 'text', name: 'title', id: 'title', required: true, placeholder: i18n.tribeMediaTitle }), br(),
  1088. label({ for: 'url' }, i18n.bookmarkUrlLabel || 'URL'), br,
  1089. input({ type: 'url', name: 'url', id: 'url', required: true, placeholder: 'https://' }), br(),br(),
  1090. label({ for: 'description' }, i18n.tribeMediaDescription), br,
  1091. textarea({ name: 'description', id: 'description', rows: 3, placeholder: i18n.tribeMediaDescription }, ''), br(),
  1092. button({ type: 'submit', class: 'create-button' }, mediaBtnLabel)
  1093. )
  1094. );
  1095. }
  1096. return div({ class: 'create-tribe-form' },
  1097. form({ method: 'POST', action: `${tribeUrl}/media/upload`, enctype: 'multipart/form-data' },
  1098. input({ type: 'hidden', name: 'mediaType', value: mediaType }),
  1099. input({ type: 'hidden', name: 'returnSection', value: sectionKey }),
  1100. label({ for: 'title' }, i18n.tribeMediaTitle), br,
  1101. input({ type: 'text', name: 'title', id: 'title', required: true, placeholder: i18n.tribeMediaTitle }), br(),
  1102. label({ for: 'description' }, i18n.tribeMediaDescription), br,
  1103. textarea({ name: 'description', id: 'description', rows: 3, placeholder: i18n.tribeMediaDescription }, ''), br(),
  1104. label({ for: 'media' }, i18n.tribeMediaUpload), br,
  1105. input({ type: 'file', name: 'media', id: 'media', accept: acceptForMediaType[mediaType] || '*/*', required: true }), br(), br(),
  1106. button({ type: 'submit', class: 'create-button' }, mediaBtnLabel)
  1107. )
  1108. );
  1109. }
  1110. const mediaFooter = (m) => [
  1111. p({ class: 'tribe-media-date' }, span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString())),
  1112. p({ class: 'tribe-media-author' }, a({ href: `/author/${encodeURIComponent(m.author)}`, class: 'user-link' }, m.author)),
  1113. m.author === userId ? form({ method: 'POST', action: `${tribeUrl}/content/delete/${encodeURIComponent(m.id)}` },
  1114. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeContentDelete)
  1115. ) : null
  1116. ];
  1117. const renderMediaItem = (m) => {
  1118. const blobUrl = toBlobUrl(m.image);
  1119. if (mediaType === 'image') {
  1120. return div({ class: 'tribe-media-item' },
  1121. blobUrl ? a({ href: blobUrl, target: '_blank' }, img({ src: blobUrl, alt: m.title || '', class: 'tribe-media-thumb' })) : null,
  1122. div({ class: 'tribe-media-item-info' },
  1123. m.title ? h2(m.title) : null,
  1124. m.description ? p(...renderUrl(m.description)) : null,
  1125. ...mediaFooter(m)
  1126. )
  1127. );
  1128. }
  1129. if (mediaType === 'audio') {
  1130. return div({ class: 'tribe-media-item' },
  1131. blobUrl ? audio({ src: blobUrl, controls: true, class: 'tribe-media-audio' }) : p(i18n.tribeMediaEmpty),
  1132. div({ class: 'tribe-media-item-info' },
  1133. m.title ? h2(m.title) : null,
  1134. m.description ? p(...renderUrl(m.description)) : null,
  1135. ...mediaFooter(m)
  1136. )
  1137. );
  1138. }
  1139. if (mediaType === 'video') {
  1140. return div({ class: 'tribe-media-item' },
  1141. blobUrl ? video({ src: blobUrl, controls: true, class: 'tribe-media-thumb' }) : p(i18n.tribeMediaEmpty),
  1142. div({ class: 'tribe-media-item-info' },
  1143. m.title ? h2(m.title) : null,
  1144. m.description ? p(...renderUrl(m.description)) : null,
  1145. ...mediaFooter(m)
  1146. )
  1147. );
  1148. }
  1149. if (mediaType === 'document') {
  1150. return div({ class: 'tribe-media-item' },
  1151. blobUrl ? a({ href: blobUrl, target: '_blank', class: 'tribe-action-btn' }, i18n.readDocument || 'Read Document') : p(i18n.tribeMediaEmpty),
  1152. div({ class: 'tribe-media-item-info' },
  1153. m.title ? h2(m.title) : null,
  1154. m.description ? p(...renderUrl(m.description)) : null,
  1155. ...mediaFooter(m)
  1156. )
  1157. );
  1158. }
  1159. if (mediaType === 'bookmark') {
  1160. const url = m.url || m.description || '';
  1161. return div({ class: 'tribe-media-item' },
  1162. div({ class: 'tribe-media-item-info' },
  1163. m.title ? h2(m.title) : null,
  1164. url ? div({ class: 'card-field' },
  1165. span({ class: 'card-label' }, 'URL:'),
  1166. a({ href: url, target: '_blank', class: 'card-value' }, url)
  1167. ) : null,
  1168. m.description && m.description !== url ? p(...renderUrl(m.description)) : null,
  1169. ...mediaFooter(m)
  1170. )
  1171. );
  1172. }
  1173. if (mediaType === 'torrent') {
  1174. return div({ class: 'tribe-media-item' },
  1175. blobUrl ? a({ href: blobUrl, class: 'tribe-action-btn' }, i18n.torrentDownloadButton || 'DOWNLOAD IT!') : p(i18n.tribeMediaEmpty),
  1176. div({ class: 'tribe-media-item-info' },
  1177. m.title ? h2(m.title) : null,
  1178. m.description ? p(...renderUrl(m.description)) : null,
  1179. ...mediaFooter(m)
  1180. )
  1181. );
  1182. }
  1183. return null;
  1184. };
  1185. return div({ class: 'tribe-content-list' },
  1186. div({ class: 'tribe-content-header' },
  1187. h2(sTitle),
  1188. form({ method: 'GET', action: tribeUrl },
  1189. input({ type: 'hidden', name: 'section', value: sectionKey }),
  1190. input({ type: 'hidden', name: 'action', value: 'create' }),
  1191. button({ type: 'submit', class: 'create-button' }, mediaBtnLabel)
  1192. )
  1193. ),
  1194. media.length === 0 ? p(i18n.tribeMediaEmpty) :
  1195. div({ class: 'tribe-media-grid' }, media.map(renderMediaItem))
  1196. );
  1197. };
  1198. const renderSubTribesSection = (tribe, items, query) => {
  1199. const action = query.action;
  1200. const canCreate = tribe.inviteMode === 'open'
  1201. ? tribe.members.includes(userId)
  1202. : tribe.author === userId;
  1203. if (action === 'create' && canCreate) {
  1204. return renderCreateForm(tribe, 'subtribes', [
  1205. { name: 'title', label: i18n.tribeTitleLabel, required: true, placeholder: 'Name of the sub-tribe' },
  1206. { name: 'description', type: 'textarea', label: i18n.tribeDescriptionLabel, required: true, placeholder: 'Description of the sub-tribe' },
  1207. { name: 'location', label: i18n.tribeLocationLabel, placeholder: 'Where is this sub-tribe located?' },
  1208. { name: 'mapUrl', label: i18n.mapLocationTitle || 'Map Location', placeholder: i18n.mapUrlPlaceholder || '/maps/MAP_ID', spaceBefore: true },
  1209. { name: 'image', type: 'file', label: i18n.tribeImageLabel },
  1210. { name: 'tags', label: i18n.tribeTagsLabel, placeholder: i18n.tribeTagsPlaceholder, spaceBefore: true },
  1211. { name: 'inviteMode', type: 'select', label: i18n.tribeModeLabel, options: [
  1212. { value: 'open', label: i18n.tribeOpen }, { value: 'strict', label: i18n.tribeStrict }
  1213. ], spaceBefore: true },
  1214. ]);
  1215. }
  1216. const subTribes = Array.isArray(items) ? items : [];
  1217. const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
  1218. return div({ class: 'tribe-content-list' },
  1219. canCreate ? form({ method: 'GET', action: tribeUrl },
  1220. input({ type: 'hidden', name: 'section', value: 'subtribes' }),
  1221. input({ type: 'hidden', name: 'action', value: 'create' }),
  1222. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeSubTribesCreate)
  1223. ) : null,
  1224. !canCreate && tribe.inviteMode === 'strict'
  1225. ? p({ class: 'tribe-strict-denied' }, i18n.tribeSubTribesStrictDenied)
  1226. : null,
  1227. subTribes.length === 0
  1228. ? null
  1229. : div({ class: 'tribe-thumb-grid' },
  1230. subTribes.map(st => {
  1231. return a({ href: `/tribe/${encodeURIComponent(st.id)}`, class: 'tribe-thumb-link', title: st.title },
  1232. img({ src: toImageUrl(st.image, '/assets/images/default-tribe.png'), class: 'tribe-thumb-img', alt: st.title })
  1233. );
  1234. })
  1235. )
  1236. );
  1237. };
  1238. const renderTribeMapsSection = (tribe, maps) => {
  1239. const items = Array.isArray(maps) ? maps : [];
  1240. const createBtn = form({ method: 'GET', action: '/maps' },
  1241. input({ type: 'hidden', name: 'filter', value: 'create' }),
  1242. input({ type: 'hidden', name: 'tribeId', value: tribe.id }),
  1243. button({ type: 'submit', class: 'create-button' }, i18n.mapUploadButton || 'Create Map'));
  1244. if (items.length === 0) return div({ class: 'tribe-content-list' }, div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionMaps || 'MAPS'), createBtn), p(i18n.noMaps || 'No maps yet'));
  1245. return div({ class: 'tribe-content-list' },
  1246. div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionMaps || 'MAPS'), createBtn),
  1247. items.map(m =>
  1248. div({ class: 'card card-rpg tribe-card-padded' },
  1249. div({ class: 'card-header' },
  1250. h2({ class: 'card-label' }, `[${(i18n.typeMap || 'MAP').toUpperCase()}]`),
  1251. form({ method: 'GET', action: `/maps/${encodeURIComponent(m.key)}` },
  1252. button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details'))
  1253. ),
  1254. div({ class: 'tribe-card-body' },
  1255. m.title ? div({ class: 'card-field' },
  1256. span({ class: 'card-label' }, (i18n.title || 'Title') + ':'),
  1257. span({ class: 'card-value' }, a({ href: `/maps/${encodeURIComponent(m.key)}` }, m.title))
  1258. ) : null,
  1259. m.description ? p(m.description.substring(0, 200)) : null,
  1260. m.lat && m.lng ? span({ class: 'map-coords' }, `📍 ${m.lat.toFixed(4)}, ${m.lng.toFixed(4)}`) : null
  1261. ),
  1262. p({ class: 'card-footer' },
  1263. span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()),
  1264. a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)
  1265. )
  1266. )
  1267. )
  1268. );
  1269. };
  1270. const renderTribeTorrentsSection = (tribe, torrents) => {
  1271. const items = Array.isArray(torrents) ? torrents : [];
  1272. const createBtn = form({ method: 'GET', action: '/torrents' },
  1273. input({ type: 'hidden', name: 'filter', value: 'create' }),
  1274. input({ type: 'hidden', name: 'tribeId', value: tribe.id }),
  1275. button({ type: 'submit', class: 'create-button' }, i18n.tribeCreateTorrent || 'Upload Torrent'));
  1276. if (items.length === 0) return div({ class: 'tribe-content-list' }, div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionTorrents || 'TORRENTS'), createBtn), p(i18n.tribeTorrentsEmpty || 'No torrents, yet.'));
  1277. return div({ class: 'tribe-content-list' },
  1278. div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionTorrents || 'TORRENTS'), createBtn),
  1279. items.map(m => {
  1280. const blobName = encodeURIComponent((m.title || 'download').replace(/\.torrent$/i, '') + '.torrent');
  1281. const blobUrl = m.url ? `/blob/${encodeURIComponent(m.url)}?name=${blobName}` : null;
  1282. return div({ class: 'card card-rpg tribe-card-padded' },
  1283. div({ class: 'card-header' },
  1284. h2({ class: 'card-label' }, `[${(i18n.typeTorrent || 'TORRENT').toUpperCase()}]`),
  1285. m._isMedia
  1286. ? (blobUrl ? a({ href: blobUrl, class: 'filter-btn' }, i18n.torrentDownload || 'Download') : null)
  1287. : form({ method: 'GET', action: `/torrents/${encodeURIComponent(m.rootId || m.key)}` },
  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' }, m._isMedia
  1294. ? (blobUrl ? a({ href: blobUrl }, m.title) : m.title)
  1295. : a({ href: `/torrents/${encodeURIComponent(m.rootId || m.key)}` }, m.title))
  1296. ) : null,
  1297. m.description ? p(String(m.description).substring(0, 200)) : null,
  1298. blobUrl && !m._isMedia ? div({ class: 'card-field' }, a({ href: blobUrl, class: 'filter-btn' }, i18n.torrentDownload || 'Download')) : 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. const renderTribePadsSection = (tribe, pads) => {
  1309. const items = Array.isArray(pads) ? pads : [];
  1310. const createBtn = form({ method: 'GET', action: '/pads' },
  1311. input({ type: 'hidden', name: 'filter', value: 'create' }),
  1312. input({ type: 'hidden', name: 'tribeId', value: tribe.id }),
  1313. button({ type: 'submit', class: 'create-button' }, i18n.tribePadCreate || 'Create Pad'));
  1314. if (items.length === 0) return div({ class: 'tribe-content-list' }, div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionPads || 'PADS'), createBtn), p(i18n.tribePadsEmpty || 'No pads, yet.'));
  1315. return div({ class: 'tribe-content-list' },
  1316. div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionPads || 'PADS'), createBtn),
  1317. items.map(m =>
  1318. div({ class: 'card card-rpg tribe-card-padded' },
  1319. div({ class: 'card-header' },
  1320. h2({ class: 'card-label' }, `[${(i18n.typePad || 'PAD').toUpperCase()}]`),
  1321. form({ method: 'GET', action: `/pads/${encodeURIComponent(m.rootId)}` },
  1322. button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details'))
  1323. ),
  1324. div({ class: 'tribe-card-body' },
  1325. m.title ? div({ class: 'card-field' },
  1326. span({ class: 'card-label' }, (i18n.title || 'Title') + ':'),
  1327. span({ class: 'card-value' }, a({ href: `/pads/${encodeURIComponent(m.rootId)}` }, m.title))
  1328. ) : null
  1329. ),
  1330. p({ class: 'card-footer' },
  1331. span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()),
  1332. a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)
  1333. )
  1334. )
  1335. )
  1336. );
  1337. };
  1338. const renderTribeChatsSection = (tribe, chats) => {
  1339. const items = Array.isArray(chats) ? chats : [];
  1340. const createBtn = form({ method: 'GET', action: '/chats' },
  1341. input({ type: 'hidden', name: 'filter', value: 'create' }),
  1342. input({ type: 'hidden', name: 'tribeId', value: tribe.id }),
  1343. button({ type: 'submit', class: 'create-button' }, i18n.tribeChatCreate || 'Create Chat'));
  1344. if (items.length === 0) return div({ class: 'tribe-content-list' }, div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionChats || 'CHATS'), createBtn), p(i18n.tribeChatsEmpty || 'No chats, yet.'));
  1345. return div({ class: 'tribe-content-list' },
  1346. div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionChats || 'CHATS'), createBtn),
  1347. items.map(m =>
  1348. div({ class: 'card card-rpg tribe-card-padded' },
  1349. div({ class: 'card-header' },
  1350. h2({ class: 'card-label' }, `[${(i18n.typeChat || 'CHAT').toUpperCase()}]`),
  1351. form({ method: 'GET', action: `/chats/${encodeURIComponent(m.key)}` },
  1352. button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details'))
  1353. ),
  1354. div({ class: 'tribe-card-body' },
  1355. m.title ? div({ class: 'card-field' },
  1356. span({ class: 'card-label' }, (i18n.title || 'Title') + ':'),
  1357. span({ class: 'card-value' }, a({ href: `/chats/${encodeURIComponent(m.key)}` }, m.title))
  1358. ) : null,
  1359. m.description ? p(m.description.substring(0, 200)) : null
  1360. ),
  1361. p({ class: 'card-footer' },
  1362. span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()),
  1363. a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)
  1364. )
  1365. )
  1366. )
  1367. );
  1368. };
  1369. const renderTribeCalendarsSection = (tribe, calendars) => {
  1370. const items = Array.isArray(calendars) ? calendars : [];
  1371. const createBtn = form({ method: 'GET', action: '/calendars' },
  1372. input({ type: 'hidden', name: 'filter', value: 'create' }),
  1373. input({ type: 'hidden', name: 'tribeId', value: tribe.id }),
  1374. button({ type: 'submit', class: 'create-button' }, i18n.tribeCalendarCreate || 'Create Calendar'));
  1375. if (items.length === 0) return div({ class: 'tribe-content-list' }, div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionCalendars || 'CALENDARS'), createBtn), p(i18n.tribeCalendarsEmpty || 'No calendars, yet.'));
  1376. return div({ class: 'tribe-content-list' },
  1377. div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionCalendars || 'CALENDARS'), createBtn),
  1378. items.map(m =>
  1379. div({ class: 'card card-rpg tribe-card-padded' },
  1380. div({ class: 'card-header' },
  1381. h2({ class: 'card-label' }, `[${(i18n.typeCalendar || 'CALENDAR').toUpperCase()}]`),
  1382. form({ method: 'GET', action: `/calendars/${encodeURIComponent(m.rootId)}` },
  1383. button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details'))
  1384. ),
  1385. div({ class: 'tribe-card-body' },
  1386. m.title ? div({ class: 'card-field' },
  1387. span({ class: 'card-label' }, (i18n.title || 'Title') + ':'),
  1388. span({ class: 'card-value' }, a({ href: `/calendars/${encodeURIComponent(m.rootId)}` }, m.title))
  1389. ) : null,
  1390. m.deadline ? div({ class: 'card-field' },
  1391. span({ class: 'card-label' }, (i18n.calendarDeadlineLabel || 'Deadline') + ':'),
  1392. span({ class: 'card-value' }, new Date(m.deadline).toLocaleDateString())
  1393. ) : null
  1394. ),
  1395. p({ class: 'card-footer' },
  1396. span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()),
  1397. a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)
  1398. )
  1399. )
  1400. )
  1401. );
  1402. };
  1403. exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => {
  1404. if (!tribe) {
  1405. return div({ class: 'error' }, i18n.tribeNotFound);
  1406. }
  1407. section = section || 'activity';
  1408. sectionData = sectionData || {};
  1409. const imageSrc = tribe.image;
  1410. const pageTitle = tribe.title;
  1411. let sectionContent;
  1412. switch (section) {
  1413. case 'inhabitants': sectionContent = renderInhabitantsSection(tribe, sectionData); break;
  1414. case 'feed':
  1415. sectionContent = div(
  1416. query.sent ? div({ class: 'card card-rpg tribe-banner' },
  1417. p({ class: 'bold' }, i18n.tribeFeedSent || 'Message sent successfully!')
  1418. ) : null,
  1419. tribe.members.includes(config.keys.id)
  1420. ? form({ class: 'tribe-feed-compose', method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/message` },
  1421. textarea({ name: 'message', rows: 4, maxlength: MAX_MESSAGE_LENGTH, placeholder: i18n.tribeFeedMessagePlaceholder }),
  1422. button({ type: 'submit', class: 'tribe-feed-send' }, i18n.tribeFeedSend)
  1423. )
  1424. : null,
  1425. await renderFeedTribeView(sectionData, tribe, query, query.filter)
  1426. );
  1427. break;
  1428. case 'events': sectionContent = renderEventsSection(tribe, sectionData, query); break;
  1429. case 'tasks': sectionContent = renderTasksSection(tribe, sectionData, query); break;
  1430. case 'votations': sectionContent = renderVotationsSection(tribe, sectionData, query); break;
  1431. case 'forum': sectionContent = renderForumSection(tribe, sectionData, query); break;
  1432. case 'subtribes': sectionContent = renderSubTribesSection(tribe, sectionData, query); break;
  1433. case 'search': sectionContent = renderTribeSearchSection(tribe, sectionData, query); break;
  1434. case 'images': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'image'); break;
  1435. case 'audios': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'audio'); break;
  1436. case 'videos': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'video'); break;
  1437. case 'documents': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'document'); break;
  1438. case 'bookmarks': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'bookmark'); break;
  1439. case 'torrents': sectionContent = renderTribeTorrentsSection(tribe, sectionData); break;
  1440. case 'maps': sectionContent = renderTribeMapsSection(tribe, sectionData); break;
  1441. case 'pads': sectionContent = renderTribePadsSection(tribe, sectionData); break;
  1442. case 'chats': sectionContent = renderTribeChatsSection(tribe, sectionData); break;
  1443. case 'calendars': sectionContent = renderTribeCalendarsSection(tribe, sectionData); break;
  1444. case 'governance': sectionContent = renderGovernance(tribe, sectionData); break;
  1445. case 'trending': sectionContent = renderTribeTrendingSection(tribe, sectionData, query); break;
  1446. case 'tags': sectionContent = renderTribeTagsSection(tribe, sectionData, query); break;
  1447. case 'activity':
  1448. default: sectionContent = renderTribeActivitySection(tribe, sectionData); break;
  1449. }
  1450. const subTribes = Array.isArray(tribe.subTribes) ? tribe.subTribes : [];
  1451. const tribeDetails = div({ class: 'tribe-details' },
  1452. div({ class: 'tribe-side' },
  1453. tribe.parentTribe
  1454. ? div({ class: 'tribe-parent-box' },
  1455. h2({ class: 'tribe-info-label' }, i18n.tribeMainTribeLabel || 'MAIN TRIBE'),
  1456. a({ href: `/tribe/${encodeURIComponent(tribe.parentTribe.id)}`, class: 'tribe-parent-link' },
  1457. img({ src: toBlobUrl(tribe.parentTribe.image) || '/assets/images/default-tribe.png', alt: tribe.parentTribe.title, class: 'tribe-parent-image' })
  1458. )
  1459. )
  1460. : null,
  1461. h2(tribe.isAnonymous ? "\uD83D\uDD12 " : "", tribe.title),
  1462. renderMediaBlob(imageSrc, '/assets/images/default-tribe.png', { alt: tribe.title, class: 'tribe-detail-image' }),
  1463. table({ class: 'tribe-info-table' },
  1464. tr(
  1465. td({ class: 'tribe-info-label' }, i18n.tribeCreatedAt || 'CREATED'),
  1466. td({ class: 'tribe-info-value', colspan: '3' }, new Date(tribe.createdAt).toLocaleString())
  1467. ),
  1468. tr(
  1469. td({ class: 'tribe-info-value', colspan: '4' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(tribe.author)}` }, tribe.author))
  1470. ),
  1471. tribe.location ? tr(
  1472. td({ class: 'tribe-info-label' }, i18n.tribeLocationLabel || 'LOCATION'),
  1473. td({ class: 'tribe-info-value', colspan: '3' }, ...renderUrl(tribe.location))
  1474. ) : null,
  1475. tr(
  1476. td({ class: 'tribe-info-label' }, i18n.tribeStatusLabel || 'STATUS'),
  1477. td({ class: 'tribe-info-value', colspan: '3' }, String(statusI18n()[tribe.status] || i18n.tribeStatusOpen).toUpperCase())
  1478. ),
  1479. tr(
  1480. td({ class: 'tribe-info-label' }, i18n.tribeModeLabel || 'MODE'),
  1481. td({ class: 'tribe-info-value', colspan: '3' }, String(inviteModeI18n()[tribe.inviteMode] || tribe.inviteMode).toUpperCase())
  1482. ),
  1483. tr(
  1484. td({ class: 'tribe-info-label' }, i18n.tribeLARPLabel || 'L.A.R.P.'),
  1485. td({ class: 'tribe-info-value', colspan: '3' }, tribe.isLARP ? i18n.tribeYes : i18n.tribeNo)
  1486. )
  1487. ),
  1488. h2({ class: 'tribe-members-count' }, `${i18n.tribeMembersCount}: ${tribe.members.length}`),
  1489. (!tribe.parentTribeId && ((tribe.inviteMode === 'open' || tribe.author === userId) || subTribes.length > 0)) ? div({ class: 'tribe-side-subtribes' },
  1490. (tribe.inviteMode === 'open' || tribe.author === userId)
  1491. ? form({ method: 'GET', action: `/tribe/${encodeURIComponent(tribe.id)}` },
  1492. input({ type: 'hidden', name: 'section', value: 'subtribes' }),
  1493. input({ type: 'hidden', name: 'action', value: 'create' }),
  1494. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeSubTribesCreate)
  1495. )
  1496. : null,
  1497. subTribes.length > 0
  1498. ? div({ class: 'tribe-subtribes-list' },
  1499. subTribes.map(st =>
  1500. form({ method: 'GET', action: `/tribe/${encodeURIComponent(st.id)}` },
  1501. button({ type: 'submit', class: 'tribe-subtribe-link' }, st.isAnonymous ? "\uD83D\uDD12 " : "", st.title)
  1502. )
  1503. )
  1504. )
  1505. : null
  1506. ) : null,
  1507. tribe.description ? p({ class: 'tribe-side-description' }, ...renderUrl(tribe.description)) : null,
  1508. renderMapLocationVisitLabel(tribe.mapUrl),
  1509. div({ class: 'tribe-side-actions' },
  1510. form({ method: 'POST', action: '/tribes/generate-invite' },
  1511. input({ type: 'hidden', name: 'tribeId', value: tribe.id }),
  1512. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeGenerateInvite)
  1513. ),
  1514. tribe.author === userId
  1515. ? form({ method: 'GET', action: `/tribes/edit/${encodeURIComponent(tribe.id)}` },
  1516. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeUpdateButton)
  1517. )
  1518. : null,
  1519. tribe.author === userId
  1520. ? form({ method: 'POST', action: `/tribes/delete/${encodeURIComponent(tribe.id)}` },
  1521. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeDeleteButton)
  1522. )
  1523. : null,
  1524. tribe.author !== userId
  1525. ? form({ method: 'POST', action: `/tribes/leave/${encodeURIComponent(tribe.id)}` },
  1526. button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeLeaveButton)
  1527. )
  1528. : null
  1529. ),
  1530. tribe.tags && tribe.tags.filter(Boolean).length ? div({ class: 'tribe-side-tags' }, tribe.tags.filter(Boolean).map(tag =>
  1531. a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
  1532. )) : null,
  1533. ),
  1534. div({ class: 'tribe-main' },
  1535. query.inviteCode ? div({ class: 'card card-rpg tribe-banner' },
  1536. p({ class: 'bold' }, i18n.tribeInviteCodeText, query.inviteCode)
  1537. ) : null,
  1538. renderSectionNav(tribe, section),
  1539. sectionContent
  1540. )
  1541. );
  1542. return template(
  1543. pageTitle,
  1544. tribeDetails
  1545. );
  1546. };
  1547. const GOVERNANCE_METHODS = ['DEMOCRACY', 'MAJORITY', 'MINORITY', 'DICTATORSHIP', 'KARMATOCRACY'];
  1548. const governanceFilterBar = (tribeId, currentFilter, showPublish) => {
  1549. const row1 = [
  1550. { key: 'government', label: i18n.tribeGovFilterGovernment || 'GOVERNMENT' },
  1551. { key: 'candidatures', label: i18n.tribeGovFilterCandidatures || 'CANDIDATURES' },
  1552. { key: 'proposals', label: i18n.tribeGovProposals || 'PROPOSALS' },
  1553. { key: 'laws', label: i18n.tribeGovFilterLaws || 'LAWS' }
  1554. ];
  1555. const row2 = [
  1556. { key: 'revocations', label: i18n.tribeGovRevocations || 'REVOCATIONS' },
  1557. { key: 'historical', label: i18n.tribeGovHistorical || 'HISTORICAL' },
  1558. { key: 'leaders', label: i18n.tribeGovLeader || 'LEADERS' },
  1559. { key: 'rules', label: i18n.tribeGovRules || 'RULES' }
  1560. ];
  1561. const mkRow = (filters) => div({ class: 'mode-buttons-row' },
  1562. filters.map(f =>
  1563. form({ method: 'GET', action: `/tribe/${encodeURIComponent(tribeId)}` },
  1564. input({ type: 'hidden', name: 'section', value: 'governance' }),
  1565. input({ type: 'hidden', name: 'filter', value: f.key }),
  1566. button({ type: 'submit', class: currentFilter === f.key ? 'filter-btn active' : 'filter-btn' }, f.label)
  1567. )
  1568. )
  1569. );
  1570. return div({ class: 'filters' },
  1571. mkRow(row1),
  1572. mkRow(row2),
  1573. showPublish
  1574. ? div({ class: 'mode-buttons-row' },
  1575. form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribeId)}/governance/publish-candidature` },
  1576. button({ type: 'submit', class: 'create-button' }, i18n.tribeGovCandidatureProposeBtn || 'Publish Candidature')
  1577. )
  1578. )
  1579. : null
  1580. );
  1581. };
  1582. const fmtTermDate = (d) => moment(d).format('YYYY-MM-DD HH:mm:ss');
  1583. const fmtTimeLeft = (end) => {
  1584. const diff = moment(end).diff(moment());
  1585. if (diff <= 0) return '0d 00:00:00';
  1586. const dur = moment.duration(diff);
  1587. const d = Math.floor(dur.asDays());
  1588. const h = String(dur.hours()).padStart(2, '0');
  1589. const m = String(dur.minutes()).padStart(2, '0');
  1590. const s = String(dur.seconds()).padStart(2, '0');
  1591. return `${d}d ${h}:${m}:${s}`;
  1592. };
  1593. const methodImageSrc = (method) => `/assets/images/${String(method || '').toLowerCase()}.png`;
  1594. const governmentCard = (tribe, term, leaders) => {
  1595. if (!term) {
  1596. return div({ class: 'card' },
  1597. h3(i18n.tribeGovernanceNoGov || 'No active government'),
  1598. p(i18n.tribeGovernanceNoGovDesc || 'This tribe has not yet elected a government. Propose candidatures to start the process.')
  1599. );
  1600. }
  1601. const method = String(term.method || 'ANARCHY').toUpperCase();
  1602. const isAnarchy = method === 'ANARCHY';
  1603. const i18nMeth = i18n[`parliamentMethod${method}`];
  1604. const methodLabel = (i18nMeth && String(i18nMeth).trim() ? String(i18nMeth) : method).toUpperCase();
  1605. const termStart = term.startAt || moment().toISOString();
  1606. const termEnd = term.endAt || moment(termStart).add(60, 'days').toISOString();
  1607. const population = Array.isArray(tribe.members) ? tribe.members.length : 0;
  1608. const winnerVotes = Number(term.winnerVotes || 0);
  1609. const totalVotes = Number(term.totalVotes || 0);
  1610. const votesDisplay = `${winnerVotes} (${totalVotes})`;
  1611. const leaderId = term.leaderId || (Array.isArray(leaders) && leaders[0]) || null;
  1612. return div({ class: 'card' },
  1613. h2(i18n.tribeGovCardTitle || 'Current Government'),
  1614. div({ class: 'cycle-info' },
  1615. div({ class: 'kpi' },
  1616. span({ class: 'kpi__label' }, ((i18n.tribeGovCycleSince || 'CYCLE SINCE') + ': ').toUpperCase()),
  1617. span({ class: 'kpi__value' }, fmtTermDate(termStart))
  1618. ),
  1619. div({ class: 'kpi' },
  1620. span({ class: 'kpi__label' }, ((i18n.tribeGovCycleEnd || 'CYCLE END') + ': ').toUpperCase()),
  1621. span({ class: 'kpi__value' }, fmtTermDate(termEnd))
  1622. ),
  1623. div({ class: 'kpi' },
  1624. span({ class: 'kpi__label' }, ((i18n.tribeGovTimeRemaining || 'TIME REMAINING') + ': ').toUpperCase()),
  1625. span({ class: 'kpi__value' }, fmtTimeLeft(termEnd))
  1626. ),
  1627. div({ class: 'kpi' },
  1628. span({ class: 'kpi__label' }, ((i18n.tribeGovPopulation || 'POPULATION') + ': ').toUpperCase()),
  1629. span({ class: 'kpi__value' }, String(population))
  1630. ),
  1631. div({ class: 'kpi' },
  1632. span({ class: 'kpi__label' }, ((i18n.tribeGovMethod || 'METHOD') + ': ').toUpperCase()),
  1633. span({ class: 'kpi__value' }, methodLabel)
  1634. ),
  1635. !isAnarchy
  1636. ? div({ class: 'kpi' },
  1637. span({ class: 'kpi__label' }, ((i18n.tribeGovVotesReceived || 'VOTES RECEIVED') + ': ').toUpperCase()),
  1638. span({ class: 'kpi__value' }, votesDisplay)
  1639. )
  1640. : null
  1641. ),
  1642. div({ class: 'method-image-centered' },
  1643. img({ src: methodImageSrc(method), alt: methodLabel })
  1644. ),
  1645. !isAnarchy && leaderId
  1646. ? div({ class: 'tribe-leader-block' },
  1647. h3(i18n.tribeGovLeader || 'LEADER'),
  1648. a({ href: `/author/${encodeURIComponent(leaderId)}`, class: 'user-link' }, leaderId)
  1649. )
  1650. : null
  1651. );
  1652. };
  1653. const candidaturesBlock = (tribe, candidatures, alreadyPublishedThisGlobalCycle) => {
  1654. const list = (Array.isArray(candidatures) ? candidatures : [])
  1655. .filter(c => (c.status || 'OPEN') === 'OPEN')
  1656. .sort((a, b) => Number(b.votes || 0) - Number(a.votes || 0));
  1657. return div({},
  1658. alreadyPublishedThisGlobalCycle
  1659. ? p({ class: 'notice' }, i18n.tribeGovernanceAlreadyPublished || 'This tribe already has an open candidature in the current global parliament cycle.')
  1660. : null,
  1661. h3(i18n.tribeGovernanceProposeInternal || 'Propose internal candidature'),
  1662. form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/candidature/propose` },
  1663. label({}, i18n.tribeGovCandidatureId || 'Candidate'), br(),
  1664. input({ type: 'text', name: 'candidateId', placeholder: '@...ed25519', required: true }), br(), br(),
  1665. label({}, i18n.tribeGovCandidatureMethod || 'Method'), br(),
  1666. select({ name: 'method' },
  1667. GOVERNANCE_METHODS.map(m => option({ value: m }, i18n[`parliamentMethod${m}`] || m))
  1668. ), br(), br(),
  1669. button({ type: 'submit', class: 'create-button' }, i18n.tribeGovCandidatureProposeBtn || 'Publish Candidature')
  1670. ),
  1671. h3(i18n.tribeGovernanceInternalCandidatures || 'Internal candidatures'),
  1672. list.length === 0
  1673. ? p(i18n.tribeGovernanceNoCandidatures || 'No open candidatures.')
  1674. : ul({}, list.map(c =>
  1675. li({},
  1676. c.candidateId
  1677. ? a({ href: `/author/${encodeURIComponent(c.candidateId)}`, class: 'user-link' }, c.candidateId)
  1678. : '?',
  1679. ` — ${c.method || 'DEMOCRACY'} — ${c.votes || 0} ${i18n.votes || 'votes'}`,
  1680. ' ',
  1681. form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/candidature/vote`, class: 'inline-form' },
  1682. input({ type: 'hidden', name: 'candidatureId', value: c.id }),
  1683. button({ type: 'submit', class: 'filter-btn' }, i18n.vote || 'Vote')
  1684. )
  1685. )
  1686. ))
  1687. );
  1688. };
  1689. const rulesBlock = (tribe, rules, isCreator) => div({},
  1690. isCreator
  1691. ? div({},
  1692. h3(i18n.tribeGovernanceAddRule || 'Add rule'),
  1693. form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/rule/add` },
  1694. label({}, i18n.tribeGovRuleTitle || 'Title'), br(),
  1695. input({ type: 'text', name: 'title', required: true }), br(), br(),
  1696. label({}, i18n.tribeGovRuleBody || 'Body'), br(),
  1697. textarea({ name: 'body', rows: 4 }), br(), br(),
  1698. button({ type: 'submit', class: 'create-button' }, i18n.save || 'Save')
  1699. )
  1700. )
  1701. : null,
  1702. h3(i18n.tribeGovRules || 'Rules'),
  1703. (!Array.isArray(rules) || rules.length === 0)
  1704. ? p(i18n.tribeGovernanceNoRules || 'No rules yet.')
  1705. : ul({}, rules.map(r =>
  1706. li({},
  1707. span({ class: 'bold' }, r.title || '-'),
  1708. r.body ? p(r.body) : null,
  1709. isCreator
  1710. ? form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/rule/delete`, class: 'inline-form' },
  1711. input({ type: 'hidden', name: 'ruleId', value: r.id }),
  1712. button({ type: 'submit', class: 'filter-btn' }, i18n.delete || 'Delete')
  1713. )
  1714. : null
  1715. )
  1716. ))
  1717. );
  1718. const renderGovernance = (tribe, data) => {
  1719. const { filter, term, candidatures, rules, leaders, isCreator, canPublishToGlobal, alreadyPublishedThisGlobalCycle, hasElectedCandidate } = data || {};
  1720. const f = filter || 'government';
  1721. const header = div({ class: 'tags-header' },
  1722. h2(i18n.tribeSectionGovernance || 'GOVERNANCE'),
  1723. p(i18n.tribeGovernanceDesc || 'Internal governance for this tribe. Propose candidatures, debate rules, elect leaders — mirrors the global Parliament.')
  1724. );
  1725. let body;
  1726. if (f === 'government') body = governmentCard(tribe, term, leaders);
  1727. else if (f === 'candidatures') body = candidaturesBlock(tribe, candidatures, alreadyPublishedThisGlobalCycle);
  1728. else if (f === 'rules') body = rulesBlock(tribe, rules, !!isCreator);
  1729. else if (f === 'leaders') body = div({ class: 'card' },
  1730. h3(i18n.tribeGovLeader || 'LEADERS'),
  1731. (!Array.isArray(leaders) || leaders.length === 0)
  1732. ? p(i18n.tribeGovernanceNoLeaders || 'No leaders elected yet.')
  1733. : ul({}, leaders.map(l => li({}, a({ href: `/author/${encodeURIComponent(l)}`, class: 'user-link' }, l))))
  1734. );
  1735. else body = div({ class: 'card' },
  1736. h3(i18n[`tribeGovFilter${f.charAt(0).toUpperCase()}${f.slice(1)}`] || i18n[`tribeGov${f.charAt(0).toUpperCase()}${f.slice(1)}`] || f.toUpperCase()),
  1737. p(i18n.tribeGovernanceComingSoon || 'Coming soon in this tribe\'s governance module.')
  1738. );
  1739. const showPublish = !!(canPublishToGlobal && hasElectedCandidate && !alreadyPublishedThisGlobalCycle);
  1740. return div({ class: 'tribe-governance' }, header, governanceFilterBar(tribe.id, f, showPublish), body);
  1741. };
  1742. exports.renderGovernance = renderGovernance;