event_view.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  1. const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option } = require("../server/node_modules/hyperaxe");
  2. const { template, i18n } = require("./main_views");
  3. const moment = require("../server/node_modules/moment");
  4. const { config } = require("../server/SSB_server.js");
  5. const { renderUrl } = require("../backend/renderUrl");
  6. const userId = config.keys.id;
  7. const opt = (value, isSelected, text) =>
  8. option(Object.assign({ value }, isSelected ? { selected: "selected" } : {}), text);
  9. const safeArray = (v) => (Array.isArray(v) ? v : []);
  10. const toValueChildren = (v) => {
  11. if (v === undefined || v === null) return [];
  12. if (Array.isArray(v)) return v;
  13. if (typeof v === "string") return renderUrl(v);
  14. if (typeof v === "number" || typeof v === "boolean") return renderUrl(String(v));
  15. return [v];
  16. };
  17. const normalizePrivacy = (v) => {
  18. const s = String(v || "public").toLowerCase();
  19. return s === "private" ? "private" : "public";
  20. };
  21. const privacyLabel = (v) => (normalizePrivacy(v) === "private" ? i18n.eventPrivate : i18n.eventPublic);
  22. const safeExternalHref = (url) => {
  23. const s = String(url || "").trim();
  24. const lower = s.toLowerCase();
  25. if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("mailto:")) return s;
  26. return "";
  27. };
  28. const renderCardField = (labelText, valueNode) =>
  29. div(
  30. { class: "card-field" },
  31. span({ class: "card-label" }, labelText),
  32. span({ class: "card-value" }, ...toValueChildren(valueNode))
  33. );
  34. const normalizeEventStatus = (v) => {
  35. const up = String(v || "").toUpperCase();
  36. if (up === "OPEN" || up === "CLOSED") return up;
  37. return up || "OPEN";
  38. };
  39. const eventStatusLabel = (v) => {
  40. const st = normalizeEventStatus(v);
  41. if (st === "OPEN") return i18n.eventStatusOpen;
  42. if (st === "CLOSED") return i18n.eventStatusClosed;
  43. return st;
  44. };
  45. const attendanceLabel = (isAttending) => (isAttending ? i18n.eventAttended : i18n.eventUnattended);
  46. const renderEventOwnerActions = (e, returnTo) => {
  47. const st = normalizeEventStatus(e.status);
  48. if (e.organizer !== userId || st !== "OPEN") return [];
  49. return [
  50. form(
  51. { method: "GET", action: `/events/edit/${encodeURIComponent(e.id)}` },
  52. input({ type: "hidden", name: "returnTo", value: returnTo }),
  53. button({ type: "submit", class: "update-btn" }, i18n.eventUpdateButton)
  54. ),
  55. form(
  56. { method: "POST", action: `/events/delete/${encodeURIComponent(e.id)}` },
  57. input({ type: "hidden", name: "returnTo", value: returnTo }),
  58. button({ type: "submit", class: "delete-btn" }, i18n.eventDeleteButton)
  59. )
  60. ];
  61. };
  62. const renderEventAttendAction = (e, isAttending, returnTo) => {
  63. const st = normalizeEventStatus(e.status);
  64. if (st !== "OPEN") return null;
  65. return form(
  66. { method: "POST", action: `/events/attend/${encodeURIComponent(e.id)}` },
  67. input({ type: "hidden", name: "returnTo", value: returnTo }),
  68. button({ type: "submit", class: "filter-btn" }, attendanceLabel(isAttending))
  69. );
  70. };
  71. const renderEventTopbar = (e, filter, opts = {}) => {
  72. const currentFilter = filter || "all";
  73. const isSingle = !!opts.single;
  74. const returnToList = `/events?filter=${encodeURIComponent(currentFilter)}`;
  75. const returnToSelf = `/events/${encodeURIComponent(e.id)}?filter=${encodeURIComponent(currentFilter)}`;
  76. const rt = isSingle ? returnToSelf : returnToList;
  77. const attendees = safeArray(e.attendees);
  78. const isAttending = attendees.includes(userId);
  79. const leftActions = [];
  80. if (!isSingle) {
  81. leftActions.push(
  82. form(
  83. { method: "GET", action: `/events/${encodeURIComponent(e.id)}` },
  84. input({ type: "hidden", name: "filter", value: currentFilter }),
  85. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  86. )
  87. );
  88. }
  89. if (e.organizer && e.organizer !== userId) {
  90. leftActions.push(
  91. form(
  92. { method: "GET", action: "/pm" },
  93. input({ type: "hidden", name: "recipients", value: e.organizer }),
  94. button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
  95. )
  96. );
  97. }
  98. const rightActions = [];
  99. const attendNode = renderEventAttendAction(e, isAttending, rt);
  100. if (attendNode) rightActions.push(attendNode);
  101. const ownerActions = renderEventOwnerActions(e, rt);
  102. if (ownerActions.length) rightActions.push(...ownerActions);
  103. const leftNode = leftActions.length ? div({ class: "bookmark-topbar-left event-topbar-left" }, ...leftActions) : null;
  104. const rightNode = rightActions.length ? div({ class: "bookmark-actions event-actions" }, ...rightActions) : null;
  105. const nodes = [];
  106. if (leftNode) nodes.push(leftNode);
  107. if (rightNode) nodes.push(rightNode);
  108. return nodes.length ? div({ class: isSingle ? "bookmark-topbar event-topbar-single" : "bookmark-topbar" }, ...nodes) : null;
  109. };
  110. const renderEventCommentsSection = (eventId, comments = [], currentFilter = "all") => {
  111. const commentsCount = Array.isArray(comments) ? comments.length : 0;
  112. const returnTo = `/events/${encodeURIComponent(eventId)}?filter=${encodeURIComponent(currentFilter || "all")}`;
  113. return div(
  114. { class: "vote-comments-section" },
  115. div(
  116. { class: "comments-count" },
  117. span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
  118. span({ class: "card-value" }, String(commentsCount))
  119. ),
  120. div(
  121. { class: "comment-form-wrapper" },
  122. h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
  123. form(
  124. { method: "POST", action: `/events/${encodeURIComponent(eventId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
  125. input({ type: "hidden", name: "returnTo", value: returnTo }),
  126. textarea({
  127. id: "comment-text",
  128. name: "text",
  129. rows: 4,
  130. class: "comment-textarea",
  131. placeholder: i18n.voteNewCommentPlaceholder
  132. }),
  133. div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
  134. br(),
  135. button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
  136. )
  137. ),
  138. comments && comments.length
  139. ? div(
  140. { class: "comments-list" },
  141. comments.map((c) => {
  142. const author = c.value && c.value.author ? c.value.author : "";
  143. const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
  144. const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
  145. const relDate = ts ? moment(ts).fromNow() : "";
  146. const userName = author && author.includes("@") ? author.split("@")[1] : author;
  147. const content = c.value && c.value.content ? c.value.content : {};
  148. const root = content.fork || content.root || "";
  149. const text = content.text || "";
  150. return div(
  151. { class: "votations-comment-card" },
  152. span(
  153. { class: "created-at" },
  154. span(i18n.createdBy),
  155. author ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`) : span("(unknown)"),
  156. absDate ? span(" | ") : "",
  157. absDate ? span({ class: "votations-comment-date" }, absDate) : "",
  158. relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
  159. relDate && root ? a({ href: `/thread/${encodeURIComponent(root)}#${encodeURIComponent(c.key)}` }, relDate) : ""
  160. ),
  161. p({ class: "votations-comment-text" }, ...renderUrl(String(text)))
  162. );
  163. })
  164. )
  165. : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
  166. );
  167. };
  168. const renderEventItem = (e, filter) => {
  169. const currentFilter = filter || "all";
  170. const attendees = safeArray(e.attendees);
  171. const commentCount = typeof e.commentCount === "number" ? e.commentCount : 0;
  172. const urlHref = safeExternalHref(e.url);
  173. const topbar = renderEventTopbar(e, currentFilter, { single: false });
  174. return div(
  175. { class: "card card-section event" },
  176. topbar ? topbar : null,
  177. renderCardField(i18n.eventTitleLabel + ":", e.title),
  178. renderCardField(i18n.eventDescriptionLabel + ":", ""),
  179. p(...renderUrl(e.description)),
  180. renderCardField(i18n.eventDateLabel + ":", e.date ? moment(e.date).format("YYYY/MM/DD HH:mm:ss") : ""),
  181. e.location && String(e.location).trim() ? renderCardField(i18n.eventLocationLabel + ":", e.location) : null,
  182. renderCardField(i18n.eventPrivacyLabel + ":", privacyLabel(e.isPublic)),
  183. renderCardField(i18n.eventStatus + ":", eventStatusLabel(e.status)),
  184. urlHref ? renderCardField(i18n.eventUrlLabel + ":", a({ href: urlHref, target: "_blank", rel: "noopener noreferrer" }, urlHref)) : null,
  185. renderCardField(i18n.eventPriceLabel + ":", parseFloat(e.price || 0).toFixed(6) + " ECO"),
  186. br(),
  187. div(
  188. { class: "card-field" },
  189. span({ class: "card-label" }, i18n.eventAttendees + ":"),
  190. span(
  191. { class: "card-value" },
  192. attendees.length
  193. ? attendees
  194. .filter(Boolean)
  195. .map((id, i) => [i > 0 ? ", " : "", a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)])
  196. .flat()
  197. : i18n.noAttendees
  198. )
  199. ),
  200. br(),
  201. e.tags && e.tags.filter(Boolean).length
  202. ? div(
  203. { class: "card-tags" },
  204. e.tags.filter(Boolean).map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
  205. )
  206. : null,
  207. div(
  208. { class: "card-comments-summary" },
  209. span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
  210. span({ class: "card-value" }, String(commentCount)),
  211. br(),
  212. br(),
  213. form(
  214. { method: "GET", action: `/events/${encodeURIComponent(e.id)}` },
  215. input({ type: "hidden", name: "filter", value: currentFilter }),
  216. button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
  217. )
  218. ),
  219. br(),
  220. p(
  221. { class: "card-footer" },
  222. span({ class: "date-link" }, `${moment(e.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
  223. a({ href: `/author/${encodeURIComponent(e.organizer)}`, class: "user-link" }, `${e.organizer}`)
  224. )
  225. );
  226. };
  227. exports.eventView = async (events, filter, eventId, returnTo) => {
  228. const list = Array.isArray(events) ? events : [events];
  229. const currentFilter = filter || "all";
  230. const title =
  231. currentFilter === "mine" ? i18n.eventMineSectionTitle :
  232. currentFilter === "create" ? i18n.eventCreateSectionTitle :
  233. currentFilter === "edit" ? i18n.eventUpdateSectionTitle :
  234. i18n.eventAllSectionTitle;
  235. const eventToEdit = list.find((e) => e.id === eventId) || {};
  236. const editTags = Array.isArray(eventToEdit.tags) ? eventToEdit.tags.filter(Boolean) : [];
  237. const canSee = (e) => {
  238. const isPub = normalizePrivacy(e.isPublic) === "public";
  239. if (isPub) return true;
  240. if (e.organizer === userId) return true;
  241. return safeArray(e.attendees).includes(userId);
  242. };
  243. const visible = list.filter(canSee);
  244. let filtered;
  245. if (currentFilter === "all") {
  246. filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public");
  247. } else if (currentFilter === "mine") {
  248. filtered = visible.filter((e) => e.organizer === userId);
  249. } else if (currentFilter === "today") {
  250. filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public" && moment(e.date).isSame(moment(), "day"));
  251. } else if (currentFilter === "week") {
  252. filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public" && moment(e.date).isBetween(moment(), moment().add(7, "days"), null, "[]"));
  253. } else if (currentFilter === "month") {
  254. filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public" && moment(e.date).isBetween(moment(), moment().add(1, "month"), null, "[]"));
  255. } else if (currentFilter === "year") {
  256. filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public" && moment(e.date).isBetween(moment(), moment().add(1, "year"), null, "[]"));
  257. } else if (currentFilter === "archived") {
  258. filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public" && normalizeEventStatus(e.status) === "CLOSED");
  259. } else {
  260. filtered = [];
  261. }
  262. filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  263. const minCreate = moment().add(1, "minute").format("YYYY-MM-DDTHH:mm");
  264. const ret = typeof returnTo === "string" && returnTo.startsWith("/events") ? returnTo : "/events?filter=mine";
  265. const editPrivacy = normalizePrivacy(eventToEdit.isPublic);
  266. return template(
  267. title,
  268. section(
  269. div({ class: "tags-header" }, h2(i18n.eventsTitle), p(i18n.eventsDescription)),
  270. div(
  271. { class: "filters" },
  272. form(
  273. { method: "GET", action: "/events" },
  274. button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterAll),
  275. button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMine),
  276. button({ type: "submit", name: "filter", value: "today", class: currentFilter === "today" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterToday),
  277. button({ type: "submit", name: "filter", value: "week", class: currentFilter === "week" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterWeek),
  278. button({ type: "submit", name: "filter", value: "month", class: currentFilter === "month" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMonth),
  279. button({ type: "submit", name: "filter", value: "year", class: currentFilter === "year" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterYear),
  280. button({ type: "submit", name: "filter", value: "archived", class: currentFilter === "archived" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterArchived),
  281. button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.eventCreateButton)
  282. )
  283. )
  284. ),
  285. section(
  286. currentFilter === "edit" || currentFilter === "create"
  287. ? div(
  288. { class: "event-form" },
  289. form(
  290. {
  291. action: currentFilter === "edit" ? `/events/update/${encodeURIComponent(eventId)}` : "/events/create",
  292. method: "POST"
  293. },
  294. input({ type: "hidden", name: "returnTo", value: ret }),
  295. label(i18n.eventTitleLabel),
  296. br(),
  297. input({
  298. type: "text",
  299. name: "title",
  300. id: "title",
  301. required: true,
  302. value: currentFilter === "edit" ? eventToEdit.title || "" : ""
  303. }),
  304. br(),
  305. br(),
  306. label(i18n.eventDescriptionLabel),
  307. br(),
  308. textarea(
  309. { name: "description", id: "description", placeholder: i18n.eventDescriptionPlaceholder, rows: "4" },
  310. currentFilter === "edit" ? eventToEdit.description || "" : ""
  311. ),
  312. br(),
  313. br(),
  314. label(i18n.eventDateLabel),
  315. br(),
  316. input({
  317. type: "datetime-local",
  318. name: "date",
  319. id: "date",
  320. required: true,
  321. min: currentFilter === "create" ? minCreate : undefined,
  322. value: currentFilter === "edit" && eventToEdit.date ? moment(eventToEdit.date).format("YYYY-MM-DDTHH:mm") : ""
  323. }),
  324. br(),
  325. br(),
  326. label(i18n.eventPrivacyLabel),
  327. br(),
  328. select(
  329. { name: "isPublic", id: "isPublic" },
  330. opt("public", editPrivacy !== "private", i18n.eventPublic),
  331. opt("private", editPrivacy === "private", i18n.eventPrivate)
  332. ),
  333. br(),
  334. br(),
  335. label(i18n.eventLocationLabel),
  336. br(),
  337. input({
  338. type: "text",
  339. name: "location",
  340. id: "location",
  341. required: true,
  342. value: currentFilter === "edit" ? eventToEdit.location || "" : ""
  343. }),
  344. br(),
  345. br(),
  346. label(i18n.eventUrlLabel),
  347. br(),
  348. input({ type: "url", name: "url", id: "url", value: currentFilter === "edit" ? eventToEdit.url || "" : "" }),
  349. br(),
  350. br(),
  351. label(i18n.eventPriceLabel),
  352. br(),
  353. input({
  354. type: "number",
  355. name: "price",
  356. id: "price",
  357. min: "0.000000",
  358. step: "0.000001",
  359. value: currentFilter === "edit" ? parseFloat(eventToEdit.price || 0).toFixed(6) : (0).toFixed(6)
  360. }),
  361. br(),
  362. br(),
  363. label(i18n.eventTagsLabel),
  364. br(),
  365. input({ type: "text", name: "tags", id: "tags", value: currentFilter === "edit" ? editTags.join(", ") : "" }),
  366. br(),
  367. br(),
  368. button({ type: "submit" }, currentFilter === "edit" ? i18n.eventUpdateButton : i18n.eventCreateButton)
  369. )
  370. )
  371. : div({ class: "event-list" }, filtered.length > 0 ? filtered.map((e) => renderEventItem(e, currentFilter)) : p(i18n.noevents))
  372. )
  373. );
  374. };
  375. exports.singleEventView = async (event, filter, comments = []) => {
  376. const currentFilter = filter || "all";
  377. const commentCount = typeof event.commentCount === "number" ? event.commentCount : 0;
  378. const attendees = safeArray(event.attendees);
  379. const urlHref = safeExternalHref(event.url);
  380. const topbar = renderEventTopbar(event, currentFilter, { single: true });
  381. return template(
  382. event.title,
  383. section(
  384. div(
  385. { class: "filters" },
  386. form(
  387. { method: "GET", action: "/events" },
  388. button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterAll),
  389. button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMine),
  390. button({ type: "submit", name: "filter", value: "today", class: currentFilter === "today" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterToday),
  391. button({ type: "submit", name: "filter", value: "week", class: currentFilter === "week" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterWeek),
  392. button({ type: "submit", name: "filter", value: "month", class: currentFilter === "month" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMonth),
  393. button({ type: "submit", name: "filter", value: "year", class: currentFilter === "year" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterYear),
  394. button({ type: "submit", name: "filter", value: "archived", class: currentFilter === "archived" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterArchived),
  395. button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.eventCreateButton)
  396. )
  397. ),
  398. div(
  399. { class: "card card-section event" },
  400. topbar ? topbar : null,
  401. renderCardField(i18n.eventTitleLabel + ":", event.title),
  402. renderCardField(i18n.eventDescriptionLabel + ":", ""),
  403. p(...renderUrl(event.description)),
  404. renderCardField(i18n.eventDateLabel + ":", event.date ? moment(event.date).format("YYYY/MM/DD HH:mm:ss") : ""),
  405. event.location && String(event.location).trim() ? renderCardField(i18n.eventLocationLabel + ":", event.location) : null,
  406. renderCardField(i18n.eventPrivacyLabel + ":", privacyLabel(event.isPublic)),
  407. renderCardField(i18n.eventStatus + ":", eventStatusLabel(event.status)),
  408. urlHref ? renderCardField(i18n.eventUrlLabel + ":", a({ href: urlHref, target: "_blank", rel: "noopener noreferrer" }, urlHref)) : null,
  409. renderCardField(i18n.eventPriceLabel + ":", parseFloat(event.price || 0).toFixed(6) + " ECO"),
  410. br(),
  411. div(
  412. { class: "card-field" },
  413. span({ class: "card-label" }, i18n.eventAttendees + ":"),
  414. span(
  415. { class: "card-value" },
  416. attendees.length
  417. ? attendees
  418. .filter(Boolean)
  419. .map((id, i) => [i > 0 ? ", " : "", a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)])
  420. .flat()
  421. : i18n.noAttendees
  422. )
  423. ),
  424. br(),
  425. event.tags && event.tags.filter(Boolean).length
  426. ? div(
  427. { class: "card-tags" },
  428. event.tags.filter(Boolean).map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
  429. )
  430. : null,
  431. div(
  432. { class: "card-comments-summary" },
  433. span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
  434. span({ class: "card-value" }, String(commentCount))
  435. ),
  436. br(),
  437. p(
  438. { class: "card-footer" },
  439. span({ class: "date-link" }, `${moment(event.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
  440. a({ href: `/author/${encodeURIComponent(event.organizer)}`, class: "user-link" }, `${event.organizer}`)
  441. )
  442. ),
  443. renderEventCommentsSection(event.id, comments, currentFilter)
  444. )
  445. );
  446. };