123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542 |
- 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")
- const { template, i18n } = require('./main_views')
- const moment = require("../server/node_modules/moment")
- const { config } = require('../server/SSB_server.js')
- const { renderUrl } = require('../backend/renderUrl')
- const userId = config.keys.id
- const FILTERS = [
- { key: 'ALL', i18n: 'projectFilterAll', title: 'projectAllTitle' },
- { key: 'MINE', i18n: 'projectFilterMine', title: 'projectMineTitle' },
- { key: 'ACTIVE', i18n: 'projectFilterActive', title: 'projectActiveTitle' },
- { key: 'PAUSED', i18n: 'projectFilterPaused', title: 'projectPausedTitle' },
- { key: 'COMPLETED', i18n: 'projectFilterCompleted', title: 'projectCompletedTitle' },
- { key: 'FOLLOWING', i18n: 'projectFilterFollowing', title: 'projectFollowingTitle' },
- { key: 'RECENT', i18n: 'projectFilterRecent', title: 'projectRecentTitle' },
- { key: 'TOP', i18n: 'projectFilterTop', title: 'projectTopTitle' },
- { key: 'BACKERS', i18n: 'projectFilterBackers', title: 'projectBackersLeaderboardTitle' }
- ]
- const field = (labelText, value) =>
- div({ class: 'card-field' },
- span({ class: 'card-label' }, labelText),
- span({ class: 'card-value' }, value)
- )
- function sumAmounts(list = []) {
- return list.reduce((s, x) => s + (parseFloat(x.amount || 0) || 0), 0)
- }
- function budgetSummary(project) {
- const goal = parseFloat(project.goal || 0) || 0
- const assigned = sumAmounts(project.bounties || [])
- const remaining = Math.max(0, goal - assigned)
- const exceeded = assigned > goal
- return { goal, assigned, remaining, exceeded }
- }
- const followersCount = (p) => Array.isArray(p.followers) ? p.followers.length : 0
- const backersTotal = (p) => sumAmounts(p.backers || [])
- const backersCount = (p) => Array.isArray(p.backers) ? p.backers.length : 0
- function aggregateTopBackers(projects = []) {
- const map = new Map()
- for (const pr of projects) {
- const backers = Array.isArray(pr.backers) ? pr.backers : []
- for (const b of backers) {
- const uid = b.userId
- const amt = Math.max(0, parseFloat(b.amount || 0) || 0)
- if (!map.has(uid)) map.set(uid, { userId: uid, total: 0, pledges: 0, projects: new Set() })
- const rec = map.get(uid)
- rec.total += amt
- rec.pledges += 1
- rec.projects.add(pr.id)
- }
- }
- return Array.from(map.values())
- .map(r => ({ ...r, projects: r.projects.size }))
- .sort((a, b) => b.total - a.total)
- }
- function renderBackersLeaderboard(projects) {
- const rows = aggregateTopBackers(projects)
- if (!rows.length) return div({ class: 'backers-leaderboard empty' }, p(i18n.projectNoBackersFound))
- return div({ class: 'backers-leaderboard' },
- h2(i18n.projectBackersLeaderboardTitle),
- ...rows.slice(0, 50).map(r =>
- div({ class: 'backer-row' },
- div({ class: 'card-field' },
- span({ class: 'card-label' }, ''),
- span({ class: 'card-value' },
- a({ href: `/author/${encodeURIComponent(r.userId)}`, class: 'user-link user-pill' }, r.userId)
- )
- ),
- div({ class: 'card-field' },
- span({ class: 'card-label' }, i18n.projectBackerAmount + ':'),
- span({ class: 'card-value' }, span({ class: 'chip chip-amt' }, `${r.total} ECO`))
- ),
- div({ class: 'card-field' },
- span({ class: 'card-label' }, i18n.projectBackerPledges + ':'),
- span({ class: 'card-value' }, span({ class: 'chip chip-pledges' }, String(r.pledges)))
- ),
- div({ class: 'card-field' },
- span({ class: 'card-label' }, i18n.projectBackerProjects + ':'),
- span({ class: 'card-value' }, span({ class: 'chip chip-projects' }, String(r.projects)))
- )
- )
- )
- )
- }
- function renderBackers(project) {
- const backers = Array.isArray(project.backers) ? project.backers : [];
- const total = sumAmounts(backers);
- const mine = sumAmounts(backers.filter(b => b.userId === userId));
- return div({ class: 'backers-block' },
- h2(i18n.projectBackersTitle),
- div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBackersTotal + ':'), span({ class: 'card-value' }, String(backers.length))),
- div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBackersTotalPledged + ':'), span({ class: 'card-value' }, `${total} ECO`)),
- mine > 0 ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBackersYourPledge + ':'), span({ class: 'card-value chip chip-you' }, `${mine} ECO`)) : null,
- backers.length
- ? table({ class: 'backers-table' },
- thead(
- tr(
- th(i18n.projectBackerDate || 'Date'),
- th(i18n.projectBackerAuthor || 'Author'),
- th(i18n.projectBackerAmount)
- )
- ),
- tbody(
- ...backers.slice(0, 8).map(b =>
- tr(
- td(b.at ? moment(b.at).format('YYYY/MM/DD HH:mm') : ''),
- td(a({ href: `/author/${encodeURIComponent(b.userId)}`, class: 'user-link' }, b.userId)),
- td(`${b.amount} ECO`)
- )
- )
- )
- )
- : p(i18n.projectBackersNone)
- );
- }
- function renderPledgeBox(project, isAuthor) {
- const isActive = String(project.status || 'ACTIVE').toUpperCase() === 'ACTIVE';
- if (!isActive || isAuthor) return null;
- return div({ class: 'pledge-box' },
- h2(i18n.projectPledgeTitle),
- form({ method: "POST", action: `/projects/pledge/${encodeURIComponent(project.id)}` },
- input({ type: "number", name: "amount", min: "0.01", step: "0.01", required: true, placeholder: i18n.projectPledgePlaceholder }),
- select({ name: "milestoneOrBounty" },
- option({ value: "" }, i18n.projectSelectMilestoneOrBounty),
- ...(project.milestones || []).map((m, idx) => option({ value: `milestone:${idx}` }, m.title)),
- ...(project.bounties || []).map((b, idx) => option({ value: `bounty:${idx}` }, b.title))
- ),
- button({ class: "btn", type: "submit" }, i18n.projectPledgeButton)
- )
- );
- }
- function bountyTotalsForMilestone(project, mIndex) {
- const list = (project.bounties || []).filter(b => (b.milestoneIndex ?? null) === mIndex)
- const total = sumAmounts(list)
- const done = list.filter(b => b.done).length
- return { total, count: list.length, done }
- }
- function renderBudget(project) {
- const S = budgetSummary(project)
- return div({ class: `budget-summary${S.exceeded ? ' over' : ''}` },
- div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBudgetGoal + ':'), span({ class: 'card-value' }, `${S.goal} ECO`)),
- div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBudgetAssigned + ':'), span({ class: 'card-value' }, `${S.assigned} ECO`)),
- div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBudgetRemaining + ':'), span({ class: 'card-value' }, `${S.remaining} ECO`)),
- S.exceeded ? p({ class: 'warning' }, i18n.projectBudgetOver) : null
- )
- }
- function renderFollowers(project) {
- const followers = Array.isArray(project.followers) ? project.followers : []
- if (!followers.length) return div({ class: 'followers-block' }, h2(i18n.projectFollowersTitle), p(i18n.projectFollowersNone))
- const show = followers.slice(0, 12)
- return div({ class: 'followers-block' },
- h2(i18n.projectFollowersTitle),
- ul(show.map(uid => li(a({ href: `/author/${encodeURIComponent(uid)}`, class: 'user-link' }, uid)))),
- followers.length > show.length ? p(`+${followers.length - show.length} ${i18n.projectMore}`) : null
- )
- }
- function renderMilestonesAndBounties(project, editable = false) {
- const milestones = project.milestones || [];
- const bounties = project.bounties || [];
- const unassigned = bounties.filter(b => b.milestoneIndex == null);
- const blocks = milestones.map((m, idx) => {
- const { total, count, done } = bountyTotalsForMilestone(project, idx);
- const items = bounties.filter(b => b.milestoneIndex === idx);
- return div({ class: 'milestone-with-bounties' },
- div({ class: 'milestone-stats' },
- div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectMilestoneStatus + ':'), span({ class: 'card-value' }, m.done ? i18n.projectMilestoneDone.toUpperCase() : i18n.projectMilestoneOpen.toUpperCase())),
- div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBounties + ':'), span({ class: 'card-value' }, `${done}/${count} · ${total} ECO`))
- ),
- div({ class: 'milestone-head' },
- span({ class: 'milestone-title' }, m.title),
- m.dueDate ? span({ class: 'chip chip-due' }, `${i18n.projectMilestoneDue}: ${moment(m.dueDate).format('YYYY/MM/DD HH:mm')}`) : null,
- m.description ? p(...renderUrl(m.description)) : null,
- (editable && !m.done) ? form({ method: 'POST', action: `/projects/milestones/complete/${encodeURIComponent(project.id)}/${idx}` },
- button({ class: 'btn', type: 'submit' }, i18n.projectMilestoneMarkDone)
- ) : null
- ),
- items.length
- ? ul(items.map(b => {
- const globalIndex = bounties.indexOf(b);
- return li({ class: 'bounty-item' },
- field(i18n.projectBountyStatus + ':', b.done ? i18n.projectBountyDone.toUpperCase() : (b.claimedBy ? i18n.projectBountyClaimed.toUpperCase() : i18n.projectBountyOpen.toUpperCase())),
- br,
- div({ class: 'bounty-main' },
- span({ class: 'bounty-title' }, b.title),
- span({ class: 'bounty-amount' }, `${b.amount} ECO`)
- ),
- b.description ? p(...renderUrl(b.description)) : null,
- b.claimedBy ? field(i18n.projectBountyClaimedBy + ':', a({ href: `/author/${encodeURIComponent(b.claimedBy)}`, class: 'user-link' }, b.claimedBy)) : null,
- (!editable && !b.done && !b.claimedBy && project.author !== userId)
- ? form({ method: 'POST', action: `/projects/bounties/claim/${encodeURIComponent(project.id)}/${globalIndex}` },
- button({ type: 'submit', class: 'btn' }, i18n.projectBountyClaimButton)
- ) : null,
- (editable && !b.done)
- ? form({ method: 'POST', action: `/projects/bounties/complete/${encodeURIComponent(project.id)}/${globalIndex}` },
- button({ type: 'submit', class: 'btn' }, i18n.projectBountyCompleteButton)
- ) : null
- )
- }))
- : p(i18n.projectNoBounties)
- );
- });
- const unassignedBlock = unassigned.length
- ? div({ class: 'bounty-milestone-block' },
- h2(`${i18n.projectBounties} — ${i18n.projectMilestoneOpen} (no milestone)`),
- ul(unassigned.map(b => {
- const globalIndex = bounties.indexOf(b);
- return li({ class: 'bounty-item' },
- div({ class: 'bounty-main' },
- span({ class: 'bounty-title' }, b.title),
- span({ class: 'bounty-amount' }, `${b.amount} ECO`)
- ),
- b.description ? p(...renderUrl(b.description)) : null,
- field(i18n.projectBountyStatus + ':', b.done ? i18n.projectBountyDone : (b.claimedBy ? i18n.projectBountyClaimed : i18n.projectBountyOpen)),
- b.claimedBy ? field(i18n.projectBountyClaimedBy + ':', a({ href: `/author/${encodeURIComponent(b.claimedBy)}`, class: 'user-link' }, b.claimedBy)) : null,
- (!editable && !b.done && !b.claimedBy && project.author !== userId)
- ? form({ method: 'POST', action: `/projects/bounties/claim/${encodeURIComponent(project.id)}/${globalIndex}` },
- button({ type: 'submit', class: 'btn' }, i18n.projectBountyClaimButton)
- ) : null,
- (editable && !b.done)
- ? form({ method: 'POST', action: `/projects/bounties/complete/${encodeURIComponent(project.id)}/${globalIndex}` },
- button({ type: 'submit', class: 'btn' }, i18n.projectBountyCompleteButton)
- ) : null,
- editable ? form({ method: 'POST', action: `/projects/bounties/update/${encodeURIComponent(project.id)}/${globalIndex}` },
- label(i18n.projectMilestoneSelect), br(),
- select({ name: 'milestoneIndex' },
- option({ value: '', selected: b.milestoneIndex == null }, '-'),
- ...(project.milestones || []).map((m, idx) =>
- option({ value: String(idx), selected: b.milestoneIndex === idx }, m.title)
- )
- ),
- button({ class: 'btn', type: 'submit' }, i18n.projectBountyCreateButton)
- ) : null
- )
- }))
- )
- : null;
- return div({ class: 'milestones-bounties' }, ...blocks, unassignedBlock);
- }
- const renderProjectList = (projects, filter) =>
- projects.length > 0 ? projects.map(pr => {
- const isMineFilter = String(filter).toUpperCase() === 'MINE';
- const isAuthor = pr.author === userId;
- const statusUpper = String(pr.status || 'ACTIVE').toUpperCase();
- const isActive = statusUpper === 'ACTIVE';
- const pct = parseFloat(pr.progress || 0) || 0;
- const ratio = pr.goal ? Math.min(100, Math.round((parseFloat(pr.pledged || 0) / parseFloat(pr.goal)) * 100)) : 0;
- const mileDone = (pr.milestones || []).filter(m => m.done).length;
- const mileTotal = (pr.milestones || []).length;
- const statusClass = `status-${statusUpper.toLowerCase()}`;
- const remain = budgetSummary(pr).remaining;
- const followers = Array.isArray(pr.followers) ? pr.followers.length : 0;
- const backers = Array.isArray(pr.backers) ? pr.backers.length : 0;
- return div({ class: `project-card ${statusClass}` },
- isMineFilter && isAuthor ? div({ class: "project-actions" },
- form({ method: "GET", action: `/projects/edit/${encodeURIComponent(pr.id)}` },
- button({ class: "update-btn", type: "submit" }, i18n.projectUpdateButton)
- ),
- form({ method: "POST", action: `/projects/delete/${encodeURIComponent(pr.id)}` },
- button({ class: "delete-btn", type: "submit" }, i18n.projectDeleteButton)
- ),
- form({ method: "POST", action: `/projects/status/${encodeURIComponent(pr.id)}`, style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
- select({ name: "status", onChange: "this.form.submit()" },
- option({ value: "ACTIVE", selected: statusUpper === 'ACTIVE' }, i18n.projectStatusACTIVE),
- option({ value: "PAUSED", selected: statusUpper === 'PAUSED' }, i18n.projectStatusPAUSED),
- option({ value: "COMPLETED", selected: statusUpper === 'COMPLETED' }, i18n.projectStatusCOMPLETED),
- option({ value: "CANCELLED", selected: statusUpper === 'CANCELLED' }, i18n.projectStatusCANCELLED)
- ),
- button({ class: "status-btn", type: "submit" }, i18n.projectSetStatus)
- ),
- form({ method: "POST", action: `/projects/progress/${encodeURIComponent(pr.id)}`, style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
- input({ type: "number", name: "progress", min: "0", max: "100", value: pct }),
- button({ class: "status-btn", type: "submit" }, i18n.projectSetProgress)
- )
- ) : null,
- div({ class: 'project-actions' },
- !isMineFilter && !isAuthor && isActive ? (Array.isArray(pr.followers) && pr.followers.includes(userId) ?
- form({ method: "POST", action: `/projects/unfollow/${encodeURIComponent(pr.id)}` },
- button({ type: "submit", class: "unsubscribe-btn" }, i18n.projectUnfollowButton)
- ) :
- form({ method: "POST", action: `/projects/follow/${encodeURIComponent(pr.id)}` },
- button({ type: "submit", class: "subscribe-btn" }, i18n.projectFollowButton)
- )
- ) : null,
- form({ method: "GET", action: `/projects/${encodeURIComponent(pr.id)}` },
- button({ type: "submit", class: "filter-btn" }, i18n.viewDetailsButton)
- ),
- ),
- br(),
- h2(pr.title),
- pr.image ? div({ class: 'activity-image-preview' }, img({ src: `/blob/${encodeURIComponent(pr.image)}` })) : null,
- field(i18n.projectDescription + ':', ''), p(...renderUrl(pr.description)),
- field(i18n.projectStatus + ':', i18n['projectStatus' + statusUpper] || statusUpper),
- field(i18n.projectProgress + ':', `${pct}%`),
- field(i18n.projectGoal + ':'), br(),
- div({ class: 'card-label' }, h2(`${pr.goal} ECO`)), br(),
- field(i18n.projectPledged + ':', `${pr.pledged || 0} ECO`),
- field(i18n.projectFunding + ':', `${ratio}%`),
- field(i18n.projectMilestones + ':', `${mileDone}/${mileTotal}`),
- field(i18n.projectFollowers + ':', String(followersCount(pr))),
- field(i18n.projectBackers + ':', `${backersCount(pr)} · ${backersTotal(pr)} ECO`), br(),
- isMineFilter && isAuthor ? [
- renderBudget(pr),
- renderMilestonesAndBounties(pr, true),
- div({ class: 'new-milestone' },
- h2(i18n.projectAddMilestoneTitle),
- form({ method: 'POST', action: `/projects/milestones/add/${encodeURIComponent(pr.id)}` },
- label(i18n.projectMilestoneTitle), br(),
- input({ type: 'text', name: 'title', required: true }), br(), br(),
- label(i18n.projectMilestoneDescription), br(),
- textarea({ name: 'description', rows: '3' }), br(), br(),
- label(i18n.projectMilestoneTargetPercent), br(),
- input({ type: 'number', name: 'targetPercent', min: '0', max: '100', step: '1', value: '0' }), br(), br(),
- label(i18n.projectMilestoneDueDate), br(),
- 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(),
- button({ class: 'btn', type: 'submit' }, i18n.projectMilestoneCreateButton)
- )
- ),
- div({ class: 'new-bounty' },
- h2(i18n.projectAddBountyTitle),
- form({ method: "POST", action: `/projects/bounties/add/${encodeURIComponent(pr.id)}` },
- label(i18n.projectBountyTitle), br(),
- input({ type: "text", name: "title", required: true }), br(), br(),
- label(i18n.projectBountyAmount), br(),
- input({ type: "number", step: "0.01", name: "amount", required: true, max: String(budgetSummary(pr).remaining) }), br(), br(),
- label(i18n.projectBountyDescription), br(),
- textarea({ name: "description", rows: "3" }), br(), br(),
- label(i18n.projectMilestoneSelect), br(),
- select({ name: 'milestoneIndex' },
- option({ value: '' }, '-'),
- ...(pr.milestones || []).map((m, idx) =>
- option({ value: String(idx) }, m.title)
- )
- ), br(), br(),
- button({ class: 'btn', type: 'submit', disabled: remain <= 0 }, remain > 0 ? i18n.projectBountyCreateButton : 'No remaining budget')
- )
- )
- ] : null,
- div({ class: 'card-footer' },
- span({ class: 'date-link' }, `${moment(pr.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
- a({ href: `/author/${encodeURIComponent(pr.author)}`, class: 'user-link' }, pr.author)
- )
- )
- }) : p(i18n.projectNoProjectsFound)
- const renderProjectForm = (project = {}, mode='create') => {
- const isEdit = mode === 'edit'
- const nowLocal = moment().format("YYYY-MM-DDTHH:mm")
- const deadlineValue = project.deadline ? moment(project.deadline).format("YYYY-MM-DDTHH:mm") : ''
- const milestoneMax = deadlineValue || undefined
- return div({ class: "div-center project-form" },
- form({
- action: isEdit ? `/projects/update/${encodeURIComponent(project.id)}` : "/projects/create",
- method: "POST",
- enctype: "multipart/form-data"
- },
- label(i18n.projectTitle), br(),
- input({ type: "text", name: "title", required: true, placeholder: i18n.projectTitlePlaceholder, value: project.title || "" }), br(), br(),
- label(i18n.projectDescription), br(),
- textarea({ name: "description", rows: "6", required: true, placeholder: i18n.projectDescriptionPlaceholder }, project.description || ""), br(), br(),
- label(i18n.projectImage), br(),
- input({ type: "file", name: "image", accept: "image/*" }), br(),
- project.image ? img({ src: `/blob/${encodeURIComponent(project.image)}`, class: 'existing-image' }) : null, br(),
- label(i18n.projectGoal), br(),
- input({ type: "number", step: "0.01", min: "0.01", name: "goal", required: true, placeholder: i18n.projectGoalPlaceholder, value: project.goal || "" }), br(), br(),
- label(i18n.projectDeadline), br(),
- input({ type: "datetime-local", name: "deadline", id: "deadline", required: true, min: nowLocal, value: deadlineValue }), br(), br(),
- h2(i18n.projectAddMilestoneTitle),
- label(i18n.projectMilestoneTitle), br(),
- input({ type: "text", name: "milestoneTitle", required: true, placeholder: i18n.projectMilestoneTitlePlaceholder }), br(), br(),
- label(i18n.projectMilestoneDescription), br(),
- textarea({ name: "milestoneDescription", rows: "3", placeholder: i18n.projectMilestoneDescriptionPlaceholder }), br(), br(),
- label(i18n.projectMilestoneTargetPercent), br(),
- input({ type: "number", name: "milestoneTargetPercent", min: "0", max: "100", step: "1", value: "0" }), br(), br(),
- label(i18n.projectMilestoneDueDate), br(),
- input({ type: "datetime-local", name: "milestoneDueDate", min: nowLocal, max: milestoneMax }), br(), br(),
- button({ type: "submit" }, isEdit ? i18n.projectUpdateButton : i18n.projectCreateButton)
- )
- )
- }
- exports.projectsView = async (projectsOrForm, filter="ALL") => {
- const filterObj = FILTERS.find(f => f.key === filter) || FILTERS[0]
- const sectionTitle = i18n[filterObj.title] || i18n.projectAllTitle
- return template(
- i18n.projectsTitle,
- section(
- div({ class: "tags-header" }, h2(sectionTitle), p(i18n.projectsDescription)),
- div({ class: "filters" },
- form({ method: "GET", action: "/projects", style: "display:flex;gap:12px;" },
- FILTERS.map(f =>
- button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, i18n[f.i18n])
- ).concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.projectCreateProject))
- )
- ),
- filter === 'CREATE' || filter === 'EDIT'
- ? (() => {
- const prToEdit = filter === 'EDIT' ? projectsOrForm[0] : {}
- return renderProjectForm(prToEdit, filter === 'EDIT' ? 'edit' : 'create')
- })()
- : (filter === 'BACKERS'
- ? renderBackersLeaderboard(projectsOrForm)
- : div({ class: "projects-list" }, renderProjectList(projectsOrForm, filter))
- )
- )
- )
- }
- exports.singleProjectView = async (project, filter="ALL") => {
- const isAuthor = project.author === userId
- const statusUpper = String(project.status || 'ACTIVE').toUpperCase()
- const isActive = statusUpper === 'ACTIVE'
- const statusClass = `status-${statusUpper.toLowerCase()}`
- const ratio = project.goal ? Math.min(100, Math.round((parseFloat(project.pledged || 0) / parseFloat(project.goal)) * 100)) : 0
- const remain = budgetSummary(project).remaining
- return template(
- i18n.projectsTitle,
- section(
- div({ class: "tags-header" }, h2(i18n.projectsTitle), p(i18n.projectsDescription)),
- div({ class: "filters" },
- form({ method: "GET", action: "/projects", style: "display:flex;gap:12px;" },
- FILTERS.map(f =>
- button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, i18n[f.i18n])
- ).concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.projectCreateProject))
- )
- ),
- div({ class: `project-card ${statusClass}` },
- isAuthor ? div({ class: "project-actions" },
- form({ method: "GET", action: `/projects/edit/${encodeURIComponent(project.id)}` },
- button({ class: "update-btn", type: "submit" }, i18n.projectUpdateButton)
- ),
- form({ method: "POST", action: `/projects/delete/${encodeURIComponent(project.id)}` },
- button({ class: "delete-btn", type: "submit" }, i18n.projectDeleteButton)
- ),
- form({ method: "POST", action: `/projects/status/${encodeURIComponent(project.id)}`, style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
- select({ name: "status" },
- option({ value: "ACTIVE", selected: statusUpper === 'ACTIVE' }, i18n.projectStatusACTIVE),
- option({ value: "PAUSED", selected: statusUpper === 'PAUSED' }, i18n.projectStatusPAUSED),
- option({ value: "COMPLETED", selected: statusUpper === 'COMPLETED' }, i18n.projectStatusCOMPLETED),
- option({ value: "CANCELLED", selected: statusUpper === 'CANCELLED' }, i18n.projectStatusCANCELLED)
- ),
- button({ class: "status-btn", type: "submit" }, i18n.projectSetStatus)
- ),
- form({ method: "POST", action: `/projects/progress/${encodeURIComponent(project.id)}`, style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
- input({ type: "number", name: "progress", min: "0", max: "100", value: project.progress || 0 }),
- button({ class: "status-btn", type: "submit" }, i18n.projectSetProgress)
- )
- ) : null,
- (!isAuthor && Array.isArray(project.followers) && project.followers.includes(userId))
- ? div({ class: 'hint' }, p({ class: 'hint' }, i18n.projectYouFollowHint))
- : null,
- h2(project.title),
- project.image ? div({ class: 'activity-image-preview' }, img({ src: `/blob/${encodeURIComponent(project.image)}` })) : null,
- field(i18n.projectDescription + ':', ''), p(...renderUrl(project.description)),
- field(i18n.projectStatus + ':', i18n['projectStatus' + statusUpper] || statusUpper),
- field(i18n.projectGoal + ':'), br(),
- div({ class: 'card-label' }, h2(`${project.goal} ECO`)), br(),
- field(i18n.projectPledged + ':', `${project.pledged || 0} ECO`),
- field(i18n.projectFunding + ':', `${ratio}%`),
- field(i18n.projectProgress + ':', `${project.progress || 0}%`), br(),
- div({ class: 'social-stats' },
- field(i18n.projectFollowers + ':', String(followersCount(project))),
- field(i18n.projectBackers + ':', `${backersCount(project)} · ${backersTotal(project)} ECO`)
- ),
- renderBudget(project),
- renderMilestonesAndBounties(project, isAuthor),
- renderFollowers(project, isAuthor),
- (!isAuthor && isActive) ? (Array.isArray(project.followers) && project.followers.includes(userId) ?
- form({ method: "POST", action: `/projects/unfollow/${encodeURIComponent(project.id)}` },
- button({ class: "filter-btn", type: "submit" }, i18n.projectUnfollowButton)
- ) :
- form({ method: "POST", action: `/projects/follow/${encodeURIComponent(project.id)}` },
- button({ class: "filter-btn", type: "submit" }, i18n.projectFollowButton)
- )
- ) : null,
- br(),
- renderBackers(project),
- renderPledgeBox(project, isAuthor),
- isAuthor ? div({ class: 'new-milestone' },
- h2(i18n.projectAddMilestoneTitle),
- form({ method: 'POST', action: `/projects/milestones/add/${encodeURIComponent(project.id)}` },
- label(i18n.projectMilestoneTitle), br(),
- input({ type: 'text', name: 'title', required: true }), br(), br(),
- label(i18n.projectMilestoneDescription), br(),
- textarea({ name: 'description', rows: '3' }), br(), br(),
- label(i18n.projectMilestoneTargetPercent), br(),
- input({ type: 'number', name: 'targetPercent', min: '0', max: '100', step: '1', value: '0' }), br(), br(),
- label(i18n.projectMilestoneDueDate), br(),
- 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(),
- button({ class: 'btn', type: 'submit' }, i18n.projectMilestoneCreateButton)
- )
- ) : null,
- isAuthor ? div({ class: 'new-bounty' },
- h2(i18n.projectAddBountyTitle),
- form({ method: "POST", action: `/projects/bounties/add/${encodeURIComponent(project.id)}` },
- label(i18n.projectBountyTitle), br(),
- input({ type: "text", name: "title", required: true }), br(), br(),
- label(i18n.projectBountyAmount), br(),
- input({ type: "number", step: "0.01", name: "amount", required: true, max: String(budgetSummary(project).remaining) }), br(), br(),
- label(i18n.projectBountyDescription), br(),
- textarea({ name: "description", rows: "3" }), br(), br(),
- label(i18n.projectMilestoneSelect), br(),
- select({ name: 'milestoneIndex' },
- option({ value: '' }, '-'),
- ...(project.milestones || []).map((m, idx) =>
- option({ value: String(idx) }, m.title)
- )
- ), br(), br(),
- button({ class: 'btn submit-bounty', type: 'submit' }, remain > 0 ? i18n.projectBountyCreateButton : i18n.projectNoRemainingBudget)
- )
- ) : null,
- div({ class: 'card-footer' },
- span({ class: 'date-link' }, `${moment(project.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
- a({ href: `/author/${encodeURIComponent(project.author)}`, class: 'user-link' }, project.author)
- )
- )
- )
- )
- }
|