event_view.js 21 KB

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