projects_view.js 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789
  1. const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, ul, li, table, thead, tbody, tr, th, td, 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: "projectFilterAll", title: "projectAllTitle" },
  22. { key: "MINE", i18n: "projectFilterMine", title: "projectMineTitle" },
  23. { key: "APPLIED", i18n: "projectFilterApplied", title: "projectAppliedTitle" },
  24. { key: "ACTIVE", i18n: "projectFilterActive", title: "projectActiveTitle" },
  25. { key: "PAUSED", i18n: "projectFilterPaused", title: "projectPausedTitle" },
  26. { key: "COMPLETED", i18n: "projectFilterCompleted", title: "projectCompletedTitle" },
  27. { key: "FOLLOWING", i18n: "projectFilterFollowing", title: "projectFollowingTitle" },
  28. { key: "RECENT", i18n: "projectFilterRecent", title: "projectRecentTitle" },
  29. { key: "TOP", i18n: "projectFilterTop", title: "projectTopTitle" },
  30. { key: "BACKERS", i18n: "projectFilterBackers", title: "projectBackersLeaderboardTitle" }
  31. ]
  32. const safeArr = (v) => (Array.isArray(v) ? v : [])
  33. const safeText = (v) => String(v || "").trim()
  34. const toNum = (v) => {
  35. const s = v === null || v === undefined ? "" : String(v)
  36. const n = parseFloat(s.replace(",", "."))
  37. return Number.isFinite(n) ? n : NaN
  38. }
  39. const clamp = (n, a, b) => Math.max(a, Math.min(b, n))
  40. const sumAmounts = (list) => safeArr(list).reduce((s, x) => s + (toNum(x && x.amount) || 0), 0)
  41. const followersCount = (p) => safeArr(p && p.followers).length
  42. const backersCount = (p) => safeArr(p && p.backers).length
  43. const backersTotal = (p) => sumAmounts((p && p.backers) || [])
  44. const budgetSummary = (project) => {
  45. const goal = Math.max(0, toNum(project && project.goal) || 0)
  46. const assigned = Math.max(0, sumAmounts((project && project.bounties) || []))
  47. const remaining = Math.max(0, goal - assigned)
  48. const exceeded = assigned > goal
  49. return { goal, assigned, remaining, exceeded }
  50. }
  51. const buildReturnTo = (filter) => `/projects?filter=${encodeURIComponent(String(filter || "ALL").toUpperCase())}`
  52. const renderCardField = (labelText, valueNode) =>
  53. div(
  54. { class: "card-field" },
  55. span({ class: "card-label" }, labelText),
  56. span({ class: "card-value" }, valueNode)
  57. )
  58. const renderCardFieldRich = (labelText, children) =>
  59. div(
  60. { class: "card-field card-field-rich" },
  61. span({ class: "card-label" }, labelText),
  62. span({ class: "card-value" }, ...children)
  63. )
  64. const renderProgressBlock = (labelText, valueText, value, maxValue) =>
  65. div(
  66. { class: "confirmations-block progress-block" },
  67. div(
  68. { class: "card-field" },
  69. span({ class: "card-label" }, labelText),
  70. span({ class: "card-value" }, valueText)
  71. ),
  72. progress({ class: "confirmations-progress", value: value, max: maxValue })
  73. )
  74. const aggregateTopBackers = (projects) => {
  75. const map = new Map()
  76. for (const pr of safeArr(projects)) {
  77. const backers = safeArr(pr && pr.backers)
  78. for (const b of backers) {
  79. const uid = b && b.userId
  80. const amt = Math.max(0, toNum(b && b.amount) || 0)
  81. if (!uid) continue
  82. if (!map.has(uid)) map.set(uid, { userId: uid, total: 0, pledges: 0, projects: new Set() })
  83. const rec = map.get(uid)
  84. rec.total += amt
  85. rec.pledges += 1
  86. rec.projects.add(pr.id)
  87. }
  88. }
  89. return Array.from(map.values())
  90. .map((r) => ({ userId: r.userId, total: r.total, pledges: r.pledges, projects: r.projects.size }))
  91. .sort((a, b) => b.total - a.total)
  92. }
  93. const renderBackersLeaderboard = (projects) => {
  94. const rows = aggregateTopBackers(projects)
  95. return rows.length
  96. ? div(
  97. { class: "backers-leaderboard" },
  98. h2(i18n.projectBackersLeaderboardTitle),
  99. ...rows.slice(0, 50).map((r) =>
  100. div(
  101. { class: "backer-row" },
  102. renderCardField(i18n.projectBackerAuthor + ":", a({ href: `/author/${encodeURIComponent(r.userId)}`, class: "user-link user-pill" }, r.userId)),
  103. renderCardField(i18n.projectBackerAmount + ":", span({ class: "chip chip-amt" }, `${r.total} ECO`)),
  104. renderCardField(i18n.projectBackerPledges + ":", span({ class: "chip chip-pledges" }, String(r.pledges))),
  105. renderCardField(i18n.projectBackerProjects + ":", span({ class: "chip chip-projects" }, String(r.projects)))
  106. )
  107. )
  108. )
  109. : div({ class: "backers-leaderboard empty" }, p(i18n.projectNoBackersFound))
  110. }
  111. const renderBudget = (project) => {
  112. const S = budgetSummary(project)
  113. const pct = S.goal > 0 ? clamp(Math.round((S.assigned / S.goal) * 100), 0, 100) : 0
  114. return div(
  115. { class: `budget-summary${S.exceeded ? " over" : ""}` },
  116. renderCardField(i18n.projectBudgetGoal + ":", `${S.goal} ECO`),
  117. renderCardField(i18n.projectBudgetAssigned + ":", `${S.assigned} ECO`),
  118. renderCardField(i18n.projectBudgetRemaining + ":", `${S.remaining} ECO`),
  119. S.goal > 0 ? renderProgressBlock(i18n.projectBudgetAssigned + ":", `${S.assigned}/${S.goal}`, pct, 100) : null,
  120. S.exceeded ? p({ class: "warning" }, i18n.projectBudgetOver) : null
  121. )
  122. }
  123. const renderFollowers = (project) => {
  124. const followers = safeArr(project && project.followers)
  125. if (!followers.length) return div({ class: "followers-block" }, h2(i18n.projectFollowersTitle), p(i18n.projectFollowersNone))
  126. const show = followers.slice(0, 12)
  127. return div(
  128. { class: "followers-block" },
  129. h2(i18n.projectFollowersTitle),
  130. ul(show.map((uid) => li(a({ href: `/author/${encodeURIComponent(uid)}`, class: "user-link" }, uid)))),
  131. followers.length > show.length ? p(`+${followers.length - show.length} ${i18n.projectMore}`) : null
  132. )
  133. }
  134. const renderBackers = (project, filter) => {
  135. const backers = safeArr(project && project.backers)
  136. const total = sumAmounts(backers)
  137. const mine = sumAmounts(backers.filter((b) => b && b.userId === userId))
  138. const rt = `/projects/${encodeURIComponent(project.id)}?filter=${encodeURIComponent(filter || "ALL")}`
  139. const pending = project && project.author === userId
  140. ? backers.filter((b) => b && b.transferId && b.confirmed === false).slice(0, 10)
  141. : []
  142. return div(
  143. { class: "backers-block" },
  144. h2(i18n.projectBackersTitle),
  145. renderCardField(i18n.projectBackersTotal + ":", String(backers.length)),
  146. renderCardField(i18n.projectBackersTotalPledged + ":", `${total} ECO`),
  147. mine > 0 ? renderCardField(i18n.projectBackersYourPledge + ":", span({ class: "chip chip-you" }, `${mine} ECO`)) : null,
  148. backers.length
  149. ? table(
  150. { class: "backers-table" },
  151. thead(tr(th(i18n.projectBackerDate), th(i18n.projectBackerAuthor), th(i18n.projectBackerAmount))),
  152. tbody(
  153. ...backers.slice(0, 8).map((b) =>
  154. tr(
  155. td(b.at ? moment(b.at).format("YYYY/MM/DD HH:mm") : ""),
  156. td(a({ href: `/author/${encodeURIComponent(b.userId)}`, class: "user-link" }, b.userId)),
  157. td(`${b.amount} ECO`)
  158. )
  159. )
  160. )
  161. )
  162. : p(i18n.projectBackersNone),
  163. pending.length
  164. ? div(
  165. { class: "pending-transfers" },
  166. h2(i18n.projectPendingTransfersTitle),
  167. ...pending.map((b) =>
  168. div(
  169. { class: "card-field" },
  170. span({ class: "card-label" }, b.userId),
  171. span(
  172. { class: "card-value" },
  173. form(
  174. { method: "POST", action: `/projects/confirm-transfer/${encodeURIComponent(b.transferId)}` },
  175. input({ type: "hidden", name: "returnTo", value: rt }),
  176. button({ type: "submit", class: "btn" }, i18n.projectConfirmTransferButton)
  177. )
  178. )
  179. )
  180. )
  181. )
  182. : null
  183. )
  184. }
  185. const renderPledgeBox = (project, filter, isAuthor) => {
  186. const statusUpper = String((project && project.status) || "ACTIVE").toUpperCase()
  187. const isActive = statusUpper === "ACTIVE"
  188. if (!isActive || isAuthor) return null
  189. const rt = `/projects/${encodeURIComponent(project.id)}?filter=${encodeURIComponent(filter || "ALL")}`
  190. return div(
  191. { class: "pledge-box" },
  192. h2(i18n.projectPledgeTitle),
  193. form(
  194. { method: "POST", action: `/projects/pledge/${encodeURIComponent(project.id)}` },
  195. input({ type: "hidden", name: "returnTo", value: rt }),
  196. input({ type: "number", name: "amount", min: "0.01", step: "0.01", required: true, placeholder: i18n.projectPledgePlaceholder }),
  197. select(
  198. { name: "milestoneOrBounty" },
  199. option({ value: "" }, i18n.projectSelectMilestoneOrBounty),
  200. ...safeArr(project && project.milestones).map((m, idx) => option({ value: `milestone:${idx}` }, m.title)),
  201. ...safeArr(project && project.bounties).map((b, idx) => option({ value: `bounty:${idx}` }, b.title))
  202. ),
  203. button({ class: "btn", type: "submit" }, i18n.projectPledgeButton)
  204. )
  205. )
  206. }
  207. const bountyTotalsForMilestone = (project, mIndex) => {
  208. const bounties = safeArr(project && project.bounties)
  209. const list = bounties.filter((b) => {
  210. const mi = b && b.milestoneIndex
  211. return mi === mIndex
  212. })
  213. const total = sumAmounts(list)
  214. const done = list.filter((b) => !!(b && b.done)).length
  215. return { total, count: list.length, done }
  216. }
  217. const renderMilestonesAndBounties = (project, filter, editable) => {
  218. const milestones = safeArr(project && project.milestones)
  219. const bounties = safeArr(project && project.bounties)
  220. const rt = `/projects/${encodeURIComponent(project.id)}?filter=${encodeURIComponent(filter || "ALL")}`
  221. const remain = budgetSummary(project).remaining
  222. const blocks = milestones.map((m, idx) => {
  223. const totals = bountyTotalsForMilestone(project, idx)
  224. const items = bounties.filter((b) => b && b.milestoneIndex === idx)
  225. const maxCount = Math.max(1, totals.count)
  226. const pctDone = clamp(Math.round((totals.done / maxCount) * 100), 0, 100)
  227. return div(
  228. { class: "milestone-with-bounties" },
  229. div(
  230. { class: "milestone-stats" },
  231. renderCardField(i18n.projectMilestoneStatus + ":", m.done ? i18n.projectMilestoneDone.toUpperCase() : i18n.projectMilestoneOpen.toUpperCase()),
  232. renderProgressBlock(i18n.projectBounties + ":", `${totals.done}/${totals.count} · ${totals.total} ECO`, pctDone, 100)
  233. ),
  234. div(
  235. { class: "milestone-head" },
  236. span({ class: "milestone-title" }, m.title),
  237. m.dueDate ? span({ class: "chip chip-due" }, `${i18n.projectMilestoneDue}: ${moment(m.dueDate).format("YYYY/MM/DD HH:mm")}`) : null,
  238. safeText(m.description) ? p(...renderUrl(m.description)) : null,
  239. editable && !m.done
  240. ? form(
  241. { method: "POST", action: `/projects/milestones/complete/${encodeURIComponent(project.id)}/${idx}` },
  242. input({ type: "hidden", name: "returnTo", value: rt }),
  243. button({ class: "btn", type: "submit" }, i18n.projectMilestoneMarkDone)
  244. )
  245. : null
  246. ),
  247. items.length
  248. ? ul(
  249. items.map((b) => {
  250. const globalIndex = bounties.indexOf(b)
  251. const statusText = b.done
  252. ? i18n.projectBountyDone.toUpperCase()
  253. : (b.claimedBy ? i18n.projectBountyClaimed.toUpperCase() : i18n.projectBountyOpen.toUpperCase())
  254. return li(
  255. { class: "bounty-item" },
  256. div(
  257. { class: "bounty-main" },
  258. span({ class: "bounty-title" }, b.title),
  259. span({ class: "bounty-amount" }, `${b.amount} ECO`)
  260. ),
  261. safeText(b.description) ? p(...renderUrl(b.description)) : null,
  262. renderCardField(i18n.projectBountyStatus + ":", statusText),
  263. b.claimedBy ? renderCardField(i18n.projectBountyClaimedBy + ":", a({ href: `/author/${encodeURIComponent(b.claimedBy)}`, class: "user-link" }, b.claimedBy)) : null,
  264. !editable && !b.done && !b.claimedBy && project.author !== userId
  265. ? form(
  266. { method: "POST", action: `/projects/bounties/claim/${encodeURIComponent(project.id)}/${globalIndex}` },
  267. input({ type: "hidden", name: "returnTo", value: rt }),
  268. button({ type: "submit", class: "btn" }, i18n.projectBountyClaimButton)
  269. )
  270. : null,
  271. editable && !b.done
  272. ? form(
  273. { method: "POST", action: `/projects/bounties/complete/${encodeURIComponent(project.id)}/${globalIndex}` },
  274. input({ type: "hidden", name: "returnTo", value: rt }),
  275. button({ type: "submit", class: "btn" }, i18n.projectBountyCompleteButton)
  276. )
  277. : null,
  278. editable
  279. ? form(
  280. { method: "POST", action: `/projects/bounties/update/${encodeURIComponent(project.id)}/${globalIndex}`, class: "bounty-update-form" },
  281. input({ type: "hidden", name: "returnTo", value: rt }),
  282. label(i18n.projectMilestoneSelect),
  283. br(),
  284. select(
  285. { name: "milestoneIndex" },
  286. option({ value: "", selected: b.milestoneIndex == null }, "-"),
  287. ...milestones.map((m2, idx2) => option({ value: String(idx2), selected: b.milestoneIndex === idx2 }, m2.title))
  288. ),
  289. br(),
  290. br(),
  291. button({ class: "btn", type: "submit", disabled: remain <= 0 }, i18n.projectBountyCreateButton)
  292. )
  293. : null
  294. )
  295. })
  296. )
  297. : p(i18n.projectNoBounties)
  298. )
  299. })
  300. const unassigned = bounties.filter((b) => b && (b.milestoneIndex === null || b.milestoneIndex === undefined))
  301. const unassignedBlock = unassigned.length
  302. ? div(
  303. { class: "bounty-milestone-block" },
  304. h2(i18n.projectBounties),
  305. ul(
  306. unassigned.map((b) => {
  307. const globalIndex = bounties.indexOf(b)
  308. const statusText = b.done
  309. ? i18n.projectBountyDone.toUpperCase()
  310. : (b.claimedBy ? i18n.projectBountyClaimed.toUpperCase() : i18n.projectBountyOpen.toUpperCase())
  311. return li(
  312. { class: "bounty-item" },
  313. div(
  314. { class: "bounty-main" },
  315. span({ class: "bounty-title" }, b.title),
  316. span({ class: "bounty-amount" }, `${b.amount} ECO`)
  317. ),
  318. safeText(b.description) ? p(...renderUrl(b.description)) : null,
  319. renderCardField(i18n.projectBountyStatus + ":", statusText),
  320. b.claimedBy ? renderCardField(i18n.projectBountyClaimedBy + ":", a({ href: `/author/${encodeURIComponent(b.claimedBy)}`, class: "user-link" }, b.claimedBy)) : null,
  321. !editable && !b.done && !b.claimedBy && project.author !== userId
  322. ? form(
  323. { method: "POST", action: `/projects/bounties/claim/${encodeURIComponent(project.id)}/${globalIndex}` },
  324. input({ type: "hidden", name: "returnTo", value: rt }),
  325. button({ type: "submit", class: "btn" }, i18n.projectBountyClaimButton)
  326. )
  327. : null,
  328. editable && !b.done
  329. ? form(
  330. { method: "POST", action: `/projects/bounties/complete/${encodeURIComponent(project.id)}/${globalIndex}` },
  331. input({ type: "hidden", name: "returnTo", value: rt }),
  332. button({ type: "submit", class: "btn" }, i18n.projectBountyCompleteButton)
  333. )
  334. : null
  335. )
  336. })
  337. )
  338. )
  339. : null
  340. return div({ class: "milestones-bounties" }, ...blocks, unassignedBlock)
  341. }
  342. const renderProjectOwnerActions = (project, returnTo, opts = {}) => {
  343. const statusUpper = String(project.status || "ACTIVE").toUpperCase()
  344. const pct = clamp(Math.round(toNum(project.progress) || 0), 0, 100)
  345. const isList = !!opts.list
  346. const rt = isList ? returnTo : `/projects/${encodeURIComponent(project.id)}?filter=${encodeURIComponent(String(opts.filter || "ALL"))}`
  347. return div(
  348. { class: "bookmark-actions project-actions" },
  349. form(
  350. { method: "GET", action: `/projects/edit/${encodeURIComponent(project.id)}` },
  351. button({ class: "update-btn", type: "submit" }, i18n.projectUpdateButton)
  352. ),
  353. form(
  354. { method: "POST", action: `/projects/delete/${encodeURIComponent(project.id)}` },
  355. input({ type: "hidden", name: "returnTo", value: returnTo }),
  356. button({ class: "delete-btn", type: "submit" }, i18n.projectDeleteButton)
  357. ),
  358. form(
  359. { method: "POST", action: `/projects/status/${encodeURIComponent(project.id)}`, class: "project-control-form project-control-form--status" },
  360. input({ type: "hidden", name: "returnTo", value: rt }),
  361. select(
  362. { name: "status", class: "project-control-select" },
  363. option({ value: "ACTIVE", selected: statusUpper === "ACTIVE" }, i18n.projectStatusACTIVE),
  364. option({ value: "PAUSED", selected: statusUpper === "PAUSED" }, i18n.projectStatusPAUSED),
  365. option({ value: "COMPLETED", selected: statusUpper === "COMPLETED" }, i18n.projectStatusCOMPLETED),
  366. option({ value: "CANCELLED", selected: statusUpper === "CANCELLED" }, i18n.projectStatusCANCELLED)
  367. ),
  368. button({ class: "status-btn project-control-btn", type: "submit" }, i18n.projectSetStatus)
  369. ),
  370. form(
  371. { method: "POST", action: `/projects/progress/${encodeURIComponent(project.id)}`, class: "project-control-form project-control-form--progress" },
  372. input({ type: "hidden", name: "returnTo", value: rt }),
  373. input({ type: "number", name: "progress", min: "0", max: "100", value: pct, class: "project-control-input project-progress-input" }),
  374. button({ class: "status-btn project-control-btn", type: "submit" }, i18n.projectSetProgress)
  375. )
  376. )
  377. }
  378. const renderProjectTopbar = (project, filter, opts) => {
  379. const o = opts || {}
  380. const isSingle = !!o.single
  381. const isAuthor = project && project.author === userId
  382. const statusUpper = String((project && project.status) || "ACTIVE").toUpperCase()
  383. const isActive = statusUpper === "ACTIVE"
  384. const returnTo = isSingle
  385. ? `/projects/${encodeURIComponent(project.id)}?filter=${encodeURIComponent(String(filter || "ALL").toUpperCase())}`
  386. : buildReturnTo(filter)
  387. const leftActions = []
  388. if (!isSingle) {
  389. leftActions.push(
  390. form(
  391. { method: "GET", action: `/projects/${encodeURIComponent(project.id)}` },
  392. input({ type: "hidden", name: "filter", value: String(filter || "ALL").toUpperCase() }),
  393. button({ type: "submit", class: "filter-btn" }, i18n.viewDetailsButton)
  394. )
  395. )
  396. }
  397. if (!isAuthor && project && project.author) {
  398. leftActions.push(
  399. form(
  400. { method: "GET", action: "/pm" },
  401. input({ type: "hidden", name: "recipients", value: project.author }),
  402. button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
  403. )
  404. )
  405. }
  406. if (!isAuthor && isActive) {
  407. const following = safeArr(project && project.followers).includes(userId)
  408. leftActions.push(
  409. following
  410. ? form(
  411. { method: "POST", action: `/projects/unfollow/${encodeURIComponent(project.id)}` },
  412. input({ type: "hidden", name: "returnTo", value: returnTo }),
  413. button({ type: "submit", class: "unsubscribe-btn" }, i18n.projectUnfollowButton)
  414. )
  415. : form(
  416. { method: "POST", action: `/projects/follow/${encodeURIComponent(project.id)}` },
  417. input({ type: "hidden", name: "returnTo", value: returnTo }),
  418. button({ type: "submit", class: "subscribe-btn" }, i18n.projectFollowButton)
  419. )
  420. )
  421. }
  422. const leftNode = leftActions.length ? div({ class: "bookmark-topbar-left project-topbar-left" }, ...leftActions) : null
  423. const rightNode = isAuthor ? renderProjectOwnerActions(project, returnTo) : null
  424. const nodes = []
  425. if (leftNode) nodes.push(leftNode)
  426. if (rightNode) nodes.push(rightNode)
  427. return nodes.length ? div({ class: isSingle ? "bookmark-topbar project-topbar-single" : "bookmark-topbar" }, ...nodes) : null
  428. }
  429. const renderProjectList = (projects, filter) => {
  430. const list = safeArr(projects)
  431. const returnTo = buildReturnTo(filter)
  432. return list.length
  433. ? list.map((pr) => {
  434. const statusUpper = String((pr && pr.status) || "ACTIVE").toUpperCase()
  435. const statusClass = `status-${statusUpper.toLowerCase()}`
  436. const pctRaw = toNum(pr && pr.progress)
  437. const pct = clamp(Math.round(Number.isFinite(pctRaw) ? pctRaw : 0), 0, 100)
  438. const goal = Math.max(0, toNum(pr && pr.goal) || 0)
  439. const pledged = Math.max(0, toNum(pr && pr.pledged) || 0)
  440. const fundingPct = goal > 0 ? clamp(Math.round((pledged / goal) * 100), 0, 100) : 0
  441. const mileDone = safeArr(pr && pr.milestones).filter((m) => !!(m && m.done)).length
  442. const mileTotal = safeArr(pr && pr.milestones).length
  443. const topbar = renderProjectTopbar(pr, filter, { single: false })
  444. const isMineAuthor = String(filter || "ALL").toUpperCase() === "MINE" && pr.author === userId
  445. return div(
  446. { class: `project-card ${statusClass}` },
  447. topbar ? topbar : null,
  448. h2(pr.title),
  449. pr.image ? div({ class: "activity-image-preview" }, renderMediaBlob(pr.image)) : null,
  450. safeText(pr.description) ? renderCardFieldRich(i18n.projectDescription + ":", renderUrl(pr.description)) : null,
  451. renderCardField(i18n.projectStatus + ":", i18n["projectStatus" + statusUpper] || statusUpper),
  452. renderProgressBlock(i18n.projectProgress + ":", `${pct}%`, pct, 100),
  453. renderCardField(i18n.projectGoal + ":", `${pr.goal} ECO`),
  454. renderCardField(i18n.projectPledged + ":", `${pr.pledged || 0} ECO`),
  455. renderProgressBlock(i18n.projectFunding + ":", `${fundingPct}%`, fundingPct, 100),
  456. renderCardField(i18n.projectMilestones + ":", `${mileDone}/${mileTotal}`),
  457. renderCardField(i18n.projectFollowers + ":", String(followersCount(pr))),
  458. renderCardField(i18n.projectBackers + ":", `${backersCount(pr)} · ${backersTotal(pr)} ECO`),
  459. isMineAuthor
  460. ? div(
  461. { class: "project-admin-block" },
  462. renderBudget(pr),
  463. renderMilestonesAndBounties(pr, filter, true),
  464. div(
  465. { class: "new-milestone" },
  466. h2(i18n.projectAddMilestoneTitle),
  467. form(
  468. { method: "POST", action: `/projects/milestones/add/${encodeURIComponent(pr.id)}` },
  469. input({ type: "hidden", name: "returnTo", value: returnTo }),
  470. label(i18n.projectMilestoneTitle),
  471. br(),
  472. input({ type: "text", name: "title", required: true }),
  473. br(),
  474. br(),
  475. label(i18n.projectMilestoneDescription),
  476. br(),
  477. textarea({ name: "description", rows: "3" }),
  478. br(),
  479. br(),
  480. label(i18n.projectMilestoneTargetPercent),
  481. br(),
  482. input({ type: "number", name: "targetPercent", min: "0", max: "100", step: "1", value: "0" }),
  483. br(),
  484. br(),
  485. label(i18n.projectMilestoneDueDate),
  486. br(),
  487. input({
  488. type: "datetime-local",
  489. name: "dueDate",
  490. min: moment().format("YYYY-MM-DDTHH:mm"),
  491. max: pr.deadline ? moment(pr.deadline).format("YYYY-MM-DDTHH:mm") : undefined
  492. }),
  493. br(),
  494. br(),
  495. button({ class: "btn", type: "submit" }, i18n.projectMilestoneCreateButton)
  496. )
  497. ),
  498. div(
  499. { class: "new-bounty" },
  500. h2(i18n.projectAddBountyTitle),
  501. form(
  502. { method: "POST", action: `/projects/bounties/add/${encodeURIComponent(pr.id)}` },
  503. input({ type: "hidden", name: "returnTo", value: returnTo }),
  504. label(i18n.projectBountyTitle),
  505. br(),
  506. input({ type: "text", name: "title", required: true }),
  507. br(),
  508. br(),
  509. label(i18n.projectBountyAmount),
  510. br(),
  511. input({ type: "number", step: "0.01", name: "amount", required: true, max: String(budgetSummary(pr).remaining) }),
  512. br(),
  513. br(),
  514. label(i18n.projectBountyDescription),
  515. br(),
  516. textarea({ name: "description", rows: "3" }),
  517. br(),
  518. br(),
  519. label(i18n.projectMilestoneSelect),
  520. br(),
  521. select(
  522. { name: "milestoneIndex" },
  523. option({ value: "" }, "-"),
  524. ...safeArr(pr && pr.milestones).map((m, idx) => option({ value: String(idx) }, m.title))
  525. ),
  526. br(),
  527. br(),
  528. button({ class: "btn", type: "submit", disabled: budgetSummary(pr).remaining <= 0 }, i18n.projectBountyCreateButton)
  529. )
  530. )
  531. )
  532. : null,
  533. br(),
  534. div(
  535. { class: "card-comments-summary" },
  536. span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
  537. span({ class: "card-value" }, String(pr.commentCount || 0)),
  538. br(),
  539. br(),
  540. form({ method: "GET", action: `/projects/${encodeURIComponent(pr.id)}` }, button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton))
  541. ),
  542. div(
  543. { class: "card-footer" },
  544. span({ class: "date-link" }, `${moment(pr.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
  545. a({ href: `/author/${encodeURIComponent(pr.author)}`, class: "user-link" }, pr.author)
  546. )
  547. )
  548. })
  549. : p(i18n.projectNoProjectsFound)
  550. }
  551. const renderProjectForm = (project, mode) => {
  552. const pr = project || {}
  553. const isEdit = mode === "edit"
  554. const nowLocal = moment().format("YYYY-MM-DDTHH:mm")
  555. const deadlineValue = pr.deadline ? moment(pr.deadline).format("YYYY-MM-DDTHH:mm") : ""
  556. const milestoneMax = deadlineValue || undefined
  557. const returnTo = "/projects?filter=MINE"
  558. return div(
  559. { class: "div-center project-form" },
  560. form(
  561. {
  562. action: isEdit ? `/projects/update/${encodeURIComponent(pr.id)}` : "/projects/create",
  563. method: "POST",
  564. enctype: "multipart/form-data"
  565. },
  566. input({ type: "hidden", name: "returnTo", value: returnTo }),
  567. label(i18n.projectTitle),
  568. br(),
  569. input({ type: "text", name: "title", required: true, placeholder: i18n.projectTitlePlaceholder, value: pr.title || "" }),
  570. br(),
  571. br(),
  572. label(i18n.projectDescription),
  573. br(),
  574. textarea({ name: "description", rows: "6", required: true, placeholder: i18n.projectDescriptionPlaceholder }, pr.description || ""),
  575. br(),
  576. br(),
  577. label(i18n.projectImage),
  578. br(),
  579. input({ type: "file", name: "image" }),
  580. br(),
  581. pr.image ? renderMediaBlob(pr.image) : null,
  582. br(),
  583. label(i18n.projectGoal),
  584. br(),
  585. input({ type: "number", step: "0.01", min: "0.01", name: "goal", required: true, placeholder: i18n.projectGoalPlaceholder, value: pr.goal || "" }),
  586. br(),
  587. br(),
  588. label(i18n.projectDeadline),
  589. br(),
  590. input({ type: "datetime-local", name: "deadline", id: "deadline", required: true, min: nowLocal, value: deadlineValue }),
  591. br(),
  592. br(),
  593. h2(i18n.projectAddMilestoneTitle),
  594. label(i18n.projectMilestoneTitle),
  595. br(),
  596. input({ type: "text", name: "milestoneTitle", required: true, placeholder: i18n.projectMilestoneTitlePlaceholder }),
  597. br(),
  598. br(),
  599. label(i18n.projectMilestoneDescription),
  600. br(),
  601. textarea({ name: "milestoneDescription", rows: "3", placeholder: i18n.projectMilestoneDescriptionPlaceholder }),
  602. br(),
  603. br(),
  604. label(i18n.projectMilestoneTargetPercent),
  605. br(),
  606. input({ type: "number", name: "milestoneTargetPercent", min: "0", max: "100", step: "1", value: "0" }),
  607. br(),
  608. br(),
  609. label(i18n.projectMilestoneDueDate),
  610. br(),
  611. input({ type: "datetime-local", name: "milestoneDueDate", min: nowLocal, max: milestoneMax }),
  612. br(),
  613. br(),
  614. button({ type: "submit" }, isEdit ? i18n.projectUpdateButton : i18n.projectCreateButton)
  615. )
  616. )
  617. }
  618. exports.projectsView = async (projectsOrForm, filter) => {
  619. const f = String(filter || "ALL").toUpperCase()
  620. const filterObj = FILTERS.find((x) => x.key === f) || FILTERS[0]
  621. const sectionTitle = i18n[filterObj.title] || i18n.projectAllTitle
  622. return template(
  623. i18n.projectsTitle,
  624. section(
  625. div({ class: "tags-header" }, h2(sectionTitle), p(i18n.projectsDescription)),
  626. div(
  627. { class: "filters" },
  628. form(
  629. { method: "GET", action: "/projects", class: "ui-toolbar ui-toolbar--filters" },
  630. FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: f === x.key ? "filter-btn active" : "filter-btn" }, i18n[x.i18n]))
  631. .concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.projectCreateProject))
  632. )
  633. ),
  634. f === "CREATE" || f === "EDIT"
  635. ? (() => {
  636. const prToEdit = f === "EDIT" ? (safeArr(projectsOrForm)[0] || {}) : {}
  637. return renderProjectForm(prToEdit, f === "EDIT" ? "edit" : "create")
  638. })()
  639. : (f === "BACKERS"
  640. ? renderBackersLeaderboard(projectsOrForm)
  641. : div({ class: "projects-list" }, renderProjectList(projectsOrForm, f))
  642. )
  643. )
  644. )
  645. }
  646. exports.singleProjectView = async (project, filter, comments) => {
  647. const pr = project || {}
  648. const f = String(filter || "ALL").toUpperCase()
  649. const isAuthor = pr.author === userId
  650. const statusUpper = String(pr.status || "ACTIVE").toUpperCase()
  651. const statusClass = `status-${statusUpper.toLowerCase()}`
  652. const pctRaw = toNum(pr.progress)
  653. const pct = clamp(Math.round(Number.isFinite(pctRaw) ? pctRaw : 0), 0, 100)
  654. const goal = Math.max(0, toNum(pr.goal) || 0)
  655. const pledged = Math.max(0, toNum(pr.pledged) || 0)
  656. const fundingPct = goal > 0 ? clamp(Math.round((pledged / goal) * 100), 0, 100) : 0
  657. const topbar = renderProjectTopbar(pr, f, { single: true })
  658. return template(
  659. i18n.projectsTitle,
  660. section(
  661. div({ class: "tags-header" }, h2(i18n.projectsTitle), p(i18n.projectsDescription)),
  662. div(
  663. { class: "filters" },
  664. form(
  665. { method: "GET", action: "/projects", class: "ui-toolbar ui-toolbar--filters" },
  666. FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: f === x.key ? "filter-btn active" : "filter-btn" }, i18n[x.i18n]))
  667. .concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.projectCreateProject))
  668. )
  669. ),
  670. div(
  671. { class: `project-card ${statusClass}` },
  672. topbar ? topbar : null,
  673. !isAuthor && safeArr(pr.followers).includes(userId) ? p({ class: "hint" }, i18n.projectYouFollowHint) : null,
  674. h2(pr.title),
  675. pr.image ? div({ class: "activity-image-preview" }, renderMediaBlob(pr.image)) : null,
  676. safeText(pr.description) ? renderCardFieldRich(i18n.projectDescription + ":", renderUrl(pr.description)) : null,
  677. renderCardField(i18n.projectStatus + ":", i18n["projectStatus" + statusUpper] || statusUpper),
  678. renderProgressBlock(i18n.projectProgress + ":", `${pct}%`, pct, 100),
  679. renderCardField(i18n.projectGoal + ":", `${pr.goal} ECO`),
  680. renderCardField(i18n.projectPledged + ":", `${pr.pledged || 0} ECO`),
  681. renderProgressBlock(i18n.projectFunding + ":", `${fundingPct}%`, fundingPct, 100),
  682. div(
  683. { class: "social-stats" },
  684. renderCardField(i18n.projectFollowers + ":", String(followersCount(pr))),
  685. renderCardField(i18n.projectBackers + ":", `${backersCount(pr)} · ${backersTotal(pr)} ECO`)
  686. ),
  687. renderBudget(pr),
  688. renderMilestonesAndBounties(pr, f, isAuthor),
  689. renderFollowers(pr),
  690. br(),
  691. renderBackers(pr, f),
  692. renderPledgeBox(pr, f, isAuthor),
  693. div(
  694. { class: "card-footer" },
  695. span({ class: "date-link" }, `${moment(pr.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
  696. a({ href: `/author/${encodeURIComponent(pr.author)}`, class: "user-link" }, pr.author)
  697. )
  698. ),
  699. div(
  700. { class: "comment-form-wrapper" },
  701. h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
  702. form(
  703. { method: "POST", action: `/projects/${encodeURIComponent(pr.id || pr.key)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
  704. textarea({ id: "comment-text", name: "text", rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }),
  705. div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
  706. br(),
  707. button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
  708. )
  709. ),
  710. comments && comments.length
  711. ? div(
  712. { class: "comments-list" },
  713. comments.map((c) => {
  714. const author = c?.value?.author || ""
  715. const ts = c?.value?.timestamp || c?.timestamp
  716. const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""
  717. const relDate = ts ? moment(ts).fromNow() : ""
  718. return div(
  719. { class: "comment-card" },
  720. div({ class: "comment-header" }, a({ href: `/author/${encodeURIComponent(author)}`, class: "user-link" }, author)),
  721. div({ class: "comment-date" }, span({ title: absDate }, relDate)),
  722. div({ class: "comment-body" }, ...renderUrl(c?.value?.content?.text || ""))
  723. )
  724. })
  725. )
  726. : null
  727. )
  728. )
  729. }