projects_view.js 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  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 } = 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 userId = config.keys.id
  7. const FILTERS = [
  8. { key: 'ALL', i18n: 'projectFilterAll', title: 'projectAllTitle' },
  9. { key: 'MINE', i18n: 'projectFilterMine', title: 'projectMineTitle' },
  10. { key: 'ACTIVE', i18n: 'projectFilterActive', title: 'projectActiveTitle' },
  11. { key: 'PAUSED', i18n: 'projectFilterPaused', title: 'projectPausedTitle' },
  12. { key: 'COMPLETED', i18n: 'projectFilterCompleted', title: 'projectCompletedTitle' },
  13. { key: 'FOLLOWING', i18n: 'projectFilterFollowing', title: 'projectFollowingTitle' },
  14. { key: 'RECENT', i18n: 'projectFilterRecent', title: 'projectRecentTitle' },
  15. { key: 'TOP', i18n: 'projectFilterTop', title: 'projectTopTitle' },
  16. { key: 'BACKERS', i18n: 'projectFilterBackers', title: 'projectBackersLeaderboardTitle' }
  17. ]
  18. const field = (labelText, value) =>
  19. div({ class: 'card-field' },
  20. span({ class: 'card-label' }, labelText),
  21. span({ class: 'card-value' }, value)
  22. )
  23. function sumAmounts(list = []) {
  24. return list.reduce((s, x) => s + (parseFloat(x.amount || 0) || 0), 0)
  25. }
  26. function budgetSummary(project) {
  27. const goal = parseFloat(project.goal || 0) || 0
  28. const assigned = sumAmounts(project.bounties || [])
  29. const remaining = Math.max(0, goal - assigned)
  30. const exceeded = assigned > goal
  31. return { goal, assigned, remaining, exceeded }
  32. }
  33. const followersCount = (p) => Array.isArray(p.followers) ? p.followers.length : 0
  34. const backersTotal = (p) => sumAmounts(p.backers || [])
  35. const backersCount = (p) => Array.isArray(p.backers) ? p.backers.length : 0
  36. function aggregateTopBackers(projects = []) {
  37. const map = new Map()
  38. for (const pr of projects) {
  39. const backers = Array.isArray(pr.backers) ? pr.backers : []
  40. for (const b of backers) {
  41. const uid = b.userId
  42. const amt = Math.max(0, parseFloat(b.amount || 0) || 0)
  43. if (!map.has(uid)) map.set(uid, { userId: uid, total: 0, pledges: 0, projects: new Set() })
  44. const rec = map.get(uid)
  45. rec.total += amt
  46. rec.pledges += 1
  47. rec.projects.add(pr.id)
  48. }
  49. }
  50. return Array.from(map.values())
  51. .map(r => ({ ...r, projects: r.projects.size }))
  52. .sort((a, b) => b.total - a.total)
  53. }
  54. function renderBackersLeaderboard(projects) {
  55. const rows = aggregateTopBackers(projects)
  56. if (!rows.length) return div({ class: 'backers-leaderboard empty' }, p(i18n.projectNoBackersFound))
  57. return div({ class: 'backers-leaderboard' },
  58. h2(i18n.projectBackersLeaderboardTitle),
  59. ...rows.slice(0, 50).map(r =>
  60. div({ class: 'backer-row' },
  61. div({ class: 'card-field' },
  62. span({ class: 'card-label' }, ''),
  63. span({ class: 'card-value' },
  64. a({ href: `/author/${encodeURIComponent(r.userId)}`, class: 'user-link user-pill' }, r.userId)
  65. )
  66. ),
  67. div({ class: 'card-field' },
  68. span({ class: 'card-label' }, i18n.projectBackerAmount + ':'),
  69. span({ class: 'card-value' }, span({ class: 'chip chip-amt' }, `${r.total} ECO`))
  70. ),
  71. div({ class: 'card-field' },
  72. span({ class: 'card-label' }, i18n.projectBackerPledges + ':'),
  73. span({ class: 'card-value' }, span({ class: 'chip chip-pledges' }, String(r.pledges)))
  74. ),
  75. div({ class: 'card-field' },
  76. span({ class: 'card-label' }, i18n.projectBackerProjects + ':'),
  77. span({ class: 'card-value' }, span({ class: 'chip chip-projects' }, String(r.projects)))
  78. )
  79. )
  80. )
  81. )
  82. }
  83. function renderBackers(project) {
  84. const backers = Array.isArray(project.backers) ? project.backers : [];
  85. const total = sumAmounts(backers);
  86. const mine = sumAmounts(backers.filter(b => b.userId === userId));
  87. return div({ class: 'backers-block' },
  88. h2(i18n.projectBackersTitle),
  89. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBackersTotal + ':'), span({ class: 'card-value' }, String(backers.length))),
  90. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBackersTotalPledged + ':'), span({ class: 'card-value' }, `${total} ECO`)),
  91. mine > 0 ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBackersYourPledge + ':'), span({ class: 'card-value chip chip-you' }, `${mine} ECO`)) : null,
  92. backers.length
  93. ? table({ class: 'backers-table' },
  94. thead(
  95. tr(
  96. th(i18n.projectBackerDate || 'Date'),
  97. th(i18n.projectBackerAuthor || 'Author'),
  98. th(i18n.projectBackerAmount)
  99. )
  100. ),
  101. tbody(
  102. ...backers.slice(0, 8).map(b =>
  103. tr(
  104. td(b.at ? moment(b.at).format('YYYY/MM/DD HH:mm') : ''),
  105. td(a({ href: `/author/${encodeURIComponent(b.userId)}`, class: 'user-link' }, b.userId)),
  106. td(`${b.amount} ECO`)
  107. )
  108. )
  109. )
  110. )
  111. : p(i18n.projectBackersNone)
  112. );
  113. }
  114. function renderPledgeBox(project, isAuthor) {
  115. const isActive = String(project.status || 'ACTIVE').toUpperCase() === 'ACTIVE';
  116. if (!isActive || isAuthor) return null;
  117. return div({ class: 'pledge-box' },
  118. h2(i18n.projectPledgeTitle),
  119. form({ method: "POST", action: `/projects/pledge/${encodeURIComponent(project.id)}` },
  120. input({ type: "number", name: "amount", min: "0.01", step: "0.01", required: true, placeholder: i18n.projectPledgePlaceholder }),
  121. select({ name: "milestoneOrBounty" },
  122. option({ value: "" }, i18n.projectSelectMilestoneOrBounty),
  123. ...(project.milestones || []).map((m, idx) => option({ value: `milestone:${idx}` }, m.title)),
  124. ...(project.bounties || []).map((b, idx) => option({ value: `bounty:${idx}` }, b.title))
  125. ),
  126. button({ class: "btn", type: "submit" }, i18n.projectPledgeButton)
  127. )
  128. );
  129. }
  130. function bountyTotalsForMilestone(project, mIndex) {
  131. const list = (project.bounties || []).filter(b => (b.milestoneIndex ?? null) === mIndex)
  132. const total = sumAmounts(list)
  133. const done = list.filter(b => b.done).length
  134. return { total, count: list.length, done }
  135. }
  136. function renderBudget(project) {
  137. const S = budgetSummary(project)
  138. return div({ class: `budget-summary${S.exceeded ? ' over' : ''}` },
  139. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBudgetGoal + ':'), span({ class: 'card-value' }, `${S.goal} ECO`)),
  140. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBudgetAssigned + ':'), span({ class: 'card-value' }, `${S.assigned} ECO`)),
  141. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBudgetRemaining + ':'), span({ class: 'card-value' }, `${S.remaining} ECO`)),
  142. S.exceeded ? p({ class: 'warning' }, i18n.projectBudgetOver) : null
  143. )
  144. }
  145. function renderFollowers(project) {
  146. const followers = Array.isArray(project.followers) ? project.followers : []
  147. if (!followers.length) return div({ class: 'followers-block' }, h2(i18n.projectFollowersTitle), p(i18n.projectFollowersNone))
  148. const show = followers.slice(0, 12)
  149. return div({ class: 'followers-block' },
  150. h2(i18n.projectFollowersTitle),
  151. ul(show.map(uid => li(a({ href: `/author/${encodeURIComponent(uid)}`, class: 'user-link' }, uid)))),
  152. followers.length > show.length ? p(`+${followers.length - show.length} ${i18n.projectMore}`) : null
  153. )
  154. }
  155. function renderMilestonesAndBounties(project, editable = false) {
  156. const milestones = project.milestones || [];
  157. const bounties = project.bounties || [];
  158. const unassigned = bounties.filter(b => b.milestoneIndex == null);
  159. const blocks = milestones.map((m, idx) => {
  160. const { total, count, done } = bountyTotalsForMilestone(project, idx);
  161. const items = bounties.filter(b => b.milestoneIndex === idx);
  162. return div({ class: 'milestone-with-bounties' },
  163. div({ class: 'milestone-stats' },
  164. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectMilestoneStatus + ':'), span({ class: 'card-value' }, m.done ? i18n.projectMilestoneDone.toUpperCase() : i18n.projectMilestoneOpen.toUpperCase())),
  165. div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBounties + ':'), span({ class: 'card-value' }, `${done}/${count} · ${total} ECO`))
  166. ),
  167. div({ class: 'milestone-head' },
  168. span({ class: 'milestone-title' }, m.title),
  169. m.dueDate ? span({ class: 'chip chip-due' }, `${i18n.projectMilestoneDue}: ${moment(m.dueDate).format('YYYY/MM/DD HH:mm')}`) : null,
  170. m.description ? p(...renderUrl(m.description)) : null,
  171. (editable && !m.done) ? form({ method: 'POST', action: `/projects/milestones/complete/${encodeURIComponent(project.id)}/${idx}` },
  172. button({ class: 'btn', type: 'submit' }, i18n.projectMilestoneMarkDone)
  173. ) : null
  174. ),
  175. items.length
  176. ? ul(items.map(b => {
  177. const globalIndex = bounties.indexOf(b);
  178. return li({ class: 'bounty-item' },
  179. field(i18n.projectBountyStatus + ':', b.done ? i18n.projectBountyDone.toUpperCase() : (b.claimedBy ? i18n.projectBountyClaimed.toUpperCase() : i18n.projectBountyOpen.toUpperCase())),
  180. br,
  181. div({ class: 'bounty-main' },
  182. span({ class: 'bounty-title' }, b.title),
  183. span({ class: 'bounty-amount' }, `${b.amount} ECO`)
  184. ),
  185. b.description ? p(...renderUrl(b.description)) : null,
  186. b.claimedBy ? field(i18n.projectBountyClaimedBy + ':', a({ href: `/author/${encodeURIComponent(b.claimedBy)}`, class: 'user-link' }, b.claimedBy)) : null,
  187. (!editable && !b.done && !b.claimedBy && project.author !== userId)
  188. ? form({ method: 'POST', action: `/projects/bounties/claim/${encodeURIComponent(project.id)}/${globalIndex}` },
  189. button({ type: 'submit', class: 'btn' }, i18n.projectBountyClaimButton)
  190. ) : null,
  191. (editable && !b.done)
  192. ? form({ method: 'POST', action: `/projects/bounties/complete/${encodeURIComponent(project.id)}/${globalIndex}` },
  193. button({ type: 'submit', class: 'btn' }, i18n.projectBountyCompleteButton)
  194. ) : null
  195. )
  196. }))
  197. : p(i18n.projectNoBounties)
  198. );
  199. });
  200. const unassignedBlock = unassigned.length
  201. ? div({ class: 'bounty-milestone-block' },
  202. h2(`${i18n.projectBounties} — ${i18n.projectMilestoneOpen} (no milestone)`),
  203. ul(unassigned.map(b => {
  204. const globalIndex = bounties.indexOf(b);
  205. return li({ class: 'bounty-item' },
  206. div({ class: 'bounty-main' },
  207. span({ class: 'bounty-title' }, b.title),
  208. span({ class: 'bounty-amount' }, `${b.amount} ECO`)
  209. ),
  210. b.description ? p(...renderUrl(b.description)) : null,
  211. field(i18n.projectBountyStatus + ':', b.done ? i18n.projectBountyDone : (b.claimedBy ? i18n.projectBountyClaimed : i18n.projectBountyOpen)),
  212. b.claimedBy ? field(i18n.projectBountyClaimedBy + ':', a({ href: `/author/${encodeURIComponent(b.claimedBy)}`, class: 'user-link' }, b.claimedBy)) : null,
  213. (!editable && !b.done && !b.claimedBy && project.author !== userId)
  214. ? form({ method: 'POST', action: `/projects/bounties/claim/${encodeURIComponent(project.id)}/${globalIndex}` },
  215. button({ type: 'submit', class: 'btn' }, i18n.projectBountyClaimButton)
  216. ) : null,
  217. (editable && !b.done)
  218. ? form({ method: 'POST', action: `/projects/bounties/complete/${encodeURIComponent(project.id)}/${globalIndex}` },
  219. button({ type: 'submit', class: 'btn' }, i18n.projectBountyCompleteButton)
  220. ) : null,
  221. editable ? form({ method: 'POST', action: `/projects/bounties/update/${encodeURIComponent(project.id)}/${globalIndex}` },
  222. label(i18n.projectMilestoneSelect), br(),
  223. select({ name: 'milestoneIndex' },
  224. option({ value: '', selected: b.milestoneIndex == null }, '-'),
  225. ...(project.milestones || []).map((m, idx) =>
  226. option({ value: String(idx), selected: b.milestoneIndex === idx }, m.title)
  227. )
  228. ),
  229. button({ class: 'btn', type: 'submit' }, i18n.projectBountyCreateButton)
  230. ) : null
  231. )
  232. }))
  233. )
  234. : null;
  235. return div({ class: 'milestones-bounties' }, ...blocks, unassignedBlock);
  236. }
  237. const renderProjectList = (projects, filter) =>
  238. projects.length > 0 ? projects.map(pr => {
  239. const isMineFilter = String(filter).toUpperCase() === 'MINE';
  240. const isAuthor = pr.author === userId;
  241. const statusUpper = String(pr.status || 'ACTIVE').toUpperCase();
  242. const isActive = statusUpper === 'ACTIVE';
  243. const pct = parseFloat(pr.progress || 0) || 0;
  244. const ratio = pr.goal ? Math.min(100, Math.round((parseFloat(pr.pledged || 0) / parseFloat(pr.goal)) * 100)) : 0;
  245. const mileDone = (pr.milestones || []).filter(m => m.done).length;
  246. const mileTotal = (pr.milestones || []).length;
  247. const statusClass = `status-${statusUpper.toLowerCase()}`;
  248. const remain = budgetSummary(pr).remaining;
  249. const followers = Array.isArray(pr.followers) ? pr.followers.length : 0;
  250. const backers = Array.isArray(pr.backers) ? pr.backers.length : 0;
  251. return div({ class: `project-card ${statusClass}` },
  252. isMineFilter && isAuthor ? div({ class: "project-actions" },
  253. form({ method: "GET", action: `/projects/edit/${encodeURIComponent(pr.id)}` },
  254. button({ class: "update-btn", type: "submit" }, i18n.projectUpdateButton)
  255. ),
  256. form({ method: "POST", action: `/projects/delete/${encodeURIComponent(pr.id)}` },
  257. button({ class: "delete-btn", type: "submit" }, i18n.projectDeleteButton)
  258. ),
  259. form({ method: "POST", action: `/projects/status/${encodeURIComponent(pr.id)}`, style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
  260. select({ name: "status", onChange: "this.form.submit()" },
  261. option({ value: "ACTIVE", selected: statusUpper === 'ACTIVE' }, i18n.projectStatusACTIVE),
  262. option({ value: "PAUSED", selected: statusUpper === 'PAUSED' }, i18n.projectStatusPAUSED),
  263. option({ value: "COMPLETED", selected: statusUpper === 'COMPLETED' }, i18n.projectStatusCOMPLETED),
  264. option({ value: "CANCELLED", selected: statusUpper === 'CANCELLED' }, i18n.projectStatusCANCELLED)
  265. ),
  266. button({ class: "status-btn", type: "submit" }, i18n.projectSetStatus)
  267. ),
  268. form({ method: "POST", action: `/projects/progress/${encodeURIComponent(pr.id)}`, style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
  269. input({ type: "number", name: "progress", min: "0", max: "100", value: pct }),
  270. button({ class: "status-btn", type: "submit" }, i18n.projectSetProgress)
  271. )
  272. ) : null,
  273. div({ class: 'project-actions' },
  274. !isMineFilter && !isAuthor && isActive ? (Array.isArray(pr.followers) && pr.followers.includes(userId) ?
  275. form({ method: "POST", action: `/projects/unfollow/${encodeURIComponent(pr.id)}` },
  276. button({ type: "submit", class: "unsubscribe-btn" }, i18n.projectUnfollowButton)
  277. ) :
  278. form({ method: "POST", action: `/projects/follow/${encodeURIComponent(pr.id)}` },
  279. button({ type: "submit", class: "subscribe-btn" }, i18n.projectFollowButton)
  280. )
  281. ) : null,
  282. form({ method: "GET", action: `/projects/${encodeURIComponent(pr.id)}` },
  283. button({ type: "submit", class: "filter-btn" }, i18n.viewDetailsButton)
  284. ),
  285. ),
  286. br(),
  287. h2(pr.title),
  288. pr.image ? div({ class: 'activity-image-preview' }, img({ src: `/blob/${encodeURIComponent(pr.image)}` })) : null,
  289. field(i18n.projectDescription + ':', ''), p(...renderUrl(pr.description)),
  290. field(i18n.projectStatus + ':', i18n['projectStatus' + statusUpper] || statusUpper),
  291. field(i18n.projectProgress + ':', `${pct}%`),
  292. field(i18n.projectGoal + ':'), br(),
  293. div({ class: 'card-label' }, h2(`${pr.goal} ECO`)), br(),
  294. field(i18n.projectPledged + ':', `${pr.pledged || 0} ECO`),
  295. field(i18n.projectFunding + ':', `${ratio}%`),
  296. field(i18n.projectMilestones + ':', `${mileDone}/${mileTotal}`),
  297. field(i18n.projectFollowers + ':', String(followersCount(pr))),
  298. field(i18n.projectBackers + ':', `${backersCount(pr)} · ${backersTotal(pr)} ECO`), br(),
  299. isMineFilter && isAuthor ? [
  300. renderBudget(pr),
  301. renderMilestonesAndBounties(pr, true),
  302. div({ class: 'new-milestone' },
  303. h2(i18n.projectAddMilestoneTitle),
  304. form({ method: 'POST', action: `/projects/milestones/add/${encodeURIComponent(pr.id)}` },
  305. label(i18n.projectMilestoneTitle), br(),
  306. input({ type: 'text', name: 'title', required: true }), br(), br(),
  307. label(i18n.projectMilestoneDescription), br(),
  308. textarea({ name: 'description', rows: '3' }), br(), br(),
  309. label(i18n.projectMilestoneTargetPercent), br(),
  310. input({ type: 'number', name: 'targetPercent', min: '0', max: '100', step: '1', value: '0' }), br(), br(),
  311. label(i18n.projectMilestoneDueDate), br(),
  312. input({ type: 'datetime-local', name: 'dueDate', min: moment().format("YYYY-MM-DDTHH:mm"), max: pr.deadline ? moment(pr.deadline).format("YYYY-MM-DDTHH:mm") : undefined }), br(), br(),
  313. button({ class: 'btn', type: 'submit' }, i18n.projectMilestoneCreateButton)
  314. )
  315. ),
  316. div({ class: 'new-bounty' },
  317. h2(i18n.projectAddBountyTitle),
  318. form({ method: "POST", action: `/projects/bounties/add/${encodeURIComponent(pr.id)}` },
  319. label(i18n.projectBountyTitle), br(),
  320. input({ type: "text", name: "title", required: true }), br(), br(),
  321. label(i18n.projectBountyAmount), br(),
  322. input({ type: "number", step: "0.01", name: "amount", required: true, max: String(budgetSummary(pr).remaining) }), br(), br(),
  323. label(i18n.projectBountyDescription), br(),
  324. textarea({ name: "description", rows: "3" }), br(), br(),
  325. label(i18n.projectMilestoneSelect), br(),
  326. select({ name: 'milestoneIndex' },
  327. option({ value: '' }, '-'),
  328. ...(pr.milestones || []).map((m, idx) =>
  329. option({ value: String(idx) }, m.title)
  330. )
  331. ), br(), br(),
  332. button({ class: 'btn', type: 'submit', disabled: remain <= 0 }, remain > 0 ? i18n.projectBountyCreateButton : 'No remaining budget')
  333. )
  334. )
  335. ] : null,
  336. div({ class: 'card-footer' },
  337. span({ class: 'date-link' }, `${moment(pr.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
  338. a({ href: `/author/${encodeURIComponent(pr.author)}`, class: 'user-link' }, pr.author)
  339. )
  340. )
  341. }) : p(i18n.projectNoProjectsFound)
  342. const renderProjectForm = (project = {}, mode='create') => {
  343. const isEdit = mode === 'edit'
  344. const nowLocal = moment().format("YYYY-MM-DDTHH:mm")
  345. const deadlineValue = project.deadline ? moment(project.deadline).format("YYYY-MM-DDTHH:mm") : ''
  346. const milestoneMax = deadlineValue || undefined
  347. return div({ class: "div-center project-form" },
  348. form({
  349. action: isEdit ? `/projects/update/${encodeURIComponent(project.id)}` : "/projects/create",
  350. method: "POST",
  351. enctype: "multipart/form-data"
  352. },
  353. label(i18n.projectTitle), br(),
  354. input({ type: "text", name: "title", required: true, placeholder: i18n.projectTitlePlaceholder, value: project.title || "" }), br(), br(),
  355. label(i18n.projectDescription), br(),
  356. textarea({ name: "description", rows: "6", required: true, placeholder: i18n.projectDescriptionPlaceholder }, project.description || ""), br(), br(),
  357. label(i18n.projectImage), br(),
  358. input({ type: "file", name: "image", accept: "image/*" }), br(),
  359. project.image ? img({ src: `/blob/${encodeURIComponent(project.image)}`, class: 'existing-image' }) : null, br(),
  360. label(i18n.projectGoal), br(),
  361. input({ type: "number", step: "0.01", min: "0.01", name: "goal", required: true, placeholder: i18n.projectGoalPlaceholder, value: project.goal || "" }), br(), br(),
  362. label(i18n.projectDeadline), br(),
  363. input({ type: "datetime-local", name: "deadline", id: "deadline", required: true, min: nowLocal, value: deadlineValue }), br(), br(),
  364. h2(i18n.projectAddMilestoneTitle),
  365. label(i18n.projectMilestoneTitle), br(),
  366. input({ type: "text", name: "milestoneTitle", required: true, placeholder: i18n.projectMilestoneTitlePlaceholder }), br(), br(),
  367. label(i18n.projectMilestoneDescription), br(),
  368. textarea({ name: "milestoneDescription", rows: "3", placeholder: i18n.projectMilestoneDescriptionPlaceholder }), br(), br(),
  369. label(i18n.projectMilestoneTargetPercent), br(),
  370. input({ type: "number", name: "milestoneTargetPercent", min: "0", max: "100", step: "1", value: "0" }), br(), br(),
  371. label(i18n.projectMilestoneDueDate), br(),
  372. input({ type: "datetime-local", name: "milestoneDueDate", min: nowLocal, max: milestoneMax }), br(), br(),
  373. button({ type: "submit" }, isEdit ? i18n.projectUpdateButton : i18n.projectCreateButton)
  374. )
  375. )
  376. }
  377. exports.projectsView = async (projectsOrForm, filter="ALL") => {
  378. const filterObj = FILTERS.find(f => f.key === filter) || FILTERS[0]
  379. const sectionTitle = i18n[filterObj.title] || i18n.projectAllTitle
  380. return template(
  381. i18n.projectsTitle,
  382. section(
  383. div({ class: "tags-header" }, h2(sectionTitle), p(i18n.projectsDescription)),
  384. div({ class: "filters" },
  385. form({ method: "GET", action: "/projects", style: "display:flex;gap:12px;" },
  386. FILTERS.map(f =>
  387. button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, i18n[f.i18n])
  388. ).concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.projectCreateProject))
  389. )
  390. ),
  391. filter === 'CREATE' || filter === 'EDIT'
  392. ? (() => {
  393. const prToEdit = filter === 'EDIT' ? projectsOrForm[0] : {}
  394. return renderProjectForm(prToEdit, filter === 'EDIT' ? 'edit' : 'create')
  395. })()
  396. : (filter === 'BACKERS'
  397. ? renderBackersLeaderboard(projectsOrForm)
  398. : div({ class: "projects-list" }, renderProjectList(projectsOrForm, filter))
  399. )
  400. )
  401. )
  402. }
  403. exports.singleProjectView = async (project, filter="ALL") => {
  404. const isAuthor = project.author === userId
  405. const statusUpper = String(project.status || 'ACTIVE').toUpperCase()
  406. const isActive = statusUpper === 'ACTIVE'
  407. const statusClass = `status-${statusUpper.toLowerCase()}`
  408. const ratio = project.goal ? Math.min(100, Math.round((parseFloat(project.pledged || 0) / parseFloat(project.goal)) * 100)) : 0
  409. const remain = budgetSummary(project).remaining
  410. return template(
  411. i18n.projectsTitle,
  412. section(
  413. div({ class: "tags-header" }, h2(i18n.projectsTitle), p(i18n.projectsDescription)),
  414. div({ class: "filters" },
  415. form({ method: "GET", action: "/projects", style: "display:flex;gap:12px;" },
  416. FILTERS.map(f =>
  417. button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, i18n[f.i18n])
  418. ).concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.projectCreateProject))
  419. )
  420. ),
  421. div({ class: `project-card ${statusClass}` },
  422. isAuthor ? div({ class: "project-actions" },
  423. form({ method: "GET", action: `/projects/edit/${encodeURIComponent(project.id)}` },
  424. button({ class: "update-btn", type: "submit" }, i18n.projectUpdateButton)
  425. ),
  426. form({ method: "POST", action: `/projects/delete/${encodeURIComponent(project.id)}` },
  427. button({ class: "delete-btn", type: "submit" }, i18n.projectDeleteButton)
  428. ),
  429. form({ method: "POST", action: `/projects/status/${encodeURIComponent(project.id)}`, style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
  430. select({ name: "status" },
  431. option({ value: "ACTIVE", selected: statusUpper === 'ACTIVE' }, i18n.projectStatusACTIVE),
  432. option({ value: "PAUSED", selected: statusUpper === 'PAUSED' }, i18n.projectStatusPAUSED),
  433. option({ value: "COMPLETED", selected: statusUpper === 'COMPLETED' }, i18n.projectStatusCOMPLETED),
  434. option({ value: "CANCELLED", selected: statusUpper === 'CANCELLED' }, i18n.projectStatusCANCELLED)
  435. ),
  436. button({ class: "status-btn", type: "submit" }, i18n.projectSetStatus)
  437. ),
  438. form({ method: "POST", action: `/projects/progress/${encodeURIComponent(project.id)}`, style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
  439. input({ type: "number", name: "progress", min: "0", max: "100", value: project.progress || 0 }),
  440. button({ class: "status-btn", type: "submit" }, i18n.projectSetProgress)
  441. )
  442. ) : null,
  443. (!isAuthor && Array.isArray(project.followers) && project.followers.includes(userId))
  444. ? div({ class: 'hint' }, p({ class: 'hint' }, i18n.projectYouFollowHint))
  445. : null,
  446. h2(project.title),
  447. project.image ? div({ class: 'activity-image-preview' }, img({ src: `/blob/${encodeURIComponent(project.image)}` })) : null,
  448. field(i18n.projectDescription + ':', ''), p(...renderUrl(project.description)),
  449. field(i18n.projectStatus + ':', i18n['projectStatus' + statusUpper] || statusUpper),
  450. field(i18n.projectGoal + ':'), br(),
  451. div({ class: 'card-label' }, h2(`${project.goal} ECO`)), br(),
  452. field(i18n.projectPledged + ':', `${project.pledged || 0} ECO`),
  453. field(i18n.projectFunding + ':', `${ratio}%`),
  454. field(i18n.projectProgress + ':', `${project.progress || 0}%`), br(),
  455. div({ class: 'social-stats' },
  456. field(i18n.projectFollowers + ':', String(followersCount(project))),
  457. field(i18n.projectBackers + ':', `${backersCount(project)} · ${backersTotal(project)} ECO`)
  458. ),
  459. renderBudget(project),
  460. renderMilestonesAndBounties(project, isAuthor),
  461. renderFollowers(project, isAuthor),
  462. (!isAuthor && isActive) ? (Array.isArray(project.followers) && project.followers.includes(userId) ?
  463. form({ method: "POST", action: `/projects/unfollow/${encodeURIComponent(project.id)}` },
  464. button({ class: "filter-btn", type: "submit" }, i18n.projectUnfollowButton)
  465. ) :
  466. form({ method: "POST", action: `/projects/follow/${encodeURIComponent(project.id)}` },
  467. button({ class: "filter-btn", type: "submit" }, i18n.projectFollowButton)
  468. )
  469. ) : null,
  470. br(),
  471. renderBackers(project),
  472. renderPledgeBox(project, isAuthor),
  473. isAuthor ? div({ class: 'new-milestone' },
  474. h2(i18n.projectAddMilestoneTitle),
  475. form({ method: 'POST', action: `/projects/milestones/add/${encodeURIComponent(project.id)}` },
  476. label(i18n.projectMilestoneTitle), br(),
  477. input({ type: 'text', name: 'title', required: true }), br(), br(),
  478. label(i18n.projectMilestoneDescription), br(),
  479. textarea({ name: 'description', rows: '3' }), br(), br(),
  480. label(i18n.projectMilestoneTargetPercent), br(),
  481. input({ type: 'number', name: 'targetPercent', min: '0', max: '100', step: '1', value: '0' }), br(), br(),
  482. label(i18n.projectMilestoneDueDate), br(),
  483. input({ type: 'datetime-local', name: 'dueDate', min: moment().format("YYYY-MM-DDTHH:mm"), max: project.deadline ? moment(project.deadline).format("YYYY-MM-DDTHH:mm") : undefined }), br(), br(),
  484. button({ class: 'btn', type: 'submit' }, i18n.projectMilestoneCreateButton)
  485. )
  486. ) : null,
  487. isAuthor ? div({ class: 'new-bounty' },
  488. h2(i18n.projectAddBountyTitle),
  489. form({ method: "POST", action: `/projects/bounties/add/${encodeURIComponent(project.id)}` },
  490. label(i18n.projectBountyTitle), br(),
  491. input({ type: "text", name: "title", required: true }), br(), br(),
  492. label(i18n.projectBountyAmount), br(),
  493. input({ type: "number", step: "0.01", name: "amount", required: true, max: String(budgetSummary(project).remaining) }), br(), br(),
  494. label(i18n.projectBountyDescription), br(),
  495. textarea({ name: "description", rows: "3" }), br(), br(),
  496. label(i18n.projectMilestoneSelect), br(),
  497. select({ name: 'milestoneIndex' },
  498. option({ value: '' }, '-'),
  499. ...(project.milestones || []).map((m, idx) =>
  500. option({ value: String(idx) }, m.title)
  501. )
  502. ), br(), br(),
  503. button({ class: 'btn submit-bounty', type: 'submit' }, remain > 0 ? i18n.projectBountyCreateButton : i18n.projectNoRemainingBudget)
  504. )
  505. ) : null,
  506. div({ class: 'card-footer' },
  507. span({ class: 'date-link' }, `${moment(project.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
  508. a({ href: `/author/${encodeURIComponent(project.author)}`, class: 'user-link' }, project.author)
  509. )
  510. )
  511. )
  512. )
  513. }