task_view.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. const { div, h2, p, section, button, form, input, select, option, a, br, textarea, label, span } = require("../server/node_modules/hyperaxe");
  2. const moment = require("../server/node_modules/moment");
  3. const { template, i18n } = require("./main_views");
  4. const { config } = require("../server/SSB_server.js");
  5. const { renderUrl } = require("../backend/renderUrl");
  6. const 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 renderCardField = (labelText, valueNode) =>
  18. div(
  19. { class: "card-field" },
  20. span({ class: "card-label" }, labelText),
  21. span({ class: "card-value" }, ...toValueChildren(valueNode))
  22. );
  23. const normalizeStatus = (v) => {
  24. const up = String(v || "").toUpperCase();
  25. if (up === "OPEN" || up === "IN-PROGRESS" || up === "CLOSED") return up;
  26. return "OPEN";
  27. };
  28. const statusLabel = (s) => {
  29. const up = normalizeStatus(s);
  30. if (up === "OPEN") return i18n.taskStatusOpen;
  31. if (up === "IN-PROGRESS") return i18n.taskStatusInProgress;
  32. return i18n.taskStatusClosed;
  33. };
  34. const visibilityLabel = (v) => {
  35. const vv = String(v || "").toUpperCase();
  36. if (vv === "PRIVATE") return i18n.taskPrivate;
  37. return i18n.taskPublic;
  38. };
  39. const renderTaskOwnerActions = (task, returnTo) => {
  40. const st = normalizeStatus(task.status || "OPEN");
  41. const setStatusLabel = i18n.taskSetStatus;
  42. return [
  43. form(
  44. { method: "GET", action: `/tasks/edit/${encodeURIComponent(task.id)}` },
  45. input({ type: "hidden", name: "returnTo", value: returnTo }),
  46. button({ type: "submit", class: "update-btn" }, i18n.taskUpdateButton)
  47. ),
  48. form(
  49. { method: "POST", action: `/tasks/delete/${encodeURIComponent(task.id)}` },
  50. input({ type: "hidden", name: "returnTo", value: returnTo }),
  51. button({ type: "submit", class: "delete-btn" }, i18n.taskDeleteButton)
  52. ),
  53. form(
  54. { method: "POST", action: `/tasks/status/${encodeURIComponent(task.id)}`, class: "project-control-form project-control-form--status" },
  55. input({ type: "hidden", name: "returnTo", value: returnTo }),
  56. select(
  57. { name: "status", class: "project-control-select" },
  58. option({ value: "OPEN", selected: st === "OPEN" }, i18n.taskStatusOpen),
  59. option({ value: "IN-PROGRESS", selected: st === "IN-PROGRESS" }, i18n.taskStatusInProgress),
  60. option({ value: "CLOSED", selected: st === "CLOSED" }, i18n.taskStatusClosed)
  61. ),
  62. button({ class: "status-btn project-control-btn", type: "submit" }, setStatusLabel)
  63. )
  64. ];
  65. };
  66. const renderTaskAssignAction = (task, isAssignedToMe, returnTo) => {
  67. const st = normalizeStatus(task.status || "OPEN");
  68. if (st === "CLOSED") return null;
  69. return form(
  70. { method: "POST", action: `/tasks/assign/${encodeURIComponent(task.id)}` },
  71. input({ type: "hidden", name: "returnTo", value: returnTo }),
  72. button({ type: "submit", class: "filter-btn" }, isAssignedToMe ? i18n.taskUnassignButton : i18n.taskAssignButton)
  73. );
  74. };
  75. const renderTaskTopbar = (task, filter, opts = {}) => {
  76. const currentFilter = filter || "all";
  77. const isSingle = !!opts.single;
  78. const returnToList = `/tasks?filter=${encodeURIComponent(currentFilter)}`;
  79. const returnToSelf = `/tasks/${encodeURIComponent(task.id)}?filter=${encodeURIComponent(currentFilter)}`;
  80. const rt = isSingle ? returnToSelf : returnToList;
  81. const assignees = safeArray(task.assignees);
  82. const isAssignedToMe = assignees.includes(userId);
  83. const leftActions = [];
  84. if (!isSingle) {
  85. leftActions.push(
  86. form(
  87. { method: "GET", action: `/tasks/${encodeURIComponent(task.id)}` },
  88. input({ type: "hidden", name: "filter", value: currentFilter }),
  89. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  90. )
  91. );
  92. }
  93. if (task.author && task.author !== userId) {
  94. leftActions.push(
  95. form(
  96. { method: "GET", action: "/pm" },
  97. input({ type: "hidden", name: "recipients", value: task.author }),
  98. button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
  99. )
  100. );
  101. }
  102. const ownerActions = task.author === userId ? renderTaskOwnerActions(task, rt) : [];
  103. const assignNode = renderTaskAssignAction(task, isAssignedToMe, rt);
  104. const rightActions = [];
  105. if (assignNode) rightActions.push(assignNode);
  106. if (ownerActions.length) rightActions.push(...ownerActions);
  107. const leftNode = leftActions.length ? div({ class: "bookmark-topbar-left task-topbar-left" }, ...leftActions) : null;
  108. const rightNode = rightActions.length ? div({ class: "bookmark-actions task-actions" }, ...rightActions) : null;
  109. const nodes = [];
  110. if (leftNode) nodes.push(leftNode);
  111. if (rightNode) nodes.push(rightNode);
  112. return nodes.length ? div({ class: isSingle ? "bookmark-topbar task-topbar-single" : "bookmark-topbar" }, ...nodes) : null;
  113. };
  114. const renderTaskCommentsSection = (taskId, comments = [], currentFilter = "all") => {
  115. const commentsCount = Array.isArray(comments) ? comments.length : 0;
  116. const returnTo = `/tasks/${encodeURIComponent(taskId)}?filter=${encodeURIComponent(currentFilter || "all")}`;
  117. return div(
  118. { class: "vote-comments-section" },
  119. div(
  120. { class: "comments-count" },
  121. span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
  122. span({ class: "card-value" }, String(commentsCount))
  123. ),
  124. div(
  125. { class: "comment-form-wrapper" },
  126. h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
  127. form(
  128. { method: "POST", action: `/tasks/${encodeURIComponent(taskId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
  129. input({ type: "hidden", name: "returnTo", value: returnTo }),
  130. textarea({
  131. id: "comment-text",
  132. name: "text",
  133. rows: 4,
  134. class: "comment-textarea",
  135. placeholder: i18n.voteNewCommentPlaceholder
  136. }),
  137. div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
  138. br(),
  139. button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
  140. )
  141. ),
  142. comments && comments.length
  143. ? div(
  144. { class: "comments-list" },
  145. comments.map((c) => {
  146. const author = c.value && c.value.author ? c.value.author : "";
  147. const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
  148. const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
  149. const relDate = ts ? moment(ts).fromNow() : "";
  150. const userName = author && author.includes("@") ? author.split("@")[1] : author;
  151. const content = c.value && c.value.content ? c.value.content : {};
  152. const root = content.fork || content.root || "";
  153. const text = content.text || "";
  154. return div(
  155. { class: "votations-comment-card" },
  156. span(
  157. { class: "created-at" },
  158. span(i18n.createdBy),
  159. author ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`) : span("(unknown)"),
  160. absDate ? span(" | ") : "",
  161. absDate ? span({ class: "votations-comment-date" }, absDate) : "",
  162. relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
  163. relDate && root ? a({ href: `/thread/${encodeURIComponent(root)}#${encodeURIComponent(c.key)}` }, relDate) : ""
  164. ),
  165. p({ class: "votations-comment-text" }, ...renderUrl(String(text)))
  166. );
  167. })
  168. )
  169. : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
  170. );
  171. };
  172. const renderTaskItem = (task, filter) => {
  173. const currentFilter = filter || "all";
  174. const assignees = safeArray(task.assignees);
  175. const commentCount = typeof task.commentCount === "number" ? task.commentCount : 0;
  176. const topbar = renderTaskTopbar(task, currentFilter, { single: false });
  177. return div(
  178. { class: "card card-section task" },
  179. topbar ? topbar : null,
  180. renderCardField(i18n.taskTitleLabel + ":", task.title),
  181. renderCardField(i18n.taskDescriptionLabel + ":", ""),
  182. p(...renderUrl(task.description)),
  183. task.location && String(task.location).trim() ? renderCardField(i18n.taskLocationLabel + ":", task.location) : null,
  184. renderCardField(i18n.taskStatus + ":", statusLabel(task.status)),
  185. renderCardField(i18n.taskPriorityLabel + ":", task.priority),
  186. renderCardField(i18n.taskVisibilityLabel + ":", visibilityLabel(task.isPublic)),
  187. renderCardField(i18n.taskStartTimeLabel + ":", task.startTime ? moment(task.startTime).format("YYYY/MM/DD HH:mm:ss") : ""),
  188. renderCardField(i18n.taskEndTimeLabel + ":", task.endTime ? moment(task.endTime).format("YYYY/MM/DD HH:mm:ss") : ""),
  189. br(),
  190. div(
  191. { class: "card-field" },
  192. span({ class: "card-label" }, i18n.taskAssignedTo + ":"),
  193. span(
  194. { class: "card-value" },
  195. assignees.length
  196. ? assignees.map((id, i) => [i > 0 ? ", " : "", a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)]).flat()
  197. : i18n.noAssignees
  198. )
  199. ),
  200. br(),
  201. div(
  202. { class: "card-comments-summary" },
  203. span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
  204. span({ class: "card-value" }, String(commentCount)),
  205. br(),
  206. br(),
  207. form(
  208. { method: "GET", action: `/tasks/${encodeURIComponent(task.id)}` },
  209. input({ type: "hidden", name: "filter", value: currentFilter }),
  210. button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
  211. )
  212. ),
  213. br(),
  214. p(
  215. { class: "card-footer" },
  216. span({ class: "date-link" }, `${moment(task.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
  217. a({ href: `/author/${encodeURIComponent(task.author)}`, class: "user-link" }, `${task.author}`)
  218. )
  219. );
  220. };
  221. exports.taskView = async (tasks, filter, taskId, returnTo) => {
  222. const list = Array.isArray(tasks) ? tasks : [tasks];
  223. const currentFilter = filter || "all";
  224. const title =
  225. currentFilter === "mine" ? i18n.taskMineSectionTitle :
  226. currentFilter === "create" ? i18n.taskCreateSectionTitle :
  227. currentFilter === "edit" ? i18n.taskUpdateSectionTitle :
  228. currentFilter === "open" ? i18n.taskOpenTitle :
  229. currentFilter === "in-progress" ? i18n.taskInProgressTitle :
  230. currentFilter === "closed" ? i18n.taskClosedTitle :
  231. currentFilter === "assigned" ? i18n.taskAssignedTitle :
  232. currentFilter === "priority-urgent" ? i18n.taskFilterUrgent :
  233. currentFilter === "priority-high" ? i18n.taskFilterHigh :
  234. currentFilter === "priority-medium" ? i18n.taskFilterMedium :
  235. currentFilter === "priority-low" ? i18n.taskFilterLow :
  236. i18n.taskAllSectionTitle;
  237. const canSee = (t) => {
  238. const vis = String(t.isPublic || "").toUpperCase();
  239. if (vis === "PUBLIC") return true;
  240. if (t.author === userId) return true;
  241. return safeArray(t.assignees).includes(userId);
  242. };
  243. const visible = list.filter(canSee);
  244. let filtered;
  245. if (currentFilter === "mine") filtered = visible.filter((t) => t.author === userId);
  246. else if (currentFilter === "assigned") filtered = visible.filter((t) => safeArray(t.assignees).includes(userId));
  247. else if (currentFilter === "open") filtered = visible.filter((t) => normalizeStatus(t.status) === "OPEN");
  248. else if (currentFilter === "in-progress") filtered = visible.filter((t) => normalizeStatus(t.status) === "IN-PROGRESS");
  249. else if (currentFilter === "closed") filtered = visible.filter((t) => normalizeStatus(t.status) === "CLOSED");
  250. else if (currentFilter === "priority-urgent") filtered = visible.filter((t) => String(t.priority).toUpperCase() === "URGENT");
  251. else if (currentFilter === "priority-high") filtered = visible.filter((t) => String(t.priority).toUpperCase() === "HIGH");
  252. else if (currentFilter === "priority-medium") filtered = visible.filter((t) => String(t.priority).toUpperCase() === "MEDIUM");
  253. else if (currentFilter === "priority-low") filtered = visible.filter((t) => String(t.priority).toUpperCase() === "LOW");
  254. else filtered = visible;
  255. filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  256. const editTask = list.find((t) => t.id === taskId) || {};
  257. const editTags = Array.isArray(editTask.tags) ? editTask.tags : [];
  258. const minCreate = moment().add(1, "minute").format("YYYY-MM-DDTHH:mm");
  259. const ret = typeof returnTo === "string" && returnTo.startsWith("/tasks")
  260. ? returnTo
  261. : "/tasks?filter=mine";
  262. return template(
  263. title,
  264. section(
  265. div(
  266. { class: "tags-header" },
  267. h2(i18n.tasksTitle),
  268. p(i18n.tasksDescription)
  269. ),
  270. div(
  271. { class: "filters" },
  272. form(
  273. { method: "GET", action: "/tasks" },
  274. button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAll),
  275. button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMine),
  276. button({ type: "submit", name: "filter", value: "assigned", class: currentFilter === "assigned" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAssigned),
  277. button({ type: "submit", name: "filter", value: "open", class: currentFilter === "open" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterOpen),
  278. button({ type: "submit", name: "filter", value: "in-progress", class: currentFilter === "in-progress" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterInProgress),
  279. button({ type: "submit", name: "filter", value: "closed", class: currentFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterClosed),
  280. button({ type: "submit", name: "filter", value: "priority-low", class: currentFilter === "priority-low" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterLow),
  281. button({ type: "submit", name: "filter", value: "priority-medium", class: currentFilter === "priority-medium" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMedium),
  282. button({ type: "submit", name: "filter", value: "priority-high", class: currentFilter === "priority-high" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterHigh),
  283. button({ type: "submit", name: "filter", value: "priority-urgent", class: currentFilter === "priority-urgent" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterUrgent),
  284. button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.taskCreateButton)
  285. )
  286. )
  287. ),
  288. section(
  289. currentFilter === "edit" || currentFilter === "create"
  290. ? div(
  291. { class: "task-form" },
  292. form(
  293. { action: currentFilter === "edit" ? `/tasks/update/${encodeURIComponent(taskId)}` : "/tasks/create", method: "POST", enctype: "multipart/form-data" },
  294. input({ type: "hidden", name: "returnTo", value: ret }),
  295. label(i18n.taskTitleLabel), br(),
  296. input({ type: "text", name: "title", required: true, value: currentFilter === "edit" ? (editTask.title || "") : "" }), br(),
  297. label(i18n.taskDescriptionLabel), br(),
  298. textarea({ name: "description", required: true, placeholder: i18n.taskDescriptionPlaceholder, rows: "4" }, currentFilter === "edit" ? (editTask.description || "") : ""), br(),
  299. label(i18n.uploadMedia), br(),
  300. input({ type: "file", name: "image", accept: "image/*" }), br(),br(),
  301. label(i18n.taskStartTimeLabel), br(),
  302. input({
  303. type: "datetime-local",
  304. name: "startTime",
  305. required: true,
  306. min: currentFilter === "create" ? minCreate : undefined,
  307. value: currentFilter === "edit" && editTask.startTime ? moment(editTask.startTime).format("YYYY-MM-DDTHH:mm") : ""
  308. }), br(), br(),
  309. label(i18n.taskEndTimeLabel), br(),
  310. input({
  311. type: "datetime-local",
  312. name: "endTime",
  313. required: true,
  314. min: currentFilter === "create" ? minCreate : undefined,
  315. value: currentFilter === "edit" && editTask.endTime ? moment(editTask.endTime).format("YYYY-MM-DDTHH:mm") : ""
  316. }), br(), br(),
  317. label(i18n.taskPriorityLabel), br(),
  318. select(
  319. { name: "priority", required: true },
  320. opt("URGENT", String(editTask.priority || "").toUpperCase() === "URGENT", i18n.taskPriorityUrgent),
  321. opt("HIGH", String(editTask.priority || "").toUpperCase() === "HIGH", i18n.taskPriorityHigh),
  322. opt("MEDIUM", String(editTask.priority || "").toUpperCase() === "MEDIUM", i18n.taskPriorityMedium),
  323. opt("LOW", !editTask.priority || String(editTask.priority || "").toUpperCase() === "LOW", i18n.taskPriorityLow)
  324. ), br(), br(),
  325. label(i18n.taskLocationLabel), br(),
  326. input({ type: "text", name: "location", value: editTask.location || "" }), br(),
  327. label(i18n.taskTagsLabel), br(),
  328. input({ type: "text", name: "tags", value: editTags.join(", ") }), br(),
  329. label(i18n.taskVisibilityLabel), br(),
  330. select(
  331. { name: "isPublic", id: "isPublic" },
  332. opt("PUBLIC", String(editTask.isPublic || "PUBLIC").toUpperCase() === "PUBLIC", i18n.taskPublic),
  333. opt("PRIVATE", String(editTask.isPublic || "").toUpperCase() === "PRIVATE", i18n.taskPrivate)
  334. ), br(), br(),
  335. button({ type: "submit" }, currentFilter === "edit" ? i18n.taskUpdateButton : i18n.taskCreateButton)
  336. )
  337. )
  338. : div(
  339. { class: "task-list" },
  340. filtered.length > 0
  341. ? filtered.map((t) => renderTaskItem(t, currentFilter))
  342. : p(i18n.notasks)
  343. )
  344. )
  345. );
  346. };
  347. exports.singleTaskView = async (task, filter, comments = []) => {
  348. const currentFilter = filter || "all";
  349. const assignees = safeArray(task.assignees);
  350. const commentCount = typeof task.commentCount === "number" ? task.commentCount : 0;
  351. const isPrivateNoAccess = String(task.isPublic || "").toUpperCase() === "PRIVATE" &&
  352. String(task.author) !== String(userId) &&
  353. !assignees.includes(userId);
  354. const filterBar = div(
  355. { class: "filters" },
  356. form(
  357. { method: "GET", action: "/tasks" },
  358. button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAll),
  359. button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMine),
  360. button({ type: "submit", name: "filter", value: "assigned", class: currentFilter === "assigned" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAssigned),
  361. button({ type: "submit", name: "filter", value: "open", class: currentFilter === "open" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterOpen),
  362. button({ type: "submit", name: "filter", value: "in-progress", class: currentFilter === "in-progress" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterInProgress),
  363. button({ type: "submit", name: "filter", value: "closed", class: currentFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterClosed),
  364. button({ type: "submit", name: "filter", value: "priority-low", class: currentFilter === "priority-low" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterLow),
  365. button({ type: "submit", name: "filter", value: "priority-medium", class: currentFilter === "priority-medium" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMedium),
  366. button({ type: "submit", name: "filter", value: "priority-high", class: currentFilter === "priority-high" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterHigh),
  367. button({ type: "submit", name: "filter", value: "priority-urgent", class: currentFilter === "priority-urgent" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterUrgent),
  368. button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.taskCreateButton)
  369. )
  370. );
  371. if (isPrivateNoAccess) {
  372. return template(
  373. task.title,
  374. section(filterBar, p({ class: "access-denied-msg" }, i18n.contentAccessDenied))
  375. );
  376. }
  377. const topbar = renderTaskTopbar(task, currentFilter, { single: true });
  378. return template(
  379. task.title,
  380. section(
  381. filterBar,
  382. div(
  383. { class: "card card-section task" },
  384. topbar ? topbar : null,
  385. renderCardField(i18n.taskTitleLabel + ":", task.title),
  386. renderCardField(i18n.taskDescriptionLabel + ":", ""),
  387. p(...renderUrl(task.description)),
  388. renderCardField(i18n.taskStartTimeLabel + ":", task.startTime ? moment(task.startTime).format("YYYY/MM/DD HH:mm:ss") : ""),
  389. renderCardField(i18n.taskEndTimeLabel + ":", task.endTime ? moment(task.endTime).format("YYYY/MM/DD HH:mm:ss") : ""),
  390. renderCardField(i18n.taskPriorityLabel + ":", task.priority),
  391. task.location && String(task.location).trim() ? renderCardField(i18n.taskLocationLabel + ":", task.location) : null,
  392. renderCardField(i18n.taskStatus + ":", statusLabel(task.status)),
  393. renderCardField(i18n.taskVisibilityLabel + ":", visibilityLabel(task.isPublic)),
  394. Array.isArray(task.tags) && task.tags.length
  395. ? div(
  396. { class: "card-tags" },
  397. task.tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
  398. )
  399. : null,
  400. br,
  401. div(
  402. { class: "card-field" },
  403. span({ class: "card-label" }, i18n.taskAssignedTo + ":"),
  404. span(
  405. { class: "card-value" },
  406. assignees.length
  407. ? assignees.map((id, i) => [i > 0 ? ", " : "", a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)]).flat()
  408. : i18n.noAssignees
  409. )
  410. ),
  411. br,
  412. p(
  413. { class: "card-footer" },
  414. span({ class: "date-link" }, `${moment(task.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
  415. a({ href: `/author/${encodeURIComponent(task.author)}`, class: "user-link" }, `${task.author}`)
  416. )
  417. ),
  418. renderTaskCommentsSection(task.id, comments, currentFilter)
  419. )
  420. );
  421. };