tribes_view.js 85 KB

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