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) ) ) ) ) }