report_view.js 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676
  1. const { div, h2, p, section, button, form, a, textarea, br, input, img, span, label, select, option, video, audio } = require("../server/node_modules/hyperaxe");
  2. const { template, i18n, userLink} = require("./main_views");
  3. const { config } = require("../server/SSB_server.js");
  4. const moment = require("../server/node_modules/moment");
  5. const { renderUrl } = require("../backend/renderUrl");
  6. const renderMediaBlob = (value) => {
  7. if (!value) return null;
  8. const s = String(value).trim();
  9. if (!s) return null;
  10. if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}` });
  11. const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/);
  12. if (mVideo) return video({ controls: true, class: 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` });
  13. const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/);
  14. if (mAudio) return audio({ controls: true, class: 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` });
  15. const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/);
  16. if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, class: 'post-image' });
  17. return null;
  18. };
  19. const userId = config.keys.id;
  20. const normU = (v) => String(v || "").trim().toUpperCase();
  21. const normalizeStatus = (v) => normU(v).replace(/\s+/g, "_").replace(/-+/g, "_");
  22. const CATEGORY_BY_FILTER = {
  23. features: "FEATURES",
  24. bugs: "BUGS",
  25. abuse: "ABUSE",
  26. content: "CONTENT"
  27. };
  28. const STATUS_BY_FILTER = {
  29. open: "OPEN",
  30. under_review: "UNDER_REVIEW",
  31. resolved: "RESOLVED",
  32. invalid: "INVALID",
  33. closed: "CLOSED"
  34. };
  35. const opt = (value, isSelected, text) =>
  36. option(Object.assign({ value }, isSelected ? { selected: "selected" } : {}), text);
  37. const hasAnyTemplateValue = (t) => {
  38. if (!t || typeof t !== "object") return false;
  39. return Object.values(t).some((v) => String(v || "").trim());
  40. };
  41. const renderCardField = (labelText, value = "") =>
  42. div(
  43. { class: "card-field" },
  44. span({ class: "card-label" }, labelText),
  45. span({ class: "card-value" }, ...renderUrl(String(value ?? "")))
  46. );
  47. const renderStackedTextField = (lbl, val) =>
  48. String(val || "").trim()
  49. ? div(
  50. { class: "card-field card-field-stacked" },
  51. span({ class: "card-label" }, lbl),
  52. span({ class: "card-value" }, ...renderUrl(String(val)))
  53. )
  54. : null;
  55. const renderPmButton = (recipientId) =>
  56. recipientId && String(recipientId) !== String(userId)
  57. ? form(
  58. { method: "GET", action: "/pm" },
  59. input({ type: "hidden", name: "recipients", value: recipientId }),
  60. button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
  61. )
  62. : null;
  63. const renderReportOwnerActions = (report, currentFilter) => {
  64. const st = normalizeStatus(report && report.status ? report.status : "OPEN");
  65. return div(
  66. { class: "bookmark-actions report-actions" },
  67. form(
  68. { method: "GET", action: `/reports/edit/${encodeURIComponent(report.id)}` },
  69. button({ type: "submit", class: "update-btn" }, i18n.reportsUpdateButton)
  70. ),
  71. form(
  72. { method: "POST", action: `/reports/delete/${encodeURIComponent(report.id)}` },
  73. button({ type: "submit", class: "delete-btn" }, i18n.reportsDeleteButton)
  74. ),
  75. form(
  76. { method: "POST", action: `/reports/status/${encodeURIComponent(report.id)}`, class: "project-control-form project-control-form--status" },
  77. select(
  78. { name: "status", class: "project-control-select" },
  79. opt("OPEN", st === "OPEN", i18n.reportsStatusOpen),
  80. opt("UNDER_REVIEW", st === "UNDER_REVIEW", i18n.reportsStatusUnderReview),
  81. opt("RESOLVED", st === "RESOLVED", i18n.reportsStatusResolved),
  82. opt("INVALID", st === "INVALID", i18n.reportsStatusInvalid),
  83. opt("CLOSED", st === "CLOSED", i18n.reportsStatusClosed || "CLOSED")
  84. ),
  85. button({ class: "status-btn project-control-btn", type: "submit" }, i18n.reportsSetStatus || i18n.projectSetStatus || "Set status")
  86. )
  87. );
  88. };
  89. const renderReportTopbar = (report, currentFilter, isSingle) => {
  90. const isAuthor = report && String(report.author) === String(userId);
  91. const leftActions = [];
  92. if (!isSingle) {
  93. leftActions.push(
  94. form(
  95. { method: "GET", action: `/reports/${encodeURIComponent(report.id)}` },
  96. input({ type: "hidden", name: "filter", value: currentFilter }),
  97. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  98. )
  99. );
  100. }
  101. const pm = renderPmButton(report && report.author);
  102. if (pm) leftActions.push(pm);
  103. const leftNode = leftActions.length ? div({ class: "bookmark-topbar-left report-topbar-left" }, ...leftActions) : null;
  104. const rightNode = isAuthor ? renderReportOwnerActions(report, currentFilter) : 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 report-topbar-single" : "bookmark-topbar" }, ...nodes) : null;
  109. };
  110. const renderTemplateDetails = (report) => {
  111. const category = normU(report.category);
  112. const t = report.template && typeof report.template === "object" ? report.template : {};
  113. if (!hasAnyTemplateValue(t)) return null;
  114. const renderValueField = (lbl, val) =>
  115. String(val || "").trim()
  116. ? renderCardField(lbl, String(val))
  117. : null;
  118. if (category === "BUGS") {
  119. return div(
  120. { class: "report-template" },
  121. h2({ class: "report-template-title" }, i18n.reportsBugTemplateTitle),
  122. renderStackedTextField(i18n.reportsStepsToReproduceLabel + ":", t.stepsToReproduce),
  123. renderStackedTextField(i18n.reportsExpectedBehaviorLabel + ":", t.expectedBehavior),
  124. renderStackedTextField(i18n.reportsActualBehaviorLabel + ":", t.actualBehavior),
  125. renderStackedTextField(i18n.reportsEnvironmentLabel + ":", t.environment),
  126. renderValueField(i18n.reportsReproduceRateLabel + ":", t.reproduceRate)
  127. );
  128. }
  129. if (category === "FEATURES") {
  130. return div(
  131. { class: "report-template" },
  132. h2({ class: "report-template-title" }, i18n.reportsFeatureTemplateTitle),
  133. renderStackedTextField(i18n.reportsProblemStatementLabel + ":", t.problemStatement),
  134. renderStackedTextField(i18n.reportsUserStoryLabel + ":", t.userStory),
  135. renderStackedTextField(i18n.reportsAcceptanceCriteriaLabel + ":", t.acceptanceCriteria)
  136. );
  137. }
  138. if (category === "ABUSE") {
  139. return div(
  140. { class: "report-template" },
  141. h2({ class: "report-template-title" }, i18n.reportsAbuseTemplateTitle),
  142. renderStackedTextField(i18n.reportsWhatHappenedLabel + ":", t.whatHappened),
  143. renderStackedTextField(i18n.reportsReportedUserLabel + ":", t.reportedUser),
  144. renderStackedTextField(i18n.reportsEvidenceLinksLabel + ":", t.evidenceLinks)
  145. );
  146. }
  147. if (category === "CONTENT") {
  148. return div(
  149. { class: "report-template" },
  150. h2({ class: "report-template-title" }, i18n.reportsContentTemplateTitle),
  151. renderStackedTextField(i18n.reportsContentLocationLabel + ":", t.contentLocation),
  152. renderStackedTextField(i18n.reportsWhyInappropriateLabel + ":", t.whyInappropriate),
  153. renderStackedTextField(i18n.reportsRequestedActionLabel + ":", t.requestedAction),
  154. renderStackedTextField(i18n.reportsEvidenceLinksLabel + ":", t.evidenceLinks)
  155. );
  156. }
  157. return null;
  158. };
  159. const renderReportCommentsSection = (reportId, comments = []) => {
  160. const commentsCount = Array.isArray(comments) ? comments.length : 0;
  161. return div(
  162. { class: "vote-comments-section" },
  163. div(
  164. { class: "comments-count" },
  165. span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
  166. span({ class: "card-value" }, String(commentsCount))
  167. ),
  168. div(
  169. { class: "comment-form-wrapper" },
  170. h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
  171. form(
  172. {
  173. method: "POST",
  174. action: `/reports/${encodeURIComponent(reportId)}/comments`,
  175. class: "comment-form",
  176. enctype: "multipart/form-data"
  177. },
  178. textarea({
  179. id: "comment-text",
  180. name: "text",
  181. rows: 4,
  182. class: "comment-textarea",
  183. placeholder: i18n.voteNewCommentPlaceholder
  184. }),
  185. div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
  186. br(),
  187. button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
  188. )
  189. ),
  190. (() => {
  191. const visibleComments = (comments || []).filter(c => {
  192. const t = c && c.value && c.value.content && c.value.content.text;
  193. return t && String(t).trim();
  194. });
  195. return visibleComments.length
  196. ? div(
  197. { class: "comments-list" },
  198. visibleComments.map((c) => {
  199. const author = c.value && c.value.author ? c.value.author : "";
  200. const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
  201. const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
  202. const relDate = ts ? moment(ts).fromNow() : "";
  203. const userName = author && author.includes("@") ? author.split("@")[1] : author;
  204. const content = c.value && c.value.content ? c.value.content : {};
  205. const root = content.fork || content.root || "";
  206. const text = content.text || "";
  207. return div(
  208. { class: "votations-comment-card" },
  209. span(
  210. { class: "created-at" },
  211. span(i18n.createdBy),
  212. author ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`) : span("(unknown)"),
  213. absDate ? span(" | ") : "",
  214. absDate ? span({ class: "votations-comment-date" }, absDate) : "",
  215. relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
  216. relDate && root ? a({ href: `/thread/${encodeURIComponent(root)}#${encodeURIComponent(c.key)}` }, relDate) : ""
  217. ),
  218. p({ class: "votations-comment-text" }, ...renderUrl(text))
  219. );
  220. })
  221. )
  222. : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet);
  223. })()
  224. );
  225. };
  226. const renderTemplateForCategory = (category, templateData = {}) => {
  227. const cat = normU(category || "FEATURES");
  228. const t = templateData && typeof templateData === "object" ? templateData : {};
  229. const tval = (k) => String(t[k] || "");
  230. const reproduceRateVal = normU(t.reproduceRate || "");
  231. if (cat === "BUGS") {
  232. return div(
  233. { class: "report-template-block" },
  234. h2({ class: "report-template-title" }, i18n.reportsBugTemplateTitle),
  235. label(i18n.reportsStepsToReproduceLabel),
  236. br(),
  237. textarea({ name: "stepsToReproduce", rows: "4", placeholder: i18n.reportsStepsToReproducePlaceholder }, tval("stepsToReproduce")),
  238. br(),
  239. br(),
  240. label(i18n.reportsExpectedBehaviorLabel),
  241. br(),
  242. textarea({ name: "expectedBehavior", rows: "3", placeholder: i18n.reportsExpectedBehaviorPlaceholder }, tval("expectedBehavior")),
  243. br(),
  244. br(),
  245. label(i18n.reportsActualBehaviorLabel),
  246. br(),
  247. textarea({ name: "actualBehavior", rows: "3", placeholder: i18n.reportsActualBehaviorPlaceholder }, tval("actualBehavior")),
  248. br(),
  249. br(),
  250. label(i18n.reportsEnvironmentLabel),
  251. br(),
  252. textarea({ name: "environment", rows: "3", placeholder: i18n.reportsEnvironmentPlaceholder }, tval("environment")),
  253. br(),
  254. br(),
  255. label(i18n.reportsReproduceRateLabel),
  256. br(),
  257. select(
  258. { name: "reproduceRate" },
  259. opt("", !reproduceRateVal, i18n.reportsReproduceRateUnknown),
  260. opt("ALWAYS", reproduceRateVal === "ALWAYS", i18n.reportsReproduceRateAlways),
  261. opt("OFTEN", reproduceRateVal === "OFTEN", i18n.reportsReproduceRateOften),
  262. opt("SOMETIMES", reproduceRateVal === "SOMETIMES", i18n.reportsReproduceRateSometimes),
  263. opt("RARELY", reproduceRateVal === "RARELY", i18n.reportsReproduceRateRarely),
  264. opt("UNABLE", reproduceRateVal === "UNABLE", i18n.reportsReproduceRateUnable)
  265. )
  266. );
  267. }
  268. if (cat === "ABUSE") {
  269. return div(
  270. { class: "report-template-block" },
  271. h2({ class: "report-template-title" }, i18n.reportsAbuseTemplateTitle),
  272. label(i18n.reportsWhatHappenedLabel),
  273. br(),
  274. textarea({ name: "whatHappened", rows: "4", placeholder: i18n.reportsWhatHappenedPlaceholder }, tval("whatHappened")),
  275. br(),
  276. br(),
  277. label(i18n.reportsReportedUserLabel),
  278. br(),
  279. textarea({ name: "reportedUser", rows: "2", placeholder: i18n.reportsReportedUserPlaceholder }, tval("reportedUser")),
  280. br(),
  281. br(),
  282. label(i18n.reportsEvidenceLinksLabel),
  283. br(),
  284. textarea({ name: "evidenceLinks", rows: "3", placeholder: i18n.reportsEvidenceLinksPlaceholder }, tval("evidenceLinks"))
  285. );
  286. }
  287. if (cat === "CONTENT") {
  288. return div(
  289. { class: "report-template-block" },
  290. h2({ class: "report-template-title" }, i18n.reportsContentTemplateTitle),
  291. label(i18n.reportsContentLocationLabel),
  292. br(),
  293. textarea({ name: "contentLocation", rows: "3", placeholder: i18n.reportsContentLocationPlaceholder }, tval("contentLocation")),
  294. br(),
  295. br(),
  296. label(i18n.reportsWhyInappropriateLabel),
  297. br(),
  298. textarea({ name: "whyInappropriate", rows: "4", placeholder: i18n.reportsWhyInappropriatePlaceholder }, tval("whyInappropriate")),
  299. br(),
  300. br(),
  301. label(i18n.reportsRequestedActionLabel),
  302. br(),
  303. textarea({ name: "requestedAction", rows: "3", placeholder: i18n.reportsRequestedActionPlaceholder }, tval("requestedAction")),
  304. br(),
  305. br(),
  306. label(i18n.reportsEvidenceLinksLabel),
  307. br(),
  308. textarea({ name: "evidenceLinks", rows: "3", placeholder: i18n.reportsEvidenceLinksPlaceholder }, tval("evidenceLinks"))
  309. );
  310. }
  311. return div(
  312. { class: "report-template-block" },
  313. h2({ class: "report-template-title" }, i18n.reportsFeatureTemplateTitle),
  314. label(i18n.reportsProblemStatementLabel),
  315. br(),
  316. textarea({ name: "problemStatement", rows: "4", placeholder: i18n.reportsProblemStatementPlaceholder }, tval("problemStatement")),
  317. br(),
  318. br(),
  319. label(i18n.reportsUserStoryLabel),
  320. br(),
  321. textarea({ name: "userStory", rows: "3", placeholder: i18n.reportsUserStoryPlaceholder }, tval("userStory")),
  322. br(),
  323. br(),
  324. label(i18n.reportsAcceptanceCriteriaLabel),
  325. br(),
  326. textarea({ name: "acceptanceCriteria", rows: "4", placeholder: i18n.reportsAcceptanceCriteriaPlaceholder }, tval("acceptanceCriteria"))
  327. );
  328. };
  329. const renderReportCard = (report, userId, currentFilter = "all") => {
  330. const confirmations = Array.isArray(report.confirmations) ? report.confirmations : [];
  331. const commentCount = typeof report.commentCount === "number" ? report.commentCount : 0;
  332. const severity = normU(report.severity || "low");
  333. const topbar = renderReportTopbar(report, currentFilter, false);
  334. const details = renderTemplateDetails(report);
  335. return div(
  336. { class: "card card-section report" },
  337. topbar ? topbar : null,
  338. renderCardField(i18n.reportsTitleLabel + ":", report.title),
  339. renderCardField(i18n.reportsStatus + ":", report.status),
  340. renderCardField(i18n.reportsSeverity + ":", severity),
  341. renderCardField(i18n.reportsCategory + ":", report.category),
  342. report.image ? br() : null,
  343. report.image ? div({ class: "card-field" }, renderMediaBlob(report.image)) : null,
  344. report.image && details ? br() : null,
  345. details ? details : null,
  346. br(),
  347. renderCardField(i18n.reportsConfirmations + ":", confirmations.length),
  348. br(),
  349. form({ method: "POST", action: `/reports/confirm/${encodeURIComponent(report.id)}` }, button({ type: "submit" }, i18n.reportsConfirmButton)),
  350. a({ href: "/tasks?filter=create", target: "_blank" }, button({ type: "button" }, i18n.reportsCreateTaskButton)),
  351. br(),
  352. br(),
  353. report.tags && report.tags.length
  354. ? div(
  355. { class: "card-tags" },
  356. report.tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
  357. )
  358. : null,
  359. div(
  360. { class: "card-comments-summary" },
  361. span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
  362. span({ class: "card-value" }, String(commentCount)),
  363. br(),
  364. br(),
  365. form(
  366. { method: "GET", action: `/reports/${encodeURIComponent(report.id)}` },
  367. input({ type: "hidden", name: "filter", value: currentFilter }),
  368. button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
  369. )
  370. ),
  371. br(),
  372. p(
  373. { class: "card-footer" },
  374. span({ class: "date-link" }, `${moment(report.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `),
  375. userLink(report.author)
  376. )
  377. );
  378. };
  379. exports.reportView = async (reports, filter, reportId, createCategory) => {
  380. const title =
  381. filter === "create" ? i18n.reportsCreateButton :
  382. filter === "edit" ? i18n.reportsUpdateButton :
  383. filter === "mine" ? i18n.reportsMineSectionTitle :
  384. filter === "features" ? i18n.reportsFeaturesSectionTitle :
  385. filter === "bugs" ? i18n.reportsBugsSectionTitle :
  386. filter === "abuse" ? i18n.reportsAbuseSectionTitle :
  387. filter === "content" ? i18n.reportsContentSectionTitle :
  388. filter === "confirmed" ? i18n.reportsConfirmedSectionTitle :
  389. filter === "open" ? i18n.reportsOpenSectionTitle :
  390. filter === "under_review" ? i18n.reportsUnderReviewSectionTitle :
  391. filter === "resolved" ? i18n.reportsResolvedSectionTitle :
  392. filter === "invalid" ? i18n.reportsInvalidSectionTitle :
  393. i18n.reportsAllSectionTitle;
  394. let filtered = Array.isArray(reports) ? reports : [];
  395. if (filter === "mine") {
  396. filtered = filtered.filter((r) => r.author === userId);
  397. } else if (filter === "confirmed") {
  398. filtered = filtered.filter((r) => Array.isArray(r.confirmations) && r.confirmations.includes(userId));
  399. } else if (CATEGORY_BY_FILTER[filter]) {
  400. const wanted = CATEGORY_BY_FILTER[filter];
  401. filtered = filtered.filter((r) => normU(r.category) === wanted);
  402. } else if (STATUS_BY_FILTER[filter]) {
  403. const wanted = STATUS_BY_FILTER[filter];
  404. filtered = filtered.filter((r) => normalizeStatus(r.status) === wanted);
  405. }
  406. filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  407. const reportToEdit = filter === "edit"
  408. ? (Array.isArray(reports) ? reports.find((r) => r.id === reportId) : null)
  409. : null;
  410. const btnClass = (v) => (filter === v ? "filter-btn active" : "filter-btn");
  411. const selectedCategory = normU(
  412. filter === "create"
  413. ? (createCategory || "FEATURES")
  414. : (reportToEdit?.category || "FEATURES")
  415. );
  416. const selectedTemplate = reportToEdit?.template && typeof reportToEdit.template === "object" ? reportToEdit.template : {};
  417. const applyLabel = i18n.apply || "Apply";
  418. const sev = String(reportToEdit?.severity || "low");
  419. const hiddenDescription = String(reportToEdit?.description || "");
  420. return template(
  421. title,
  422. section(
  423. div(
  424. { class: "tags-header" },
  425. h2(i18n.reportsTitle),
  426. p(i18n.reportsDescription)
  427. ),
  428. div(
  429. { class: "filters" },
  430. form(
  431. { method: "GET", action: "/reports" },
  432. button({ type: "submit", name: "filter", value: "all", class: btnClass("all") }, i18n.reportsFilterAll),
  433. button({ type: "submit", name: "filter", value: "mine", class: btnClass("mine") }, i18n.reportsFilterMine),
  434. button({ type: "submit", name: "filter", value: "features", class: btnClass("features") }, i18n.reportsFilterFeatures),
  435. button({ type: "submit", name: "filter", value: "bugs", class: btnClass("bugs") }, i18n.reportsFilterBugs),
  436. button({ type: "submit", name: "filter", value: "abuse", class: btnClass("abuse") }, i18n.reportsFilterAbuse),
  437. button({ type: "submit", name: "filter", value: "content", class: btnClass("content") }, i18n.reportsFilterContent),
  438. button({ type: "submit", name: "filter", value: "confirmed", class: btnClass("confirmed") }, i18n.reportsFilterConfirmed),
  439. button({ type: "submit", name: "filter", value: "open", class: btnClass("open") }, i18n.reportsFilterOpen),
  440. button({ type: "submit", name: "filter", value: "under_review", class: btnClass("under_review") }, i18n.reportsFilterUnderReview),
  441. button({ type: "submit", name: "filter", value: "resolved", class: btnClass("resolved") }, i18n.reportsFilterResolved),
  442. button({ type: "submit", name: "filter", value: "invalid", class: btnClass("invalid") }, i18n.reportsFilterInvalid),
  443. button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.reportsCreateButton)
  444. )
  445. )
  446. ),
  447. section(
  448. filter === "edit" || filter === "create"
  449. ? div(
  450. { class: "report-form" },
  451. filter === "create"
  452. ? div(
  453. label(i18n.reportsTitleLabel),
  454. br(),
  455. input({ type: "text", name: "title", required: true, value: "", form: "report-create-form" }),
  456. br(),
  457. br(),
  458. form(
  459. { id: "report-category-form", method: "GET", action: "/reports" },
  460. input({ type: "hidden", name: "filter", value: "create" }),
  461. label(i18n.reportsCategory),
  462. br(),
  463. select(
  464. { name: "category" },
  465. opt("FEATURES", selectedCategory === "FEATURES", i18n.reportsCategoryFeatures),
  466. opt("BUGS", selectedCategory === "BUGS", i18n.reportsCategoryBugs),
  467. opt("ABUSE", selectedCategory === "ABUSE", i18n.reportsCategoryAbuse),
  468. opt("CONTENT", selectedCategory === "CONTENT", i18n.reportsCategoryContent)
  469. ),
  470. br(),
  471. br(),
  472. button({ type: "submit", class: "create-button" }, applyLabel)
  473. ),
  474. br(),
  475. h2({ class: "report-category-fixed" }, selectedCategory),
  476. br(),
  477. form(
  478. { id: "report-create-form", action: "/reports/create", method: "POST", enctype: "multipart/form-data" },
  479. input({ type: "hidden", name: "category", value: selectedCategory }),
  480. input({ type: "hidden", name: "description", value: "" }),
  481. label(i18n.reportsSeverity),
  482. br(),
  483. select(
  484. { name: "severity" },
  485. opt("critical", sev === "critical", i18n.reportsSeverityCritical),
  486. opt("high", sev === "high", i18n.reportsSeverityHigh),
  487. opt("medium", sev === "medium", i18n.reportsSeverityMedium),
  488. opt("low", sev === "low", i18n.reportsSeverityLow)
  489. ),
  490. br(),
  491. br(),
  492. h2({ class: "report-template-main-title" }, i18n.reportsTemplateSectionTitle),
  493. renderTemplateForCategory(selectedCategory, {}),
  494. label(i18n.reportsUploadFile),
  495. br(),
  496. input({ type: "file", name: "image" }),
  497. br(),
  498. br(),
  499. label("Tags"),
  500. br(),
  501. input({ type: "text", name: "tags", value: "" }),
  502. br(),
  503. br(),
  504. button({ type: "submit", class: "create-button" }, i18n.reportsCreateButton)
  505. )
  506. )
  507. : div(
  508. form(
  509. { id: "report-edit-form", action: `/reports/update/${encodeURIComponent(reportId)}`, method: "POST", enctype: "multipart/form-data" },
  510. label(i18n.reportsTitleLabel),
  511. br(),
  512. input({ type: "text", name: "title", required: true, value: reportToEdit?.title || "" }),
  513. br(),
  514. br(),
  515. input({ type: "hidden", name: "description", value: hiddenDescription }),
  516. label(i18n.reportsCategory),
  517. br(),
  518. select(
  519. { name: "category", required: true },
  520. opt("FEATURES", selectedCategory === "FEATURES", i18n.reportsCategoryFeatures),
  521. opt("BUGS", selectedCategory === "BUGS", i18n.reportsCategoryBugs),
  522. opt("ABUSE", selectedCategory === "ABUSE", i18n.reportsCategoryAbuse),
  523. opt("CONTENT", selectedCategory === "CONTENT", i18n.reportsCategoryContent)
  524. ),
  525. br(),
  526. br(),
  527. label(i18n.reportsSeverity),
  528. br(),
  529. select(
  530. { name: "severity" },
  531. opt("critical", sev === "critical", i18n.reportsSeverityCritical),
  532. opt("high", sev === "high", i18n.reportsSeverityHigh),
  533. opt("medium", sev === "medium", i18n.reportsSeverityMedium),
  534. opt("low", sev === "low", i18n.reportsSeverityLow)
  535. ),
  536. br(),
  537. br(),
  538. h2({ class: "report-template-main-title" }, i18n.reportsTemplateSectionTitle),
  539. renderTemplateForCategory(selectedCategory, selectedTemplate),
  540. br(),
  541. br(),
  542. label(i18n.reportsUploadFile),
  543. br(),
  544. input({ type: "file", name: "image" }),
  545. br(),
  546. br(),
  547. label("Tags"),
  548. br(),
  549. input({ type: "text", name: "tags", value: reportToEdit?.tags?.join(", ") || "" }),
  550. br(),
  551. br(),
  552. button({ type: "submit" }, i18n.reportsUpdateButton)
  553. )
  554. )
  555. )
  556. : div(
  557. { class: "report-list" },
  558. filtered.length > 0 ? filtered.map((r) => renderReportCard(r, userId, filter)) : p(i18n.reportsNoItems)
  559. )
  560. )
  561. );
  562. };
  563. exports.singleReportView = async (report, filter, comments = []) => {
  564. const btnClass = (v) => (filter === v ? "filter-btn active" : "filter-btn");
  565. const confirmations = Array.isArray(report.confirmations) ? report.confirmations : [];
  566. const severity = normU(report.severity || "low");
  567. const topbar = renderReportTopbar(report, filter || "all", true);
  568. const details = renderTemplateDetails(report);
  569. return template(
  570. report.title,
  571. section(
  572. div(
  573. { class: "filters" },
  574. form(
  575. { method: "GET", action: "/reports" },
  576. button({ type: "submit", name: "filter", value: "all", class: btnClass("all") }, i18n.reportsFilterAll),
  577. button({ type: "submit", name: "filter", value: "mine", class: btnClass("mine") }, i18n.reportsFilterMine),
  578. button({ type: "submit", name: "filter", value: "features", class: btnClass("features") }, i18n.reportsFilterFeatures),
  579. button({ type: "submit", name: "filter", value: "bugs", class: btnClass("bugs") }, i18n.reportsFilterBugs),
  580. button({ type: "submit", name: "filter", value: "abuse", class: btnClass("abuse") }, i18n.reportsFilterAbuse),
  581. button({ type: "submit", name: "filter", value: "content", class: btnClass("content") }, i18n.reportsFilterContent),
  582. button({ type: "submit", name: "filter", value: "confirmed", class: btnClass("confirmed") }, i18n.reportsFilterConfirmed),
  583. button({ type: "submit", name: "filter", value: "open", class: btnClass("open") }, i18n.reportsFilterOpen),
  584. button({ type: "submit", name: "filter", value: "under_review", class: btnClass("under_review") }, i18n.reportsFilterUnderReview),
  585. button({ type: "submit", name: "filter", value: "resolved", class: btnClass("resolved") }, i18n.reportsFilterResolved),
  586. button({ type: "submit", name: "filter", value: "invalid", class: btnClass("invalid") }, i18n.reportsFilterInvalid),
  587. button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.reportsCreateButton)
  588. )
  589. ),
  590. div(
  591. { class: "card card-section report" },
  592. topbar ? topbar : null,
  593. renderCardField(i18n.reportsTitleLabel + ":", report.title),
  594. renderCardField(i18n.reportsStatus + ":", report.status),
  595. renderCardField(i18n.reportsSeverity + ":", severity),
  596. renderCardField(i18n.reportsCategory + ":", report.category),
  597. report.image ? br() : null,
  598. report.image ? div({ class: "card-field" }, renderMediaBlob(report.image)) : null,
  599. report.image && details ? br() : null,
  600. details ? details : null,
  601. br(),
  602. renderCardField(i18n.reportsConfirmations + ":", confirmations.length),
  603. br(),
  604. form({ method: "POST", action: `/reports/confirm/${encodeURIComponent(report.id)}` }, button({ type: "submit" }, i18n.reportsConfirmButton)),
  605. a({ href: "/tasks?filter=create", target: "_blank" }, button({ type: "button" }, i18n.reportsCreateTaskButton)),
  606. br(),
  607. br(),
  608. report.tags && report.tags.length
  609. ? div(
  610. { class: "card-tags" },
  611. report.tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
  612. )
  613. : null,
  614. br(),
  615. p(
  616. { class: "card-footer" },
  617. span({ class: "date-link" }, `${moment(report.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `),
  618. userLink(report.author)
  619. )
  620. ),
  621. renderReportCommentsSection(report.id, comments)
  622. )
  623. );
  624. };