vote_view.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. const { div, h2, p, section, button, form, a, textarea, br, input, table, tr, th, td, label, span } = require("../server/node_modules/hyperaxe");
  2. const { template, i18n, userLink} = require("./main_views");
  3. const moment = require("../server/node_modules/moment");
  4. const { config } = require("../server/SSB_server.js");
  5. const opinionCategories = require("../backend/opinion_categories");
  6. const { renderUrl } = require("../backend/renderUrl");
  7. const userId = config.keys.id;
  8. const safeArray = (v) => Array.isArray(v) ? v : [];
  9. const voteLabel = (opt) =>
  10. i18n["vote" + opt.split("_").map(w => w.charAt(0) + w.slice(1).toLowerCase()).join("")] || opt;
  11. const toValueChildren = (v) => {
  12. if (v === undefined || v === null) return [];
  13. if (Array.isArray(v)) return v;
  14. if (typeof v === "string") return renderUrl(v);
  15. if (typeof v === "number" || typeof v === "boolean") return renderUrl(String(v));
  16. return [v];
  17. };
  18. const renderCardField = (labelText, valueNode) =>
  19. div(
  20. { class: "card-field" },
  21. span({ class: "card-label" }, labelText),
  22. span({ class: "card-value" }, ...toValueChildren(valueNode))
  23. );
  24. const normalizeStatus = (v) => {
  25. const up = String(v || "").toUpperCase();
  26. if (up === "OPEN" || up === "CLOSED") return up;
  27. return up || "OPEN";
  28. };
  29. const statusLabel = (s) => {
  30. const up = normalizeStatus(s);
  31. if (up === "OPEN") return i18n.voteStatusOpen || i18n.voteFilterOpen || "OPEN";
  32. if (up === "CLOSED") return i18n.voteStatusClosed || i18n.voteFilterClosed || "CLOSED";
  33. return up;
  34. };
  35. const renderVoteOwnerActions = (v, returnTo, mode) => {
  36. const showUpdateButton = mode === "mine" && !Object.keys(v.opinions || {}).length;
  37. const showDeleteButton = mode === "mine";
  38. const actions = [];
  39. if (showUpdateButton) {
  40. actions.push(
  41. form(
  42. { method: "GET", action: `/votes/edit/${encodeURIComponent(v.id)}` },
  43. input({ type: "hidden", name: "returnTo", value: returnTo }),
  44. button({ class: "update-btn", type: "submit" }, i18n.voteUpdateButton)
  45. )
  46. );
  47. }
  48. if (showDeleteButton) {
  49. actions.push(
  50. form(
  51. { method: "POST", action: `/votes/delete/${encodeURIComponent(v.id)}` },
  52. input({ type: "hidden", name: "returnTo", value: returnTo }),
  53. button({ class: "delete-btn", type: "submit" }, i18n.voteDeleteButton)
  54. )
  55. );
  56. }
  57. return actions;
  58. };
  59. const renderVotePMActions = (v) => {
  60. if (!v.createdBy || v.createdBy === userId) return [];
  61. return [
  62. form(
  63. { method: "GET", action: "/pm" },
  64. input({ type: "hidden", name: "recipients", value: v.createdBy }),
  65. button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
  66. )
  67. ];
  68. };
  69. const renderVoteTopbar = (v, activeFilter, opts = {}) => {
  70. const isSingle = !!opts.single;
  71. const currentFilter = activeFilter || "all";
  72. const returnToList = `/votes?filter=${encodeURIComponent(currentFilter)}`;
  73. const returnToSelf = `/votes/${encodeURIComponent(v.id)}?filter=${encodeURIComponent(currentFilter)}`;
  74. const rt = isSingle ? returnToSelf : returnToList;
  75. const leftActions = [];
  76. if (!isSingle) {
  77. leftActions.push(
  78. form(
  79. { method: "GET", action: `/votes/${encodeURIComponent(v.id)}` },
  80. input({ type: "hidden", name: "filter", value: currentFilter }),
  81. button({ class: "filter-btn", type: "submit" }, i18n.viewDetails)
  82. )
  83. );
  84. }
  85. leftActions.push(...renderVotePMActions(v));
  86. const ownerActions = renderVoteOwnerActions(v, rt, opts.mode || "");
  87. const rightActions = [];
  88. if (ownerActions.length) rightActions.push(...ownerActions);
  89. const leftNode = leftActions.length ? div({ class: "bookmark-topbar-left" }, ...leftActions) : null;
  90. const rightNode = rightActions.length ? div({ class: "bookmark-actions vote-actions" }, ...rightActions) : null;
  91. const nodes = [];
  92. if (leftNode) nodes.push(leftNode);
  93. if (rightNode) nodes.push(rightNode);
  94. return nodes.length ? div({ class: isSingle ? "bookmark-topbar vote-topbar-single" : "bookmark-topbar" }, ...nodes) : null;
  95. };
  96. const renderVoteButtons = (v, voteOptions, firstRow, secondRow, returnTo) => {
  97. if (normalizeStatus(v.status) !== "OPEN") return null;
  98. return div(
  99. { class: "vote-buttons-block" },
  100. div(
  101. { class: "vote-buttons-row" },
  102. ...firstRow.map((opt) =>
  103. form(
  104. { method: "POST", action: `/votes/vote/${encodeURIComponent(v.id)}` },
  105. input({ type: "hidden", name: "returnTo", value: returnTo }),
  106. button({ type: "submit", name: "choice", value: opt }, voteLabel(opt))
  107. )
  108. )
  109. ),
  110. div(
  111. { class: "vote-buttons-row" },
  112. ...secondRow.map((opt) =>
  113. form(
  114. { method: "POST", action: `/votes/vote/${encodeURIComponent(v.id)}` },
  115. input({ type: "hidden", name: "returnTo", value: returnTo }),
  116. button({ type: "submit", name: "choice", value: opt }, voteLabel(opt))
  117. )
  118. )
  119. )
  120. );
  121. };
  122. const renderOpinionsBar = (v, returnTo) =>
  123. div(
  124. { class: "voting-buttons" },
  125. opinionCategories.map((category) =>
  126. form(
  127. { method: "POST", action: `/votes/opinions/${encodeURIComponent(v.id)}/${category}` },
  128. input({ type: "hidden", name: "returnTo", value: returnTo }),
  129. button(
  130. { class: "vote-btn", type: "submit" },
  131. `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${(v.opinions && v.opinions[category]) ? v.opinions[category] : 0}]`
  132. )
  133. )
  134. )
  135. );
  136. const renderVoteCard = (v, voteOptions, firstRow, secondRow, mode, activeFilter) => {
  137. const baseCounts = voteOptions.reduce((acc, opt) => {
  138. acc[opt] = (v.votes && v.votes[opt]) ? v.votes[opt] : 0;
  139. return acc;
  140. }, {});
  141. const maxOpt = voteOptions
  142. .filter((opt) => opt !== "FOLLOW_MAJORITY")
  143. .reduce((top, opt) => baseCounts[opt] > baseCounts[top] ? opt : top, "NOT_INTERESTED");
  144. const totalVotesNum = typeof v.totalVotes === "number" ? v.totalVotes : parseInt(String(v.totalVotes || "0"), 10) || 0;
  145. const result = totalVotesNum === 0 ? "NOT_INTERESTED" : maxOpt;
  146. const commentCount = typeof v.commentCount === "number" ? v.commentCount : 0;
  147. const showCommentsSummaryInCard = mode !== "detail";
  148. const listReturnTo = `/votes?filter=${encodeURIComponent(activeFilter || "all")}`;
  149. const detailReturnTo = `/votes/${encodeURIComponent(v.id)}?filter=${encodeURIComponent(activeFilter || "all")}`;
  150. const returnTo = mode === "detail" ? detailReturnTo : listReturnTo;
  151. const topbar = renderVoteTopbar(v, activeFilter, { single: mode === "detail", mode });
  152. return div(
  153. { class: "card card-section vote" },
  154. topbar ? topbar : null,
  155. renderCardField(i18n.voteQuestionLabel + ":", v.question),
  156. renderCardField(i18n.voteDeadline + ":", v.deadline ? moment(v.deadline).format("YYYY/MM/DD HH:mm:ss") : ""),
  157. renderCardField(i18n.voteStatus + ":", statusLabel(v.status)),
  158. br(),
  159. renderVoteButtons(v, voteOptions, firstRow, secondRow, returnTo),
  160. renderCardField(i18n.voteTotalVotes + ":", totalVotesNum),
  161. br(),
  162. div(
  163. { class: "vote-table" },
  164. table(
  165. tr(...voteOptions.map((opt) => th(voteLabel(opt)))),
  166. tr(...voteOptions.map((opt) => td(baseCounts[opt])))
  167. )
  168. ),
  169. renderCardField(
  170. i18n.voteBreakdown + ":",
  171. span(
  172. voteLabel(result), " = ", String(baseCounts[result] || 0),
  173. " + ", voteLabel("FOLLOW_MAJORITY"), ": ", String(baseCounts.FOLLOW_MAJORITY || 0)
  174. )
  175. ),
  176. br(),
  177. div({ class: "vote-buttons-row" }, h2(voteLabel(result))),
  178. v.tags && v.tags.filter(Boolean).length
  179. ? div(
  180. { class: "card-tags" },
  181. v.tags.filter(Boolean).map((tag) =>
  182. a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
  183. )
  184. )
  185. : null,
  186. showCommentsSummaryInCard
  187. ? div(
  188. { class: "card-comments-summary" },
  189. span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
  190. span({ class: "card-value" }, String(commentCount)),
  191. br(),
  192. br(),
  193. form(
  194. { method: "GET", action: `/votes/${encodeURIComponent(v.id)}` },
  195. input({ type: "hidden", name: "filter", value: activeFilter || "all" }),
  196. button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
  197. )
  198. )
  199. : null,
  200. br(),
  201. p(
  202. { class: "card-footer" },
  203. span({ class: "date-link" }, `${moment(v.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
  204. userLink(v.createdBy)
  205. ),
  206. renderOpinionsBar(v, returnTo)
  207. );
  208. };
  209. const renderCommentsSection = (voteId, comments, activeFilter) => {
  210. const commentsCount = Array.isArray(comments) ? comments.length : 0;
  211. const returnTo = `/votes/${encodeURIComponent(voteId)}?filter=${encodeURIComponent(activeFilter || "all")}`;
  212. return div(
  213. { class: "vote-comments-section" },
  214. div(
  215. { class: "comments-count" },
  216. span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
  217. span({ class: "card-value" }, String(commentsCount))
  218. ),
  219. div(
  220. { class: "comment-form-wrapper" },
  221. h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
  222. form(
  223. { method: "POST", action: `/votes/${encodeURIComponent(voteId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
  224. input({ type: "hidden", name: "returnTo", value: returnTo }),
  225. textarea({
  226. id: "comment-text",
  227. name: "text",
  228. rows: 4,
  229. class: "comment-textarea",
  230. placeholder: i18n.voteNewCommentPlaceholder
  231. }),
  232. div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
  233. br(),
  234. button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
  235. )
  236. ),
  237. comments && comments.length
  238. ? div(
  239. { class: "comments-list" },
  240. comments.map((c) => {
  241. const author = c.value && c.value.author ? c.value.author : "";
  242. const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
  243. const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
  244. const relDate = ts ? moment(ts).fromNow() : "";
  245. const userName = author && author.includes("@") ? author.split("@")[1] : author;
  246. const content = c.value && c.value.content ? c.value.content : {};
  247. const root = content.fork || content.root || "";
  248. const text = content.text || "";
  249. return div(
  250. { class: "votations-comment-card" },
  251. span(
  252. { class: "created-at" },
  253. span(i18n.createdBy),
  254. author ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`) : span("(unknown)"),
  255. absDate ? span(" | ") : "",
  256. absDate ? span({ class: "votations-comment-date" }, absDate) : "",
  257. relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
  258. relDate && root ? a({ href: `/thread/${encodeURIComponent(root)}#${encodeURIComponent(c.key)}` }, relDate) : ""
  259. ),
  260. p({ class: "votations-comment-text" }, ...renderUrl(String(text)))
  261. );
  262. })
  263. )
  264. : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
  265. );
  266. };
  267. exports.voteView = async (votes, mode, voteId, comments = [], activeFilterParam) => {
  268. const list = Array.isArray(votes) ? votes : [votes];
  269. const standardFilters = ["all", "mine", "open", "closed"];
  270. const activeFilter = standardFilters.includes(activeFilterParam)
  271. ? activeFilterParam
  272. : (standardFilters.includes(mode) ? mode : "all");
  273. const title =
  274. mode === "mine" ? i18n.voteMineSectionTitle :
  275. mode === "create" ? i18n.voteCreateSectionTitle :
  276. mode === "edit" ? i18n.voteUpdateSectionTitle :
  277. mode === "open" ? i18n.voteOpenTitle :
  278. mode === "closed" ? i18n.voteClosedTitle :
  279. mode === "detail" ? (i18n.voteDetailSectionTitle || i18n.voteAllSectionTitle) :
  280. i18n.voteAllSectionTitle;
  281. const voteToEdit = list.find((v) => v.id === voteId) || {};
  282. const editTags = Array.isArray(voteToEdit.tags) ? voteToEdit.tags.filter(Boolean) : [];
  283. let filtered =
  284. mode === "mine" ? list.filter((v) => v.createdBy === userId) :
  285. mode === "open" ? list.filter((v) => normalizeStatus(v.status) === "OPEN") :
  286. mode === "closed" ? list.filter((v) => normalizeStatus(v.status) === "CLOSED") :
  287. list;
  288. filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  289. const voteOptions = ["ABSTENTION", "YES", "NO", "CONFUSED", "FOLLOW_MAJORITY", "NOT_INTERESTED"];
  290. const firstRow = ["ABSTENTION", "YES", "NO"];
  291. const secondRow = ["CONFUSED", "FOLLOW_MAJORITY", "NOT_INTERESTED"];
  292. const header = div(
  293. { class: "tags-header" },
  294. h2(i18n.votationsTitle),
  295. p(i18n.votationsDescription)
  296. );
  297. const listReturnTo = standardFilters.includes(activeFilter) ? `/votes?filter=${encodeURIComponent(activeFilter)}` : "/votes";
  298. const deadlineMin = moment().add(1, "minute").format("YYYY-MM-DDTHH:mm");
  299. const deadlineValue = voteToEdit.deadline ? moment(voteToEdit.deadline).format("YYYY-MM-DDTHH:mm") : "";
  300. return template(
  301. title,
  302. section(
  303. header,
  304. div(
  305. { class: "filters" },
  306. form(
  307. { method: "GET", action: "/votes" },
  308. button({ type: "submit", name: "filter", value: "all", class: mode === "all" ? "filter-btn active" : "filter-btn" }, i18n.voteFilterAll),
  309. button({ type: "submit", name: "filter", value: "mine", class: mode === "mine" ? "filter-btn active" : "filter-btn" }, i18n.voteFilterMine),
  310. button({ type: "submit", name: "filter", value: "open", class: mode === "open" ? "filter-btn active" : "filter-btn" }, i18n.voteFilterOpen),
  311. button({ type: "submit", name: "filter", value: "closed", class: mode === "closed" ? "filter-btn active" : "filter-btn" }, i18n.voteFilterClosed),
  312. button({ type: "submit", name: "filter", value: "create", class: mode === "create" ? "create-button active" : "create-button" }, i18n.voteCreateButton)
  313. )
  314. )
  315. ),
  316. section(
  317. (mode === "edit" || mode === "create")
  318. ? div(
  319. { class: "vote-form" },
  320. form(
  321. { action: mode === "edit" ? `/votes/update/${encodeURIComponent(voteId)}` : "/votes/create", method: "POST" },
  322. input({ type: "hidden", name: "returnTo", value: listReturnTo }),
  323. h2(i18n.voteQuestionLabel),
  324. input({ type: "text", name: "question", id: "question", required: true, value: voteToEdit.question || "" }), br(), br(),
  325. label(i18n.voteDeadlineLabel), br(),
  326. input({
  327. type: "datetime-local",
  328. name: "deadline",
  329. id: "deadline",
  330. required: true,
  331. min: mode === "create" ? deadlineMin : undefined,
  332. value: deadlineValue
  333. }), br(), br(),
  334. label(i18n.voteTagsLabel), br(),
  335. input({ type: "text", name: "tags", id: "tags", value: editTags.join(", ") }), br(), br(),
  336. button({ type: "submit" }, mode === "edit" ? i18n.voteUpdateButton : i18n.voteCreateButton)
  337. )
  338. )
  339. : div(
  340. { class: "vote-list" },
  341. filtered.length > 0
  342. ? filtered.map((v) => renderVoteCard(v, voteOptions, firstRow, secondRow, mode, activeFilter))
  343. : p(i18n.novotes)
  344. ),
  345. (mode === "detail" && voteId) ? renderCommentsSection(voteId, comments, activeFilter) : null
  346. )
  347. );
  348. };