jobs_view.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, progress, video, audio } = 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 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 FILTERS = [
  21. { key: "ALL", i18n: "jobsFilterAll", title: "jobsAllTitle" },
  22. { key: "MINE", i18n: "jobsFilterMine", title: "jobsMineTitle" },
  23. { key: "APPLIED", i18n: "jobsFilterApplied", title: "jobsAppliedTitle" },
  24. { key: "REMOTE", i18n: "jobsFilterRemote", title: "jobsRemoteTitle" },
  25. { key: "PRESENCIAL", i18n: "jobsFilterPresencial", title: "jobsPresencialTitle" },
  26. { key: "FREELANCER", i18n: "jobsFilterFreelancer", title: "jobsFreelancerTitle" },
  27. { key: "EMPLOYEE", i18n: "jobsFilterEmployee", title: "jobsEmployeeTitle" },
  28. { key: "OPEN", i18n: "jobsFilterOpen", title: "jobsOpenTitle" },
  29. { key: "CLOSED", i18n: "jobsFilterClosed", title: "jobsClosedTitle" },
  30. { key: "RECENT", i18n: "jobsFilterRecent", title: "jobsRecentTitle" },
  31. { key: "TOP", i18n: "jobsFilterTop", title: "jobsTopTitle" },
  32. { key: "CV", i18n: "jobsCV", title: "jobsCVTitle" }
  33. ]
  34. function resolvePhoto(photoField, size = 256) {
  35. if (typeof photoField === "string" && photoField.startsWith("/image/")) return photoField
  36. if (typeof photoField === "string" && /^&[A-Za-z0-9+/=]+\.sha256$/.test(photoField)) return `/image/${size}/${encodeURIComponent(photoField)}`
  37. return "/assets/images/default-avatar.png"
  38. }
  39. const safeArr = (v) => (Array.isArray(v) ? v : [])
  40. const safeText = (v) => String(v || "").trim()
  41. const parseNum = (v) => {
  42. const n = parseFloat(String(v ?? "").replace(",", "."))
  43. return Number.isFinite(n) ? n : NaN
  44. }
  45. const fmtSalary = (v) => {
  46. const n = parseNum(v)
  47. return Number.isFinite(n) ? n.toFixed(6) : String(v ?? "")
  48. }
  49. const buildReturnTo = (filter, params = {}) => {
  50. const f = safeText(filter || "ALL")
  51. const q = safeText(params.search || params.q || "")
  52. const minSalary = params.minSalary ?? ""
  53. const maxSalary = params.maxSalary ?? ""
  54. const sort = safeText(params.sort || "")
  55. const parts = [`filter=${encodeURIComponent(f)}`]
  56. if (q) parts.push(`search=${encodeURIComponent(q)}`)
  57. if (String(minSalary) !== "") parts.push(`minSalary=${encodeURIComponent(String(minSalary))}`)
  58. if (String(maxSalary) !== "") parts.push(`maxSalary=${encodeURIComponent(String(maxSalary))}`)
  59. if (sort) parts.push(`sort=${encodeURIComponent(sort)}`)
  60. return `/jobs?${parts.join("&")}`
  61. }
  62. const renderPmButton = (recipientId) =>
  63. recipientId && String(recipientId) !== String(userId)
  64. ? form(
  65. { method: "GET", action: "/pm" },
  66. input({ type: "hidden", name: "recipients", value: recipientId }),
  67. button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
  68. )
  69. : null
  70. const renderCardField = (labelText, value) =>
  71. div(
  72. { class: "card-field" },
  73. span({ class: "card-label" }, labelText),
  74. span({ class: "card-value" }, String(value ?? ""))
  75. )
  76. const renderCardFieldRich = (labelText, parts) =>
  77. div(
  78. { class: "card-field" },
  79. span({ class: "card-label" }, labelText),
  80. span({ class: "card-value" }, ...(Array.isArray(parts) ? parts : [String(parts ?? "")]))
  81. )
  82. const renderTags = (tags = []) => {
  83. const arr = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean)
  84. return arr.length
  85. ? div(
  86. { class: "card-tags" },
  87. arr.map((tag) => a({ class: "tag-link", href: `/search?query=%23${encodeURIComponent(tag)}` }, `#${tag}`))
  88. )
  89. : null
  90. }
  91. const renderApplicantsProgress = (subsCount, vacants) => {
  92. const s = Math.max(0, Number(subsCount || 0))
  93. const v = Math.max(1, Number(vacants || 1))
  94. const colorClass = s < v ? "applicants-under" : s === v ? "applicants-at" : "applicants-over"
  95. return div(
  96. { class: "confirmations-block" },
  97. div(
  98. { class: "card-field" },
  99. span({ class: "card-label" }, `${i18n.jobsApplicants}: `),
  100. span({ class: `card-value ${colorClass}` }, `${s}/${v}`)
  101. ),
  102. progress({ class: "confirmations-progress", value: s, max: v })
  103. )
  104. }
  105. const renderSubscribers = (subs = []) => {
  106. const n = safeArr(subs).length
  107. return div(
  108. { class: "card-field" },
  109. span({ class: "card-label" }, `${i18n.jobSubscribers}:`),
  110. span({ class: "card-value" }, n > 0 ? String(n) : i18n.noSubscribers.toUpperCase())
  111. )
  112. }
  113. const renderUpdatedLabel = (createdAt, updatedAt) => {
  114. const createdTs = createdAt ? new Date(createdAt).getTime() : NaN
  115. const updatedTs = updatedAt ? new Date(updatedAt).getTime() : NaN
  116. const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs)
  117. return showUpdated
  118. ? span({ class: "votations-comment-date" }, ` | ${i18n.jobsUpdatedAt}: ${moment(updatedAt).format("YYYY/MM/DD HH:mm:ss")}`)
  119. : null
  120. }
  121. const renderJobOwnerActions = (job, returnTo) => {
  122. const isAuthor = String(job.author) === String(userId)
  123. if (!isAuthor) return []
  124. const isOpen = String(job.status || "").toUpperCase() === "OPEN"
  125. return [
  126. form(
  127. { method: "POST", action: `/jobs/status/${encodeURIComponent(job.id)}` },
  128. input({ type: "hidden", name: "returnTo", value: returnTo }),
  129. button({ class: "status-btn", type: "submit", name: "status", value: isOpen ? "CLOSED" : "OPEN" }, isOpen ? i18n.jobSetClosed : i18n.jobSetOpen)
  130. ),
  131. form(
  132. { method: "GET", action: `/jobs/edit/${encodeURIComponent(job.id)}` },
  133. input({ type: "hidden", name: "returnTo", value: returnTo }),
  134. button({ class: "update-btn", type: "submit" }, i18n.jobsUpdateButton)
  135. ),
  136. form(
  137. { method: "POST", action: `/jobs/delete/${encodeURIComponent(job.id)}` },
  138. input({ type: "hidden", name: "returnTo", value: returnTo }),
  139. button({ class: "delete-btn", type: "submit" }, i18n.jobsDeleteButton)
  140. )
  141. ]
  142. }
  143. const renderJobTopbar = (job, filter, params = {}) => {
  144. const returnTo = buildReturnTo(filter, params)
  145. const isAuthor = String(job.author) === String(userId)
  146. const isOpen = String(job.status || "").toUpperCase() === "OPEN"
  147. const subs = safeArr(job.subscribers)
  148. const isSubscribed = subs.includes(userId)
  149. const isSingle = params && params.single === true
  150. const chips = []
  151. if (isSubscribed) chips.push(span({ class: "chip chip-you" }, i18n.jobsAppliedBadge))
  152. const leftActions = []
  153. if (!isSingle) {
  154. leftActions.push(
  155. form(
  156. { method: "GET", action: `/jobs/${encodeURIComponent(job.id)}` },
  157. input({ type: "hidden", name: "returnTo", value: returnTo }),
  158. input({ type: "hidden", name: "filter", value: filter || "ALL" }),
  159. params.search ? input({ type: "hidden", name: "search", value: params.search }) : null,
  160. params.minSalary !== undefined ? input({ type: "hidden", name: "minSalary", value: String(params.minSalary ?? "") }) : null,
  161. params.maxSalary !== undefined ? input({ type: "hidden", name: "maxSalary", value: String(params.maxSalary ?? "") }) : null,
  162. params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
  163. button({ type: "submit", class: "filter-btn" }, i18n.viewDetailsButton)
  164. )
  165. )
  166. }
  167. leftActions.push(renderPmButton(job.author))
  168. if (!isAuthor && isOpen) {
  169. leftActions.push(
  170. isSubscribed
  171. ? form(
  172. { method: "POST", action: `/jobs/unsubscribe/${encodeURIComponent(job.id)}` },
  173. input({ type: "hidden", name: "returnTo", value: returnTo }),
  174. button({ type: "submit", class: "filter-btn" }, i18n.jobUnsubscribeButton)
  175. )
  176. : form(
  177. { method: "POST", action: `/jobs/subscribe/${encodeURIComponent(job.id)}` },
  178. input({ type: "hidden", name: "returnTo", value: returnTo }),
  179. button({ type: "submit", class: "filter-btn" }, i18n.jobSubscribeButton)
  180. )
  181. )
  182. }
  183. const leftChildren = []
  184. if (chips.length) leftChildren.push(div({ class: "transfer-chips" }, ...chips))
  185. const leftActionNodes = leftActions.filter(Boolean)
  186. if (leftActionNodes.length) leftChildren.push(...leftActionNodes)
  187. const ownerActions = renderJobOwnerActions(job, returnTo)
  188. const leftNode = leftChildren.length ? div({ class: "bookmark-topbar-left transfer-topbar-left" }, ...leftChildren) : null
  189. const actionsNode = ownerActions.length ? div({ class: "bookmark-actions transfer-actions" }, ...ownerActions) : null
  190. const topbarChildren = []
  191. if (leftNode) topbarChildren.push(leftNode)
  192. if (actionsNode) topbarChildren.push(actionsNode)
  193. const topbarClass = isSingle ? "bookmark-topbar transfer-topbar-single" : "bookmark-topbar"
  194. return topbarChildren.length ? div({ class: topbarClass }, ...topbarChildren) : null
  195. }
  196. const renderJobList = (jobs, filter, params = {}) => {
  197. const returnTo = buildReturnTo(filter, params)
  198. const list = safeArr(jobs)
  199. return list.length
  200. ? list.map((job) => {
  201. const topbar = renderJobTopbar(job, filter, params)
  202. const subs = safeArr(job.subscribers)
  203. const tagsNode = renderTags(job.tags)
  204. const salaryText = `${fmtSalary(job.salary)} ECO`
  205. return div(
  206. { class: "job-card" },
  207. topbar ? topbar : null,
  208. safeText(job.title) ? h2(job.title) : null,
  209. job.image ? div({ class: "activity-image-preview" }, renderMediaBlob(job.image)) : null,
  210. br(),
  211. safeText(job.description) ? renderCardFieldRich(`${i18n.jobDescription}:`, renderUrl(job.description)) : null,
  212. br(),
  213. renderApplicantsProgress(subs.length, job.vacants),
  214. renderCardField(`${i18n.jobLanguages}:`, String(job.languages || "").toUpperCase()),
  215. renderCardField(`${i18n.jobType}:`, i18n["jobType" + String(job.job_type || "").toUpperCase()] || String(job.job_type || "").toUpperCase()),
  216. renderCardField(`${i18n.jobLocation}:`, String(job.location || "").toUpperCase()),
  217. renderCardField(`${i18n.jobTime}:`, i18n["jobTime" + String(job.job_time || "").toUpperCase()] || String(job.job_time || "").toUpperCase()),
  218. renderCardFieldRich(`${i18n.jobSalary}:`, [span({ class: "card-salary" }, salaryText)]),
  219. br(),
  220. div(
  221. { class: "card-comments-summary" },
  222. span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
  223. span({ class: "card-value" }, String(job.commentCount || 0)),
  224. br(),
  225. br(),
  226. form(
  227. { method: "GET", action: `/jobs/${encodeURIComponent(job.id)}#comments` },
  228. input({ type: "hidden", name: "returnTo", value: returnTo }),
  229. input({ type: "hidden", name: "filter", value: filter || "ALL" }),
  230. params.search ? input({ type: "hidden", name: "search", value: params.search }) : null,
  231. params.minSalary !== undefined ? input({ type: "hidden", name: "minSalary", value: String(params.minSalary ?? "") }) : null,
  232. params.maxSalary !== undefined ? input({ type: "hidden", name: "maxSalary", value: String(params.maxSalary ?? "") }) : null,
  233. params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
  234. button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
  235. )
  236. ),
  237. p(
  238. { class: "card-footer" },
  239. span({ class: "date-link" }, `${moment(job.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
  240. a({ href: `/author/${encodeURIComponent(job.author)}`, class: "user-link" }, job.author),
  241. renderUpdatedLabel(job.createdAt, job.updatedAt)
  242. )
  243. )
  244. })
  245. : p(i18n.noJobsMatch || i18n.noJobsFound)
  246. }
  247. const renderJobForm = (job = {}, mode = "create") => {
  248. const isEdit = mode === "edit"
  249. return div(
  250. { class: "div-center job-form" },
  251. form(
  252. {
  253. action: isEdit ? `/jobs/update/${encodeURIComponent(job.id)}` : "/jobs/create",
  254. method: "POST",
  255. enctype: "multipart/form-data"
  256. },
  257. input({ type: "hidden", name: "returnTo", value: "/jobs?filter=MINE" }),
  258. label(i18n.jobType),
  259. br(),
  260. select(
  261. { name: "job_type", required: true },
  262. option({ value: "freelancer", selected: job.job_type === "freelancer" }, i18n.jobTypeFreelance),
  263. option({ value: "employee", selected: job.job_type === "employee" }, i18n.jobTypeSalary)
  264. ),
  265. br(),
  266. br(),
  267. label(i18n.jobTitle),
  268. br(),
  269. input({ type: "text", name: "title", required: true, placeholder: i18n.jobTitlePlaceholder, value: job.title || "" }),
  270. br(),
  271. br(),
  272. label(i18n.jobImage),
  273. br(),
  274. input({ type: "file", name: "image" }),
  275. br(),
  276. job.image ? renderMediaBlob(job.image) : null,
  277. br(),
  278. label(i18n.jobDescription),
  279. br(),
  280. textarea({ name: "description", rows: "6", required: true, placeholder: i18n.jobDescriptionPlaceholder }, job.description || ""),
  281. br(),
  282. br(),
  283. label(i18n.jobRequirements),
  284. br(),
  285. textarea({ name: "requirements", rows: "6", placeholder: i18n.jobRequirementsPlaceholder }, job.requirements || ""),
  286. br(),
  287. br(),
  288. label(i18n.jobsTagsLabel),
  289. br(),
  290. input({ type: "text", name: "tags", value: Array.isArray(job.tags) ? job.tags.join(", ") : (job.tags || "") }),
  291. br(),
  292. br(),
  293. label(i18n.jobLanguages),
  294. br(),
  295. input({ type: "text", name: "languages", placeholder: i18n.jobLanguagesPlaceholder, value: job.languages || "" }),
  296. br(),
  297. br(),
  298. label(i18n.jobTime),
  299. br(),
  300. select(
  301. { name: "job_time", required: true },
  302. option({ value: "partial", selected: job.job_time === "partial" }, i18n.jobTimePartial),
  303. option({ value: "complete", selected: job.job_time === "complete" }, i18n.jobTimeComplete)
  304. ),
  305. br(),
  306. br(),
  307. label(i18n.jobTasks),
  308. br(),
  309. textarea({ name: "tasks", rows: "6", placeholder: i18n.jobTasksPlaceholder }, job.tasks || ""),
  310. br(),
  311. br(),
  312. label(i18n.jobLocation),
  313. br(),
  314. select(
  315. { name: "location", required: true },
  316. option({ value: "remote", selected: job.location === "remote" }, i18n.jobLocationRemote),
  317. option({ value: "presencial", selected: job.location === "presencial" }, i18n.jobLocationPresencial)
  318. ),
  319. br(),
  320. br(),
  321. label(i18n.jobVacants),
  322. br(),
  323. input({ type: "number", name: "vacants", min: "1", placeholder: i18n.jobVacantsPlaceholder, value: job.vacants || 1, required: true }),
  324. br(),
  325. br(),
  326. label(i18n.jobSalary),
  327. br(),
  328. input({ type: "number", name: "salary", step: "0.000001", min: "0", placeholder: i18n.jobSalaryPlaceholder, value: job.salary || "" }),
  329. br(),
  330. br(),
  331. button({ type: "submit" }, isEdit ? i18n.jobsUpdateButton : i18n.createJobButton)
  332. )
  333. )
  334. }
  335. const renderCVList = (inhabitants) =>
  336. div(
  337. { class: "cv-list" },
  338. safeArr(inhabitants).length
  339. ? safeArr(inhabitants).map((user) => {
  340. const isMe = String(user.id) === String(userId)
  341. return div(
  342. { class: "inhabitant-card" },
  343. div(
  344. { class: "inhabitant-left" },
  345. a({ href: `/author/${encodeURIComponent(user.id)}` },
  346. img({ class: "inhabitant-photo", src: resolvePhoto(user.photo) })
  347. ),
  348. h2(user.name)
  349. ),
  350. div(
  351. { class: "inhabitant-details" },
  352. user.description ? p(...renderUrl(user.description)) : null,
  353. p(a({ class: "user-link", href: `/author/${encodeURIComponent(user.id)}` }, user.id)),
  354. div(
  355. { class: "cv-actions" },
  356. form({ method: "GET", action: `/inhabitant/${encodeURIComponent(user.id)}` }, button({ type: "submit", class: "filter-btn" }, i18n.inhabitantviewDetails)),
  357. !isMe ? renderPmButton(user.id) : null
  358. )
  359. )
  360. )
  361. })
  362. : p({ class: "no-results" }, i18n.noInhabitantsFound)
  363. )
  364. exports.jobsView = async (jobsOrCVs, filter = "ALL", params = {}) => {
  365. const search = safeText(params.search || "")
  366. const minSalary = params.minSalary ?? ""
  367. const maxSalary = params.maxSalary ?? ""
  368. const sort = safeText(params.sort || "recent")
  369. const filterObj = FILTERS.find((f) => f.key === filter) || FILTERS[0]
  370. const sectionTitle = i18n[filterObj.title] || i18n.jobsTitle
  371. return template(
  372. i18n.jobsTitle,
  373. section(
  374. div({ class: "tags-header" }, h2(sectionTitle), p(i18n.jobsDescription)),
  375. div(
  376. { class: "filters" },
  377. form(
  378. { method: "GET", action: "/jobs", class: "ui-toolbar ui-toolbar--filters" },
  379. input({ type: "hidden", name: "search", value: search }),
  380. input({ type: "hidden", name: "minSalary", value: String(minSalary ?? "") }),
  381. input({ type: "hidden", name: "maxSalary", value: String(maxSalary ?? "") }),
  382. input({ type: "hidden", name: "sort", value: sort }),
  383. ...FILTERS.map((f) =>
  384. button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, i18n[f.i18n])
  385. ),
  386. button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.jobsCreateJob)
  387. )
  388. )
  389. ),
  390. section(
  391. filter === "CV"
  392. ? section(
  393. form(
  394. { method: "GET", action: "/jobs", class: "cv-filter-form" },
  395. input({ type: "hidden", name: "filter", value: "CV" }),
  396. input({ type: "text", name: "location", placeholder: i18n.filterLocation, value: params.location || "" }),
  397. input({ type: "text", name: "language", placeholder: i18n.filterLanguage, value: params.language || "" }),
  398. input({ type: "text", name: "skills", placeholder: i18n.filterSkills, value: params.skills || "" }),
  399. div({ class: "cv-filter-submit" },
  400. button({ type: "submit", class: "filter-btn" }, i18n.applyFilters)
  401. )
  402. ),
  403. br(),
  404. renderCVList(jobsOrCVs)
  405. )
  406. : filter === "CREATE" || filter === "EDIT"
  407. ? (() => {
  408. const jobToEdit = filter === "EDIT" ? (Array.isArray(jobsOrCVs) ? jobsOrCVs[0] : {}) : {}
  409. return renderJobForm(jobToEdit, filter === "EDIT" ? "edit" : "create")
  410. })()
  411. : section(
  412. div(
  413. { class: "jobs-search" },
  414. form(
  415. { method: "GET", action: "/jobs", class: "filter-box" },
  416. input({ type: "hidden", name: "filter", value: filter || "ALL" }),
  417. input({ type: "text", name: "search", value: search, placeholder: i18n.jobsSearchPlaceholder, class: "filter-box__input" }),
  418. div(
  419. { class: "filter-box__controls" },
  420. div(
  421. { class: "transfer-range" },
  422. input({ type: "number", name: "minSalary", step: "0.000001", min: "0", value: String(minSalary ?? ""), placeholder: i18n.jobsMinSalaryLabel, class: "filter-box__number transfer-amount-input" }),
  423. input({ type: "number", name: "maxSalary", step: "0.000001", min: "0", value: String(maxSalary ?? ""), placeholder: i18n.jobsMaxSalaryLabel, class: "filter-box__number transfer-amount-input" })
  424. ),
  425. select(
  426. { name: "sort", class: "filter-box__select" },
  427. option({ value: "recent", selected: sort === "recent" }, i18n.jobsSortRecent),
  428. option({ value: "salary", selected: sort === "salary" }, i18n.jobsSortSalary),
  429. option({ value: "subscribers", selected: sort === "subscribers" }, i18n.jobsSortSubscribers)
  430. ),
  431. button({ type: "submit", class: "filter-box__button" }, i18n.jobsSearchButton)
  432. )
  433. )
  434. ),
  435. br(),
  436. div({ class: "jobs-list" }, renderJobList(jobsOrCVs, filter, { ...params, search, minSalary, maxSalary, sort }))
  437. )
  438. )
  439. )
  440. }
  441. const renderJobCommentsSection = (jobId, returnTo, comments = []) => {
  442. const list = safeArr(comments)
  443. const commentsCount = list.length
  444. return div(
  445. { class: "vote-comments-section" },
  446. div(
  447. { class: "comments-count" },
  448. span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
  449. span({ class: "card-value" }, String(commentsCount))
  450. ),
  451. div(
  452. { class: "comment-form-wrapper" },
  453. h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
  454. form(
  455. { method: "POST", action: `/jobs/${encodeURIComponent(jobId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
  456. input({ type: "hidden", name: "returnTo", value: returnTo }),
  457. textarea({ id: "comment-text", name: "text", rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }),
  458. div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
  459. br(),
  460. button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
  461. )
  462. ),
  463. list.length
  464. ? div(
  465. { class: "comments-list" },
  466. list.map((c) => {
  467. const author = c?.value?.author || ""
  468. const ts = c?.value?.timestamp || c?.timestamp
  469. const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""
  470. const relDate = ts ? moment(ts).fromNow() : ""
  471. const userName = author && author.includes("@") ? author.split("@")[1] : author
  472. const rootId = c?.value?.content ? (c.value.content.fork || c.value.content.root) : null
  473. const text = c?.value?.content?.text || ""
  474. return div(
  475. { class: "votations-comment-card" },
  476. span(
  477. { class: "created-at" },
  478. span(i18n.createdBy),
  479. author ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`) : span("(unknown)"),
  480. absDate ? span(" | ") : "",
  481. absDate ? span({ class: "votations-comment-date" }, absDate) : "",
  482. relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
  483. relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : ""
  484. ),
  485. p({ class: "votations-comment-text" }, ...renderUrl(text))
  486. )
  487. })
  488. )
  489. : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
  490. )
  491. }
  492. exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {}) => {
  493. const returnTo = safeText(params.returnTo) || buildReturnTo(filter, params)
  494. const topbar = renderJobTopbar(job, filter, { ...params, single: true })
  495. const subs = safeArr(job.subscribers)
  496. const tagsNode = renderTags(job.tags)
  497. const salaryText = `${fmtSalary(job.salary)} ECO`
  498. return template(
  499. i18n.jobsTitle,
  500. section(
  501. div(
  502. { class: "filters" },
  503. form(
  504. { method: "GET", action: "/jobs", class: "ui-toolbar ui-toolbar--filters" },
  505. input({ type: "hidden", name: "search", value: safeText(params.search || "") }),
  506. input({ type: "hidden", name: "minSalary", value: String(params.minSalary ?? "") }),
  507. input({ type: "hidden", name: "maxSalary", value: String(params.maxSalary ?? "") }),
  508. input({ type: "hidden", name: "sort", value: safeText(params.sort || "recent") }),
  509. ...FILTERS.map((f) =>
  510. button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, i18n[f.i18n])
  511. ),
  512. button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.jobsCreateJob)
  513. )
  514. ),
  515. div(
  516. { class: "job-card" },
  517. topbar ? topbar : null,
  518. safeText(job.title) ? h2(job.title) : null,
  519. job.image ? div({ class: "activity-image-preview" }, renderMediaBlob(job.image)) : null,
  520. safeText(job.description) ? renderCardFieldRich(`${i18n.jobDescription}:`, renderUrl(job.description)) : null,
  521. renderCardField(`${i18n.jobStatus}:`, i18n["jobStatus" + String(job.status || "").toUpperCase()] || String(job.status || "").toUpperCase()),
  522. renderCardFieldRich(`${i18n.jobSalary}:`, [span({ class: "card-salary" }, salaryText)]),
  523. renderCardField(`${i18n.jobVacants}:`, job.vacants),
  524. renderCardField(`${i18n.jobLanguages}:`, String(job.languages || "").toUpperCase()),
  525. renderCardField(`${i18n.jobType}:`, i18n["jobType" + String(job.job_type || "").toUpperCase()] || String(job.job_type || "").toUpperCase()),
  526. renderCardField(`${i18n.jobLocation}:`, String(job.location || "").toUpperCase()),
  527. renderCardField(`${i18n.jobTime}:`, i18n["jobTime" + String(job.job_time || "").toUpperCase()] || String(job.job_time || "").toUpperCase()),
  528. safeText(job.requirements) ? renderCardFieldRich(`${i18n.jobRequirements}:`, renderUrl(job.requirements)) : null,
  529. safeText(job.tasks) ? renderCardFieldRich(`${i18n.jobTasks}:`, renderUrl(job.tasks)) : null,
  530. renderApplicantsProgress(subs.length, job.vacants),
  531. renderSubscribers(subs),
  532. br(),
  533. tagsNode ? tagsNode : null,
  534. br(),
  535. p(
  536. { class: "card-footer" },
  537. span({ class: "date-link" }, `${moment(job.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
  538. a({ href: `/author/${encodeURIComponent(job.author)}`, class: "user-link" }, job.author),
  539. renderUpdatedLabel(job.createdAt, job.updatedAt)
  540. )
  541. ),
  542. div({ id: "comments" }, renderJobCommentsSection(job.id, returnTo, comments))
  543. )
  544. )
  545. }