Oasis 0.8.7 mobile (nodejs-project baseline)
This commit is contained in:
commit
e563b86ede
226 changed files with 141968 additions and 0 deletions
147
src/views/AI_view.js
Normal file
147
src/views/AI_view.js
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
const { div, h2, p, section, button, form, textarea, br, span, input, label, select, option } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n } = require('./main_views');
|
||||
const { renderUrl } = require('../backend/renderUrl');
|
||||
|
||||
exports.aiView = (history = [], userPrompt = '') => {
|
||||
return template(
|
||||
i18n.aiTitle,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.aiTitle),
|
||||
p(i18n.aiDescription),
|
||||
userPrompt ? div({ class: 'user-prompt', style: 'margin-bottom: 2em; font-size: 0.95em; color: #888;' },
|
||||
`${i18n.aiPromptUsed || 'System Prompt'}: `,
|
||||
span({ style: 'font-style: italic;' }, `"${userPrompt}"`)
|
||||
) : null,
|
||||
form({ method: 'POST', action: '/ai', style: "margin-bottom: 0;" },
|
||||
textarea({ name: 'input', rows: 4, placeholder: i18n.aiInputPlaceholder, required: true }),
|
||||
br(),
|
||||
div({ style: "display: flex; gap: 1.5em; justify-content: flex-end; align-items: center; margin-top: 0.7em;" },
|
||||
button({ type: 'submit' }, i18n.aiSubmitButton)
|
||||
)
|
||||
),
|
||||
div({ style: "display: flex; justify-content: flex-end; margin-bottom: 2em;" },
|
||||
form({ method: 'POST', action: '/ai/clear', style: "display: inline;" },
|
||||
button({
|
||||
type: 'submit',
|
||||
style: `
|
||||
background: #b80c09;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.4em 1.2em;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
margin-left: 1em;
|
||||
`
|
||||
}, i18n.aiClearHistory)
|
||||
)
|
||||
),
|
||||
br(),
|
||||
...history.map(entry =>
|
||||
div({
|
||||
class: 'chat-entry',
|
||||
style: `
|
||||
margin-bottom: 2em;
|
||||
position: relative;
|
||||
background: #191919;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 8px #0004;
|
||||
padding-top: 1.8em;
|
||||
`
|
||||
},
|
||||
entry.timestamp ? span({
|
||||
style: `
|
||||
position: absolute;
|
||||
top: 0.5em;
|
||||
right: 1.3em;
|
||||
font-size: 0.92em;
|
||||
color: #888;
|
||||
`
|
||||
}, new Date(entry.timestamp).toLocaleString()) : null,
|
||||
br(), br(),
|
||||
div({ class: 'user-question', style: 'margin-bottom: 0.75em;' },
|
||||
h2(`${i18n.aiUserQuestion}:`),
|
||||
p(...renderUrl(entry.question))
|
||||
),
|
||||
div({
|
||||
class: 'ai-response',
|
||||
style: `
|
||||
max-width: 800px;
|
||||
margin: auto;
|
||||
background: #111;
|
||||
padding: 1.25em;
|
||||
border-radius: 6px;
|
||||
font-family: sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #ffcc00;
|
||||
`
|
||||
},
|
||||
h2(`${i18n.aiResponseTitle}:`),
|
||||
...String(entry.answer || '')
|
||||
.split('\n\n')
|
||||
.flatMap(paragraph =>
|
||||
paragraph
|
||||
.split('\n')
|
||||
.map(line =>
|
||||
p({ style: "margin-bottom: 1.2em;" }, ...renderUrl(line.trim()))
|
||||
)
|
||||
)
|
||||
),
|
||||
div({
|
||||
class: 'ai-train-bar',
|
||||
style: `
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:12px;
|
||||
margin: 12px auto 8px auto;
|
||||
max-width: 800px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid #2a2a2a;
|
||||
flex-wrap: wrap;
|
||||
`
|
||||
},
|
||||
Array.isArray(entry.snippets) && entry.snippets.length
|
||||
? span({ style: 'color:#9aa; font-size:0.95em;' }, `${i18n.aiSnippetsUsed}: ${entry.snippets.length}`)
|
||||
: null,
|
||||
h2(`${i18n.statsAITraining}:`),
|
||||
entry.trainStatus === 'approved'
|
||||
? span({ style: 'color:#5ad25a; font-weight:600;' }, i18n.aiTrainApproved)
|
||||
: entry.trainStatus === 'rejected'
|
||||
? span({ style: 'color:#ff6b6b; font-weight:600;' }, i18n.aiTrainRejected)
|
||||
: null,
|
||||
entry.trainStatus === 'approved' || entry.trainStatus === 'rejected'
|
||||
? null
|
||||
: div({ class: 'ai-approve-block' },
|
||||
form({ method: 'POST', action: '/ai/approve', class: 'ai-approve-form' },
|
||||
input({ type: 'hidden', name: 'ts', value: String(entry.timestamp) }),
|
||||
div({ class: 'ai-approve-meta' },
|
||||
label({ class: 'ai-approve-meta-label' }, i18n.aiApproveTagsLabel || 'Tags (comma-separated)'),
|
||||
input({ type: 'text', name: 'tags', placeholder: i18n.aiApproveTagsPlaceholder || 'e.g. oasis, governance, ecology', maxlength: '160' }),
|
||||
label({ class: 'ai-approve-meta-label' }, i18n.aiApproveRatingLabel || 'Rating'),
|
||||
select({ name: 'rating' },
|
||||
option({ value: '0' }, '—'),
|
||||
option({ value: '1' }, '★'),
|
||||
option({ value: '2' }, '★★'),
|
||||
option({ value: '3' }, '★★★'),
|
||||
option({ value: '4' }, '★★★★'),
|
||||
option({ value: '5' }, '★★★★★')
|
||||
)
|
||||
),
|
||||
textarea({ name: 'custom', rows: 3, placeholder: i18n.aiCustomAnswerPlaceholder, class: 'ai-approve-custom' }),
|
||||
div({ class: 'ai-approve-actions' },
|
||||
button({ type: 'submit', class: 'approve-btn' }, i18n.aiApproveTrain)
|
||||
)
|
||||
),
|
||||
form({ method: 'POST', action: '/ai/reject', class: 'ai-approve-reject' },
|
||||
input({ type: 'hidden', name: 'ts', value: String(entry.timestamp) }),
|
||||
button({ type: 'submit', class: 'reject-btn' }, i18n.aiRejectTrain)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
1881
src/views/activity_view.js
Normal file
1881
src/views/activity_view.js
Normal file
File diff suppressed because it is too large
Load diff
260
src/views/agenda_view.js
Normal file
260
src/views/agenda_view.js
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
const { div, h2, p, section, button, form, img, textarea, a, br, h1, span } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, userLink, renderContentActions } = require('./main_views');
|
||||
const moment = require('../server/node_modules/moment');
|
||||
const { config } = require('../server/SSB_server.js');
|
||||
|
||||
const userId = config.keys.id;
|
||||
|
||||
const renderCardField = (labelText, value) =>
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, labelText),
|
||||
span(
|
||||
{ class: 'card-value' },
|
||||
...(Array.isArray(value) ? value : [value ?? ''])
|
||||
)
|
||||
);
|
||||
|
||||
function getViewDetailsAction(item) {
|
||||
switch (item.type) {
|
||||
case 'transfer': return `/transfers/${encodeURIComponent(item.id)}`;
|
||||
case 'tribe': return `/tribe/${encodeURIComponent(item.id)}`;
|
||||
case 'event': return `/events/${encodeURIComponent(item.id)}`;
|
||||
case 'task': return `/tasks/${encodeURIComponent(item.id)}`;
|
||||
case 'market': return `/market/${encodeURIComponent(item.id)}`;
|
||||
case 'report': return `/reports/${encodeURIComponent(item.id)}`;
|
||||
case 'job': return `/jobs/${encodeURIComponent(item.id)}`;
|
||||
case 'project': return `/projects/${encodeURIComponent(item.id)}`;
|
||||
case 'calendar': return `/calendars/${encodeURIComponent(item.id)}`;
|
||||
default: return `/messages/${encodeURIComponent(item.id)}`;
|
||||
}
|
||||
}
|
||||
|
||||
const renderAgendaItem = (item, userId, filter) => {
|
||||
const fmt = d => moment(d).format('YYYY/MM/DD HH:mm:ss');
|
||||
const author = item.seller || item.organizer || item.from || item.author || '';
|
||||
|
||||
const commonFields = [
|
||||
p({ class: 'card-footer' },
|
||||
span({ class: 'date-link' }, `${item.createdAt ? moment(item.createdAt).format('YYYY/MM/DD HH:mm:ss') : ''} ${i18n.performed} `),
|
||||
author ? userLink(author) : ''
|
||||
)
|
||||
];
|
||||
|
||||
let details = [];
|
||||
let actionButton = null;
|
||||
|
||||
if (filter === 'discarded') {
|
||||
actionButton = form({ method: 'POST', action: `/agenda/restore/${encodeURIComponent(item.id)}` },
|
||||
button({ type: 'submit', class: 'restore-btn' }, i18n.agendaRestoreButton)
|
||||
);
|
||||
} else {
|
||||
actionButton = form({ method: 'POST', action: `/agenda/discard/${encodeURIComponent(item.id)}` },
|
||||
button({ type: 'submit', class: 'discard-btn' }, i18n.agendaDiscardButton)
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'market') {
|
||||
details = [
|
||||
renderCardField(i18n.marketItemType + ":", String(item.item_type || '').toUpperCase()),
|
||||
renderCardField(i18n.marketItemStatus + ":", item.status),
|
||||
renderCardField(i18n.marketItemStock + ":", item.stock),
|
||||
renderCardField(i18n.marketItemPrice + ":", `${item.price} ECO`),
|
||||
renderCardField(i18n.marketItemIncludesShipping + ":", item.includesShipping ? i18n.agendaYes : i18n.agendaNo),
|
||||
renderCardField(i18n.deadline + ":", item.deadline ? new Date(item.deadline).toLocaleString() : '')
|
||||
];
|
||||
if (String(item.item_type || '').toLowerCase() === 'auction') {
|
||||
const bids = Array.isArray(item.auctions_poll) ? item.auctions_poll.map(bid => parseFloat(String(bid).split(':')[1])).filter(n => !isNaN(n)) : [];
|
||||
const maxBid = bids.length ? Math.max(...bids) : 0;
|
||||
details.push(renderCardField(i18n.marketItemHighestBid + ":", `${maxBid} ECO`));
|
||||
}
|
||||
const seller = author ? p(userLink(author)) : '';
|
||||
details.push(br(), div({ class: 'members-list' }, i18n.marketItemSeller + ': ', seller));
|
||||
}
|
||||
|
||||
if (item.type === 'tribe') {
|
||||
details = [
|
||||
renderCardField(i18n.agendaAnonymousLabel + ":", item.isAnonymous ? i18n.agendaYes : i18n.agendaNo),
|
||||
renderCardField(i18n.agendaInviteModeLabel + ":", (item.inviteMode ? String(item.inviteMode).toUpperCase() : i18n.noInviteMode)),
|
||||
renderCardField(i18n.agendaLocationLabel + ":", item.location || i18n.noLocation),
|
||||
renderCardField(i18n.agendaMembersCount + ":", Array.isArray(item.members) ? item.members.length : 0),
|
||||
br()
|
||||
];
|
||||
const membersList = Array.isArray(item.members) ? item.members.map(member => p(userLink(member))) : [];
|
||||
details.push(div({ class: 'members-list' }, `${i18n.agendaMembersLabel}:`, membersList));
|
||||
}
|
||||
|
||||
if (item.type === 'report') {
|
||||
details = [
|
||||
renderCardField(i18n.agendareportStatus + ":", item.status || i18n.noStatus),
|
||||
renderCardField(i18n.agendareportCategory + ":", item.category || i18n.noCategory),
|
||||
renderCardField(i18n.agendareportSeverity + ":", (item.severity ? String(item.severity).toUpperCase() : i18n.noSeverity))
|
||||
];
|
||||
}
|
||||
|
||||
if (item.type === 'event') {
|
||||
details = [
|
||||
renderCardField(i18n.eventDateLabel + ":", item.date ? fmt(item.date) : ''),
|
||||
renderCardField(i18n.eventLocationLabel + ":", item.location || ''),
|
||||
renderCardField(i18n.eventPriceLabel + ":", `${item.price} ECO`),
|
||||
renderCardField(
|
||||
i18n.eventUrlLabel + ":",
|
||||
item.url ? p(a({ href: item.url, target: "_blank" }, item.url)) : p(i18n.noUrl)
|
||||
)
|
||||
];
|
||||
actionButton = actionButton || form({ method: 'POST', action: `/events/attend/${encodeURIComponent(item.id)}` },
|
||||
button({ type: 'submit', class: 'assign-btn' }, `${i18n.eventAttendButton}`)
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'task') {
|
||||
details = [
|
||||
renderCardField(i18n.taskStatus + ":", item.status),
|
||||
renderCardField(i18n.taskPriorityLabel + ":", item.priority),
|
||||
renderCardField(i18n.taskStartTimeLabel + ":", item.startTime ? new Date(item.startTime).toLocaleString() : ''),
|
||||
renderCardField(i18n.taskEndTimeLabel + ":", item.endTime ? new Date(item.endTime).toLocaleString() : ''),
|
||||
renderCardField(i18n.taskLocationLabel + ":", item.location || '')
|
||||
];
|
||||
const assigned = Array.isArray(item.assignees) && item.assignees.includes(userId);
|
||||
actionButton = actionButton || form({ method: 'POST', action: `/tasks/assign/${encodeURIComponent(item.id)}` },
|
||||
button({ type: 'submit', class: 'assign-btn' }, assigned ? i18n.taskUnassignButton : i18n.taskAssignButton)
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'transfer') {
|
||||
details = [
|
||||
renderCardField(i18n.agendaTransferConcept + ":", item.concept),
|
||||
renderCardField(i18n.agendaTransferAmount + ":", item.amount),
|
||||
renderCardField(i18n.agendaTransferDeadline + ":", item.deadline ? fmt(item.deadline) : ''),
|
||||
br()
|
||||
];
|
||||
const membersList = item.to ? p(userLink(item.to)) : '';
|
||||
details.push(div({ class: 'members-list' }, i18n.to + ': ', membersList));
|
||||
}
|
||||
|
||||
if (item.type === 'project') {
|
||||
details = [
|
||||
renderCardField(i18n.projectStatus + ":", item.status || i18n.noStatus),
|
||||
renderCardField(i18n.projectProgress + ":", `${item.progress || 0}%`),
|
||||
renderCardField(i18n.projectGoal + ":", `${item.goal} ECO`),
|
||||
renderCardField(i18n.projectPledged + ":", `${item.pledged || 0} ECO`),
|
||||
renderCardField(i18n.projectDeadline + ":", item.deadline ? new Date(item.deadline).toLocaleString() : i18n.noDeadline)
|
||||
];
|
||||
}
|
||||
|
||||
if (item.type === 'calendar') {
|
||||
details = [
|
||||
renderCardField((i18n.calendarStatusLabel || 'Status') + ':', item.isClosed ? (i18n.calendarStatusClosed || 'CLOSED') : (i18n.calendarStatusOpen || 'OPEN')),
|
||||
renderCardField((i18n.calendarDeadlineLabel || 'Deadline') + ':', item.deadline ? moment(item.deadline).format('YYYY/MM/DD HH:mm') : ''),
|
||||
renderCardField((i18n.calendarParticipantsLabel || 'Participants') + ':', Array.isArray(item.participants) ? item.participants.length : 0)
|
||||
];
|
||||
}
|
||||
|
||||
if (item.type === 'job') {
|
||||
const subs = Array.isArray(item.subscribers)
|
||||
? item.subscribers
|
||||
: (typeof item.subscribers === 'string'
|
||||
? item.subscribers.split(',').map(s => s.trim()).filter(Boolean)
|
||||
: (item.subscribers && typeof item.subscribers.length === 'number'
|
||||
? Array.from(item.subscribers)
|
||||
: []));
|
||||
|
||||
const subsInterleaved = subs
|
||||
.map((id, i) => [i > 0 ? ', ' : '', userLink(id)])
|
||||
.flat();
|
||||
|
||||
details = [
|
||||
renderCardField(i18n.jobStatus + ":", item.status),
|
||||
renderCardField(i18n.jobLocation + ":", (item.location || '').toUpperCase()),
|
||||
renderCardField(i18n.jobType + ":", (item.job_type || '').toUpperCase()),
|
||||
renderCardField(i18n.jobSalary + ":", `${item.salary} ECO`),
|
||||
renderCardField(i18n.jobVacants + ":", item.vacants),
|
||||
renderCardField(i18n.jobLanguages + ":", (item.languages || '').toUpperCase()),
|
||||
br(),
|
||||
div(
|
||||
{ class: 'members-list' },
|
||||
i18n.jobSubscribers + ': ',br(),br(),
|
||||
...(subs.length ? subsInterleaved : [i18n.noSubscribers.toUpperCase()])
|
||||
),
|
||||
];
|
||||
|
||||
const subscribed = subs.includes(userId);
|
||||
if (!subscribed && String(item.status).toUpperCase() !== 'CLOSED' && item.author !== userId) {
|
||||
actionButton = form({ method: 'POST', action: `/jobs/subscribe/${encodeURIComponent(item.id)}` },
|
||||
button({ type: 'submit', class: 'subscribe-btn' }, i18n.jobSubscribeButton)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isOwn = author && String(author) === String(userId);
|
||||
return div({ class: 'trending-card agenda-card' + (isOwn ? ' own-content' : '') },
|
||||
div({ class: 'card-header activity-card-header' },
|
||||
span({ class: 'pm-exposition-chip pm-exposition-whole' },
|
||||
span({ class: 'pm-exposition-text' }, String(item.type || '').toUpperCase())
|
||||
),
|
||||
renderContentActions(item.id, getViewDetailsAction(item))
|
||||
),
|
||||
div({ class: 'card-section agenda-card-body' },
|
||||
actionButton,
|
||||
br(),
|
||||
h2(item.title || item.name || item.concept || ''),
|
||||
...details,
|
||||
...commonFields
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.agendaView = async (data, filter) => {
|
||||
const { items = [], counts: _c = {} } = data || {};
|
||||
const counts = { all: 0, open: 0, closed: 0, events: 0, tasks: 0, reports: 0, tribes: 0, jobs: 0, market: 0, projects: 0, transfers: 0, calendars: 0, discarded: 0, ..._c };
|
||||
return template(
|
||||
i18n.agendaTitle,
|
||||
section(
|
||||
div({ class: 'tags-header' },
|
||||
h2(i18n.agendaTitle),
|
||||
p(i18n.agendaDescription)
|
||||
),
|
||||
div({ class: 'filters' },
|
||||
form({ method: 'GET', action: '/agenda' },
|
||||
button({ type: 'submit', name: 'filter', value: 'all', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' },
|
||||
`${i18n.agendaFilterAll} (${counts.all})`),
|
||||
button({ type: 'submit', name: 'filter', value: 'today', class: filter === 'today' ? 'filter-btn active' : 'filter-btn' },
|
||||
`${i18n.agendaFilterToday || 'TODAY'} (${counts.today || 0})`),
|
||||
button({ type: 'submit', name: 'filter', value: 'upcoming', class: filter === 'upcoming' ? 'filter-btn active' : 'filter-btn' },
|
||||
`${i18n.agendaFilterUpcoming || 'UPCOMING'} (${counts.upcoming || 0})`),
|
||||
button({ type: 'submit', name: 'filter', value: 'overdue', class: filter === 'overdue' ? 'filter-btn active' : 'filter-btn' },
|
||||
`${i18n.agendaFilterOverdue || 'OVERDUE'} (${counts.overdue || 0})`),
|
||||
button({ type: 'submit', name: 'filter', value: 'open', class: filter === 'open' ? 'filter-btn active' : 'filter-btn' },
|
||||
`${i18n.agendaFilterOpen} (${counts.open})`),
|
||||
button({ type: 'submit', name: 'filter', value: 'closed', class: filter === 'closed' ? 'filter-btn active' : 'filter-btn' },
|
||||
`${i18n.agendaFilterClosed} (${counts.closed})`),
|
||||
button({ type: 'submit', name: 'filter', value: 'events', class: filter === 'events' ? 'filter-btn active' : 'filter-btn' },
|
||||
`${i18n.agendaFilterEvents} (${counts.events})`),
|
||||
button({ type: 'submit', name: 'filter', value: 'tasks', class: filter === 'tasks' ? 'filter-btn active' : 'filter-btn' },
|
||||
`${i18n.agendaFilterTasks} (${counts.tasks})`),
|
||||
button({ type: 'submit', name: 'filter', value: 'reports', class: filter === 'reports' ? 'filter-btn active' : 'filter-btn' },
|
||||
`${i18n.agendaFilterReports} (${counts.reports})`),
|
||||
button({ type: 'submit', name: 'filter', value: 'tribes', class: filter === 'tribes' ? 'filter-btn active' : 'filter-btn' },
|
||||
`${i18n.agendaFilterTribes} (${counts.tribes})`),
|
||||
button({ type: 'submit', name: 'filter', value: 'jobs', class: filter === 'jobs' ? 'filter-btn active' : 'filter-btn' },
|
||||
`${i18n.agendaFilterJobs} (${counts.jobs})`),
|
||||
button({ type: 'submit', name: 'filter', value: 'market', class: filter === 'market' ? 'filter-btn active' : 'filter-btn' },
|
||||
`${i18n.agendaFilterMarket} (${counts.market})`),
|
||||
button({ type: 'submit', name: 'filter', value: 'projects', class: filter === 'projects' ? 'filter-btn active' : 'filter-btn' },
|
||||
`${i18n.agendaFilterProjects} (${counts.projects})`),
|
||||
button({ type: 'submit', name: 'filter', value: 'calendars', class: filter === 'calendars' ? 'filter-btn active' : 'filter-btn' },
|
||||
`${i18n.agendaFilterCalendars || 'CALENDARS'} (${counts.calendars})`),
|
||||
button({ type: 'submit', name: 'filter', value: 'transfers', class: filter === 'transfers' ? 'filter-btn active' : 'filter-btn' },
|
||||
`${i18n.agendaFilterTransfers} (${counts.transfers})`),
|
||||
button({ type: 'submit', name: 'filter', value: 'discarded', class: filter === 'discarded' ? 'filter-btn active' : 'filter-btn' },
|
||||
`DISCARDED (${counts.discarded})`)
|
||||
)
|
||||
),
|
||||
div({ class: 'agenda-list' },
|
||||
items.length
|
||||
? items.map(item => renderAgendaItem(item, userId, filter))
|
||||
: p(i18n.agendaNoItems)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
566
src/views/audio_view.js
Normal file
566
src/views/audio_view.js
Normal file
|
|
@ -0,0 +1,566 @@
|
|||
const {
|
||||
form,
|
||||
button,
|
||||
div,
|
||||
h2,
|
||||
p,
|
||||
section,
|
||||
input,
|
||||
br,
|
||||
a,
|
||||
audio: audioHyperaxe,
|
||||
span,
|
||||
textarea,
|
||||
select,
|
||||
label,
|
||||
option
|
||||
} = require("../server/node_modules/hyperaxe");
|
||||
|
||||
const { template, i18n, renderOpinionsVoting, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions } = require("./main_views");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const { renderUrl } = require("../backend/renderUrl")
|
||||
const { renderMapLocationVisitLabel } = require("./maps_view");
|
||||
|
||||
const userId = config.keys.id;
|
||||
|
||||
const safeArr = (v) => (Array.isArray(v) ? v : []);
|
||||
const safeText = (v) => String(v || "").trim();
|
||||
|
||||
const buildReturnTo = (filter, params = {}) => {
|
||||
const f = safeText(filter || "all");
|
||||
const q = safeText(params.q || "");
|
||||
const sort = safeText(params.sort || "recent");
|
||||
const parts = [`filter=${encodeURIComponent(f)}`];
|
||||
if (q) parts.push(`q=${encodeURIComponent(q)}`);
|
||||
if (sort) parts.push(`sort=${encodeURIComponent(sort)}`);
|
||||
return `/audios?${parts.join("&")}`;
|
||||
};
|
||||
|
||||
const renderTags = (tags) => {
|
||||
const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
|
||||
return list.length
|
||||
? div(
|
||||
{ class: "card-tags" },
|
||||
list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
|
||||
)
|
||||
: null;
|
||||
};
|
||||
|
||||
const renderAudioFavoriteToggle = (audioObj, returnTo = "") =>
|
||||
form(
|
||||
{
|
||||
method: "POST",
|
||||
action: audioObj.isFavorite
|
||||
? `/audios/favorites/remove/${encodeURIComponent(audioObj.key)}`
|
||||
: `/audios/favorites/add/${encodeURIComponent(audioObj.key)}`
|
||||
},
|
||||
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
||||
button(
|
||||
{ type: "submit", class: "filter-btn" },
|
||||
audioObj.isFavorite ? i18n.audioRemoveFavoriteButton : i18n.audioAddFavoriteButton
|
||||
)
|
||||
);
|
||||
|
||||
const renderTranscodeButton = (audioObj) =>
|
||||
audioObj.isBcs
|
||||
? form(
|
||||
{ method: "GET", action: `/melody/transcode/${encodeURIComponent(audioObj.key)}`, class: "audio-transcode-form" },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.audioTranscodeButton || "TRANSCODE")
|
||||
)
|
||||
: null;
|
||||
|
||||
const renderAudioPlayer = (audioObj, opts = {}) =>
|
||||
audioObj?.url
|
||||
? div(
|
||||
{ class: "audio-container" },
|
||||
audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(audioObj.url)}`, preload: "metadata" }),
|
||||
opts.skipTranscode ? null : renderTranscodeButton(audioObj)
|
||||
)
|
||||
: p(i18n.audioNoFile);
|
||||
|
||||
const renderAudioOwnerActions = (filter, audioObj, params = {}) => {
|
||||
const returnTo = buildReturnTo(filter, params);
|
||||
const isAuthor = String(audioObj.author) === String(userId);
|
||||
const hasOpinions = Object.keys(audioObj.opinions || {}).length > 0;
|
||||
|
||||
if (!isAuthor) return [];
|
||||
|
||||
const items = [];
|
||||
if (!hasOpinions) {
|
||||
items.push(
|
||||
form(
|
||||
{ method: "GET", action: `/audios/edit/${encodeURIComponent(audioObj.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "update-btn", type: "submit" }, i18n.audioUpdateButton)
|
||||
)
|
||||
);
|
||||
}
|
||||
items.push(
|
||||
form(
|
||||
{ method: "POST", action: `/audios/delete/${encodeURIComponent(audioObj.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "delete-btn", type: "submit" }, i18n.audioDeleteButton)
|
||||
)
|
||||
);
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const renderAudioCommentsSection = (audioId, comments = [], returnTo = null) => {
|
||||
const list = safeArr(comments).filter(c => {
|
||||
const t = c && c.value && c.value.content && c.value.content.text;
|
||||
return t && String(t).trim();
|
||||
});
|
||||
const commentsCount = list.length;
|
||||
|
||||
return div(
|
||||
{ class: "vote-comments-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
|
||||
span({ class: "card-value" }, String(commentsCount))
|
||||
),
|
||||
div(
|
||||
{ class: "comment-form-wrapper" },
|
||||
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
|
||||
form(
|
||||
{ method: "POST", action: `/audios/${encodeURIComponent(audioId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
|
||||
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
||||
textarea({
|
||||
id: "comment-text",
|
||||
name: "text",
|
||||
rows: 4,
|
||||
class: "comment-textarea",
|
||||
placeholder: i18n.voteNewCommentPlaceholder
|
||||
}),
|
||||
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
|
||||
br(),
|
||||
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
|
||||
)
|
||||
),
|
||||
list.length
|
||||
? div(
|
||||
{ class: "comments-list" },
|
||||
list.map((c) => {
|
||||
const author = c?.value?.author || "";
|
||||
const ts = c?.value?.timestamp || c?.timestamp;
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
|
||||
const relDate = ts ? moment(ts).fromNow() : "";
|
||||
const content = c?.value?.content || {};
|
||||
const rootId = content.fork || content.root || null;
|
||||
const text = content.text || "";
|
||||
|
||||
return div(
|
||||
{ class: "votations-comment-card" },
|
||||
span(
|
||||
{ class: "created-at" },
|
||||
span(i18n.createdBy),
|
||||
author ? userLink(author) : span("(unknown)"),
|
||||
absDate ? span(" | ") : "",
|
||||
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
|
||||
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
|
||||
relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : ""
|
||||
),
|
||||
p({ class: "votations-comment-text" }, ...renderUrl(text))
|
||||
);
|
||||
})
|
||||
)
|
||||
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
|
||||
);
|
||||
};
|
||||
|
||||
const renderAudioList = exports.renderAudioList = (audios, filter, params = {}) => {
|
||||
const returnTo = buildReturnTo(filter, params);
|
||||
|
||||
return audios.length
|
||||
? audios.map((audioObj) => {
|
||||
const commentCount = typeof audioObj.commentCount === "number" ? audioObj.commentCount : 0;
|
||||
const title = safeText(audioObj.title);
|
||||
const ownerActions = renderAudioOwnerActions(filter, audioObj, params);
|
||||
|
||||
const isOwn = audioObj.author && String(audioObj.author) === String(userId);
|
||||
return div(
|
||||
{ class: "trending-card audio-card" + (isOwn ? " own-content" : "") },
|
||||
div(
|
||||
{ class: "card-header activity-card-header" },
|
||||
span(),
|
||||
renderContentActions(audioObj.key, `/audios/${encodeURIComponent(audioObj.key)}`)
|
||||
),
|
||||
div(
|
||||
{ class: "card-section audio-card-body" },
|
||||
div(
|
||||
{ class: "bookmark-topbar" },
|
||||
div(
|
||||
{ class: "bookmark-topbar-left" },
|
||||
renderAudioFavoriteToggle(audioObj, returnTo),
|
||||
audioObj.author && String(audioObj.author) !== String(userId)
|
||||
? form(
|
||||
{ method: "GET", action: "/pm" },
|
||||
input({ type: "hidden", name: "recipients", value: audioObj.author }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.audioMessageAuthorButton)
|
||||
)
|
||||
: null
|
||||
),
|
||||
ownerActions.length
|
||||
? div({ class: "bookmark-topbar-right" }, div({ class: "bookmark-actions" }, ...ownerActions))
|
||||
: null
|
||||
),
|
||||
title ? h2(title) : null,
|
||||
audioObj.lifetime ? div({ class: "card-chips-row" }, renderLifespanChip(audioObj.lifetime, i18n)) : null,
|
||||
renderAudioPlayer(audioObj),
|
||||
div(
|
||||
{ class: "card-comments-summary" },
|
||||
span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
|
||||
span({ class: "card-value" }, String(commentCount)),
|
||||
br(),
|
||||
br(),
|
||||
form(
|
||||
{ method: "GET", action: `/audios/${encodeURIComponent(audioObj.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
input({ type: "hidden", name: "filter", value: filter || "all" }),
|
||||
params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
|
||||
params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
|
||||
)
|
||||
),
|
||||
div(
|
||||
{ class: "card-comments-summary feed-opinions-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "),
|
||||
span({ class: "card-value" }, String(Object.values(audioObj.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)))
|
||||
),
|
||||
renderOpinionsVoting('/audios/opinions', audioObj.key, audioObj.opinions, returnTo, audioObj.opinions_inhabitants)
|
||||
),
|
||||
div({ class: "card-spread-left" }, renderSpreadButton(audioObj.key, (params.spreadMap && params.spreadMap.get(audioObj.key)) || params.spreads)),
|
||||
renderMapLocationVisitLabel(audioObj.mapUrl),
|
||||
br(),
|
||||
(() => {
|
||||
const createdTs = audioObj.createdAt ? new Date(audioObj.createdAt).getTime() : NaN;
|
||||
const updatedTs = audioObj.updatedAt ? new Date(audioObj.updatedAt).getTime() : NaN;
|
||||
const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
|
||||
|
||||
return p(
|
||||
{ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
|
||||
userLink(audioObj.author),
|
||||
showUpdated
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
` | ${i18n.audioUpdatedAt}: ${moment(audioObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
|
||||
)
|
||||
: null
|
||||
);
|
||||
})()
|
||||
)
|
||||
);
|
||||
})
|
||||
: p(params.q ? i18n.audioNoMatch : i18n.noAudios);
|
||||
};
|
||||
|
||||
const renderAudioForm = (filter, audioId, audioToEdit, params = {}) => {
|
||||
const returnTo = safeText(params.returnTo) || buildReturnTo("all", params);
|
||||
return div(
|
||||
{ class: "div-center audio-form" },
|
||||
form(
|
||||
{
|
||||
action: filter === "edit" ? `/audios/update/${encodeURIComponent(audioId)}` : "/audios/create",
|
||||
method: "POST",
|
||||
enctype: "multipart/form-data"
|
||||
},
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
span(i18n.audioFileLabel),
|
||||
br(),
|
||||
input({ type: "file", name: "audio", required: filter !== "edit" }),
|
||||
br(),
|
||||
br(),
|
||||
span(i18n.audioTitleLabel),
|
||||
br(),
|
||||
input({ type: "text", name: "title", placeholder: i18n.audioTitlePlaceholder, value: audioToEdit?.title || "" }),
|
||||
br(),
|
||||
span(i18n.audioDescriptionLabel),
|
||||
br(),
|
||||
textarea({ name: "description", placeholder: i18n.audioDescriptionPlaceholder, rows: "4" }, audioToEdit?.description || ""),
|
||||
br(),
|
||||
span(i18n.mapLocationTitle || "Map Location"),
|
||||
br(),
|
||||
input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: audioToEdit?.mapUrl || "" }),
|
||||
br(),
|
||||
span(i18n.audioTagsLabel),
|
||||
br(),
|
||||
input({
|
||||
type: "text",
|
||||
name: "tags",
|
||||
placeholder: i18n.audioTagsPlaceholder,
|
||||
value: safeArr(audioToEdit?.tags).join(", ")
|
||||
}),
|
||||
br(),
|
||||
br(),
|
||||
button({ type: "submit" }, filter === "edit" ? i18n.audioUpdateButton : i18n.audioCreateButton)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.audioView = async (audios, filter = "all", audioId = null, params = {}) => {
|
||||
const title =
|
||||
filter === "mine"
|
||||
? i18n.audioMineSectionTitle
|
||||
: filter === "create"
|
||||
? i18n.audioCreateSectionTitle
|
||||
: filter === "edit"
|
||||
? i18n.audioUpdateSectionTitle
|
||||
: filter === "recent"
|
||||
? i18n.audioRecentSectionTitle
|
||||
: filter === "top"
|
||||
? i18n.audioTopSectionTitle
|
||||
: filter === "favorites"
|
||||
? i18n.audioFavoritesSectionTitle
|
||||
: i18n.audioAllSectionTitle;
|
||||
|
||||
const q = safeText(params.q || "");
|
||||
const sort = safeText(params.sort || "recent");
|
||||
|
||||
const list = safeArr(audios);
|
||||
const audioToEdit = audioId ? list.find((a) => a.key === audioId) : null;
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(title),
|
||||
p(i18n.audioDescription)
|
||||
),
|
||||
(() => {
|
||||
const { renderReachChip } = require('./clearnet_view');
|
||||
const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetAudios);
|
||||
return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n));
|
||||
})(),
|
||||
br(),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/audios", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterRecent),
|
||||
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterTop),
|
||||
button({ type: "submit", name: "filter", value: "bcs", class: filter === "bcs" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterBcs || "BCS"),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
|
||||
i18n.audioFilterFavorites
|
||||
),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.audioCreateButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
filter === "create" || filter === "edit"
|
||||
? renderAudioForm(filter, audioId, audioToEdit, { ...params, filter })
|
||||
: section(
|
||||
div(
|
||||
{ class: "audios-search" },
|
||||
form(
|
||||
{ method: "GET", action: "/audios", class: "filter-box" },
|
||||
input({ type: "hidden", name: "filter", value: filter }),
|
||||
input({
|
||||
type: "text",
|
||||
name: "q",
|
||||
value: q,
|
||||
placeholder: i18n.audioSearchPlaceholder,
|
||||
class: "filter-box__input"
|
||||
}),
|
||||
div(
|
||||
{ class: "filter-box__controls" },
|
||||
select(
|
||||
{ name: "sort", class: "filter-box__select" },
|
||||
option({ value: "recent", selected: sort === "recent" }, i18n.audioSortRecent),
|
||||
option({ value: "oldest", selected: sort === "oldest" }, i18n.audioSortOldest),
|
||||
option({ value: "top", selected: sort === "top" }, i18n.audioSortTop)
|
||||
),
|
||||
button({ type: "submit", class: "filter-box__button" }, i18n.audioSearchButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
div({ class: "audios-list" }, renderAudioList(list, filter, { q, sort }))
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.singleAudioView = async (audioObj, filter = "all", comments = [], params = {}) => {
|
||||
const q = safeText(params.q || "");
|
||||
const sort = safeText(params.sort || "recent");
|
||||
const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort });
|
||||
|
||||
const title = safeText(audioObj.title);
|
||||
const isAuthor = String(audioObj.author) === String(userId);
|
||||
const { renderReachChip } = require('./clearnet_view');
|
||||
const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetAudios);
|
||||
|
||||
const chips = [
|
||||
renderLifespanChip(audioObj.lifetime, i18n),
|
||||
audioObj.sizeBytes ? renderEcoTax(audioObj.sizeBytes, audioObj.key) : null
|
||||
].filter(Boolean);
|
||||
|
||||
const ownerActions = renderAudioOwnerActions(filter, audioObj, { q, sort });
|
||||
const sideActions = [];
|
||||
sideActions.push(renderAudioFavoriteToggle(audioObj, returnTo));
|
||||
if (audioObj.author && String(audioObj.author) !== String(userId)) {
|
||||
sideActions.push(form(
|
||||
{ method: "GET", action: "/pm" },
|
||||
input({ type: "hidden", name: "recipients", value: audioObj.author }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.audioMessageAuthorButton)
|
||||
));
|
||||
}
|
||||
if (audioObj.isBcs) {
|
||||
sideActions.push(form(
|
||||
{ method: "GET", action: `/melody/transcode/${encodeURIComponent(audioObj.key)}` },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.audioTranscodeButton || "TRANSCODE")
|
||||
));
|
||||
}
|
||||
for (const a of ownerActions) sideActions.push(a);
|
||||
|
||||
const tagsNode = renderTags(audioObj.tags);
|
||||
|
||||
const audioSide = div({ class: "tribe-side" },
|
||||
div({ class: "shop-title-row" },
|
||||
title ? h2({ class: "tribe-card-title" }, title) : null,
|
||||
renderReachChip(isClearnet, i18n)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
safeText(audioObj.description)
|
||||
? p({ class: "tribe-side-description" }, ...renderUrl(audioObj.description))
|
||||
: null,
|
||||
tagsNode,
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(audioObj.key, params.spreads)),
|
||||
renderMapLocationVisitLabel(audioObj.mapUrl),
|
||||
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null
|
||||
);
|
||||
|
||||
const audioMain = div({ class: "tribe-main" },
|
||||
renderAudioPlayer(audioObj),
|
||||
(() => {
|
||||
const createdTs = audioObj.createdAt ? new Date(audioObj.createdAt).getTime() : NaN;
|
||||
const updatedTs = audioObj.updatedAt ? new Date(audioObj.updatedAt).getTime() : NaN;
|
||||
const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
|
||||
|
||||
return p(
|
||||
{ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
|
||||
userLink(audioObj.author),
|
||||
showUpdated
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
` | ${i18n.audioUpdatedAt}: ${moment(audioObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
|
||||
)
|
||||
: null
|
||||
);
|
||||
})(),
|
||||
renderOpinionsVoting('/audios/opinions', audioObj.key, audioObj.opinions, returnTo, audioObj.opinions_inhabitants),
|
||||
renderAudioCommentsSection(audioObj.key, comments, returnTo)
|
||||
);
|
||||
|
||||
return template(
|
||||
i18n.audioTitle,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.audioAllSectionTitle || i18n.audioTitle),
|
||||
p(i18n.audioDescription)
|
||||
),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/audios", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterRecent),
|
||||
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterTop),
|
||||
button({ type: "submit", name: "filter", value: "bcs", class: filter === "bcs" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterBcs || "BCS"),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
|
||||
i18n.audioFilterFavorites
|
||||
),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.audioCreateButton)
|
||||
)
|
||||
),
|
||||
div({ class: "tribe-details" }, audioSide, audioMain)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const { renderCompositionSequence } = require("./melody_view");
|
||||
|
||||
exports.audioTranscodeDetailView = async ({ audio, decoded = false, stegoPayload = null, availableIds = null, itemSize = null }) => {
|
||||
const title = i18n.audioTranscodeDetailTitle || "Transcode";
|
||||
const composition = Array.isArray(audio.bcsComposition) ? audio.bcsComposition : [];
|
||||
const hasStego = decoded && stegoPayload && (stegoPayload.id || stegoPayload.ts || stegoPayload.msg);
|
||||
const stegoDate = hasStego && Number.isFinite(stegoPayload.ts) ? moment(stegoPayload.ts).format("YYYY/MM/DD HH:mm:ss") : null;
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(title),
|
||||
p(i18n.audioTranscodeDetailDescription || "Decode the embedded payload and the original blockchain composition map.")
|
||||
),
|
||||
div({ class: "filters" },
|
||||
form({ method: "GET", action: "/melody", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "filter", value: "all" }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.audioBackToBcs || "Back to BCS")
|
||||
)
|
||||
),
|
||||
div({ class: "bookmark-item card" },
|
||||
audio.title ? h2(audio.title) : null,
|
||||
renderAudioPlayer(audio, { skipTranscode: true }),
|
||||
p({ class: "transcode-meta card-footer" },
|
||||
userLink(audio.author),
|
||||
span({ class: "melody-meta-sep" }, " · "),
|
||||
span({ class: "card-value" }, moment(audio.createdAt).format("YYYY/MM/DD HH:mm:ss")),
|
||||
itemSize ? span({ class: "melody-meta-sep" }, " · ") : null,
|
||||
itemSize ? renderEcoTax(itemSize, audio.key) : null
|
||||
),
|
||||
safeText(audio.description) ? p({ class: "melody-bcs-desc" }, audio.description) : null,
|
||||
renderTags(audio.tags),
|
||||
br(),
|
||||
form({ method: "POST", action: `/melody/transcode/${encodeURIComponent(audio.key)}`, class: "audio-transcode-run-form" },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.audioTranscodeButton || "TRANSCODE")
|
||||
),
|
||||
br(),
|
||||
decoded
|
||||
? div({ class: "transcode-result" },
|
||||
hasStego
|
||||
? [
|
||||
div({ class: "transcode-stego-field" },
|
||||
span({ class: "card-label" }, (i18n.audioTranscodeStegoTimestamp || "Generated at") + ": "),
|
||||
span({ class: "card-value" }, stegoDate || (i18n.audioTranscodeStegoUnknown || "—"))
|
||||
),
|
||||
div({ class: "transcode-stego-field" },
|
||||
span({ class: "card-label" }, (i18n.audioTranscodeStegoOasisId || "By") + ": "),
|
||||
stegoPayload.id ? userLink(stegoPayload.id) : span({ class: "card-value" }, i18n.audioTranscodeStegoUnknown || "—")
|
||||
),
|
||||
div({ class: "transcode-stego-field transcode-stego-msg" },
|
||||
span({ class: "card-label" }, (i18n.audioTranscodeStegoMessage || "TEXT") + ":"),
|
||||
br(),
|
||||
stegoPayload.msg
|
||||
? p({ class: "transcode-stego-text" }, stegoPayload.msg)
|
||||
: span({ class: "card-value" }, i18n.audioTranscodeStegoEmpty || "(none)")
|
||||
)
|
||||
]
|
||||
: p({ class: "empty" }, i18n.audioTranscodeStegoNotFound || "No steganographic payload could be decoded from this audio."),
|
||||
composition.length
|
||||
? renderCompositionSequence(composition, availableIds)
|
||||
: p({ class: "empty" }, i18n.audioTranscodeCompositionEmpty || "This audio does not include a stored blockchain composition.")
|
||||
)
|
||||
: null
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.audiosTranscodeView = exports.audioTranscodeDetailView;
|
||||
|
||||
694
src/views/banking_views.js
Normal file
694
src/views/banking_views.js
Normal file
|
|
@ -0,0 +1,694 @@
|
|||
const { div, h2, h3, p, section, button, form, a, input, span, pre, table, thead, tbody, tr, td, th, br, strong, label } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, userLink, formatCarbon } = require("../views/main_views");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
|
||||
const FILTER_LABELS = {
|
||||
overview: i18n.bankOverview,
|
||||
exchange: i18n.bankExchange,
|
||||
taxes: i18n.bankTaxes || "Taxes",
|
||||
mine: i18n.mine,
|
||||
pending: i18n.pending,
|
||||
closed: i18n.closed,
|
||||
claimed: i18n.bankStatusClaimed,
|
||||
expired: i18n.bankStatusExpired,
|
||||
epochs: i18n.bankEpochs,
|
||||
rules: i18n.bankRules,
|
||||
addresses: i18n.bankAddresses
|
||||
};
|
||||
|
||||
const generateFilterButtons = (filters, currentFilter, action) =>
|
||||
div({ class: "mode-buttons-row" },
|
||||
...filters.map(mode =>
|
||||
form({ method: "GET", action },
|
||||
input({ type: "hidden", name: "filter", value: mode }),
|
||||
button({ type: "submit", class: currentFilter === mode ? "filter-btn active" : "filter-btn" }, (FILTER_LABELS[mode] || mode).toUpperCase())
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const kvRow = (label, value) =>
|
||||
tr(td({ class: "card-label" }, label), td({ class: "card-value" }, value));
|
||||
|
||||
const fmtIndex = (value) => {
|
||||
return value ? value.toFixed(6) : "0.000000";
|
||||
};
|
||||
|
||||
const pct = (value) => {
|
||||
if (value === undefined || value === null) return "0.000001%";
|
||||
const formattedValue = (value).toFixed(6);
|
||||
const sign = value >= 0 ? "+" : "";
|
||||
return `${sign}${formattedValue}%`;
|
||||
};
|
||||
|
||||
const fmtDate = (timestamp) => {
|
||||
return moment(timestamp).format('YYYY-MM-DD HH:mm:ss');
|
||||
};
|
||||
|
||||
const fmtEcoTime = (ms) => {
|
||||
if (!ms || ms <= 0) return `0 ${i18n.bankUnitMs || 'ms'}`;
|
||||
if (ms < 1000) return `${Number(ms).toFixed(3)} ${i18n.bankUnitMs || 'ms'}`;
|
||||
const s = ms / 1000;
|
||||
if (s < 60) return `${s.toFixed(2)} ${i18n.bankUnitSeconds || 'seconds'}`;
|
||||
const m = s / 60;
|
||||
if (m < 60) return `${m.toFixed(2)} ${i18n.bankUnitMinutes || 'minutes'}`;
|
||||
const h = m / 60;
|
||||
if (h < 24) return `${h.toFixed(2)} ${i18n.bankHoursOfWork || 'hours'}`;
|
||||
return `${(h / 24).toFixed(2)} ${i18n.bankUnitDays || 'days'}`;
|
||||
};
|
||||
|
||||
const escAttr = (s) => String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const buildEcoValueChartSvg = (history, labels) => {
|
||||
const arr = Array.isArray(history) ? history.slice(-120) : [];
|
||||
const W = 720, H = 320;
|
||||
const padL = 56, padR = 16, padT = 16, padB = 70;
|
||||
const plotW = W - padL - padR;
|
||||
const plotH = H - padT - padB;
|
||||
if (arr.length < 2) {
|
||||
return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" class="bank-eco-chart-svg" preserveAspectRatio="xMidYMid meet">`
|
||||
+ `<rect x="0" y="0" width="${W}" height="${H}" class="bank-eco-chart-bg" />`
|
||||
+ `<text x="${W/2}" y="${H/2}" text-anchor="middle" class="bank-eco-chart-empty">${escAttr(labels.empty || 'Not enough samples yet')}</text>`
|
||||
+ `</svg>`;
|
||||
}
|
||||
const values = arr.map(s => Number(s.ecoValue || 0));
|
||||
const supplies = arr.map(s => Number(s.currentSupply || 0));
|
||||
const inflations = arr.map(s => Number(s.inflationFactor || 0));
|
||||
const minV = Math.min(...values);
|
||||
const maxV = Math.max(...values);
|
||||
const rangeV = maxV - minV || 1;
|
||||
const minS = Math.min(...supplies);
|
||||
const maxS = Math.max(...supplies);
|
||||
const rangeS = maxS - minS || 1;
|
||||
const minI = Math.min(...inflations);
|
||||
const maxI = Math.max(...inflations);
|
||||
const rangeI = maxI - minI || 1;
|
||||
const stepX = arr.length > 1 ? plotW / (arr.length - 1) : plotW;
|
||||
const xy = (i, v, minR, rangeR) => {
|
||||
const x = padL + i * stepX;
|
||||
const y = padT + plotH - ((v - minR) / rangeR) * plotH;
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
};
|
||||
const pointsValue = values.map((v, i) => xy(i, v, minV, rangeV)).join(' ');
|
||||
const pointsSupply = supplies.map((v, i) => xy(i, v, minS, rangeS)).join(' ');
|
||||
const pointsInfl = inflations.map((v, i) => xy(i, v, minI, rangeI)).join(' ');
|
||||
const tsStart = moment(arr[0].ts).format('YYYY-MM-DD HH:mm');
|
||||
const tsEnd = moment(arr[arr.length - 1].ts).format('YYYY-MM-DD HH:mm');
|
||||
const tsMid = moment(arr[Math.floor(arr.length / 2)].ts).format('YYYY-MM-DD HH:mm');
|
||||
const yTicks = 4;
|
||||
const grid = [];
|
||||
for (let i = 0; i <= yTicks; i++) {
|
||||
const y = padT + (plotH / yTicks) * i;
|
||||
grid.push(`<line x1="${padL}" x2="${W - padR}" y1="${y.toFixed(2)}" y2="${y.toFixed(2)}" class="bank-eco-chart-grid" />`);
|
||||
const val = maxV - (rangeV / yTicks) * i;
|
||||
grid.push(`<text x="${padL - 6}" y="${(y + 4).toFixed(2)}" text-anchor="end" class="bank-eco-chart-axis">${val.toFixed(4)}</text>`);
|
||||
}
|
||||
const xLabelY = padT + plotH + 16;
|
||||
const xLabels = `<text x="${padL}" y="${xLabelY}" text-anchor="start" class="bank-eco-chart-axis">${escAttr(tsStart)}</text>`
|
||||
+ `<text x="${(padL + plotW/2).toFixed(2)}" y="${xLabelY}" text-anchor="middle" class="bank-eco-chart-axis">${escAttr(tsMid)}</text>`
|
||||
+ `<text x="${W - padR}" y="${xLabelY}" text-anchor="end" class="bank-eco-chart-axis">${escAttr(tsEnd)}</text>`;
|
||||
const legendY = padT + plotH + 44;
|
||||
const legendBaseX = padL;
|
||||
const legend = `<g class="bank-eco-chart-legend">`
|
||||
+ `<rect x="${legendBaseX}" y="${(legendY - 7).toFixed(2)}" width="14" height="3" class="bank-eco-chart-line-value-legend" />`
|
||||
+ `<text x="${(legendBaseX + 18).toFixed(2)}" y="${legendY}" class="bank-eco-chart-legend-text">${escAttr(labels.value || 'Value')}</text>`
|
||||
+ `<rect x="${(legendBaseX + 170).toFixed(2)}" y="${(legendY - 7).toFixed(2)}" width="14" height="3" class="bank-eco-chart-line-supply-legend" />`
|
||||
+ `<text x="${(legendBaseX + 188).toFixed(2)}" y="${legendY}" class="bank-eco-chart-legend-text">${escAttr(labels.supply || 'Supply')}</text>`
|
||||
+ `<rect x="${(legendBaseX + 320).toFixed(2)}" y="${(legendY - 7).toFixed(2)}" width="14" height="3" class="bank-eco-chart-line-inflation-legend" />`
|
||||
+ `<text x="${(legendBaseX + 338).toFixed(2)}" y="${legendY}" class="bank-eco-chart-legend-text">${escAttr(labels.inflation || 'Inflation')}</text>`
|
||||
+ `</g>`;
|
||||
return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" class="bank-eco-chart-svg" preserveAspectRatio="xMidYMid meet">`
|
||||
+ `<rect x="0" y="0" width="${W}" height="${H}" class="bank-eco-chart-bg" />`
|
||||
+ grid.join('')
|
||||
+ `<polyline points="${pointsSupply}" class="bank-eco-chart-line-supply" />`
|
||||
+ `<polyline points="${pointsInfl}" class="bank-eco-chart-line-inflation" />`
|
||||
+ `<polyline points="${pointsValue}" class="bank-eco-chart-line-value" />`
|
||||
+ xLabels
|
||||
+ legend
|
||||
+ `</svg>`;
|
||||
};
|
||||
|
||||
const renderExchange = (ex, history, taxStats) => {
|
||||
if (!ex) return div(p(i18n.bankExchangeNoData));
|
||||
const syncStatus = ex.isSynced ? i18n.bankingSyncStatusSynced : i18n.bankingSyncStatusOutdated;
|
||||
const syncStatusClass = ex.isSynced ? 'synced' : 'outdated';
|
||||
const ecoTimeLabel = ex.isSynced ? fmtEcoTime(ex.ecoTimeMs) : fmtEcoTime(0);
|
||||
const chartLabels = {
|
||||
value: i18n.bankExchangeChartValue || 'Value (ECO/h)',
|
||||
supply: i18n.bankExchangeChartSupply || 'Supply',
|
||||
inflation: i18n.bankExchangeChartInflation || 'Inflation %',
|
||||
empty: i18n.bankExchangeChartEmpty || 'Not enough samples yet — revisit later'
|
||||
};
|
||||
const hasEnoughSamples = Array.isArray(history) && history.length >= 2 && ex.isSynced;
|
||||
const totals = (taxStats && taxStats.totals) || {};
|
||||
const taxRows = taxStats ? [
|
||||
kvRow(i18n.bankExchangeEcoTaxAnnual || 'ECOin Taxes (annual)', `${Number(totals.annualEcoinTax || 0).toFixed(6)} ECO`),
|
||||
kvRow(i18n.bankExchangeEcoTaxMonthly || 'ECOin Taxes (monthly)', `${Number(totals.monthlyEcoinTax || 0).toFixed(6)} ECO`)
|
||||
] : [];
|
||||
return div(
|
||||
div({ class: "bank-summary" },
|
||||
table({ class: "bank-info-table" },
|
||||
tbody(
|
||||
kvRow(i18n.bankingSyncStatus,
|
||||
span({ class: syncStatusClass }, syncStatus)
|
||||
),
|
||||
kvRow(i18n.bankExchangeCurrentValue, `${fmtIndex(ex.ecoValue)} ECO`),
|
||||
kvRow(i18n.bankCurrentSupply, `${Number(ex.currentSupply || 0).toFixed(6)} ECO`),
|
||||
kvRow(i18n.bankTotalSupply, `${Number(ex.totalSupply || 0).toFixed(6)} ECO`),
|
||||
kvRow(i18n.bankEcoinHours, ecoTimeLabel),
|
||||
kvRow(i18n.bankInflation, `${ex.inflationFactor.toFixed(2)}%`),
|
||||
kvRow(i18n.bankInflationMonthly, `${Number(ex.inflationMonthly || 0).toFixed(2)}%`),
|
||||
...taxRows
|
||||
)
|
||||
)
|
||||
),
|
||||
hasEnoughSamples
|
||||
? div({ class: "bank-eco-chart-block" },
|
||||
h2({ class: "bank-eco-chart-title" }, i18n.bankExchangeChartTitle || 'ECOin value over time'),
|
||||
div({ class: "bank-eco-chart-canvas", innerHTML: buildEcoValueChartSvg(history, chartLabels) })
|
||||
)
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const renderTaxes = (data, lookup) => {
|
||||
const taxStats = data.taxStats || {};
|
||||
const userTax = Number(data.userEcoinTax || 0);
|
||||
const userArchTax = Number(data.userArchTax || 0);
|
||||
const lookupBlock = lookup && lookup.block ? lookup.block : null;
|
||||
return div(
|
||||
(() => {
|
||||
const selectedTypes = Array.isArray(data.selectedTaxTypes) ? data.selectedTaxTypes : ['eco', 'arch'];
|
||||
const isOn = (t) => selectedTypes.includes(t);
|
||||
const eco = (taxStats.byType && taxStats.byType.eco) || { lifetime: 0, annual: 0, monthly: 0 };
|
||||
const arch = (taxStats.byType && taxStats.byType.arch) || { lifetime: 0, annual: 0, monthly: 0 };
|
||||
const sumField = (field) =>
|
||||
(isOn('eco') ? (eco[field] || 0) : 0) +
|
||||
(isOn('arch') ? (arch[field] || 0) : 0);
|
||||
const totalLifetime = sumField('lifetime');
|
||||
const totalAnnual = sumField('annual');
|
||||
const totalMonthly = sumField('monthly');
|
||||
const blockHidden = lookupBlock ? input({ type: 'hidden', name: 'block', value: lookupBlock }) : null;
|
||||
return [
|
||||
div({ class: "bank-summary" },
|
||||
table({ class: "bank-info-table" },
|
||||
tbody(
|
||||
kvRow(i18n.bankTaxesTotalBlocks || 'Total blocks', String(taxStats.totalBlocks || 0)),
|
||||
kvRow(i18n.bankTaxesSpan || 'Sampling span', `${Number(taxStats.spanDays || 0).toFixed(2)} days`),
|
||||
isOn('eco') ? kvRow(i18n.bankTaxesEcoTaxLifetime || 'ECO Tax (lifetime)', `${Number(eco.lifetime || 0).toFixed(6)} ECO`) : null,
|
||||
isOn('eco') ? kvRow(i18n.bankTaxesAnnualEcoin || 'ECO Tax (annual)', `${Number(eco.annual || 0).toFixed(6)} ECO`) : null,
|
||||
isOn('eco') ? kvRow(i18n.bankTaxesMonthlyEcoin || 'ECO Tax (monthly)', `${Number(eco.monthly || 0).toFixed(6)} ECO`) : null,
|
||||
isOn('arch') ? kvRow(i18n.bankTaxesArchTaxLifetime || 'ARCH Tax (lifetime)', `${Number(arch.lifetime || 0).toFixed(6)} ECO`) : null,
|
||||
isOn('arch') ? kvRow(i18n.bankTaxesArchTaxAnnual || 'ARCH Tax (annual)', `${Number(arch.annual || 0).toFixed(6)} ECO`) : null,
|
||||
isOn('arch') ? kvRow(i18n.bankTaxesArchTaxMonthly || 'ARCH Tax (monthly)', `${Number(arch.monthly || 0).toFixed(6)} ECO`) : null,
|
||||
kvRow(strong(i18n.bankTaxesTotalLifetime || 'Total (lifetime)'), strong(`${Number(totalLifetime).toFixed(6)} ECO`)),
|
||||
kvRow(strong(i18n.bankTaxesTotalAnnual || 'Total (annual)'), strong(`${Number(totalAnnual).toFixed(6)} ECO`)),
|
||||
kvRow(strong(i18n.bankTaxesTotalMonthly || 'Total (monthly)'), strong(`${Number(totalMonthly).toFixed(6)} ECO`))
|
||||
)
|
||||
),
|
||||
br(),
|
||||
form({ method: "GET", action: "/banking", class: "bank-taxes-types-form" },
|
||||
input({ type: "hidden", name: "filter", value: "taxes" }),
|
||||
input({ type: "hidden", name: "types", value: "eco" }),
|
||||
blockHidden,
|
||||
span({ class: "bank-taxes-types-label" }, (i18n.bankTaxesTypesLabel || 'Select which taxes you want to pay') + ': '),
|
||||
label({ class: "bank-taxes-type-toggle bank-taxes-type-toggle-locked" },
|
||||
input({ type: "checkbox", checked: 'checked', disabled: 'disabled' }),
|
||||
' ',
|
||||
(i18n.ecoTaxLabel || 'ECO Tax')
|
||||
),
|
||||
label({ class: "bank-taxes-type-toggle" },
|
||||
input(Object.assign({ type: "checkbox", name: "types", value: "arch" }, isOn('arch') ? { checked: 'checked' } : {})),
|
||||
' ',
|
||||
(i18n.bankTaxesArchTaxTitle || 'ARCH Tax')
|
||||
),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.bankTaxesTypesApply || 'Set my taxes')
|
||||
)
|
||||
)
|
||||
];
|
||||
})(),
|
||||
div({ class: "bank-summary" },
|
||||
h2(i18n.bankTaxesEcoTaxTitle || 'ECO Tax'),
|
||||
table({ class: "bank-info-table" },
|
||||
tbody(
|
||||
kvRow(i18n.bankTaxesTotalBytes || 'Total bytes', `${Number(taxStats.totalBytes || 0).toLocaleString()} B`),
|
||||
kvRow(i18n.bankTaxesTotalCarbon || 'Total carbon', formatCarbon(taxStats.totalBytes || 0)),
|
||||
kvRow(i18n.bankRulesCarbonFactor || 'Carbon factor', `${Number(0.095).toFixed(4)} g CO₂ / MiB`),
|
||||
kvRow(i18n.bankTaxesRate || 'Rate', `${Number(taxStats.ecoinPerGramCO2 || 0).toFixed(4)} ECO / g CO₂`),
|
||||
kvRow(i18n.bankTaxesUserAmount || 'Your ECO tax (price to return)', `${userTax.toFixed(6)} ECO`)
|
||||
)
|
||||
),
|
||||
p({ class: "bank-taxes-user-note" },
|
||||
(i18n.bankTaxesUserNoteIntro || 'Your ECO tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Those other inhabitants who receive more UBI for their projects will likely have some task dedicated to helping you reduce your ECO tax. Or they may dedicate themselves to '),
|
||||
strong(i18n.bankTaxesUserNoteBold || 'reducing the ECO tax directly'),
|
||||
(i18n.bankTaxesUserNoteOutro || ', and their ongoing contribution may be required by networking consensus.')
|
||||
),
|
||||
p({ class: "bank-taxes-user-note" }, i18n.bankTaxesUserNoteChips || 'The ECO Tax chip shown next to each item changes color (green / yellow / red) depending on the item size relative to the largest block ever seen on the network, softened by the number of active inhabitants — a larger solar-punk network distributes the load and pushes more items into the green band.'),
|
||||
p({ class: "bank-taxes-user-note" }, i18n.bankTaxesUserNoteChipsClick || 'Click any chip to inspect its block in the blockexplorer.'),
|
||||
(() => {
|
||||
const firstBlock = data.firstBlock || null;
|
||||
const sampleSize = Number(data.firstBlockSize || 0);
|
||||
const sampleHref = firstBlock
|
||||
? `/blockexplorer?inspect=${encodeURIComponent(firstBlock)}`
|
||||
: '/blockexplorer';
|
||||
const sampleValue = sampleSize > 0 ? formatCarbon(sampleSize) : '— CO₂';
|
||||
const label = i18n.ecoTaxLabel || 'ECO Tax';
|
||||
return p({ class: "bank-taxes-example-chips" },
|
||||
a({ href: sampleHref, class: 'eco-tax-chip eco-tax-chip-low', title: label + ' · low' },
|
||||
span({ class: 'eco-tax-chip-label' }, label + ': '),
|
||||
span({ class: 'eco-tax-chip-value' }, sampleValue)
|
||||
),
|
||||
' ',
|
||||
a({ href: sampleHref, class: 'eco-tax-chip eco-tax-chip-mid', title: label + ' · mid' },
|
||||
span({ class: 'eco-tax-chip-label' }, label + ': '),
|
||||
span({ class: 'eco-tax-chip-value' }, sampleValue)
|
||||
),
|
||||
' ',
|
||||
a({ href: sampleHref, class: 'eco-tax-chip eco-tax-chip-high', title: label + ' · high' },
|
||||
span({ class: 'eco-tax-chip-label' }, label + ': '),
|
||||
span({ class: 'eco-tax-chip-value' }, sampleValue)
|
||||
)
|
||||
);
|
||||
})(),
|
||||
p({ class: "bank-taxes-user-note" }, i18n.bankTaxesUserNoteOtherParams || 'ECO Tax is currently derived from the carbon footprint of each block, but it may incorporate other parameters over time — energy spent on replication, redundant storage across peers, blob bandwidth, computational cost of decryption, mining footprint of associated transactions, or any other measurable load the network agrees to value.')
|
||||
),
|
||||
div({ class: "bank-summary" },
|
||||
h2(i18n.bankTaxesArchTaxTitle || 'ARCH Tax'),
|
||||
table({ class: "bank-info-table" },
|
||||
tbody(
|
||||
kvRow(i18n.bankTaxesArchTaxFirstBlock || 'Your first block age', userArchTax > 0
|
||||
? `${(userArchTax / Number(taxStats.ecoinPerDayOfHistory || 0.001)).toFixed(2)} days`
|
||||
: '—'),
|
||||
kvRow(i18n.bankTaxesArchTaxRate || 'Rate', `${Number(taxStats.ecoinPerDayOfHistory || 0).toFixed(6)} ECO / day of history`),
|
||||
kvRow(i18n.bankTaxesArchTaxUserAmount || 'Your ARCH tax (price to return)', `${userArchTax.toFixed(6)} ECO`)
|
||||
)
|
||||
),
|
||||
p({ class: "bank-taxes-user-note" },
|
||||
(i18n.bankTaxesArchTaxNoteIntro || 'Your ARCH tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Newer inhabitants and those specifically dedicated to archival, replication and maintenance tasks will likely have some task dedicated to keeping the network archive healthy on your behalf. Or they may dedicate themselves to '),
|
||||
strong(i18n.bankTaxesArchTaxNoteBold || 'reducing the ARCH tax directly'),
|
||||
(i18n.bankTaxesArchTaxNoteOutro || ' through pruning agreements, deduplication of redundant data, and shared maintenance infrastructure, and their ongoing contribution may be required by networking consensus.')
|
||||
),
|
||||
p({ class: "bank-taxes-user-note" }, i18n.bankTaxesArchTaxFootprintNote || 'ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost.')
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const renderOverviewSummaryTable = (s, rules, userEcoinTax) => {
|
||||
const score = Number(s.userEngagementScore || 0);
|
||||
const pool = Number(s.pool || 0);
|
||||
const W = Math.max(1, Number(s.weightsSum || 1));
|
||||
const w = 1 + score / 100;
|
||||
const cap = rules?.caps?.cap_user_epoch ?? 50;
|
||||
const floor = rules?.caps?.floor_user ?? 1;
|
||||
const gross = Math.max(floor, Math.min(pool * (w / W), cap));
|
||||
const surplus = Math.max(0, gross - floor);
|
||||
const tax = Number(userEcoinTax || 0);
|
||||
const future = floor + Math.max(0, surplus - tax);
|
||||
const availClass = s.ubiAvailability === "OK" ? "ubi-available" : "ubi-unavailable";
|
||||
const availLabel = s.ubiAvailability === "OK" ? i18n.bankUbiAvailableOk : i18n.bankUbiAvailableNo;
|
||||
return div({ class: "bank-summary" },
|
||||
table({ class: "bank-info-table" },
|
||||
tbody(
|
||||
kvRow(i18n.bankUserBalance, `${Number(s.userBalance || 0).toFixed(6)} ECO`),
|
||||
kvRow(i18n.bankUbiAvailability, span({ class: availClass }, availLabel)),
|
||||
s.pubId ? kvRow(i18n.pubIdLabel, userLink(s.pubId)) : null,
|
||||
kvRow(i18n.bankEpoch, String(s.epochId || "-")),
|
||||
kvRow(i18n.bankPool, `${pool.toFixed(6)} ECO`),
|
||||
kvRow(i18n.bankWeightsSum, String(W.toFixed(6))),
|
||||
kvRow(i18n.bankingUserEngagementScore, String(score)),
|
||||
kvRow(i18n.bankOverviewYourTaxes || 'Your taxes', a({ href: '/banking?filter=taxes' }, `${tax.toFixed(6)} ECO`)),
|
||||
kvRow(i18n.bankUbiThisMonth, `${future.toFixed(6)} ECO`)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderClaimUBIBlock = (pendingAllocation, isPub, alreadyClaimed, pubId, hasValidWallet, ubiAvailability) => {
|
||||
if (alreadyClaimed) return "";
|
||||
if (!pubId && !isPub) return "";
|
||||
if (!isPub && !hasValidWallet) return "";
|
||||
if (!isPub && ubiAvailability !== "OK") return "";
|
||||
if (!pendingAllocation && !isPub) {
|
||||
return div({ class: "bank-claim-ubi" },
|
||||
div({ class: "bank-claim-card" },
|
||||
form({ method: "POST", action: "/banking/claim-ubi" },
|
||||
button({ type: "submit", class: "create-button bank-claim-btn" }, i18n.bankClaimUBI)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!pendingAllocation) return "";
|
||||
return div({ class: "bank-claim-ubi" },
|
||||
div({ class: "bank-claim-card" },
|
||||
p(`${i18n.bankUbiThisMonth}: `, span({ class: "accent" }, `${Number(pendingAllocation.amount || 0).toFixed(6)} ECO`)),
|
||||
p(`${i18n.bankEpoch}: `, span(pendingAllocation.concept || "")),
|
||||
form({ method: "POST", action: `/banking/claim/${encodeURIComponent(pendingAllocation.id)}` },
|
||||
button({ type: "submit", class: "create-button bank-claim-btn" }, isPub ? i18n.bankClaimAndPay : i18n.bankClaimUBI)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const filterAllocations = (allocs, filter, userId) => {
|
||||
if (filter === "mine") return allocs.filter(a => a.to === userId && (a.status === "UNCLAIMED" || a.status === "UNCONFIRMED"));
|
||||
if (filter === "pending") return allocs.filter(a => a.status === "UNCLAIMED" || a.status === "UNCONFIRMED");
|
||||
if (filter === "closed") return allocs.filter(a => a.status === "CLOSED");
|
||||
if (filter === "claimed") return allocs.filter(a => a.status === "CLAIMED");
|
||||
if (filter === "expired") return allocs.filter(a => a.status === "EXPIRED");
|
||||
return allocs;
|
||||
};
|
||||
|
||||
const allocationsTable = (rows = [], userId) =>
|
||||
rows.length === 0
|
||||
? div(p(i18n.bankNoAllocations))
|
||||
: table(
|
||||
{ class: "bank-allocs" },
|
||||
thead(
|
||||
tr(
|
||||
th(i18n.bankAllocDate),
|
||||
th(i18n.bankAllocConcept),
|
||||
th(i18n.bankAllocFrom),
|
||||
th(i18n.bankAllocTo),
|
||||
th(i18n.bankAllocAmount),
|
||||
th(i18n.bankAllocStatus),
|
||||
th("")
|
||||
)
|
||||
),
|
||||
tbody(
|
||||
...rows.map(r =>
|
||||
tr(
|
||||
td(new Date(r.createdAt).toLocaleString()),
|
||||
td(r.concept || ""),
|
||||
td(userLink(r.from)),
|
||||
td(userLink(r.to)),
|
||||
td(String(Number(r.amount || 0).toFixed(6))),
|
||||
td(r.status),
|
||||
td(
|
||||
(r.status === "UNCLAIMED" || r.status === "UNCONFIRMED") && r.to === userId
|
||||
? form({ method: "POST", action: `/banking/claim/${encodeURIComponent(r.id)}` },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.bankClaimNow)
|
||||
)
|
||||
: r.status === "CLOSED" && r.txid
|
||||
? a({ href: `https://ecoin.03c8.net/blockexplorer/search?q=${encodeURIComponent(r.txid)}`, target: "_blank", class: "btn-singleview" }, i18n.bankViewTx)
|
||||
: null
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const renderEpochList = (epochs = []) =>
|
||||
epochs.length === 0
|
||||
? div(p(i18n.bankNoEpochs))
|
||||
: table(
|
||||
{ class: "bank-epochs" },
|
||||
thead(tr(th(i18n.bankEpochId), th(i18n.bankPool), th(i18n.bankWeightsSum), th(i18n.bankRuleHash), th(""))),
|
||||
tbody(
|
||||
...epochs
|
||||
.sort((a, b) => String(b.id).localeCompare(String(a.id)))
|
||||
.map(e =>
|
||||
tr(
|
||||
td(e.id),
|
||||
td(String(Number(e.pool || 0).toFixed(6))),
|
||||
td(String(Number(e.weightsSum || 0).toFixed(6))),
|
||||
td(e.hash || "-"),
|
||||
td(
|
||||
form({ method: "GET", action: `/banking/epoch/${encodeURIComponent(e.id)}` },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.bankViewEpoch)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const rulesBlock = (rules, taxRules) => {
|
||||
const r = rules || {};
|
||||
const caps = r.caps || {};
|
||||
const tr = taxRules || {};
|
||||
const fmt = (n, d = 4) => (Number.isFinite(Number(n)) ? Number(n).toFixed(d) : "—");
|
||||
return div({ class: "bank-rules" },
|
||||
h2(i18n.bankRulesPoolTitle || "Monthly pool"),
|
||||
table({ class: "bank-info-table" },
|
||||
tbody(
|
||||
kvRow(i18n.bankRulesEpochKind || "Epoch granularity", String(r.epochKind || "MONTHLY")),
|
||||
kvRow(i18n.bankRulesAlpha || "Alpha (max share of pub balance per epoch)", `${fmt((r.alpha ?? 0) * 100, 2)} %`),
|
||||
kvRow(i18n.bankRulesReserveMin || "Reserve minimum (kept in pub balance)", `${fmt(r.reserveMin, 6)} ECO`),
|
||||
kvRow(i18n.bankRulesCapPerEpoch || "Cap per epoch (absolute)", `${fmt(r.capPerEpoch, 6)} ECO`),
|
||||
kvRow(i18n.bankRulesGraceDays || "Claim grace period", `${fmt(r.graceDays, 0)} days`)
|
||||
)
|
||||
),
|
||||
p({ class: "bank-rules-formula" },
|
||||
strong(i18n.bankRulesPoolFormula || "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)")
|
||||
),
|
||||
|
||||
h2(i18n.bankRulesShareTitle || "Per-user share"),
|
||||
table({ class: "bank-info-table" },
|
||||
tbody(
|
||||
kvRow(i18n.bankRulesWMin || "Minimum weight (w_min)", fmt(caps.w_min, 4)),
|
||||
kvRow(i18n.bankRulesWMax || "Maximum weight (w_max)", fmt(caps.w_max, 4)),
|
||||
kvRow(i18n.bankRulesFloor || "Floor per user (gross)", `${fmt(caps.floor_user, 6)} ECO`),
|
||||
kvRow(i18n.bankRulesCapUser || "Cap per user per epoch", `${fmt(caps.cap_user_epoch, 6)} ECO`)
|
||||
)
|
||||
),
|
||||
p({ class: "bank-rules-formula" },
|
||||
strong(i18n.bankRulesWeightFormula || "w = clamp(1 + karma/100, w_min, w_max)")
|
||||
),
|
||||
p({ class: "bank-rules-formula" },
|
||||
strong(i18n.bankRulesShareFormula || "gross_UBI(user) = clamp(Pool × w / Σw, floor_user, cap_user_epoch)")
|
||||
),
|
||||
|
||||
h2(i18n.bankRulesKarmaTitle || "Karma (user engagement score)"),
|
||||
p({ class: "bank-rules-note" }, i18n.bankRulesKarmaNote || "Karma is derived from your actions (posts, votes, parliament/courts participation, etc.) with a time-decay factor, minus the carbon grams generated by your feed."),
|
||||
p({ class: "bank-rules-formula" },
|
||||
strong(i18n.bankRulesKarmaFormula || "karma = max(0, round(scoreFromActions − carbonGramsForUser))")
|
||||
),
|
||||
|
||||
h2(i18n.bankRulesEcoTaxTitle || "ECO Tax"),
|
||||
table({ class: "bank-info-table" },
|
||||
tbody(
|
||||
kvRow(i18n.bankRulesEcoTaxRate || "Rate", `${fmt(tr.ecoinPerGramCO2, 4)} ECO / g CO₂`),
|
||||
kvRow(i18n.bankRulesCarbonFactor || "Carbon factor", `${fmt(tr.gramsCO2PerMiB, 4)} g CO₂ / MiB`)
|
||||
)
|
||||
),
|
||||
p({ class: "bank-rules-formula" },
|
||||
strong(i18n.bankRulesEcoTaxFormula || "ECO Tax(user) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2")
|
||||
),
|
||||
p({ class: "bank-rules-note" }, i18n.bankRulesEcoTaxNote || "ECO Tax is deducted only from the surplus that the network generates above the immovable floor — the base UBI (floor_user) is never reduced. The deducted ECO are not redistributed in this epoch; they remain in the pub balance and feed the next epoch's pool through reserveMin / alpha caps."),
|
||||
h2(i18n.bankRulesChipTitle || "ECO Tax chip color band"),
|
||||
p({ class: "bank-rules-note" }, i18n.bankRulesChipNote || "Each item's chip is colored relative to the largest message ever observed in the network and softened by the number of active inhabitants — a larger network distributes the load and pushes more items into the green band."),
|
||||
p({ class: "bank-rules-formula" },
|
||||
strong(i18n.bankRulesChipFormula || "band = ratio / reducer, where ratio = sizeBytes / maxBlockBytes, reducer = 1 + log10(inhabitants). high ≥ 0.66, mid ≥ 0.33, otherwise low.")
|
||||
),
|
||||
|
||||
h2(i18n.bankRulesArchTaxTitle || "ARCH Tax"),
|
||||
table({ class: "bank-info-table" },
|
||||
tbody(
|
||||
kvRow(i18n.bankRulesArchTaxRate || "Rate", `${fmt(tr.ecoinPerDayOfHistory, 6)} ECO / day of history`)
|
||||
)
|
||||
),
|
||||
p({ class: "bank-rules-formula" },
|
||||
strong(i18n.bankRulesArchTaxFormula || "ARCH Tax(user) = max(0, (newestBlockTs − userFirstBlockTs) / 86400000) × ecoinPerDayOfHistory")
|
||||
),
|
||||
p({ class: "bank-rules-note" }, i18n.bankRulesArchTaxNote || "ARCH Tax reflects the long-term cost of growing and maintaining the network archive: the older your data lives in the network, the larger your share of the maintenance cost."),
|
||||
|
||||
p({ class: "bank-rules-formula" },
|
||||
strong(i18n.bankRulesNetFormula || "surplus = max(0, gross_UBI − floor_user); net_UBI = floor_user + max(0, surplus − (ECO Tax + ARCH Tax))")
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const flashText = (key) => {
|
||||
if (key === "added") return i18n.bankAddressAdded;
|
||||
if (key === "updated") return i18n.bankAddressUpdated;
|
||||
if (key === "exists") return i18n.bankAddressExists;
|
||||
if (key === "invalid") return i18n.bankAddressInvalid;
|
||||
if (key === "deleted") return i18n.bankAddressDeleted;
|
||||
if (key === "not_found") return i18n.bankAddressNotFound;
|
||||
if (key === "claimed_pending") return i18n.bankClaimedPending;
|
||||
if (key === "already_claimed") return i18n.bankAlreadyClaimedThisMonth;
|
||||
if (key === "no_pub_configured") return i18n.bankNoPubConfigured;
|
||||
if (key === "no_funds") return i18n.bankUbiAvailableNo;
|
||||
if (key === "forbidden") return i18n.bankAddressForbidden;
|
||||
return "";
|
||||
};
|
||||
|
||||
const flashBanner = (msgKey) =>
|
||||
!msgKey ? null : div({ class: "flash-banner" }, p(flashText(msgKey) || msgKey));
|
||||
|
||||
const addressesToolbar = (rows = [], search = "") =>
|
||||
div({ class: "addr-toolbar" },
|
||||
div({ class: "addr-counter accent-pill" },
|
||||
span({ class: "acc-title accent" }, i18n.bankAddressTotal + ":"),
|
||||
span({ class: "acc-badge" }, String(rows.length))
|
||||
),
|
||||
form({ method: "GET", action: "/banking", class: "addr-search" },
|
||||
input({ type: "hidden", name: "filter", value: "addresses" }),
|
||||
input({ type: "text", name: "q", placeholder: i18n.bankAddressSearch, value: search || "" }),
|
||||
br(),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.search)
|
||||
)
|
||||
);
|
||||
|
||||
const renderAddresses = (data, userId) => {
|
||||
const rows = data.addresses || [];
|
||||
const search = data.search || "";
|
||||
return div(
|
||||
data.flash ? flashBanner(data.flash) : null,
|
||||
addressesToolbar(rows, search),
|
||||
div({ class: "bank-addresses-stack" },
|
||||
div({ class: "addr-form-card wide" },
|
||||
h2(i18n.bankAddAddressTitle),
|
||||
form({ method: "POST", action: "/banking/addresses", class: "addr-form" },
|
||||
div({ class: "form-row" },
|
||||
span({ class: "form-label accent" }, i18n.bankAddAddressUser + ":"),
|
||||
input({
|
||||
class: "form-input xl",
|
||||
type: "text",
|
||||
name: "userId",
|
||||
required: true,
|
||||
pattern: "^@[A-Za-z0-9+/]+={0,2}\\.ed25519$",
|
||||
placeholder: "@...=.ed25519",
|
||||
id: "addr-user-id"
|
||||
})
|
||||
),
|
||||
div({ class: "form-row" },
|
||||
span({ class: "form-label accent" }, i18n.bankAddAddressAddress + ":"),
|
||||
input({
|
||||
class: "form-input xl",
|
||||
type: "text",
|
||||
name: "address",
|
||||
required: true,
|
||||
pattern: "^[A-Za-z0-9]{20,64}$",
|
||||
placeholder: "ETQ17sBv8QFoiCPGKDQzNcDJeXmB2317HX"
|
||||
})
|
||||
),
|
||||
div({ class: "form-actions" },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.bankAddAddressSave)
|
||||
)
|
||||
)
|
||||
),
|
||||
div({ class: "addr-list-card" },
|
||||
rows.length === 0
|
||||
? div(p(i18n.bankNoAddresses))
|
||||
: table(
|
||||
{ class: "bank-addresses" },
|
||||
thead(
|
||||
tr(
|
||||
th(i18n.bankUser),
|
||||
th(i18n.bankAddress),
|
||||
th(i18n.bankAddressSource),
|
||||
th(i18n.bankAddressActions)
|
||||
)
|
||||
),
|
||||
tbody(
|
||||
...rows.map(r =>
|
||||
tr(
|
||||
td(userLink(r.id)),
|
||||
td(r.address),
|
||||
td(r.source === "local" ? i18n.bankLocal : i18n.bankFromOasis),
|
||||
td(
|
||||
div({ class: "row-actions" },
|
||||
form({ method: "POST", action: "/banking/addresses/delete", class: "addr-del" },
|
||||
input({ type: "hidden", name: "userId", value: r.id }),
|
||||
input({ type: "hidden", name: "source", value: r.source || "local" }),
|
||||
button({ type: "submit", class: "delete-btn", onclick: `return confirm(${JSON.stringify(i18n.bankAddressDeleteConfirm)})` }, i18n.bankAddressDelete)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderBankingView = (data, filter, userId, isPub) =>
|
||||
template(
|
||||
i18n.banking,
|
||||
section(
|
||||
div({ class: "tags-header" }, h2(i18n.banking), p(i18n.bankingDescription)),
|
||||
data.flash ? div({ class: "flash-banner" }, p(flashText(data.flash) || data.flash)) : null,
|
||||
generateFilterButtons(["overview","exchange","taxes","mine","pending","closed","claimed","expired","epochs","rules","addresses"], filter, "/banking"),
|
||||
filter === "overview"
|
||||
? div(
|
||||
renderOverviewSummaryTable(data.summary || {}, data.rules, data.userTotalTax || data.userEcoinTax),
|
||||
renderClaimUBIBlock(data.pendingUBI || null, isPub, data.alreadyClaimed, (data.summary || {}).pubId, (data.summary || {}).hasValidWallet, (data.summary || {}).ubiAvailability),
|
||||
allocationsTable((data.allocations || []).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), userId)
|
||||
)
|
||||
: filter === "exchange"
|
||||
? renderExchange(data.exchange, data.exchangeHistory, data.taxStats)
|
||||
: filter === "taxes"
|
||||
? renderTaxes(data, data.lookup || null)
|
||||
: filter === "epochs"
|
||||
? renderEpochList(data.epochs || [])
|
||||
: filter === "rules"
|
||||
? rulesBlock(data.rules || {}, data.taxRules || {})
|
||||
: filter === "addresses"
|
||||
? renderAddresses(data, userId)
|
||||
: allocationsTable(
|
||||
filterAllocations((data.allocations || []).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), filter, userId),
|
||||
userId
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const renderSingleAllocationView = (alloc, userId) => {
|
||||
if (!alloc) return template(i18n.banking, section(div(p(i18n.bankNoAllocations))));
|
||||
return template(
|
||||
i18n.banking,
|
||||
section(
|
||||
div({ class: "tags-header" }, h2(i18n.banking)),
|
||||
div({ class: "bank-summary" },
|
||||
table({ class: "bank-info-table" },
|
||||
tbody(
|
||||
kvRow("ID", alloc.id || "-"),
|
||||
kvRow(i18n.bankAllocConcept, alloc.concept || "-"),
|
||||
kvRow(i18n.bankAllocFrom, alloc.from || "-"),
|
||||
kvRow(i18n.bankAllocTo, alloc.to || "-"),
|
||||
kvRow(i18n.bankAllocAmount, `${Number(alloc.amount || 0).toFixed(6)} ECO`),
|
||||
kvRow(i18n.bankAllocStatus, alloc.status || "-"),
|
||||
kvRow(i18n.bankAllocDate, alloc.createdAt ? fmtDate(alloc.createdAt) : "-"),
|
||||
alloc.txid ? kvRow("TxID", a({ href: `https://ecoin.03c8.net/blockexplorer/search?q=${encodeURIComponent(alloc.txid)}`, target: "_blank" }, alloc.txid)) : null
|
||||
)
|
||||
)
|
||||
),
|
||||
alloc.status === "UNCONFIRMED" && alloc.to === userId
|
||||
? form({ method: "POST", action: `/banking/claim/${encodeURIComponent(alloc.id)}` },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.bankClaimNow)
|
||||
)
|
||||
: null,
|
||||
div(a({ href: "/banking", class: "filter-btn" }, i18n.bankOverview))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderEpochView = (epoch, allocations) => {
|
||||
if (!epoch) return template(i18n.banking, section(div(p(i18n.bankNoEpochs))));
|
||||
return template(
|
||||
i18n.banking,
|
||||
section(
|
||||
div({ class: "tags-header" }, h2(`${i18n.bankEpoch}: ${epoch.id}`)),
|
||||
div({ class: "bank-summary" },
|
||||
table({ class: "bank-info-table" },
|
||||
tbody(
|
||||
kvRow(i18n.bankEpochId, epoch.id || "-"),
|
||||
kvRow(i18n.bankPool, `${Number(epoch.pool || 0).toFixed(6)} ECO`),
|
||||
kvRow(i18n.bankWeightsSum, String(Number(epoch.weightsSum || 0).toFixed(6))),
|
||||
kvRow(i18n.bankRuleHash, epoch.hash || "-")
|
||||
)
|
||||
)
|
||||
),
|
||||
h2(i18n.bankEpochAllocations),
|
||||
allocationsTable(allocations || [], "")
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = { renderBankingView, renderSingleAllocationView, renderEpochView };
|
||||
554
src/views/blockchain_view.js
Normal file
554
src/views/blockchain_view.js
Normal file
|
|
@ -0,0 +1,554 @@
|
|||
const { div, h2, h3, p, section, button, form, a, input, span, pre, table, tr, td, strong } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, userLink } = require("../views/main_views");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
|
||||
const FILTER_LABELS = {
|
||||
votes: i18n.typeVotes, vote: i18n.typeVote, recent: i18n.recent, all: i18n.all,
|
||||
mine: i18n.mine, tombstone: i18n.typeTombstone, logs: i18n.typeLog || 'LOGS', pixelia: i18n.typePixelia,
|
||||
curriculum: i18n.typeCurriculum, document: i18n.typeDocument, bookmark: i18n.typeBookmark,
|
||||
feed: i18n.typeFeed, event: i18n.typeEvent, task: i18n.typeTask, report: i18n.typeReport,
|
||||
image: i18n.typeImage, audio: i18n.typeAudio, video: i18n.typeVideo, post: i18n.typePost,
|
||||
forum: i18n.typeForum, about: i18n.typeAbout, contact: i18n.typeContact, pub: i18n.typePub,
|
||||
transfer: i18n.typeTransfer, market: i18n.typeMarket, job: i18n.typeJob, tribe: i18n.typeTribe,
|
||||
project: i18n.typeProject, banking: i18n.typeBanking, bankWallet: i18n.typeBankWallet, bankClaim: i18n.typeBankClaim,
|
||||
aiExchange: i18n.typeAiExchange, parliament: i18n.typeParliament, courts: i18n.typeCourts,
|
||||
map: i18n.typeMap, shop: i18n.typeShop, shopProduct: i18n.typeShopProduct || 'Shop Product',
|
||||
pad: i18n.typePad || 'PAD', chat: i18n.typeChat || 'CHAT', gameScore: i18n.typeGameScore || 'GAME SCORE',
|
||||
calendar: i18n.typeCalendar || 'CALENDAR', torrent: i18n.typeTorrent
|
||||
};
|
||||
|
||||
const BASE_FILTERS = ['recent', 'all', 'mine', 'tombstone', 'logs'];
|
||||
const CAT_BLOCK1 = ['votes', 'event', 'task', 'report', 'calendar', 'parliament', 'courts'];
|
||||
const CAT_BLOCK2 = ['pub', 'tribe', 'about', 'contact', 'curriculum', 'vote', 'aiExchange'];
|
||||
const CAT_BLOCK3 = ['banking', 'job', 'market', 'project', 'transfer', 'feed', 'post', 'pixelia', 'shop', 'gameScore'];
|
||||
const CAT_BLOCK4 = ['forum', 'pad', 'chat', 'bookmark', 'image', 'video', 'audio', 'document', 'map', 'torrent'];
|
||||
|
||||
const SEARCH_FIELDS = ['author','id','from','to'];
|
||||
|
||||
const formatCarbon = (bytes) => {
|
||||
const n = Number(bytes) || 0;
|
||||
if (!n) return '0 µg CO₂';
|
||||
const grams = (n / (1024 * 1024)) * 0.095;
|
||||
if (grams >= 1) return `${grams.toFixed(2)} g CO₂`;
|
||||
const mg = grams * 1000;
|
||||
if (mg >= 1) return `${mg.toFixed(2)} mg CO₂`;
|
||||
const ug = mg * 1000;
|
||||
return `${ug.toFixed(2)} µg CO₂`;
|
||||
};
|
||||
|
||||
const hiddenSearchInputs = (search) =>
|
||||
SEARCH_FIELDS.map(k => {
|
||||
const v = String(search?.[k] ?? '').trim();
|
||||
return v ? input({ type: 'hidden', name: k, value: v }) : null;
|
||||
}).filter(Boolean);
|
||||
|
||||
const toDatetimeLocal = (s) => {
|
||||
const raw = String(s || '').trim();
|
||||
if (!raw) return '';
|
||||
const ts = new Date(raw).getTime();
|
||||
if (!Number.isFinite(ts)) return '';
|
||||
return moment(ts).format('YYYY-MM-DDTHH:mm');
|
||||
};
|
||||
|
||||
const toQueryString = (filter, search = {}) => {
|
||||
const parts = [];
|
||||
const f = String(filter || '').trim();
|
||||
if (f) parts.push(`filter=${encodeURIComponent(f)}`);
|
||||
for (const k of SEARCH_FIELDS) {
|
||||
const v = String(search?.[k] ?? '').trim();
|
||||
if (v) parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
|
||||
}
|
||||
return parts.length ? `?${parts.join('&')}` : '';
|
||||
};
|
||||
|
||||
const filterBlocks = (blocks, filter, userId) => {
|
||||
if (filter === 'recent') return blocks.filter(b => Date.now() - b.ts < 24*60*60*1000);
|
||||
if (filter === 'mine') return blocks.filter(b => b.author === userId);
|
||||
if (filter === 'all') return blocks;
|
||||
if (filter === 'banking') return blocks.filter(b => b.type === 'bankWallet' || b.type === 'bankClaim');
|
||||
if (filter === 'parliament') {
|
||||
const pset = new Set(['parliamentTerm','parliamentProposal','parliamentLaw','parliamentCandidature','parliamentRevocation']);
|
||||
return blocks.filter(b => pset.has(b.type));
|
||||
}
|
||||
if (filter === 'courts') {
|
||||
const cset = new Set(['courtsCase','courtsEvidence','courtsAnswer','courtsVerdict','courtsSettlement','courtsSettlementProposal','courtsSettlementAccepted','courtsNomination','courtsNominationVote']);
|
||||
return blocks.filter(b => cset.has(b.type));
|
||||
}
|
||||
if (filter === 'shop') return blocks.filter(b => b.type === 'shop' || b.type === 'shopProduct');
|
||||
if (filter === 'logs') return blocks.filter(b => b.type === 'log' && b.author === userId);
|
||||
return blocks.filter(b => b.type === filter);
|
||||
};
|
||||
|
||||
const computeStats = (blocks) => {
|
||||
const arr = Array.isArray(blocks) ? blocks : [];
|
||||
const byType = new Map();
|
||||
const byAuthor = new Map();
|
||||
for (const b of arr) {
|
||||
if (!b || !b.type) continue;
|
||||
byType.set(b.type, (byType.get(b.type) || 0) + 1);
|
||||
if (b.author) byAuthor.set(b.author, (byAuthor.get(b.author) || 0) + 1);
|
||||
}
|
||||
const typeBreakdown = Array.from(byType.entries())
|
||||
.map(([type, count]) => ({ type, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
const topAuthors = Array.from(byAuthor.entries())
|
||||
.map(([author, count]) => ({ author, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 5);
|
||||
return { total: arr.length, typeBreakdown, topAuthors };
|
||||
};
|
||||
|
||||
const renderStatsPanel = (stats, currentFilter, search) => {
|
||||
if (!stats || stats.total === 0) return null;
|
||||
const topTypes = stats.typeBreakdown.slice(0, 10);
|
||||
return div({ class: 'blockchain-stats' },
|
||||
div({ class: 'tags-header' },
|
||||
h3(`${i18n.blockchainStatsTitle || 'Stats'} (${stats.total})`)
|
||||
),
|
||||
topTypes.length
|
||||
? div({ class: 'blockchain-stats-types' },
|
||||
h3({ class: 'blockchain-stats-subtitle' }, i18n.blockchainStatsTypeBreakdown || 'Type breakdown'),
|
||||
div({ class: 'mode-buttons-cols' },
|
||||
topTypes.map(({ type, count }) =>
|
||||
form({ method: 'GET', action: '/blockexplorer' },
|
||||
input({ type: 'hidden', name: 'filter', value: type }),
|
||||
...hiddenSearchInputs(search),
|
||||
button({
|
||||
type: 'submit',
|
||||
class: currentFilter === type ? 'filter-btn active' : 'filter-btn'
|
||||
}, `${(FILTER_LABELS[type] || type).toUpperCase()} (${count})`)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: null,
|
||||
stats.topAuthors.length
|
||||
? div({ class: 'blockchain-stats-authors' },
|
||||
h3({ class: 'blockchain-stats-subtitle' }, i18n.blockchainStatsTopAuthors || 'Top authors'),
|
||||
table({ class: 'block-info-table' },
|
||||
tr(
|
||||
td({ class: 'card-label' }, i18n.blockchainBlockAuthor),
|
||||
td({ class: 'card-label' }, i18n.blockchainStatsCount || 'Blocks')
|
||||
),
|
||||
...stats.topAuthors.map(({ author, count }) =>
|
||||
tr(
|
||||
td(a({ href: `/blockexplorer?filter=all&author=${encodeURIComponent(author)}`, class: 'user-link block-author' }, author)),
|
||||
td(String(count))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const generateFilterButtons = (filters, currentFilter, action, search = {}) =>
|
||||
div({ class: 'mode-buttons-cols' },
|
||||
filters.map(mode =>
|
||||
form({ method: 'GET', action },
|
||||
input({ type: 'hidden', name: 'filter', value: mode }),
|
||||
...hiddenSearchInputs(search),
|
||||
button({
|
||||
type: 'submit',
|
||||
class: currentFilter === mode ? 'filter-btn active' : 'filter-btn'
|
||||
}, (FILTER_LABELS[mode]||mode).toUpperCase())
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const getViewDetailsAction = (type, block) => {
|
||||
if (block && block.content && typeof block.content.encryptedPayload === 'string') return null;
|
||||
switch (type) {
|
||||
case 'votes': return `/votes/${encodeURIComponent(block.id)}`;
|
||||
case 'transfer': return `/transfers/${encodeURIComponent(block.id)}`;
|
||||
case 'pixelia': return `/pixelia`;
|
||||
case 'tribe': return `/tribe/${encodeURIComponent(block.id)}`;
|
||||
case 'curriculum': return `/inhabitant/${encodeURIComponent(block.author)}`;
|
||||
case 'image': return `/images/${encodeURIComponent(block.id)}`;
|
||||
case 'audio': return `/audios/${encodeURIComponent(block.id)}`;
|
||||
case 'video': return `/videos/${encodeURIComponent(block.id)}`;
|
||||
case 'forum': return `/forum/${encodeURIComponent(block.content?.key||block.id)}`;
|
||||
case 'document': return `/documents/${encodeURIComponent(block.id)}`;
|
||||
case 'bookmark': return `/bookmarks/${encodeURIComponent(block.id)}`;
|
||||
case 'event': return `/events/${encodeURIComponent(block.id)}`;
|
||||
case 'task': return `/tasks/${encodeURIComponent(block.id)}`;
|
||||
case 'about': return `/author/${encodeURIComponent(block.author)}`;
|
||||
case 'post': return `/thread/${encodeURIComponent(block.id)}#${encodeURIComponent(block.id)}`;
|
||||
case 'vote': return `/thread/${encodeURIComponent(block.content.vote.link)}#${encodeURIComponent(block.content.vote.link)}`;
|
||||
case 'contact': return `/inhabitants`;
|
||||
case 'pub': return `/invites`;
|
||||
case 'market': return `/market/${encodeURIComponent(block.id)}`;
|
||||
case 'job': return `/jobs/${encodeURIComponent(block.id)}`;
|
||||
case 'project': return `/projects/${encodeURIComponent(block.id)}`;
|
||||
case 'report': return `/reports/${encodeURIComponent(block.id)}`;
|
||||
case 'calendar': return `/calendars/${encodeURIComponent(block.id)}`;
|
||||
case 'bankWallet': return `/wallet`;
|
||||
case 'bankClaim': return `/banking${block.content?.epochId ? `/epoch/${encodeURIComponent(block.content.epochId)}` : ''}`;
|
||||
case 'parliamentTerm': return `/parliament`;
|
||||
case 'parliamentProposal': return `/parliament`;
|
||||
case 'parliamentLaw': return `/parliament`;
|
||||
case 'parliamentCandidature': return `/parliament`;
|
||||
case 'parliamentRevocation': return `/parliament`;
|
||||
case 'courtsCase': return `/courts`;
|
||||
case 'courtsEvidence': return `/courts`;
|
||||
case 'courtsAnswer': return `/courts`;
|
||||
case 'courtsVerdict': return `/courts`;
|
||||
case 'courtsSettlement': return `/courts`;
|
||||
case 'courtsSettlementProposal': return `/courts`;
|
||||
case 'courtsSettlementAccepted': return `/courts`;
|
||||
case 'courtsNomination': return `/courts`;
|
||||
case 'courtsNominationVote': return `/courts`;
|
||||
case 'map': return `/maps/${encodeURIComponent(block.id)}`;
|
||||
case 'torrent': return `/torrents/${encodeURIComponent(block.id)}`;
|
||||
case 'mapMarker': return block.content?.mapId ? `/maps/${encodeURIComponent(block.content.mapId)}` : `/maps`;
|
||||
case 'shop': return `/shops/${encodeURIComponent(block.id)}`;
|
||||
case 'shopProduct': return `/shops/product/${encodeURIComponent(block.id)}`;
|
||||
case 'pad': return `/pads/${encodeURIComponent(block.id)}`;
|
||||
case 'chat': return `/chats/${encodeURIComponent(block.id)}`;
|
||||
case 'gameScore': return `/games?filter=scoring`;
|
||||
case 'log': return `/logs/view/${encodeURIComponent(block.id)}`;
|
||||
case 'calendarDate':
|
||||
case 'calendarNote': return block.content?.calendarId ? `/calendars/${encodeURIComponent(block.content.calendarId)}` : `/calendars`;
|
||||
case 'padEntry': return block.content?.padId ? `/pads/${encodeURIComponent(block.content.padId)}` : `/pads`;
|
||||
case 'chatMessage': return block.content?.roomId ? `/chats/${encodeURIComponent(block.content.roomId)}` : `/chats`;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const TYPE_COLORS = {
|
||||
post:'#3498db', vote:'#9b59b6', votes:'#9b59b6', about:'#1abc9c', contact:'#16a085',
|
||||
pub:'#2ecc71', tribe:'#e67e22', event:'#e74c3c', task:'#f39c12', report:'#c0392b',
|
||||
image:'#2980b9', audio:'#8e44ad', video:'#d35400', document:'#27ae60', bookmark:'#f1c40f',
|
||||
forum:'#1abc9c', feed:'#95a5a6', transfer:'#e74c3c', market:'#e67e22', job:'#3498db',
|
||||
project:'#2ecc71', banking:'#f39c12', bankWallet:'#f39c12', bankClaim:'#f39c12',
|
||||
pixelia:'#9b59b6', curriculum:'#1abc9c', aiExchange:'#3498db', tombstone:'#7f8c8d',
|
||||
parliamentTerm:'#8e44ad', parliamentProposal:'#8e44ad', parliamentLaw:'#8e44ad',
|
||||
parliamentCandidature:'#8e44ad', parliamentRevocation:'#8e44ad',
|
||||
courtsCase:'#c0392b', courtsEvidence:'#c0392b', courtsAnswer:'#c0392b',
|
||||
courtsVerdict:'#c0392b', courtsSettlement:'#c0392b', courtsNomination:'#c0392b',
|
||||
map:'#27ae60', mapMarker:'#27ae60',
|
||||
shop:'#e67e22', shopProduct:'#e67e22',
|
||||
pad:'#2ecc71', chat:'#3498db', gameScore:'#f39c12',
|
||||
calendar:'#e74c3c'
|
||||
};
|
||||
|
||||
const renderBlockDiagram = (blocks, qs) => {
|
||||
const last2 = blocks.slice(0, 2);
|
||||
if (!last2.length) return null;
|
||||
|
||||
return div({ class: 'block-diagram-section' },
|
||||
h3({ class: 'block-diagram-title' }, i18n.blockchainLatestDatagram || 'Latest Datagram'),
|
||||
...last2.map(block => {
|
||||
const ts = moment(block.ts).format('YYYY-MM-DD HH:mm:ss');
|
||||
const typeLabel = (FILTER_LABELS[block.type] || block.type).toUpperCase();
|
||||
const color = TYPE_COLORS[block.type] || '#95a5a6';
|
||||
const shortId = block.id.length > 20 ? block.id.slice(0, 10) + '…' + block.id.slice(-8) : block.id;
|
||||
const shortAuthor = block.author.length > 20 ? block.author.slice(0, 10) + '…' + block.author.slice(-8) : block.author;
|
||||
const contentKeys = Object.keys(block.content || {}).filter(k => k !== 'type').join(', ');
|
||||
const flags = [
|
||||
block.isTombstoned ? 'TOMBSTONED' : null,
|
||||
block.isReplaced ? 'REPLACED' : null,
|
||||
block.content?.replaces ? 'EDIT' : null
|
||||
].filter(Boolean).join(' | ') || '—';
|
||||
|
||||
const datagramQs = qs ? `${qs}&view=datagram` : '?view=datagram';
|
||||
const typeClass = `bd-type-${String(block.type || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '-')}`;
|
||||
return a({ href: `/blockexplorer/block/${encodeURIComponent(block.id)}${datagramQs}`, class: 'block-diagram-link' },
|
||||
div({ class: `block-diagram ${typeClass}` },
|
||||
div({ class: 'block-diagram-ruler' },
|
||||
span('0'), span('4'), span('8'), span('16'), span('24'), span('31')
|
||||
),
|
||||
div({ class: 'block-diagram-grid' },
|
||||
div({ class: 'block-diagram-cell bd-seq' },
|
||||
span({ class: 'bd-label' }, 'SEQ:'),
|
||||
span({ class: 'bd-value' }, String(block.content?.sequence || '—'))
|
||||
),
|
||||
div({ class: 'block-diagram-cell bd-type' },
|
||||
span({ class: 'bd-label' }, 'TYPE:'),
|
||||
span({ class: 'bd-value' }, typeLabel)
|
||||
),
|
||||
div({ class: 'block-diagram-cell bd-ts' },
|
||||
span({ class: 'bd-label' }, 'TIMESTAMP:'),
|
||||
span({ class: 'bd-value' }, ts)
|
||||
),
|
||||
div({ class: 'block-diagram-cell bd-id' },
|
||||
span({ class: 'bd-label' }, 'BLOCK ID:'),
|
||||
span({ class: 'bd-value' }, shortId)
|
||||
),
|
||||
div({ class: 'block-diagram-cell bd-author' },
|
||||
span({ class: 'bd-label' }, 'AUTHOR:'),
|
||||
span({ class: 'bd-value' }, shortAuthor)
|
||||
),
|
||||
div({ class: 'block-diagram-cell bd-flags' },
|
||||
span({ class: 'bd-label' }, 'FLAGS:'),
|
||||
span({ class: 'bd-value' }, flags)
|
||||
),
|
||||
div({ class: 'block-diagram-cell bd-ctype' },
|
||||
span({ class: 'bd-label' }, 'CONTENT.TYPE:'),
|
||||
span({ class: 'bd-value' }, block.content?.type || '—')
|
||||
),
|
||||
div({ class: 'block-diagram-cell bd-data' },
|
||||
span({ class: 'bd-label' }, 'CONTENT:'),
|
||||
span({ class: 'bd-value' }, contentKeys || '—')
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, viewMode = 'block', restricted = false) => {
|
||||
if (!block || block.notAvailable) {
|
||||
return template(
|
||||
i18n.blockchain,
|
||||
section(
|
||||
div({ class: 'tags-header' },
|
||||
h2(i18n.blockchain),
|
||||
p(i18n.blockchainDescription)
|
||||
),
|
||||
div({ class: 'block-single' },
|
||||
block && block.id ? div({ class: 'block-row block-row--meta' },
|
||||
span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockID || 'Block ID'}:`),
|
||||
span({ class: 'blockchain-card-value' }, block.id)
|
||||
) : null,
|
||||
p({ class: 'empty' }, i18n.blockchainBlockNotAvailable || 'This block is not available in your local feed. It may belong to a peer you are not yet replicating from.'),
|
||||
div({ class: 'larp-actions' },
|
||||
a({ href: '/blockexplorer', class: 'filter-btn' }, i18n.blockchainBackToBlockexplorer || 'Back to blockexplorer')
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const qs = toQueryString(filter, search);
|
||||
const isDatagram = viewMode === 'datagram';
|
||||
|
||||
const blockContent = restricted
|
||||
? div(
|
||||
div({ class: 'block-single' },
|
||||
div({ class: 'block-row block-row--meta' },
|
||||
span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockID}:`),
|
||||
span({ class: 'blockchain-card-value' }, block.id)
|
||||
),
|
||||
div({ class: 'block-row block-row--meta' },
|
||||
span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockType}:`),
|
||||
span({ class: 'blockchain-card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())
|
||||
),
|
||||
div({ class: 'block-row block-row--meta' },
|
||||
span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockTimestamp}:`),
|
||||
span({ class: 'blockchain-card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ'))
|
||||
)
|
||||
),
|
||||
div({ class: 'block-row block-row--content' },
|
||||
p({ class: 'access-denied-msg' }, i18n.blockAccessRestricted)
|
||||
)
|
||||
)
|
||||
: isDatagram
|
||||
? renderBlockDiagram([block], qs)
|
||||
: div(
|
||||
div({ class: 'block-single' },
|
||||
div({ class: 'block-row block-row--meta' },
|
||||
span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockID}:`),
|
||||
span({ class: 'blockchain-card-value' }, block.id)
|
||||
),
|
||||
div({ class: 'block-row block-row--meta' },
|
||||
span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockType}:`),
|
||||
span({ class: 'blockchain-card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())
|
||||
),
|
||||
div({ class: 'block-row block-row--meta' },
|
||||
span({ class: 'blockchain-card-label' }, `${i18n.oasisIdLabel || 'OasisID'}:`),
|
||||
a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author)
|
||||
)
|
||||
),
|
||||
div({ class:'block-row block-row--content' },
|
||||
div({ class:'block-content-preview' },
|
||||
block.content && typeof block.content.encryptedPayload === 'string'
|
||||
? (() => {
|
||||
const { renderEncryptedChip } = require('./clearnet_view');
|
||||
return div({ class: 'encrypted-payload-box' },
|
||||
div({ class: 'title-with-chip' }, renderEncryptedChip(i18n)),
|
||||
pre({ class: 'json-content' }, String(block.content.encryptedPayload).slice(0, 128) + (String(block.content.encryptedPayload).length > 128 ? '…' : ''))
|
||||
);
|
||||
})()
|
||||
: pre({ class:'json-content' }, JSON.stringify(block.content,null,2))
|
||||
)
|
||||
),
|
||||
div({ class: 'block-single' },
|
||||
div({ class: 'block-row block-row--meta' },
|
||||
span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockTimestamp}:`),
|
||||
span({ class: 'blockchain-card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ'))
|
||||
),
|
||||
div({ class: 'block-row block-row--meta' },
|
||||
span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockCarbon || 'Carbon footprint'}:`),
|
||||
span({ class: 'blockchain-card-value' }, formatCarbon(block.size))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return template(
|
||||
i18n.blockchain,
|
||||
section(
|
||||
div({ class: 'tags-header' },
|
||||
h2(i18n.blockchain),
|
||||
p(i18n.blockchainDescription)
|
||||
),
|
||||
div({ class: 'mode-buttons-row' },
|
||||
div({ class: 'filter-column' },
|
||||
generateFilterButtons(BASE_FILTERS, filter, '/blockexplorer', search)
|
||||
),
|
||||
div({ class: 'filter-column' },
|
||||
generateFilterButtons(CAT_BLOCK1, filter, '/blockexplorer', search),
|
||||
generateFilterButtons(CAT_BLOCK2, filter, '/blockexplorer', search)
|
||||
),
|
||||
div({ class: 'filter-column' },
|
||||
generateFilterButtons(CAT_BLOCK3, filter, '/blockexplorer', search),
|
||||
generateFilterButtons(CAT_BLOCK4, filter, '/blockexplorer', search)
|
||||
)
|
||||
),
|
||||
blockContent,
|
||||
div({ class:'block-row block-row--back' },
|
||||
form({ method:'GET', action:'/blockexplorer' },
|
||||
input({ type: 'hidden', name: 'filter', value: filter }),
|
||||
...hiddenSearchInputs(search),
|
||||
button({ type:'submit', class:'filter-btn' }, `← ${i18n.blockchainBack}`)
|
||||
),
|
||||
!block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
|
||||
form({ method:'GET', action:getViewDetailsAction(block.type, block) },
|
||||
button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
|
||||
)
|
||||
: (block.isTombstoned || block.isReplaced) ?
|
||||
div({ class: 'deleted-label' },
|
||||
i18n.blockchainContentDeleted || "This content has been deleted."
|
||||
)
|
||||
: null
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderBlockchainView = (blocks, filter, userId, search = {}, extras = {}) => {
|
||||
const s = search || {};
|
||||
const authorVal = String(s.author || '');
|
||||
const idVal = String(s.id || '');
|
||||
const fromVal = toDatetimeLocal(s.from);
|
||||
const toVal = toDatetimeLocal(s.to);
|
||||
const inspect = extras && extras.inspect ? extras.inspect : null;
|
||||
const inspectVal = inspect ? String(inspect.block || '') : '';
|
||||
|
||||
const shown = filterBlocks(blocks, filter, userId);
|
||||
const qs = toQueryString(filter, s);
|
||||
|
||||
return template(
|
||||
i18n.blockchain,
|
||||
section(
|
||||
div({ class:'tags-header' },
|
||||
h2(i18n.blockchain),
|
||||
p(i18n.blockchainDescription)
|
||||
),
|
||||
div({ class:'mode-buttons-row' },
|
||||
div({ class: 'filter-column' },
|
||||
generateFilterButtons(BASE_FILTERS, filter, '/blockexplorer', s)
|
||||
),
|
||||
div({ class: 'filter-column' },
|
||||
generateFilterButtons(CAT_BLOCK1, filter, '/blockexplorer', s),
|
||||
generateFilterButtons(CAT_BLOCK2, filter, '/blockexplorer', s)
|
||||
),
|
||||
div({ class: 'filter-column' },
|
||||
generateFilterButtons(CAT_BLOCK3, filter, '/blockexplorer', s),
|
||||
generateFilterButtons(CAT_BLOCK4, filter, '/blockexplorer', s)
|
||||
)
|
||||
),
|
||||
div({ class: 'blockexplorer-search' },
|
||||
form({ method: 'GET', action: '/blockexplorer', class: 'blockexplorer-search-form' },
|
||||
input({ type: 'hidden', name: 'filter', value: filter }),
|
||||
div({ class: 'blockexplorer-search-row' },
|
||||
div({ class: 'blockexplorer-search-pair' },
|
||||
input({ type: 'text', name: 'id', value: idVal, placeholder: i18n.blockchainBlockID, class: 'blockexplorer-search-input' }),
|
||||
input({ type: 'text', name: 'author', value: authorVal, placeholder: i18n.courtsJudgeIdPh, class: 'blockexplorer-search-input' })
|
||||
),
|
||||
div({ class: 'blockexplorer-search-dates' },
|
||||
input({ type: 'datetime-local', name: 'from', value: fromVal, class: 'blockexplorer-search-input' }),
|
||||
input({ type: 'datetime-local', name: 'to', value: toVal, class: 'blockexplorer-search-input' })
|
||||
),
|
||||
div({ class: 'blockexplorer-search-actions' },
|
||||
button({ type: 'submit', class: 'filter-box__button' }, i18n.searchSubmit)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
div({ id: 'inspect', class: 'blockexplorer-inspect' },
|
||||
h3(i18n.bankTaxesLookupTitle || 'Inspect a block'),
|
||||
p(i18n.bankTaxesLookupNote || 'Enter a block ID to look it up in your local feed. The inspector shows the block ID (linked to its detail page), its author, its content type, its size in bytes, its carbon footprint and its ECO tax in ECO.'),
|
||||
form({ method: 'GET', action: '/blockexplorer', class: 'blockexplorer-search-form' },
|
||||
input({ type: 'hidden', name: 'filter', value: filter }),
|
||||
div({ class: 'blockexplorer-search-row' },
|
||||
div({ class: 'blockexplorer-search-pair' },
|
||||
input({ type: 'text', name: 'inspect', value: inspectVal, placeholder: i18n.bankTaxesLookupPlaceholder || 'Block ID (e.g. %abc...=.sha256)', class: 'blockexplorer-search-input' })
|
||||
),
|
||||
div({ class: 'blockexplorer-search-actions' },
|
||||
button({ type: 'submit', class: 'filter-box__button' }, i18n.bankTaxesLookupButton || 'Inspect')
|
||||
)
|
||||
)
|
||||
),
|
||||
inspect
|
||||
? (inspect.found
|
||||
? div({ class: 'bank-taxes-lookup-result' },
|
||||
table({ class: 'bank-info-table' },
|
||||
tr(td({ class: 'card-label' }, i18n.blockchainBlockID || 'Block ID'), td({ class: 'card-value' }, a({ href: `/blockexplorer/block/${encodeURIComponent(inspect.block)}` }, inspect.block))),
|
||||
tr(td({ class: 'card-label' }, i18n.bankTaxesLookupAuthor || 'Author'), td({ class: 'card-value' }, inspect.author ? userLink(inspect.author) : '—')),
|
||||
tr(td({ class: 'card-label' }, i18n.bankTaxesLookupType || 'Type'), td({ class: 'card-value' }, String(inspect.blockType || '—'))),
|
||||
tr(td({ class: 'card-label' }, i18n.bankTaxesLookupSize || 'Size'), td({ class: 'card-value' }, `${Number(inspect.size || 0).toLocaleString()} B`)),
|
||||
tr(td({ class: 'card-label' }, i18n.bankTaxesLookupCarbon || 'Carbon footprint'), td({ class: 'card-value' }, formatCarbon(inspect.size || 0))),
|
||||
tr(td({ class: 'card-label' }, i18n.bankTaxesLookupEcoin || 'ECO Tax'), td({ class: 'card-value' }, `${Number(inspect.ecoinTax || 0).toFixed(6)} ECO`))
|
||||
)
|
||||
)
|
||||
: p({ class: 'empty' }, i18n.bankTaxesLookupNotFound || 'No block found in your local feed with that ID.'))
|
||||
: null
|
||||
),
|
||||
renderBlockDiagram(shown, qs),
|
||||
h2({ class: 'block-diagram-title' }, 'Blockchain Blocks'),
|
||||
shown.length === 0
|
||||
? div(p(i18n.blockchainNoBlocks))
|
||||
: shown
|
||||
.sort((a,b)=>{
|
||||
const ta = a.type==='market'&&a.content.updatedAt
|
||||
? new Date(a.content.updatedAt).getTime()
|
||||
: a.ts;
|
||||
const tb = b.type==='market'&&b.content.updatedAt
|
||||
? new Date(b.content.updatedAt).getTime()
|
||||
: b.ts;
|
||||
return tb - ta;
|
||||
})
|
||||
.map(block=>
|
||||
div({ class:'block' },
|
||||
div({ class:'block-buttons' },
|
||||
block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}`, class:'btn-singleview', title:i18n.blockchainDetails }, '⦿'),
|
||||
block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}&view=datagram`, class:'btn-singleview btn-datagram', title:i18n.blockchainDatagram || 'Datagram' }, '⊞'),
|
||||
!block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
|
||||
form({ method:'GET', action:getViewDetailsAction(block.type, block) },
|
||||
button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
|
||||
)
|
||||
: (block.isTombstoned || block.isReplaced) ?
|
||||
div({ class: 'deleted-label' },
|
||||
i18n.blockchainContentDeleted || "This content has been deleted."
|
||||
)
|
||||
: null
|
||||
),
|
||||
div({ class:'block-row block-row--meta' },
|
||||
table({ class:'block-info-table' },
|
||||
tr(td({ class:'card-label' }, i18n.blockchainBlockTimestamp), td({ class:'card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ'))),
|
||||
tr(td({ class:'card-label' }, i18n.blockchainBlockID), td({ class:'card-value' }, block.id)),
|
||||
tr(td({ class:'card-label' }, i18n.blockchainBlockType), td({ class:'card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())),
|
||||
tr(td({ class:'card-label' }, i18n.blockchainBlockAuthor), td({ class:'card-value' }, a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author))),
|
||||
tr(td({ class:'card-label' }, i18n.blockchainBlockCarbon || 'Carbon footprint'), td({ class:'card-value' }, formatCarbon(block.size)))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = { renderBlockchainView, renderSingleBlockView, computeStats };
|
||||
|
||||
511
src/views/bookmark_view.js
Normal file
511
src/views/bookmark_view.js
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option } =
|
||||
require("../server/node_modules/hyperaxe");
|
||||
|
||||
const { template, i18n, renderOpinionsVoting, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions } = 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 safeArr = (v) => (Array.isArray(v) ? v : []);
|
||||
const safeText = (v) => String(v || "").trim();
|
||||
|
||||
const buildReturnTo = (filter, params = {}) => {
|
||||
const f = safeText(filter || "all");
|
||||
const q = safeText(params.q || "");
|
||||
const sort = safeText(params.sort || "recent");
|
||||
const parts = [`filter=${encodeURIComponent(f)}`];
|
||||
if (q) parts.push(`q=${encodeURIComponent(q)}`);
|
||||
if (sort) parts.push(`sort=${encodeURIComponent(sort)}`);
|
||||
return `/bookmarks?${parts.join("&")}`;
|
||||
};
|
||||
|
||||
const renderPMButton = (recipient, className = "filter-btn") => {
|
||||
const r = safeText(recipient);
|
||||
if (!r) return null;
|
||||
if (String(r) === String(userId)) return null;
|
||||
|
||||
return form(
|
||||
{ method: "GET", action: "/pm" },
|
||||
input({ type: "hidden", name: "recipients", value: r }),
|
||||
button({ type: "submit", class: className }, i18n.privateMessage)
|
||||
);
|
||||
};
|
||||
|
||||
const renderBookmarkActions = (filter, bookmark, params = {}) => {
|
||||
const returnTo = buildReturnTo(filter, params);
|
||||
const isAuthor = String(bookmark.author) === String(userId);
|
||||
const hasOpinions = Object.keys(bookmark.opinions || {}).length > 0;
|
||||
|
||||
return isAuthor
|
||||
? div(
|
||||
{ class: "bookmark-actions" },
|
||||
!hasOpinions
|
||||
? form(
|
||||
{ method: "GET", action: `/bookmarks/edit/${encodeURIComponent(bookmark.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "update-btn", type: "submit" }, i18n.bookmarkUpdateButton)
|
||||
)
|
||||
: null,
|
||||
form(
|
||||
{ method: "POST", action: `/bookmarks/delete/${encodeURIComponent(bookmark.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "delete-btn", type: "submit" }, i18n.bookmarkDeleteButton)
|
||||
)
|
||||
)
|
||||
: null;
|
||||
};
|
||||
|
||||
const renderBookmarkCommentsSection = (bookmarkId, rootId, comments = [], returnTo = null) => {
|
||||
const list = safeArr(comments).filter(c => {
|
||||
const t = c && c.value && c.value.content && c.value.content.text;
|
||||
return t && String(t).trim();
|
||||
});
|
||||
const commentsCount = list.length;
|
||||
|
||||
return div(
|
||||
{ class: "vote-comments-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
|
||||
span({ class: "card-value" }, String(commentsCount))
|
||||
),
|
||||
div(
|
||||
{ class: "comment-form-wrapper" },
|
||||
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
|
||||
form(
|
||||
{ method: "POST", action: `/bookmarks/${encodeURIComponent(bookmarkId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
|
||||
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
||||
rootId ? input({ type: "hidden", name: "rootId", value: rootId }) : null,
|
||||
textarea({
|
||||
id: "comment-text",
|
||||
name: "text",
|
||||
rows: 4,
|
||||
class: "comment-textarea",
|
||||
placeholder: i18n.voteNewCommentPlaceholder
|
||||
}),
|
||||
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
|
||||
br(),
|
||||
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
|
||||
)
|
||||
),
|
||||
list.length
|
||||
? div(
|
||||
{ class: "comments-list" },
|
||||
list.map((c) => {
|
||||
const author = c?.value?.author || "";
|
||||
const ts = c?.value?.timestamp || c?.timestamp;
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
|
||||
const relDate = ts ? moment(ts).fromNow() : "";
|
||||
|
||||
const content = c?.value?.content || {};
|
||||
const text = content.text || "";
|
||||
const threadRoot = content.fork || content.root || null;
|
||||
|
||||
return div(
|
||||
{ class: "votations-comment-card" },
|
||||
span(
|
||||
{ class: "created-at" },
|
||||
span(i18n.createdBy),
|
||||
author ? userLink(author) : span("(unknown)"),
|
||||
absDate ? span(" | ") : "",
|
||||
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
|
||||
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
|
||||
relDate && threadRoot
|
||||
? a({ href: `/thread/${encodeURIComponent(threadRoot)}#${encodeURIComponent(c.key)}` }, relDate)
|
||||
: ""
|
||||
),
|
||||
p({ class: "votations-comment-text" }, ...renderUrl(text))
|
||||
);
|
||||
})
|
||||
)
|
||||
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
|
||||
);
|
||||
};
|
||||
|
||||
const renderCardField = (labelText, value) =>
|
||||
div(
|
||||
{ class: "card-field" },
|
||||
span({ class: "card-label" }, labelText),
|
||||
span({ class: "card-value" }, value)
|
||||
);
|
||||
|
||||
const renderFavoriteToggle = (bookmark, returnTo) =>
|
||||
form(
|
||||
{
|
||||
method: "POST",
|
||||
action: bookmark.isFavorite
|
||||
? `/bookmarks/favorites/remove/${encodeURIComponent(bookmark.id)}`
|
||||
: `/bookmarks/favorites/add/${encodeURIComponent(bookmark.id)}`,
|
||||
class: "bookmark-favorite-form"
|
||||
},
|
||||
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
||||
button(
|
||||
{ type: "submit", class: "filter-btn" },
|
||||
bookmark.isFavorite ? i18n.bookmarkRemoveFavoriteButton : i18n.bookmarkAddFavoriteButton
|
||||
)
|
||||
);
|
||||
|
||||
const renderTags = (tags) => {
|
||||
const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
|
||||
return list.length
|
||||
? div(
|
||||
{ class: "card-tags" },
|
||||
list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
|
||||
)
|
||||
: null;
|
||||
};
|
||||
|
||||
const renderBookmarkList = (filteredBookmarks, filter, params = {}) => {
|
||||
const returnTo = buildReturnTo(filter, params);
|
||||
|
||||
return filteredBookmarks.length
|
||||
? filteredBookmarks.map((bookmark) => {
|
||||
const commentCount = typeof bookmark.commentCount === "number" ? bookmark.commentCount : 0;
|
||||
|
||||
const lastVisit = bookmark.lastVisit ? moment(bookmark.lastVisit) : null;
|
||||
const lastVisitTxt =
|
||||
lastVisit && lastVisit.isValid()
|
||||
? `${lastVisit.format("YYYY/MM/DD HH:mm:ss")} (${lastVisit.fromNow()})`
|
||||
: i18n.noLastVisit;
|
||||
|
||||
const urlLink = bookmark.url
|
||||
? a({ href: bookmark.url, target: "_blank", rel: "noreferrer noopener", class: "bookmark-url" }, bookmark.url)
|
||||
: i18n.noUrl;
|
||||
|
||||
const isOwn = bookmark.author && String(bookmark.author) === String(userId);
|
||||
return div(
|
||||
{ class: "trending-card bookmark-card" + (isOwn ? " own-content" : "") },
|
||||
div(
|
||||
{ class: "card-header activity-card-header" },
|
||||
span(),
|
||||
renderContentActions(bookmark.id, `/bookmarks/${encodeURIComponent(bookmark.id)}`)
|
||||
),
|
||||
div(
|
||||
{ class: "card-section bookmark-card-body" },
|
||||
div(
|
||||
{ class: "bookmark-topbar" },
|
||||
div(
|
||||
{ class: "bookmark-topbar-left" },
|
||||
renderPMButton(bookmark.author),
|
||||
renderFavoriteToggle(bookmark, returnTo)
|
||||
),
|
||||
div(
|
||||
{ class: "bookmark-topbar-right" },
|
||||
renderBookmarkActions(filter, bookmark, params)
|
||||
)
|
||||
),
|
||||
h2({ class: "bookmark-title" }, bookmark.category || bookmark.url || ""),
|
||||
bookmark.lifetime ? div({ class: "card-chips-row" }, renderLifespanChip(bookmark.lifetime, i18n)) : null,
|
||||
renderCardField(i18n.bookmarkUrlLabel + ":", urlLink),
|
||||
renderCardField(i18n.bookmarkLastVisitLabel + ":", lastVisitTxt),
|
||||
br,
|
||||
div(
|
||||
{ class: "card-comments-summary" },
|
||||
span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
|
||||
span({ class: "card-value" }, String(commentCount)),
|
||||
br(),
|
||||
br(),
|
||||
form(
|
||||
{ method: "GET", action: `/bookmarks/${encodeURIComponent(bookmark.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
input({ type: "hidden", name: "filter", value: filter || "all" }),
|
||||
params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
|
||||
params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
|
||||
)
|
||||
),
|
||||
div(
|
||||
{ class: "card-comments-summary feed-opinions-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "),
|
||||
span({ class: "card-value" }, String(Object.values(bookmark.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)))
|
||||
),
|
||||
renderOpinionsVoting('/bookmarks/opinions', bookmark.id, bookmark.opinions, returnTo, bookmark.opinions_inhabitants)
|
||||
),
|
||||
div({ class: "card-spread-left" }, renderSpreadButton(bookmark.id, (params.spreadMap && params.spreadMap.get(bookmark.id)) || params.spreads)),
|
||||
(() => {
|
||||
const createdTs = bookmark.createdAt ? new Date(bookmark.createdAt).getTime() : NaN;
|
||||
const updatedTs = bookmark.updatedAt ? new Date(bookmark.updatedAt).getTime() : NaN;
|
||||
const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
|
||||
|
||||
return p(
|
||||
{ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${moment(bookmark.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
|
||||
userLink(bookmark.author),
|
||||
showUpdated
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
` | ${i18n.bookmarkUpdatedAt}: ${moment(bookmark.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
|
||||
)
|
||||
: null
|
||||
);
|
||||
})()
|
||||
)
|
||||
);
|
||||
})
|
||||
: p(params.q ? i18n.bookmarkNoMatch : i18n.noBookmarks);
|
||||
};
|
||||
|
||||
const renderBookmarkForm = (filter, bookmarkId, bookmarkToEdit, tags, params = {}) => {
|
||||
const returnFilter = filter === "create" ? "all" : params.filter || "all";
|
||||
const returnTo = params.returnTo || buildReturnTo(returnFilter, params);
|
||||
|
||||
const lastVisitValue =
|
||||
bookmarkToEdit?.lastVisit && moment(bookmarkToEdit.lastVisit).isValid()
|
||||
? moment(bookmarkToEdit.lastVisit).format("YYYY-MM-DDTHH:mm")
|
||||
: "";
|
||||
|
||||
const lastVisitMax = moment().format("YYYY-MM-DDTHH:mm");
|
||||
|
||||
return div(
|
||||
{ class: "div-center bookmark-form" },
|
||||
form(
|
||||
{ action: filter === "edit" ? `/bookmarks/update/${encodeURIComponent(bookmarkId)}` : "/bookmarks/create", method: "POST" },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
label(i18n.bookmarkUrlLabel),
|
||||
br(),
|
||||
input({
|
||||
type: "url",
|
||||
name: "url",
|
||||
id: "url",
|
||||
required: true,
|
||||
placeholder: i18n.bookmarkUrlPlaceholder,
|
||||
value: filter === "edit" ? bookmarkToEdit.url || "" : ""
|
||||
}),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.bookmarkDescriptionLabel),
|
||||
br(),
|
||||
textarea(
|
||||
{ name: "description", id: "description", placeholder: i18n.bookmarkDescriptionPlaceholder, rows: "4" },
|
||||
filter === "edit" ? bookmarkToEdit.description || "" : ""
|
||||
),
|
||||
br(),
|
||||
label(i18n.bookmarkLastVisitLabel),
|
||||
br(),
|
||||
input({
|
||||
type: "datetime-local",
|
||||
name: "lastVisit",
|
||||
max: lastVisitMax,
|
||||
value: filter === "edit" ? lastVisitValue : ""
|
||||
}),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.bookmarkCategoryLabel),
|
||||
br(),
|
||||
input({
|
||||
type: "text",
|
||||
name: "category",
|
||||
id: "category",
|
||||
placeholder: i18n.bookmarkCategoryPlaceholder,
|
||||
value: filter === "edit" ? bookmarkToEdit.category || "" : ""
|
||||
}),
|
||||
br(),
|
||||
label(i18n.bookmarkTagsLabel),
|
||||
br(),
|
||||
input({
|
||||
type: "text",
|
||||
name: "tags",
|
||||
id: "tags",
|
||||
placeholder: i18n.bookmarkTagsPlaceholder,
|
||||
value: filter === "edit" ? safeArr(tags).join(", ") : ""
|
||||
}),
|
||||
br(),
|
||||
br(),
|
||||
button({ type: "submit" }, filter === "edit" ? i18n.bookmarkUpdateButton : i18n.bookmarkCreateButton)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.bookmarkView = async (bookmarks, filter = "all", bookmarkId = null, params = {}) => {
|
||||
const title =
|
||||
filter === "mine"
|
||||
? i18n.bookmarkMineSectionTitle
|
||||
: filter === "create"
|
||||
? i18n.bookmarkCreateSectionTitle
|
||||
: filter === "edit"
|
||||
? i18n.bookmarkUpdateSectionTitle
|
||||
: filter === "recent"
|
||||
? i18n.bookmarkRecentSectionTitle
|
||||
: filter === "top"
|
||||
? i18n.bookmarkTopSectionTitle
|
||||
: filter === "favorites"
|
||||
? i18n.bookmarkFavoritesSectionTitle
|
||||
: i18n.bookmarkAllSectionTitle;
|
||||
|
||||
const q = safeText(params.q || "");
|
||||
const sort = safeText(params.sort || "recent");
|
||||
|
||||
const list = safeArr(bookmarks);
|
||||
const bookmarkToEdit = bookmarkId ? list.find((b) => b.id === bookmarkId) : null;
|
||||
const tags = bookmarkToEdit && Array.isArray(bookmarkToEdit.tags) ? bookmarkToEdit.tags : [];
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: "tags-header" }, h2(title), p(i18n.bookmarkDescription)),
|
||||
(() => {
|
||||
const { renderReachChip } = require('./clearnet_view');
|
||||
const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetBookmarks);
|
||||
return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n));
|
||||
})(),
|
||||
br(),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/bookmarks", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterTop),
|
||||
button({ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterFavorites),
|
||||
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterRecent),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.bookmarkCreateButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
filter === "edit" || filter === "create"
|
||||
? renderBookmarkForm(filter, bookmarkId, bookmarkToEdit || {}, tags, { ...params, filter })
|
||||
: section(
|
||||
div(
|
||||
{ class: "bookmarks-search" },
|
||||
form(
|
||||
{ method: "GET", action: "/bookmarks", class: "filter-box" },
|
||||
input({ type: "hidden", name: "filter", value: filter }),
|
||||
input({ type: "text", name: "q", value: q, placeholder: i18n.bookmarkSearchPlaceholder, class: "filter-box__input" }),
|
||||
div(
|
||||
{ class: "filter-box__controls" },
|
||||
select(
|
||||
{ name: "sort", class: "filter-box__select" },
|
||||
option({ value: "recent", selected: sort === "recent" }, i18n.bookmarkSortRecent),
|
||||
option({ value: "oldest", selected: sort === "oldest" }, i18n.bookmarkSortOldest),
|
||||
option({ value: "top", selected: sort === "top" }, i18n.bookmarkSortTop)
|
||||
),
|
||||
button({ type: "submit", class: "filter-box__button" }, i18n.bookmarkSearchButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
div({ class: "bookmark-list" }, renderBookmarkList(list, filter, { q, sort }))
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.singleBookmarkView = async (bookmark, filter = "all", comments = [], params = {}) => {
|
||||
const q = safeText(params.q || "");
|
||||
const sort = safeText(params.sort || "recent");
|
||||
const returnTo = params.returnTo || buildReturnTo(filter, { q, sort });
|
||||
|
||||
const isAuthor = String(bookmark.author) === String(userId);
|
||||
const hasOpinions = Object.keys(bookmark.opinions || {}).length > 0;
|
||||
const { renderReachChip } = require('./clearnet_view');
|
||||
const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetBookmarks);
|
||||
|
||||
const lastVisit = bookmark.lastVisit ? moment(bookmark.lastVisit) : null;
|
||||
const lastVisitTxt =
|
||||
lastVisit && lastVisit.isValid()
|
||||
? `${lastVisit.format("YYYY/MM/DD HH:mm:ss")} (${lastVisit.fromNow()})`
|
||||
: i18n.noLastVisit;
|
||||
|
||||
const urlLink = bookmark.url
|
||||
? a({ href: bookmark.url, target: "_blank", rel: "noreferrer noopener", class: "bookmark-url" }, bookmark.url)
|
||||
: i18n.noUrl;
|
||||
|
||||
const chips = [
|
||||
renderLifespanChip(bookmark.lifetime, i18n),
|
||||
bookmark.sizeBytes ? renderEcoTax(bookmark.sizeBytes, bookmark.id) : null
|
||||
].filter(Boolean);
|
||||
|
||||
const sideActions = [];
|
||||
sideActions.push(renderFavoriteToggle(bookmark, returnTo));
|
||||
if (bookmark.author && String(bookmark.author) !== String(userId)) {
|
||||
sideActions.push(renderPMButton(bookmark.author));
|
||||
}
|
||||
if (isAuthor && !hasOpinions) {
|
||||
sideActions.push(form(
|
||||
{ method: "GET", action: `/bookmarks/edit/${encodeURIComponent(bookmark.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "update-btn", type: "submit" }, i18n.bookmarkUpdateButton)
|
||||
));
|
||||
}
|
||||
if (isAuthor) {
|
||||
sideActions.push(form(
|
||||
{ method: "POST", action: `/bookmarks/delete/${encodeURIComponent(bookmark.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "delete-btn", type: "submit" }, i18n.bookmarkDeleteButton)
|
||||
));
|
||||
}
|
||||
|
||||
const tagsNode = renderTags(bookmark.tags);
|
||||
|
||||
const bookmarkSide = div({ class: "tribe-side" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, bookmark.category || bookmark.url || ""),
|
||||
renderReachChip(isClearnet, i18n)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
safeText(bookmark.description)
|
||||
? p({ class: "tribe-side-description" }, ...renderUrl(bookmark.description))
|
||||
: null,
|
||||
safeText(bookmark.category)
|
||||
? renderCardField(i18n.bookmarkCategoryLabel + ":", safeText(bookmark.category))
|
||||
: null,
|
||||
tagsNode,
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(bookmark.id, params.spreads)),
|
||||
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null
|
||||
);
|
||||
|
||||
const bookmarkMain = div({ class: "tribe-main" },
|
||||
renderCardField(i18n.bookmarkUrlLabel + ":", urlLink),
|
||||
renderCardField(i18n.bookmarkLastVisitLabel + ":", lastVisitTxt),
|
||||
(() => {
|
||||
const createdTs = bookmark.createdAt ? new Date(bookmark.createdAt).getTime() : NaN;
|
||||
const updatedTs = bookmark.updatedAt ? new Date(bookmark.updatedAt).getTime() : NaN;
|
||||
const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
|
||||
return p(
|
||||
{ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${moment(bookmark.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
|
||||
userLink(bookmark.author),
|
||||
showUpdated
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
` | ${i18n.bookmarkUpdatedAt}: ${moment(bookmark.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
|
||||
)
|
||||
: null
|
||||
);
|
||||
})(),
|
||||
renderOpinionsVoting('/bookmarks/opinions', bookmark.id, bookmark.opinions, returnTo, bookmark.opinions_inhabitants),
|
||||
renderBookmarkCommentsSection(bookmark.id, bookmark.rootId, comments, returnTo)
|
||||
);
|
||||
|
||||
return template(
|
||||
i18n.bookmarkTitle,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.bookmarkAllSectionTitle || i18n.bookmarkTitle),
|
||||
p(i18n.bookmarkDescription)
|
||||
),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/bookmarks", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterTop),
|
||||
button({ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterFavorites),
|
||||
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterRecent),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.bookmarkCreateButton)
|
||||
)
|
||||
),
|
||||
div({ class: "tribe-details" }, bookmarkSide, bookmarkMain)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
444
src/views/calendars_view.js
Normal file
444
src/views/calendars_view.js
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
const { div, h2, h3, h4, p, section, button, form, a, span, br, textarea, input, label, select, option, table, tr, td, ul, li } = require("../server/node_modules/hyperaxe")
|
||||
const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton } = require("./main_views")
|
||||
const { renderEncryptedChip } = require("./clearnet_view")
|
||||
const moment = require("../server/node_modules/moment")
|
||||
const { config } = require("../server/SSB_server.js")
|
||||
|
||||
const userId = config.keys.id
|
||||
|
||||
const renderNoteText = (text) => {
|
||||
if (!text) return []
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g
|
||||
const result = []
|
||||
let last = 0
|
||||
text.replace(urlRegex, (match, _g, offset) => {
|
||||
if (offset > last) result.push(text.slice(last, offset))
|
||||
result.push(a({ href: match, target: "_blank" }, match))
|
||||
last = offset + match.length
|
||||
})
|
||||
if (last < text.length) result.push(text.slice(last))
|
||||
return result
|
||||
}
|
||||
|
||||
const renderCalendarFavoriteToggle = (cal, returnTo) =>
|
||||
form(
|
||||
{ method: "POST", action: cal.isFavorite ? `/calendars/favorites/remove/${encodeURIComponent(cal.rootId)}` : `/calendars/favorites/add/${encodeURIComponent(cal.rootId)}` },
|
||||
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
||||
button({ type: "submit", class: "tribe-action-btn" }, cal.isFavorite ? (i18n.calendarRemoveFavorite || "Remove Favorite") : (i18n.calendarAddFavorite || "Add Favorite"))
|
||||
)
|
||||
|
||||
const renderModeButtons = (currentFilter) =>
|
||||
div({ class: "tribe-mode-buttons" },
|
||||
["all", "mine", "recent", "favorites", "open", "closed"].map(f =>
|
||||
form({ method: "GET", action: "/calendars" },
|
||||
input({ type: "hidden", name: "filter", value: f }),
|
||||
button({ type: "submit", class: currentFilter === f ? "filter-btn active" : "filter-btn" },
|
||||
i18n[`calendarFilter${f.charAt(0).toUpperCase() + f.slice(1)}`] || f.toUpperCase())
|
||||
)
|
||||
),
|
||||
form({ method: "GET", action: "/calendars" },
|
||||
input({ type: "hidden", name: "filter", value: "create" }),
|
||||
button({ type: "submit", class: "create-button" }, i18n.calendarCreate || "Create Calendar")
|
||||
)
|
||||
)
|
||||
|
||||
const renderStatus = (cal) => {
|
||||
if (cal.isClosed) return span({ class: "pad-status-closed" }, i18n.calendarStatusClosed || "CLOSED")
|
||||
return span({ class: "pad-status-open" }, i18n.calendarStatusOpen || "OPEN")
|
||||
}
|
||||
|
||||
const renderCalendarStatusChip = (cal) => {
|
||||
const isClosed = !!cal.isClosed
|
||||
const variant = isClosed ? "closed" : "mutuals"
|
||||
const icon = isClosed ? "\u2717" : "\u2713"
|
||||
const label = isClosed ? (i18n.calendarStatusClosed || "CLOSED") : (i18n.calendarStatusOpen || "OPEN")
|
||||
return renderStateChip(variant, icon, label)
|
||||
}
|
||||
|
||||
const renderCalendarCard = (cal, spreadInfo) => {
|
||||
const href = `/calendars/${encodeURIComponent(cal.rootId)}`
|
||||
const chips = [
|
||||
renderCalendarStatusChip(cal),
|
||||
renderEncryptedChip(i18n),
|
||||
renderLifespanChip(cal.lifetime, i18n)
|
||||
].filter(Boolean)
|
||||
return div({ class: "tribe-card" },
|
||||
div({ class: "tribe-card-body" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, a({ href }, cal.title || "\u2014"))
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
cal.deadline
|
||||
? p({ class: "job-meta-line" }, `${i18n.calendarDeadlineLabel || "Deadline"}: ${moment(cal.deadline).format("YYYY-MM-DD HH:mm")}`)
|
||||
: null,
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count calendar-participants-count" }, `${i18n.calendarParticipantsLabel || "Participants"}: ${cal.participants.length}`)
|
||||
),
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(cal.rootId, spreadInfo)),
|
||||
div({ class: "card-visit-btn-centered" },
|
||||
a({ href, class: "filter-btn" }, i18n.calendarVisitCalendar || "Visit Calendar")
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const renderIntervalBlock = () =>
|
||||
div({ class: "calendar-interval-block" },
|
||||
span({ class: "calendar-interval-label" }, i18n.calendarIntervalLabel || "Interval"),
|
||||
div({ class: "calendar-interval-row" },
|
||||
input({ type: "hidden", name: "intervalWeekly", value: "0" }),
|
||||
label({ class: "calendar-interval-option" },
|
||||
input({ type: "checkbox", name: "intervalWeekly", value: "1" }),
|
||||
" ", i18n.calendarIntervalWeekly || "Weekly"
|
||||
),
|
||||
input({ type: "hidden", name: "intervalMonthly", value: "0" }),
|
||||
label({ class: "calendar-interval-option" },
|
||||
input({ type: "checkbox", name: "intervalMonthly", value: "1" }),
|
||||
" ", i18n.calendarIntervalMonthly || "Monthly"
|
||||
),
|
||||
input({ type: "hidden", name: "intervalYearly", value: "0" }),
|
||||
label({ class: "calendar-interval-option" },
|
||||
input({ type: "checkbox", name: "intervalYearly", value: "1" }),
|
||||
" ", i18n.calendarIntervalYearly || "Yearly"
|
||||
)
|
||||
),
|
||||
span({ class: "calendar-interval-label calendar-interval-until" }, i18n.calendarIntervalUntil || "Until"),
|
||||
input({ type: "datetime-local", name: "intervalDeadline" }),
|
||||
br()
|
||||
)
|
||||
|
||||
const renderCreateForm = (calendarToEdit, params) => {
|
||||
const isEdit = !!calendarToEdit
|
||||
const tribeId = (params && params.tribeId) || ""
|
||||
const now = moment().add(1, "minute").format("YYYY-MM-DDTHH:mm")
|
||||
const action = isEdit ? `/calendars/update/${encodeURIComponent(calendarToEdit.rootId)}` : "/calendars/create"
|
||||
const sectionTitle = isEdit ? (i18n.calendarUpdateSectionTitle || "Update Calendar") : (i18n.calendarCreateSectionTitle || "Create New Calendar")
|
||||
return div({ class: "div-center audio-form" },
|
||||
h2(sectionTitle),
|
||||
form({ method: "POST", action },
|
||||
tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null,
|
||||
span(i18n.calendarTitleLabel || "Title"), br(),
|
||||
input({ type: "text", name: "title", required: true, placeholder: i18n.calendarTitlePlaceholder || "Calendar title...", value: calendarToEdit ? calendarToEdit.title : "" }),
|
||||
br(), br(),
|
||||
span(i18n.calendarStatusLabel || "Status"), br(),
|
||||
select({ name: "status", required: true },
|
||||
option({ value: "OPEN", ...((!calendarToEdit || calendarToEdit.status === "OPEN") ? { selected: true } : {}) }, i18n.calendarStatusOpen || "OPEN"),
|
||||
option({ value: "CLOSED", ...((calendarToEdit && calendarToEdit.status === "CLOSED") ? { selected: true } : {}) }, i18n.calendarStatusClosed || "CLOSED")
|
||||
),
|
||||
br(), br(),
|
||||
span(i18n.calendarDeadlineLabel || "Deadline"), br(),
|
||||
input({ type: "datetime-local", name: "deadline", required: true, min: now, value: calendarToEdit && calendarToEdit.deadline ? moment(calendarToEdit.deadline).format("YYYY-MM-DDTHH:mm") : "" }),
|
||||
br(), br(),
|
||||
span(i18n.calendarTagsLabel || "Tags"), br(),
|
||||
input({ type: "text", name: "tags", placeholder: i18n.calendarTagsPlaceholder || "tag1, tag2...", value: calendarToEdit && Array.isArray(calendarToEdit.tags) ? calendarToEdit.tags.join(", ") : "" }),
|
||||
br(), br(),
|
||||
!isEdit
|
||||
? [
|
||||
span(i18n.calendarFirstDateLabel || "Date"), br(),
|
||||
input({ type: "datetime-local", name: "firstDate", required: true, min: now }),
|
||||
br(), br(),
|
||||
span(i18n.calendarFormDescription || "Description"), br(),
|
||||
input({ type: "text", name: "firstDateLabel", placeholder: i18n.calendarDatePlaceholder || "Describe this date..." }),
|
||||
br(), br(),
|
||||
renderIntervalBlock(),
|
||||
span(i18n.calendarFirstNoteLabel || "Notes"), br(),
|
||||
textarea({ name: "firstNote", rows: "3", placeholder: i18n.calendarNotePlaceholder || "Add a note..." }),
|
||||
br(), br()
|
||||
]
|
||||
: null,
|
||||
button({ type: "submit", class: "create-button" }, isEdit ? (i18n.calendarUpdate || "Update") : (i18n.calendarCreate || "Create Calendar"))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const renderMonthGrid = (year, month, datesMap, calendarId) => {
|
||||
const DAY_NAMES = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"]
|
||||
const firstDay = new Date(year, month, 1)
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||
const startPad = (firstDay.getDay() + 6) % 7
|
||||
const monthStr = `${year}-${String(month + 1).padStart(2, "0")}`
|
||||
|
||||
const headerCells = DAY_NAMES.map(d => div({ class: "calendar-day-header" }, d))
|
||||
const cells = []
|
||||
|
||||
for (let i = 0; i < startPad; i++) cells.push(div({ class: "calendar-day calendar-day-empty" }, " "))
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
|
||||
const marked = datesMap && datesMap[dateStr] && datesMap[dateStr].length > 0
|
||||
if (marked) {
|
||||
cells.push(
|
||||
div({ class: "calendar-day calendar-day-marked" },
|
||||
a({ href: `/calendars/${encodeURIComponent(calendarId)}?month=${monthStr}&day=${dateStr}` }, String(day))
|
||||
)
|
||||
)
|
||||
} else {
|
||||
cells.push(div({ class: "calendar-day" }, String(day)))
|
||||
}
|
||||
}
|
||||
|
||||
return div({ class: "calendar-grid" }, ...headerCells, ...cells)
|
||||
}
|
||||
|
||||
exports.renderCalendarInvitePage = (code) => {
|
||||
const pageContent = div({ class: "invite-page" },
|
||||
h2(i18n.tribeInviteCodeText, code),
|
||||
form({ method: "GET", action: "/calendars" },
|
||||
input({ type: "hidden", name: "filter", value: "all" }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.walletBack)
|
||||
)
|
||||
)
|
||||
return template(i18n.calendarGenerateInvite || "Invite", section(pageContent))
|
||||
}
|
||||
|
||||
exports.calendarsView = async (calendars, filter, calendarToEdit, params) => {
|
||||
const q = (params && params.q) || ""
|
||||
const showForm = filter === "create" || filter === "edit" || !!calendarToEdit
|
||||
const headerMap = {
|
||||
all: i18n.calendarAllSectionTitle || "Calendars",
|
||||
mine: i18n.calendarMineSectionTitle || "Your Calendars",
|
||||
recent: i18n.calendarRecentSectionTitle || "Recent Calendars",
|
||||
favorites: i18n.calendarFavoritesSectionTitle || "Favorites",
|
||||
open: i18n.calendarOpenSectionTitle || "Open Calendars",
|
||||
closed: i18n.calendarClosedSectionTitle || "Closed Calendars"
|
||||
}
|
||||
const headerText = headerMap[filter] || headerMap.all
|
||||
|
||||
return template(
|
||||
i18n.calendarsTitle || "Calendars",
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(headerText),
|
||||
p(i18n.calendarsDescription || "Discover and manage calendars.")
|
||||
),
|
||||
renderModeButtons(filter),
|
||||
q
|
||||
? div({ class: "filters" },
|
||||
form({ method: "GET", action: "/calendars" },
|
||||
input({ type: "text", name: "q", value: q, placeholder: i18n.calendarSearchPlaceholder || "Search calendars..." }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.searchButton || "Search")
|
||||
)
|
||||
)
|
||||
: null
|
||||
),
|
||||
section(
|
||||
showForm
|
||||
? renderCreateForm(calendarToEdit, params)
|
||||
: (calendars.length > 0
|
||||
? div({ class: "tribe-grid" }, ...calendars.map(c => renderCalendarCard(c, params && params.spreadMap && params.spreadMap.get(c.rootId))))
|
||||
: p({ class: "no-content" }, i18n.calendarsNoItems || "No calendars found."))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
exports.singleCalendarView = async (calendar, dates, notesByDate, params) => {
|
||||
const { month: monthStr, day: selectedDay } = params || {}
|
||||
const isAuthor = calendar.author === userId
|
||||
const isParticipant = calendar.participants.includes(userId)
|
||||
const calClosed = calendar.isClosed
|
||||
const shareUrl = `/calendars/${encodeURIComponent(calendar.rootId)}`
|
||||
|
||||
const now = moment()
|
||||
const currentMonth = monthStr ? moment(monthStr, "YYYY-MM") : now.clone().startOf("month")
|
||||
const prevMonth = currentMonth.clone().subtract(1, "month").format("YYYY-MM")
|
||||
const nextMonth = currentMonth.clone().add(1, "month").format("YYYY-MM")
|
||||
const year = currentMonth.year()
|
||||
const month = currentMonth.month()
|
||||
|
||||
const datesMap = {}
|
||||
for (const d of dates) {
|
||||
const dayKey = moment(d.date).format("YYYY-MM-DD")
|
||||
if (!datesMap[dayKey]) datesMap[dayKey] = []
|
||||
datesMap[dayKey].push(d)
|
||||
}
|
||||
|
||||
const tags = Array.isArray(calendar.tags) && calendar.tags.length > 0
|
||||
? div({ class: "tribe-side-tags" }, ...calendar.tags.map(t => a({ href: `/search?query=%23${encodeURIComponent(t)}` }, `#${t} `)))
|
||||
: null
|
||||
|
||||
const detailChips = [
|
||||
renderCalendarStatusChip(calendar),
|
||||
renderEncryptedChip(i18n),
|
||||
renderLifespanChip(calendar.lifetime, i18n)
|
||||
].filter(Boolean)
|
||||
const calSide = div({ class: "tribe-side" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, calendar.title || "\u2014")
|
||||
),
|
||||
detailChips.length ? div({ class: "card-chips-row" }, ...detailChips) : null,
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(calendar.rootId, params && params.spreads)),
|
||||
div({ class: "shop-share" },
|
||||
span({ class: "tribe-info-label" }, i18n.calendarsShareUrl || "Share URL"),
|
||||
input({ type: "text", readonly: true, value: shareUrl, class: "shop-share-input" })
|
||||
),
|
||||
table({ class: "tribe-info-table" },
|
||||
tr(td({ class: "tribe-info-label" }, i18n.calendarCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(calendar.createdAt).format("YYYY-MM-DD"))),
|
||||
tr(td({ class: "tribe-info-label" }, i18n.calendarStatusLabel || "Status"), td({ class: "tribe-info-value", colspan: "3" }, renderStatus(calendar))),
|
||||
tr(td({ class: "tribe-info-value", colspan: "4" }, userLink(calendar.author))),
|
||||
calendar.deadline ? tr(td({ class: "tribe-info-label" }, i18n.calendarDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, moment(calendar.deadline).format("YYYY-MM-DD HH:mm"))) : null
|
||||
),
|
||||
div({ class: "tribe-side-actions" },
|
||||
renderCalendarFavoriteToggle(calendar, shareUrl),
|
||||
isAuthor
|
||||
? form({ method: "GET", action: "/calendars" },
|
||||
input({ type: "hidden", name: "filter", value: "edit" }),
|
||||
input({ type: "hidden", name: "id", value: calendar.rootId }),
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.calendarUpdate || "Update")
|
||||
)
|
||||
: null,
|
||||
isAuthor && calendar.status !== "OPEN"
|
||||
? form({ method: "POST", action: `/calendars/generate-invite/${encodeURIComponent(calendar.rootId)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeGenerateInvite)
|
||||
)
|
||||
: null,
|
||||
(() => {
|
||||
if (!(isAuthor && !calendar.tribeId)) return null
|
||||
const invs = Array.isArray(calendar.invites) ? calendar.invites : []
|
||||
const openInvite = invs.find(inv => typeof inv === "object" && inv && inv.public === true && inv.code)
|
||||
if (openInvite) return [
|
||||
div({ class: "tribe-open-invite" },
|
||||
span({ class: "card-label" }, i18n.tribeInviteCodeText),
|
||||
span({ class: "tribe-open-invite-code" }, openInvite.code)
|
||||
),
|
||||
form({ method: "POST", action: `/calendars/open-invite/remove/${encodeURIComponent(calendar.rootId)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeRemoveInvitation)
|
||||
)
|
||||
]
|
||||
return form({ method: "POST", action: `/calendars/open-invite/create/${encodeURIComponent(calendar.rootId)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeOpenInvitation)
|
||||
)
|
||||
})(),
|
||||
isAuthor
|
||||
? form({ method: "POST", action: `/calendars/delete/${encodeURIComponent(calendar.rootId)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.calendarDelete || "Delete")
|
||||
)
|
||||
: null,
|
||||
!isAuthor
|
||||
? a({ href: `/pm?to=${encodeURIComponent(calendar.author)}`, class: "tribe-action-btn" }, "PM")
|
||||
: null,
|
||||
!isAuthor && !isParticipant && calendar.status === "OPEN"
|
||||
? form({ method: "POST", action: `/calendars/join/${encodeURIComponent(calendar.rootId)}` },
|
||||
button({ type: "submit", class: "create-button" }, i18n.calendarJoin || "Join Calendar")
|
||||
)
|
||||
: null,
|
||||
!isAuthor && !isParticipant && calendar.status !== "OPEN"
|
||||
? a({ class: "tribe-action-btn", href: "/invites#invites-calendars" }, i18n.tribeEnterInvite)
|
||||
: null,
|
||||
!isAuthor && isParticipant
|
||||
? form({ method: "POST", action: `/calendars/leave/${encodeURIComponent(calendar.rootId)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeLeaveButton)
|
||||
)
|
||||
: null
|
||||
),
|
||||
tags,
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count calendar-participants-count" }, `${i18n.calendarParticipantsLabel || "Participants"}: ${calendar.participants.length}`)
|
||||
)
|
||||
)
|
||||
|
||||
const minDate = now.add(1, "minute").format("YYYY-MM-DDTHH:mm")
|
||||
const canAddDate = !calClosed && (calendar.status === "OPEN" || isAuthor)
|
||||
|
||||
const unifiedForm = canAddDate
|
||||
? div({ class: "div-center audio-form" },
|
||||
h4(i18n.calendarAddEntry || "Add Entry"),
|
||||
form({ method: "POST", action: `/calendars/add-date/${encodeURIComponent(calendar.rootId)}` },
|
||||
span(i18n.calendarDateLabel || "Date"), br(),
|
||||
input({ type: "datetime-local", name: "date", required: true, min: minDate }),
|
||||
br(), br(),
|
||||
span(i18n.calendarFormDescription || "Description"), br(),
|
||||
input({ type: "text", name: "label", placeholder: i18n.calendarDatePlaceholder || "Describe this date..." }),
|
||||
br(), br(),
|
||||
renderIntervalBlock(),
|
||||
br(),
|
||||
isParticipant
|
||||
? [
|
||||
span(i18n.calendarNoteLabel || "Note (optional)"), br(),
|
||||
textarea({ name: "text", rows: "3", placeholder: i18n.calendarNotePlaceholder || "Add a note..." }),
|
||||
br(), br()
|
||||
]
|
||||
: null,
|
||||
button({ type: "submit", class: "create-button" }, i18n.calendarAddEntry || "Add Entry")
|
||||
)
|
||||
)
|
||||
: null
|
||||
|
||||
const monthLabel = currentMonth.format("MMMM YYYY")
|
||||
const calNav = div({ class: "calendar-nav" },
|
||||
a({ href: `${shareUrl}?month=${prevMonth}`, class: "filter-btn" }, i18n.calendarMonthPrev || "\u2190 Prev"),
|
||||
span({ class: "tribe-info-label" }, monthLabel),
|
||||
a({ href: `${shareUrl}?month=${nextMonth}`, class: "filter-btn" }, i18n.calendarMonthNext || "Next \u2192")
|
||||
)
|
||||
|
||||
const grid = renderMonthGrid(year, month, datesMap, calendar.rootId)
|
||||
|
||||
const dayEntries = selectedDay
|
||||
? dates.filter(d => moment(d.date).format("YYYY-MM-DD") === selectedDay)
|
||||
: []
|
||||
|
||||
const dayNotesSection = selectedDay
|
||||
? div({ class: "calendar-day-notes" },
|
||||
h4(`${selectedDay}${dayEntries.length > 0 && dayEntries[0].label ? " \u2014 " + dayEntries[0].label : ""}`),
|
||||
dayEntries.length === 0
|
||||
? p({ class: "no-content" }, i18n.calendarNoDates || "No dates added yet.")
|
||||
: div(null, ...dayEntries.map(d => {
|
||||
const notes = (notesByDate && notesByDate[d.key]) ? notesByDate[d.key] : []
|
||||
return div({ class: "calendar-date-item" },
|
||||
(isAuthor || String(d.author) === String(userId))
|
||||
? form({ method: "POST", action: `/calendars/delete-date/${encodeURIComponent(d.key)}`, class: "calendar-date-delete" },
|
||||
input({ type: "hidden", name: "calendarId", value: calendar.rootId }),
|
||||
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.calendarDeleteDate || "Delete Date")
|
||||
)
|
||||
: null,
|
||||
div({ class: "calendar-date-item-header" },
|
||||
`${moment(d.date).format("YYYY-MM-DD HH:mm")}${d.label ? " \u2014 " + d.label : ""}`
|
||||
),
|
||||
(() => {
|
||||
const visibleNotes = notes.filter(n => n.text && String(n.text).trim())
|
||||
return visibleNotes.length === 0
|
||||
? p({ class: "no-content" }, i18n.calendarNoNotes || "No notes.")
|
||||
: div(null, ...visibleNotes.map(n => {
|
||||
const isSelf = String(n.author) === String(userId)
|
||||
const dateStr = moment(n.createdAt).format("YYYY/MM/DD HH:mm")
|
||||
const shortId = n.author ? "@" + n.author.slice(1, 9) + "\u2026" : "?"
|
||||
return div({ class: (isSelf ? "chat-message chat-message-self" : "chat-message") + " calendar-note-card" },
|
||||
isSelf
|
||||
? form({ method: "POST", action: `/calendars/delete-note/${encodeURIComponent(n.key)}`, class: "calendar-note-delete" },
|
||||
input({ type: "hidden", name: "calendarId", value: calendar.rootId }),
|
||||
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.calendarDeleteNote || "Delete")
|
||||
)
|
||||
: null,
|
||||
div({ class: "chat-message-meta" },
|
||||
span({ class: "chat-message-sender" },
|
||||
userLink(n.author)
|
||||
),
|
||||
span({ class: "chat-message-date" }, ` [ ${dateStr} ]`)
|
||||
),
|
||||
span({ class: "chat-message-text" }, ...renderNoteText(n.text || ""))
|
||||
)
|
||||
}))
|
||||
})()
|
||||
)
|
||||
}))
|
||||
)
|
||||
: null
|
||||
|
||||
const calMain = div({ class: "tribe-main" },
|
||||
calNav,
|
||||
grid,
|
||||
dayNotesSection,
|
||||
unifiedForm
|
||||
)
|
||||
|
||||
return template(
|
||||
calendar.title || i18n.calendarsTitle || "Calendar",
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.calendarsTitle || "Calendars"),
|
||||
p(i18n.calendarsDescription || "Discover and manage calendars.")
|
||||
),
|
||||
renderModeButtons("all")
|
||||
),
|
||||
section(div({ class: "tribe-details" }, calSide, calMain))
|
||||
)
|
||||
}
|
||||
387
src/views/chats_view.js
Normal file
387
src/views/chats_view.js
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, td, ul, li } = require("../server/node_modules/hyperaxe")
|
||||
const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton } = require("./main_views")
|
||||
const { renderEncryptedChip } = require("./clearnet_view")
|
||||
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 safeArr = (v) => (Array.isArray(v) ? v : [])
|
||||
const safeText = (v) => String(v || "").trim()
|
||||
|
||||
const CAT_BLOCK1 = ["GENERAL", "OASIS", "L.A.R.P.", "POLITICS", "TECH"]
|
||||
const CAT_BLOCK2 = ["SCIENCE", "MUSIC", "ART", "GAMING", "BOOKS", "FILMS"]
|
||||
const CAT_BLOCK3 = ["PHILOSOPHY", "SOCIETY", "PRIVACY", "CYBERWARFARE", "SURVIVALISM"]
|
||||
const ALL_CATS = [...CAT_BLOCK1, ...CAT_BLOCK2, ...CAT_BLOCK3]
|
||||
|
||||
const catKey = (c) => "forumCat" + String(c || "").replace(/\./g, "").replace(/[\s-]/g, "").toUpperCase()
|
||||
const catLabel = (c) => i18n[catKey(c)] || c
|
||||
|
||||
const renderMediaBlob = (value, fallbackSrc = null, attrs = {}) => {
|
||||
if (!value) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
|
||||
const s = String(value).trim()
|
||||
if (!s) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
|
||||
if (s.startsWith("&")) return img({ src: `/blob/${encodeURIComponent(s)}`, ...attrs })
|
||||
const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
|
||||
if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, ...attrs })
|
||||
return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
|
||||
}
|
||||
|
||||
const buildReturnTo = (filter, params = {}) => {
|
||||
const f = safeText(filter || "all")
|
||||
const q = safeText(params.q || "")
|
||||
const parts = [`filter=${encodeURIComponent(f)}`]
|
||||
if (q) parts.push(`q=${encodeURIComponent(q)}`)
|
||||
return `/chats?${parts.join("&")}`
|
||||
}
|
||||
|
||||
const renderModeButtons = (currentFilter) =>
|
||||
div({ class: "tribe-mode-buttons" },
|
||||
["all", "mine", "recent", "favorites", "open", "closed"].map(f =>
|
||||
form({ method: "GET", action: "/chats" },
|
||||
input({ type: "hidden", name: "filter", value: f }),
|
||||
button({ type: "submit", class: currentFilter === f ? "filter-btn active" : "filter-btn" }, i18n[`chatFilter${f.charAt(0).toUpperCase() + f.slice(1)}`] || f.toUpperCase())
|
||||
)
|
||||
),
|
||||
form({ method: "GET", action: "/chats" },
|
||||
input({ type: "hidden", name: "filter", value: "create" }),
|
||||
button({ type: "submit", class: "create-button" }, i18n.chatCreate)
|
||||
)
|
||||
)
|
||||
|
||||
const renderChatStatusChip = (status) => {
|
||||
const s = String(status || "OPEN").toUpperCase()
|
||||
const variant = s === "CLOSED" ? "closed" : s === "INVITE-ONLY" ? "whole" : "mutuals"
|
||||
const icon = s === "CLOSED" ? "\u2717" : s === "INVITE-ONLY" ? "\uD83D\uDD11" : "\u2713"
|
||||
const label = s === "CLOSED" ? i18n.chatStatusClosed
|
||||
: s === "INVITE-ONLY" ? i18n.chatStatusInviteOnly
|
||||
: i18n.chatStatusOpen
|
||||
return renderStateChip(variant, icon, label)
|
||||
}
|
||||
|
||||
const renderChatCard = (chat, filter, params = {}) => {
|
||||
const chips = [
|
||||
renderChatStatusChip(chat.status),
|
||||
renderEncryptedChip(i18n),
|
||||
renderLifespanChip(chat.lifetime, i18n)
|
||||
].filter(Boolean)
|
||||
|
||||
return div({ class: "tribe-card" },
|
||||
div({ class: "tribe-card-image-wrapper" },
|
||||
a({ href: `/chats/${encodeURIComponent(chat.key)}` },
|
||||
renderMediaBlob(chat.image, "/assets/images/default-avatar.png", { class: "tribe-card-hero-image" })
|
||||
)
|
||||
),
|
||||
div({ class: "tribe-card-body" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" },
|
||||
a({ href: `/chats/${encodeURIComponent(chat.key)}` }, chat.title || i18n.chatUntitled)
|
||||
)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
chat.description ? p({ class: "tribe-card-description" }, chat.description) : null,
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.chatParticipants}: ${safeArr(chat.members).length}`)
|
||||
),
|
||||
(() => {
|
||||
const btn = renderSpreadButton(chat.key, (params && params.spreadMap && params.spreadMap.get(chat.key)) || (params && params.spreads));
|
||||
return btn ? div({ class: "card-spread-left" }, btn) : null;
|
||||
})(),
|
||||
div({ class: "visit-btn-centered" },
|
||||
a({ href: `/chats/${encodeURIComponent(chat.key)}`, class: "filter-btn" }, i18n.chatVisitChat)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const renderChatForm = (filter, chat = {}, params = {}) => {
|
||||
const isEdit = filter === "edit"
|
||||
const returnTo = safeText(params.returnTo) || buildReturnTo("all")
|
||||
const tribeId = safeText(params.tribeId || "")
|
||||
return div({ class: "div-center audio-form" },
|
||||
h2(isEdit ? i18n.chatUpdate : i18n.chatCreate),
|
||||
form({ action: isEdit ? `/chats/update/${encodeURIComponent(chat.key || "")}` : "/chats/create", method: "POST", enctype: "multipart/form-data" },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null,
|
||||
span(i18n.title || "Title"), br(),
|
||||
input({ type: "text", name: "title", required: true, placeholder: i18n.chatTitlePlaceholder, value: chat.title || "" }), br(), br(),
|
||||
span(i18n.chatDescription), br(),
|
||||
textarea({ name: "description", rows: 4, placeholder: i18n.chatDescriptionPlaceholder }, chat.description || ""), br(), br(),
|
||||
span(i18n.chatImageLabel || "Select an image file (.jpeg, .jpg, .png, .gif)"), br(),
|
||||
input({ type: "file", name: "image", accept: "image/*" }), br(), br(),
|
||||
span(i18n.chatCategory), br(),
|
||||
select({ name: "category" },
|
||||
option({ value: "" }, "\u2014"),
|
||||
ALL_CATS.map(cat =>
|
||||
option({ value: cat, ...(chat.category === cat ? { selected: true } : {}) }, catLabel(cat))
|
||||
)
|
||||
), br(), br(),
|
||||
span(i18n.chatStatusLabel || "Status"), br(),
|
||||
select({ name: "status" },
|
||||
option({ value: "OPEN", ...((!chat.status || chat.status === "OPEN") ? { selected: true } : {}) }, i18n.chatStatusOpen),
|
||||
option({ value: "INVITE-ONLY", ...(chat.status === "INVITE-ONLY" ? { selected: true } : {}) }, i18n.chatStatusInviteOnly)
|
||||
), br(), br(),
|
||||
span(i18n.shopTags || "Tags"), br(),
|
||||
input({ type: "text", name: "tags", placeholder: i18n.chatTagsPlaceholder, value: safeArr(chat.tags).join(", ") }), br(), br(),
|
||||
button({ type: "submit" }, isEdit ? i18n.chatUpdate : i18n.chatCreate)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const renderMessageText = (text) => {
|
||||
if (!text) return span({ class: "chat-message-text" }, "")
|
||||
const lines = String(text).split("\n")
|
||||
const nodes = []
|
||||
lines.forEach((line, idx) => {
|
||||
const rendered = renderUrl(line)
|
||||
nodes.push(...rendered)
|
||||
if (idx < lines.length - 1) nodes.push(br())
|
||||
})
|
||||
return span({ class: "chat-message-text" }, ...nodes)
|
||||
}
|
||||
|
||||
const renderMessage = (msg, chatAuthor) => {
|
||||
const isAuthor = String(msg.author) === String(chatAuthor)
|
||||
const isSelf = String(msg.author) === String(userId)
|
||||
const dateStr = moment(msg.createdAt).format("YYYY/MM/DD HH:mm")
|
||||
const shortId = msg.author ? "@" + msg.author.slice(1, 9) + "\u2026" : "?"
|
||||
const authorLink = msg.author ? userLink(msg.author) : span("?")
|
||||
|
||||
const imageNode = msg.image ? renderMediaBlob(msg.image, null, { class: "chat-message-image" }) : null
|
||||
|
||||
return div({ class: isSelf ? "chat-message chat-message-self" : isAuthor ? "chat-message chat-message-author" : "chat-message" },
|
||||
div({ class: "chat-message-meta" },
|
||||
span({ class: "chat-message-sender" }, authorLink),
|
||||
span({ class: "chat-message-date" }, ` [ ${dateStr} ]`)
|
||||
),
|
||||
imageNode ? div({ class: "chat-message-image-wrap" }, imageNode) : null,
|
||||
renderMessageText(msg.text || "")
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
exports.renderChatInvitePage = (code) => {
|
||||
const pageContent = div({ class: "invite-page" },
|
||||
h2(i18n.tribeInviteCodeText, code),
|
||||
form({ method: "GET", action: "/chats" },
|
||||
input({ type: "hidden", name: "filter", value: "all" }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.walletBack)
|
||||
)
|
||||
)
|
||||
return template(i18n.chatInviteMode || "Invite", section(pageContent))
|
||||
}
|
||||
|
||||
exports.chatsView = async (chats, filter, chatToEdit = null, params = {}) => {
|
||||
const q = safeText(params.q || "")
|
||||
const list = safeArr(chats)
|
||||
|
||||
const isForm = filter === "create" || filter === "edit"
|
||||
|
||||
const chatHeaderMap = {
|
||||
all: i18n.chatsTitle,
|
||||
mine: i18n.chatMineSectionTitle || "Your Chats",
|
||||
recent: i18n.chatRecentTitle || "Recent Chats",
|
||||
favorites: i18n.chatFavoritesTitle || "Favorites",
|
||||
open: i18n.chatOpenTitle || "Open Chats",
|
||||
closed: i18n.chatClosedTitle || "Closed Chats"
|
||||
}
|
||||
const headerText = chatHeaderMap[filter] || i18n.chatsTitle
|
||||
|
||||
return template(
|
||||
i18n.chatsTitle,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(headerText),
|
||||
p(i18n.modulesChatsDescription)
|
||||
)
|
||||
),
|
||||
section(renderModeButtons(filter)),
|
||||
!isForm
|
||||
? section(
|
||||
div({ class: "filters" },
|
||||
form({ method: "GET", action: "/chats" },
|
||||
input({ type: "hidden", name: "filter", value: filter }),
|
||||
input({ type: "text", name: "q", placeholder: i18n.chatSearchPlaceholder, value: q }),
|
||||
br(),
|
||||
button({ type: "submit" }, i18n.search),
|
||||
br()
|
||||
)
|
||||
)
|
||||
)
|
||||
: null,
|
||||
section(
|
||||
isForm
|
||||
? renderChatForm(filter, filter === "edit" ? (chatToEdit || {}) : {}, params)
|
||||
: div({ class: "tribe-grid" },
|
||||
list.length
|
||||
? list.map(chat => renderChatCard(chat, filter, { q }))
|
||||
: p(i18n.chatNoItems)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
|
||||
const q = safeText(params.q || "")
|
||||
const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q })
|
||||
const isAuthor = String(chat.author) === String(userId)
|
||||
const isMember = safeArr(chat.members).includes(userId) || (!!chat.tribeId && !!chat.isTribeMember)
|
||||
const fullShareUrl = `/chats/${encodeURIComponent(chat.key)}`
|
||||
const isRestrictedInviteOnly = !isMember && !isAuthor && chat.status === "INVITE-ONLY"
|
||||
|
||||
const statusLabel = chat.status === "CLOSED" ? i18n.chatStatusClosed :
|
||||
chat.status === "INVITE-ONLY" ? i18n.chatStatusInviteOnly : i18n.chatStatusOpen
|
||||
|
||||
const detailChips = [
|
||||
renderChatStatusChip(chat.status),
|
||||
renderEncryptedChip(i18n),
|
||||
renderLifespanChip(chat.lifetime, i18n)
|
||||
].filter(Boolean)
|
||||
const chatSide = div({ class: "tribe-side" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, chat.title || i18n.chatUntitled)
|
||||
),
|
||||
detailChips.length ? div({ class: "card-chips-row" }, ...detailChips) : null,
|
||||
renderMediaBlob(chat.image, "/assets/images/default-avatar.png", { class: "tribe-detail-image" }),
|
||||
div({ class: "shop-share" },
|
||||
span({ class: "tribe-info-label" }, `${i18n.chatShareUrl}: `),
|
||||
input({ type: "text", value: fullShareUrl, readonly: true, class: "shop-share-input" })
|
||||
),
|
||||
(() => {
|
||||
const btn = renderSpreadButton(chat.key, params.spreads);
|
||||
return btn ? div({ class: "card-spread-centered" }, btn) : null;
|
||||
})(),
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.chatParticipants}: ${safeArr(chat.members).length}`)
|
||||
),
|
||||
table({ class: "tribe-info-table" },
|
||||
tr(
|
||||
td({ class: "tribe-info-label" }, i18n.chatCreatedAt),
|
||||
td({ class: "tribe-info-value", colspan: "3" }, moment(chat.createdAt).format("YYYY/MM/DD HH:mm"))
|
||||
),
|
||||
isRestrictedInviteOnly ? null : tr(
|
||||
td({ class: "tribe-info-value", colspan: "4" },
|
||||
userLink(chat.author)
|
||||
)
|
||||
),
|
||||
!isRestrictedInviteOnly && chat.category ? tr(
|
||||
td({ class: "tribe-info-label" }, i18n.chatCategoryLabel),
|
||||
td({ class: "tribe-info-value", colspan: "3" }, catLabel(chat.category))
|
||||
) : null
|
||||
),
|
||||
isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions" },
|
||||
isAuthor && chat.status === "INVITE-ONLY"
|
||||
? form({ method: "POST", action: `/chats/generate-invite` },
|
||||
input({ type: "hidden", name: "chatId", value: chat.key }),
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeGenerateInvite)
|
||||
)
|
||||
: null,
|
||||
(() => {
|
||||
if (!(isAuthor && chat.status === "INVITE-ONLY")) return null
|
||||
const openInvite = safeArr(chat.invites).find(inv => typeof inv === "object" && inv && inv.public === true && inv.code)
|
||||
if (openInvite) return [
|
||||
div({ class: "tribe-open-invite" },
|
||||
span({ class: "card-label" }, i18n.tribeInviteCodeText),
|
||||
span({ class: "tribe-open-invite-code" }, openInvite.code)
|
||||
),
|
||||
form({ method: "POST", action: `/chats/open-invite/remove` },
|
||||
input({ type: "hidden", name: "chatId", value: chat.key }),
|
||||
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeRemoveInvitation)
|
||||
)
|
||||
]
|
||||
return form({ method: "POST", action: `/chats/open-invite/create` },
|
||||
input({ type: "hidden", name: "chatId", value: chat.key }),
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeOpenInvitation)
|
||||
)
|
||||
})(),
|
||||
form(
|
||||
{ method: "POST", action: chat.isFavorite ? `/chats/favorites/remove/${encodeURIComponent(chat.key)}` : `/chats/favorites/add/${encodeURIComponent(chat.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", class: "tribe-action-btn" }, chat.isFavorite ? i18n.chatRemoveFavorite : i18n.chatAddFavorite)
|
||||
),
|
||||
chat.author && String(chat.author) !== String(userId)
|
||||
? form({ method: "GET", action: "/pm" },
|
||||
input({ type: "hidden", name: "recipients", value: chat.author }),
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.chatPM || i18n.privateMessage)
|
||||
)
|
||||
: null,
|
||||
isAuthor
|
||||
? form({ method: "GET", action: `/chats/edit/${encodeURIComponent(chat.key)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.chatUpdate)
|
||||
)
|
||||
: null,
|
||||
isAuthor && chat.status !== "CLOSED"
|
||||
? form({ method: "POST", action: `/chats/close/${encodeURIComponent(chat.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.chatClose)
|
||||
)
|
||||
: null,
|
||||
isAuthor
|
||||
? form({ method: "POST", action: `/chats/delete/${encodeURIComponent(chat.key)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.chatDelete)
|
||||
)
|
||||
: null,
|
||||
!isAuthor && isMember
|
||||
? form({ method: "POST", action: `/chats/leave/${encodeURIComponent(chat.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeLeaveButton)
|
||||
)
|
||||
: null
|
||||
),
|
||||
!isMember && chat.status === "INVITE-ONLY"
|
||||
? div({ class: "chat-join-section" },
|
||||
a({ class: "tribe-action-btn", href: "/invites#invites-chats" }, i18n.tribeEnterInvite)
|
||||
)
|
||||
: null,
|
||||
!isRestrictedInviteOnly && safeArr(chat.tags).length
|
||||
? div({ class: "tribe-side-tags" },
|
||||
safeArr(chat.tags).map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
|
||||
)
|
||||
: null
|
||||
)
|
||||
|
||||
const msgList = safeArr(messages)
|
||||
const canWrite = (isMember || chat.status === "OPEN") && chat.status !== "CLOSED"
|
||||
|
||||
const chatMain = isRestrictedInviteOnly
|
||||
? div({ class: "tribe-main chat-full-width" }, p({ class: "access-denied-msg" }, i18n.chatAccessDenied))
|
||||
: div({ class: "tribe-main chat-full-width" },
|
||||
canWrite
|
||||
? div({ class: "chat-message-form" },
|
||||
form({ method: "POST", action: `/chats/${encodeURIComponent(chat.key)}/message`, enctype: "multipart/form-data" },
|
||||
input({ type: "hidden", name: "returnTo", value: `/chats/${encodeURIComponent(chat.key)}` }),
|
||||
textarea({ name: "text", rows: 3, placeholder: i18n.chatMessagePlaceholder }), br(),
|
||||
span(i18n.chatImageLabel || "Select an image file (.jpeg, .jpg, .png, .gif)"), br(),
|
||||
input({ type: "file", name: "image", accept: "image/*" }), br(), br(),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.chatSendMessage)
|
||||
)
|
||||
)
|
||||
: null,
|
||||
div({ class: "chat-messages-list" },
|
||||
(() => {
|
||||
const visible = msgList.filter(msg => (msg.text && String(msg.text).trim()) || msg.image)
|
||||
return visible.length
|
||||
? visible.map(msg => renderMessage(msg, chat.author))
|
||||
: p({ class: "chat-no-messages" }, i18n.chatNoMessages)
|
||||
})()
|
||||
)
|
||||
)
|
||||
|
||||
return template(
|
||||
chat.title || i18n.chatUntitled,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.chatsTitle),
|
||||
p(i18n.modulesChatsDescription)
|
||||
),
|
||||
renderModeButtons(filter || "all")
|
||||
),
|
||||
section(
|
||||
div({ class: "tribe-details" },
|
||||
chatSide,
|
||||
chatMain
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
101
src/views/cipher_view.js
Normal file
101
src/views/cipher_view.js
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
const { form, button, div, h2, p, section, textarea, label, input, br } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n } = require('./main_views');
|
||||
const crypto = require('crypto');
|
||||
|
||||
function generateRandomPassword(length = 32) {
|
||||
return crypto.randomBytes(length).toString('hex').slice(0, length);
|
||||
}
|
||||
|
||||
const cipherView = async (encryptedText = "", decryptedText = "", iv = "", password = "") => {
|
||||
const randomPassword = generateRandomPassword();
|
||||
|
||||
const header = div({ class: "tags-header" },
|
||||
h2(i18n.cipherTitle),
|
||||
p(i18n.cipherDescription)
|
||||
);
|
||||
|
||||
const encryptForm = form(
|
||||
{ action: "/cipher/encrypt", method: "POST", id: "encryptForm" },
|
||||
textarea({
|
||||
name: "text",
|
||||
id: "text",
|
||||
required: true,
|
||||
placeholder: i18n.cipherTextPlaceholder,
|
||||
rows: 4
|
||||
}),
|
||||
br(),
|
||||
label(i18n.cipherPasswordLabel),
|
||||
br(),
|
||||
input({
|
||||
type: "password",
|
||||
name: "password",
|
||||
id: "password",
|
||||
required: true,
|
||||
placeholder: i18n.cipherPasswordPlaceholder,
|
||||
minlength: 32
|
||||
}),
|
||||
br(),
|
||||
button({ type: "submit" }, i18n.cipherEncryptButton)
|
||||
);
|
||||
|
||||
const decryptForm = form(
|
||||
{ action: "/cipher/decrypt", method: "POST", id: "decryptForm" },
|
||||
textarea({
|
||||
name: "encryptedText",
|
||||
id: "encryptedText",
|
||||
required: true,
|
||||
placeholder: i18n.cipherEncryptedTextPlaceholder,
|
||||
rows: 4,
|
||||
value: encryptedText
|
||||
}),
|
||||
br(),
|
||||
label(i18n.cipherPasswordDecryptLabel),
|
||||
br(),
|
||||
input({
|
||||
type: "password",
|
||||
name: "password",
|
||||
id: "password",
|
||||
required: true,
|
||||
placeholder: i18n.cipherPasswordPlaceholder,
|
||||
minlength: 32
|
||||
}),
|
||||
br(),
|
||||
button({ type: "submit" }, i18n.cipherDecryptButton)
|
||||
);
|
||||
|
||||
const encryptResult = encryptedText
|
||||
? div({ class: "cipher-result visible encrypted-result" },
|
||||
label(i18n.cipherEncryptedMessageLabel),
|
||||
br(),br(),
|
||||
div({ class: "cipher-text" }, encryptedText)
|
||||
)
|
||||
: null;
|
||||
|
||||
const decryptResult = decryptedText
|
||||
? div({ class: "cipher-result visible decrypted-result" },
|
||||
label(i18n.cipherDecryptedMessageLabel),
|
||||
br(),br(),
|
||||
div({ class: "cipher-text" }, decryptedText)
|
||||
)
|
||||
: null;
|
||||
|
||||
return template(
|
||||
i18n.cipherTitle,
|
||||
section(
|
||||
header,
|
||||
div({ id: "randomPassword" },
|
||||
h2({ class: "generated-password" }, randomPassword)
|
||||
),
|
||||
div({ class: "div-center" },
|
||||
encryptForm,
|
||||
br(),
|
||||
encryptResult,
|
||||
decryptForm,
|
||||
br(),
|
||||
decryptResult
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.cipherView = cipherView;
|
||||
310
src/views/clearnet_view.js
Normal file
310
src/views/clearnet_view.js
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
const { a, br, div, input, span, strong } = require("../server/node_modules/hyperaxe");
|
||||
|
||||
const STAT_TYPE_KEYS = { post:'statsPost', event:'statsEvent', task:'statsTask', forum:'statsForum', tribe:'statsTribe', market:'statsMarket', job:'statsJob', project:'statsProject', shop:'statsShop', image:'statsImage', video:'statsVideo', audio:'statsAudio', document:'statsDocument', bookmark:'statsBookmark', transfer:'statsTransfer', map:'statsMap' };
|
||||
const STAT_ORDER = ['post','event','task','forum','tribe','market','job','project','shop','image','video','audio','document','bookmark','transfer','map'];
|
||||
const renderContentStats = (stats, i18nObj = {}) => {
|
||||
if (!stats || typeof stats !== 'object') return null;
|
||||
const chips = STAT_ORDER
|
||||
.filter(t => (stats[t] || 0) > 0)
|
||||
.map(t => span({ class: 'inhabitant-stat' },
|
||||
span({ class: 'inhabitant-stat-label' }, i18nObj[STAT_TYPE_KEYS[t]] || t),
|
||||
strong({ class: 'inhabitant-stat-value' }, String(stats[t]))
|
||||
));
|
||||
if (!chips.length) return null;
|
||||
return div({ class: 'inhabitant-stats-box' },
|
||||
div({ class: 'inhabitant-stats-grid' }, ...chips)
|
||||
);
|
||||
};
|
||||
|
||||
const escapeHtml = (s) => String(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const blobIdOf = (v) => {
|
||||
if (!v) return null;
|
||||
const s = String(v).trim();
|
||||
if (!s) return null;
|
||||
if (s.startsWith('&')) return s;
|
||||
const m = s.match(/\((&[^)]+\.sha256)\)/);
|
||||
if (m) return m[1];
|
||||
return null;
|
||||
};
|
||||
|
||||
const blobUrl = (v) => {
|
||||
const id = blobIdOf(v);
|
||||
return id ? `/c/blob/${encodeURIComponent(id)}` : null;
|
||||
};
|
||||
|
||||
const renderReachChip = (isClearnet, i18nObj = {}, href = null) => {
|
||||
const icon = isClearnet ? '🌐' : '🏝';
|
||||
const label = isClearnet
|
||||
? (i18nObj.shopReachClearnet || 'Clearnet')
|
||||
: (i18nObj.shopReachOasis || 'Oasis');
|
||||
const chip = span({ class: `pm-exposition-chip pm-exposition-${isClearnet ? 'whole' : 'mutuals'}` },
|
||||
span({ class: 'pm-exposition-icon' }, icon),
|
||||
span({ class: 'pm-exposition-text' }, label)
|
||||
);
|
||||
if (href && isClearnet) {
|
||||
return a({ href, target: '_blank', rel: 'noopener noreferrer', class: 'pm-exposition-chip-link' }, chip);
|
||||
}
|
||||
return chip;
|
||||
};
|
||||
|
||||
const fediverseProfileUrl = (handle) => {
|
||||
const h = String(handle || '').trim().replace(/^@/, '');
|
||||
const at = h.indexOf('@');
|
||||
if (at <= 0) return '';
|
||||
const acct = h.slice(0, at), host = h.slice(at + 1);
|
||||
if (!acct || !host || /[\s/]/.test(host)) return '';
|
||||
return `https://${host}/@${acct}`;
|
||||
};
|
||||
|
||||
const renderFediverseReach = (prefs, i18nObj = {}) => {
|
||||
if (!prefs || prefs.fediverse !== true) return null;
|
||||
const handle = String(prefs.fediverseHandle || '').trim();
|
||||
const url = fediverseProfileUrl(handle);
|
||||
if (!url) return null;
|
||||
return div({ class: 'profile-reach fediverse-reach' },
|
||||
a({ href: url, target: '_blank', rel: 'noopener noreferrer', class: 'pm-exposition-chip-link' },
|
||||
span({ class: 'pm-exposition-chip pm-exposition-fediverse' },
|
||||
span({ class: 'pm-exposition-icon' }, '🐘'),
|
||||
span({ class: 'pm-exposition-text' }, i18nObj.fediverse || 'Fediverse')
|
||||
)
|
||||
),
|
||||
a({ href: url, target: '_blank', rel: 'noopener noreferrer', class: 'fediverse-reach-url' }, handle)
|
||||
);
|
||||
};
|
||||
|
||||
const renderEncryptedChip = (i18nObj = {}) => {
|
||||
return span({ class: 'pm-exposition-chip pm-exposition-encrypted' },
|
||||
span({ class: 'pm-exposition-icon' }, '🔒'),
|
||||
span({ class: 'pm-exposition-text' }, i18nObj.encryptedChipLabel || 'Encrypted')
|
||||
);
|
||||
};
|
||||
|
||||
const renderDoubleEncryptionChip = (i18nObj = {}) => {
|
||||
return span({ class: 'pm-exposition-chip pm-exposition-encrypted pm-double-enc-chip' },
|
||||
span({ class: 'pm-exposition-icon' }, '🔒'),
|
||||
span({ class: 'pm-exposition-text' }, i18nObj.pmCrypterChip || '2xE2E')
|
||||
);
|
||||
};
|
||||
|
||||
const INTERNAL_OASIS_PATHS = [
|
||||
'author','thread','hashtag','inbox','pm','profile','settings','banking','wallet',
|
||||
'jobs','events','projects','shops','audios','videos','images','documents','torrents',
|
||||
'tribes','tribe','forum','votes','votations','reports','tasks','maps','chats','pads',
|
||||
'calendars','trending','opinions','feed','pixelia','cv','invites','peers','stats',
|
||||
'blockexplorer','modules','publish','search','tags','mentions','popular','threads',
|
||||
'topics','latest','summaries','multiverse','legacy','cipher','graphos','agenda',
|
||||
'favorites','logs','games','parliament','courts','market','ai','public','spread',
|
||||
'follow','unfollow','block','like','unlike'
|
||||
];
|
||||
|
||||
const stripInternalAnchors = (html) => {
|
||||
if (typeof html !== 'string' || !html) return html;
|
||||
const list = INTERNAL_OASIS_PATHS.join('|');
|
||||
const hrefedClosed = new RegExp(`<a\\b[^>]*\\bhref=["']\\/(?:${list})\\/[^"']*["'][^>]*>([\\s\\S]*?)<\\/a>`, 'gi');
|
||||
const hrefedBare = new RegExp(`<a\\b[^>]*\\bhref=["']\\/(?:${list})["'][^>]*>([\\s\\S]*?)<\\/a>`, 'gi');
|
||||
return html.replace(hrefedClosed, '$1').replace(hrefedBare, '$1');
|
||||
};
|
||||
|
||||
const renderClearnetSearchForm = ({ authorFeedId = '', query = '', placeholder = 'Search…' }) => {
|
||||
if (!authorFeedId) return '';
|
||||
const safeQuery = escapeHtml(query || '');
|
||||
const safePh = escapeHtml(placeholder);
|
||||
return `<form class="cn-search" method="GET" action="/c/inhabitant/${encodeURIComponent(authorFeedId)}"><input type="text" name="q" value="${safeQuery}" placeholder="${safePh}" autocomplete="off"/></form>`;
|
||||
};
|
||||
|
||||
const renderClearnetUrlBlock = ({ baseUrl = '', path, i18nObj = {} }) => {
|
||||
return div({ class: 'shop-clearnet-url' },
|
||||
a({ href: path, target: '_blank', rel: 'noopener noreferrer', class: 'clearnet-link' }, path)
|
||||
);
|
||||
};
|
||||
|
||||
const CLEARNET_SEARCH_CSS = `
|
||||
.cn-search{margin:0}
|
||||
.cn-search input[type=text]{width:240px;max-width:100%;background:var(--bg-sub);color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:8px 12px;font-size:14px;font-family:inherit}
|
||||
.cn-search input[type=text]:focus{outline:none;border-color:var(--fg)}
|
||||
`;
|
||||
|
||||
const THEME_PALETTES = {
|
||||
'Dark-SNH': {
|
||||
bg: '#121212', bgElev: '#1C1C1C', bgSub: '#222',
|
||||
fg: '#FFD700', fgSoft: '#E6C200', fgDim: '#9a8a2e',
|
||||
border: '#333', accent: '#FFDD44',
|
||||
font: "system-ui,-apple-system,sans-serif"
|
||||
},
|
||||
'OasisMobile': {
|
||||
bg: '#121212', bgElev: '#1C1C1C', bgSub: '#222',
|
||||
fg: '#FFD700', fgSoft: '#E6C200', fgDim: '#9a8a2e',
|
||||
border: '#333', accent: '#FFDD44',
|
||||
font: "system-ui,-apple-system,sans-serif"
|
||||
},
|
||||
'Clear-SNH': {
|
||||
bg: '#F9F9F9', bgElev: '#FFFFFF', bgSub: '#F0F0F0',
|
||||
fg: '#2C2C2C', fgSoft: '#555555', fgDim: '#888888',
|
||||
border: '#E0E0E0', accent: '#FF6F00',
|
||||
font: "'Roboto',sans-serif"
|
||||
},
|
||||
'Matrix-SNH': {
|
||||
bg: '#000000', bgElev: '#0a0a0a', bgSub: '#050505',
|
||||
fg: '#00FF00', fgSoft: '#00CC00', fgDim: '#008800',
|
||||
border: '#00FF00', accent: '#66FF66',
|
||||
font: "'Courier New',monospace"
|
||||
},
|
||||
'Purple-SNH': {
|
||||
bg: '#4B0A6D', bgElev: '#39006D', bgSub: '#6A0066',
|
||||
fg: '#E5E5E5', fgSoft: '#C8C8C8', fgDim: '#9B7CAA',
|
||||
border: '#9B1C96', accent: '#9B1C96',
|
||||
font: "'Arial',sans-serif"
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentPalette = () => {
|
||||
try {
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
const theme = getConfig()?.themes?.current || 'Dark-SNH';
|
||||
return THEME_PALETTES[theme] || THEME_PALETTES['Dark-SNH'];
|
||||
} catch (_) {
|
||||
return THEME_PALETTES['Dark-SNH'];
|
||||
}
|
||||
};
|
||||
|
||||
const buildBaseCss = (p) => `
|
||||
:root{
|
||||
--bg:${p.bg}; --bg-elev:${p.bgElev}; --bg-sub:${p.bgSub};
|
||||
--fg:${p.fg}; --fg-soft:${p.fgSoft}; --fg-dim:${p.fgDim};
|
||||
--border:${p.border}; --border-strong:${p.border};
|
||||
--accent:${p.accent};
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{background:var(--bg);color:var(--fg);font-family:${p.font};max-width:960px;margin:0 auto;padding:32px 24px;line-height:1.5}
|
||||
a{color:var(--fg);text-decoration:none}
|
||||
a:hover{color:var(--accent);text-decoration:underline}
|
||||
header.cn-header{display:flex;align-items:center;gap:16px;padding-bottom:16px;margin-bottom:24px;border-bottom:1px solid var(--border);flex-wrap:wrap}
|
||||
.cn-brand-block{flex:0 0 auto}
|
||||
.cn-brand{font-size:20px;font-weight:700;color:var(--fg);letter-spacing:1px}
|
||||
.cn-brand-sub{color:var(--fg-dim);font-size:12px;text-transform:uppercase;letter-spacing:2px;margin-top:2px}
|
||||
.cn-header-extra{flex:1 1 auto;display:flex;justify-content:flex-end;align-items:center;min-width:0}
|
||||
h2.cn-section{color:var(--fg);font-size:18px;text-transform:uppercase;letter-spacing:2px;margin:32px 0 16px 0;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
footer.cn-footer{margin-top:48px;padding-top:20px;border-top:1px solid var(--border);font-size:12px;color:var(--fg-dim);text-align:center;letter-spacing:0.5px}
|
||||
footer.cn-footer a{color:var(--fg-soft)}
|
||||
footer.cn-footer .cn-footer-logo{width:56px;height:auto;display:block;margin:0 auto 10px auto;border-radius:6px}
|
||||
`;
|
||||
|
||||
const renderClearnetPage = ({ title, ogTitle, ogDescription = '', ogImage = null, extraCss = '', body, headerExtra = '', hubFeedId = null }) => {
|
||||
const safeTitle = escapeHtml(title || 'Oasis');
|
||||
const safeOgTitle = escapeHtml(ogTitle || title || 'Oasis');
|
||||
const safeOgDesc = escapeHtml(ogDescription || '');
|
||||
const palette = getCurrentPalette();
|
||||
const baseCss = buildBaseCss(palette);
|
||||
const brandInner = `<div class="cn-brand">⛱ Oasis HUB</div><div class="cn-brand-sub">Libre · P2P · Federated</div>`;
|
||||
const brandBlock = hubFeedId
|
||||
? `<a class="cn-brand-block cn-brand-link" href="/c/inhabitant/${encodeURIComponent(hubFeedId)}">${brandInner}</a>`
|
||||
: `<div class="cn-brand-block">${brandInner}</div>`;
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>${safeTitle}</title>
|
||||
<meta property="og:title" content="${safeOgTitle}"/>
|
||||
<meta property="og:description" content="${safeOgDesc}"/>
|
||||
${ogImage ? `<meta property="og:image" content="${ogImage}"/>` : ''}
|
||||
<meta name="description" content="${safeOgDesc}"/>
|
||||
<meta name="robots" content="index, follow"/>
|
||||
<style>${baseCss}${CLEARNET_SEARCH_CSS}${extraCss}
|
||||
.cn-brand-link{display:block;text-decoration:none}
|
||||
.cn-brand-link:hover .cn-brand{color:var(--accent)}
|
||||
.cn-brand-link:hover{text-decoration:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="cn-header">
|
||||
${brandBlock}
|
||||
${headerExtra ? `<div class="cn-header-extra">${headerExtra}</div>` : ''}
|
||||
</header>
|
||||
${stripInternalAnchors(body)}
|
||||
<footer class="cn-footer">
|
||||
<a href="https://code.03c8.net/krakenslab/oasis" target="_blank" rel="noopener"><img class="cn-footer-logo" src="/assets/images/snh-oasis.jpg" alt="Oasis"/></a>
|
||||
Powered by <a href="https://code.03c8.net/krakenslab/oasis" target="_blank" rel="noopener">Oasis</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
|
||||
const renderClearnetNotFound = () => {
|
||||
return renderClearnetPage({
|
||||
title: 'Oasis',
|
||||
ogTitle: 'Oasis',
|
||||
ogDescription: '',
|
||||
extraCss: `.cn-notfound{color:var(--fg-soft);font-size:16px;max-width:480px;margin:80px auto 40px auto;text-align:center;line-height:1.5}`,
|
||||
body: `<p class="cn-notfound">The content is not accessible at this moment.</p>`
|
||||
});
|
||||
};
|
||||
|
||||
const renderClearnetMediaView = ({ kind, item }) => {
|
||||
const blob = blobUrl(item.url);
|
||||
const title = escapeHtml(item.title || 'Untitled');
|
||||
const desc = escapeHtml(item.description || '');
|
||||
const dateStr = item.createdAt ? escapeHtml(new Date(item.createdAt).toISOString().slice(0, 10)) : '';
|
||||
const extraCss = `
|
||||
.cn-media-meta{color:var(--fg-dim);font-size:13px;margin-bottom:16px;display:flex;gap:14px;flex-wrap:wrap;align-items:baseline}
|
||||
.cn-id-meta{font-family:monospace;font-size:11px;word-break:break-all;color:var(--fg-dim)}
|
||||
.cn-media-title{color:var(--fg);font-size:26px;font-weight:700;margin:0 0 12px 0}
|
||||
.cn-media-desc{color:var(--fg-soft);white-space:pre-wrap;line-height:1.6;margin:16px 0}
|
||||
.cn-media-frame{margin:16px 0}
|
||||
.cn-media-frame img{max-width:100%;height:auto;border-radius:6px;border:1px solid var(--border);display:block}
|
||||
.cn-media-frame audio,.cn-media-frame video{width:100%;max-width:100%;display:block;border-radius:6px;background:#000}
|
||||
.cn-media-frame .cn-media-doc{display:inline-block;background:var(--bg-elev);border:1px solid var(--border);border-radius:6px;padding:10px 18px;color:var(--fg);text-decoration:none}
|
||||
.cn-media-frame .cn-media-doc:hover{border-color:var(--fg)}
|
||||
`;
|
||||
let mediaHtml = '';
|
||||
if (blob) {
|
||||
if (kind === 'image') {
|
||||
mediaHtml = `<img src="${blob}" alt="${title}"/>`;
|
||||
} else if (kind === 'audio') {
|
||||
mediaHtml = `<audio controls preload="metadata" src="${blob}"></audio>`;
|
||||
} else if (kind === 'video') {
|
||||
mediaHtml = `<video controls preload="metadata" src="${blob}"></video>`;
|
||||
} else if (kind === 'document' || kind === 'torrent') {
|
||||
mediaHtml = `<a class="cn-media-doc" href="${blob}" target="_blank" rel="noopener">⇩ ${title}</a>`;
|
||||
}
|
||||
}
|
||||
const body = `
|
||||
<div class="cn-media-meta">
|
||||
${dateStr ? `<span>📅 ${dateStr}</span>` : ''}
|
||||
</div>
|
||||
<h1 class="cn-media-title">${title}</h1>
|
||||
${mediaHtml ? `<div class="cn-media-frame">${mediaHtml}</div>` : ''}
|
||||
${desc ? `<p class="cn-media-desc">${desc}</p>` : ''}
|
||||
`;
|
||||
return renderClearnetPage({
|
||||
title: `${title} — Oasis`,
|
||||
ogTitle: item.title || 'Oasis',
|
||||
ogDescription: item.description || '',
|
||||
ogImage: (kind === 'image') ? blob : null,
|
||||
extraCss,
|
||||
body,
|
||||
hubFeedId: item.author || null
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
escapeHtml,
|
||||
blobIdOf,
|
||||
blobUrl,
|
||||
renderReachChip,
|
||||
renderFediverseReach,
|
||||
renderContentStats,
|
||||
renderEncryptedChip,
|
||||
renderDoubleEncryptionChip,
|
||||
renderClearnetUrlBlock,
|
||||
renderClearnetSearchForm,
|
||||
renderClearnetPage,
|
||||
renderClearnetNotFound,
|
||||
renderClearnetMediaView
|
||||
};
|
||||
1407
src/views/courts_view.js
Normal file
1407
src/views/courts_view.js
Normal file
File diff suppressed because it is too large
Load diff
257
src/views/cv_view.js
Normal file
257
src/views/cv_view.js
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
const { form, button, div, h2, p, section, textarea, label, input, br, img, a, select, option, span } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, userLink, renderVisibilityChip } = require('./main_views');
|
||||
const { renderUrl } = require('../backend/renderUrl');
|
||||
|
||||
const generateCVBox = (label, content, className) => {
|
||||
return div({ class: `cv-box ${className}` },
|
||||
h2(label),
|
||||
content
|
||||
);
|
||||
};
|
||||
|
||||
const generateTags = (tags) => {
|
||||
return tags && tags.length
|
||||
? div(
|
||||
tags.map(tag =>
|
||||
a({
|
||||
href: `/search?query=%23${encodeURIComponent(tag)}`,
|
||||
class: "tag-link",
|
||||
style: "margin-right:0.8em;margin-bottom:0.5em;"
|
||||
}, `#${tag}`)
|
||||
)
|
||||
)
|
||||
: null;
|
||||
};
|
||||
|
||||
exports.createCVView = async (cv = {}, editMode = false) => {
|
||||
const title = editMode ? i18n.cvEditSectionTitle : i18n.cvCreateSectionTitle;
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(title),
|
||||
p(i18n.cvDescription)
|
||||
),
|
||||
div({ class: "cv-form" },
|
||||
form({
|
||||
method: "POST",
|
||||
action: editMode ? `/cv/update/${encodeURIComponent(cv.id)}` : "/cv/upload",
|
||||
enctype: "multipart/form-data"
|
||||
},
|
||||
|
||||
generateCVBox(i18n.cvPersonal, [
|
||||
label(i18n.cvNameLabel), br(),
|
||||
input({ type: "text", name: "name", required: true, value: cv.name || "" }), br(),
|
||||
label(i18n.cvDescriptionLabel), br(),
|
||||
textarea({ name: "description", required: true, rows: 4 }, cv.description || ""), br(),
|
||||
label(i18n.cvLanguagesLabel), br(),
|
||||
input({ type: "text", name: "languages", value: cv.languages || "" }), br(),
|
||||
label(i18n.cvPhotoLabel), br(),
|
||||
input({ type: "file", name: "image" }), br(), br(),
|
||||
label(i18n.cvPersonalExperiencesLabel), br(),
|
||||
textarea({ name: "personalExperiences", rows: 4 }, cv.personalExperiences || ""), br(),
|
||||
label(i18n.cvPersonalSkillsLabel), br(),
|
||||
input({ type: "text", name: "personalSkills", required: true, value: (cv.personalSkills || []).join(", ") }), br()
|
||||
], "personal"),
|
||||
|
||||
generateCVBox(i18n.cvOasis, [
|
||||
label(i18n.cvOasisExperiencesLabel), br(),
|
||||
textarea({ name: "oasisExperiences", rows: 4 }, cv.oasisExperiences || ""), br(),
|
||||
label(i18n.cvOasisSkillsLabel), br(),
|
||||
input({ type: "text", name: "oasisSkills", value: (cv.oasisSkills || []).join(", ") }), br()
|
||||
], "oasis"),
|
||||
|
||||
generateCVBox(i18n.cvEducational, [
|
||||
label(i18n.cvEducationExperiencesLabel), br(),
|
||||
textarea({ name: "educationExperiences", rows: 4 }, cv.educationExperiences || ""), br(),
|
||||
label(i18n.cvEducationalSkillsLabel), br(),
|
||||
input({ type: "text", name: "educationalSkills", value: (cv.educationalSkills || []).join(", ") }), br()
|
||||
], "education"),
|
||||
|
||||
generateCVBox(i18n.cvProfessional, [
|
||||
label(i18n.cvProfessionalExperiencesLabel), br(),
|
||||
textarea({ name: "professionalExperiences", rows: 4 }, cv.professionalExperiences || ""), br(),
|
||||
label(i18n.cvProfessionalSkillsLabel), br(),
|
||||
input({ type: "text", name: "professionalSkills", value: (cv.professionalSkills || []).join(", ") }), br()
|
||||
], "professional"),
|
||||
|
||||
generateCVBox(i18n.cvAvailability, [
|
||||
label(i18n.cvLocationLabel), br(),
|
||||
input({ type: "text", name: "location", required: true, value: cv.location || "UNKNOWN" }), br(),
|
||||
label(i18n.cvStatusLabel), br(),
|
||||
select({ name: "status", required: true },
|
||||
option({ value: "AVAILABLE", selected: cv.status === "AVAILABLE FOR COLLABORATION" }, "AVAILABLE FOR COLLABORATION"),
|
||||
option({ value: "UNAVAILABLE", selected: cv.status === "NOT CURRENTLY AVAILABLE" }, "NOT CURRENTLY AVAILABLE"),
|
||||
option({ value: "LOOKING FOR WORK", selected: !cv.status || cv.status === "LOOKING FOR WORK" }, "LOOKING FOR WORK")
|
||||
), br(), br(),
|
||||
label(i18n.cvPreferencesLabel), br(),
|
||||
select({ name: "preferences", required: true },
|
||||
option({ value: "IN PERSON", selected: cv.preferences === "IN-PERSON ONLY" }, "IN-PERSON ONLY"),
|
||||
option({ value: "REMOTE WORKING", selected: !cv.preferences || cv.preferences === "REMOTE WORKING" }, "REMOTE-WORKING")
|
||||
), br(), br(),
|
||||
label(i18n.visibilityLabel || "Visibility"), br(),
|
||||
select({ name: "visibility" },
|
||||
option({ value: "PUBLIC", selected: (cv.visibility || "PUBLIC") === "PUBLIC" }, i18n.visibilityPublic || "Public"),
|
||||
option({ value: "HIDDEN", selected: cv.visibility === "HIDDEN" }, i18n.visibilityHidden || "Hidden")
|
||||
), br()
|
||||
], "availability"),
|
||||
|
||||
button({ type: "submit" }, editMode ? i18n.cvUpdateButton : i18n.cvCreateButton)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
};
|
||||
|
||||
exports.cvView = async (cv) => {
|
||||
const title = i18n.cvTitle;
|
||||
|
||||
if (!cv) {
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(title),
|
||||
p(i18n.cvDescription)
|
||||
),
|
||||
div({ class: "no-cv" },
|
||||
p(i18n.cvNoCV),
|
||||
form({ method: "GET", action: "/cv/create" },
|
||||
button({ type: "submit" }, i18n.cvCreateButton)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const hasPersonal = cv.contact || cv.name || cv.description || cv.photo || typeof cv.oasisContributor === "boolean" || (cv.personalSkills && cv.personalSkills.length);
|
||||
const hasPersonalExp = cv.personalExperiences;
|
||||
const hasOasis = cv.oasisExperiences || (cv.oasisSkills && cv.oasisSkills.length);
|
||||
const hasEducational = cv.educationExperiences || cv.languages || (cv.educationalSkills && cv.educationalSkills.length);
|
||||
const hasProfessional = cv.professionalExperiences || (cv.professionalSkills && cv.professionalSkills.length);
|
||||
const hasAvailability = cv.location || cv.status || cv.preferences;
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(title),
|
||||
p(i18n.cvDescription)
|
||||
),
|
||||
div({ class: "cv-section" },
|
||||
div({ class: "cv-item" }, ...[
|
||||
(() => {
|
||||
const vis = (cv.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC';
|
||||
const next = vis === 'PUBLIC' ? 'HIDDEN' : 'PUBLIC';
|
||||
return div({ class: "cv-visibility-row" },
|
||||
span({ class: "card-label" }, `${i18n.visibilityLabel || 'Visibility'}: `),
|
||||
renderVisibilityChip(vis, i18n),
|
||||
" ",
|
||||
form({ method: "POST", action: `/cv/visibility/${encodeURIComponent(cv.id)}`, class: "inline-form" },
|
||||
input({ type: "hidden", name: "visibility", value: next }),
|
||||
button({ type: "submit", class: "filter-btn" },
|
||||
next === 'PUBLIC' ? (i18n.visibilityMakePublic || 'Make public') : (i18n.visibilityMakeHidden || 'Make hidden')
|
||||
)
|
||||
)
|
||||
);
|
||||
})(),
|
||||
div({ class: "cv-actions" },
|
||||
form({ method: "GET", action: `/cv/edit/${encodeURIComponent(cv.id)}` },
|
||||
button({ type: "submit" }, i18n.cvEditButton)
|
||||
),
|
||||
form({ method: "POST", action: `/cv/delete/${encodeURIComponent(cv.id)}` },
|
||||
button({ type: "submit", class: "danger-btn" }, i18n.cvDeleteButton)
|
||||
)
|
||||
),
|
||||
div({ class: "cv-meta" },
|
||||
p(`${i18n.cvCreatedAt}: ${new Date(cv.createdAt).toLocaleString()}`),
|
||||
cv.updatedAt ? p(`${i18n.cvUpdatedAt}: ${new Date(cv.updatedAt).toLocaleString()}`) : null
|
||||
),
|
||||
hasPersonal ? div({ class: "cv-box personal" }, ...[
|
||||
cv.photo
|
||||
? img({
|
||||
src: `/blob/${encodeURIComponent(cv.photo)}`,
|
||||
class: "cv-photo"
|
||||
})
|
||||
: null,
|
||||
cv.name ? h2(`${cv.name}`) : null,
|
||||
(cv.contact || cv.author)
|
||||
? a({ href: `/author/${encodeURIComponent(cv.contact || cv.author)}`, class: 'inhabitant-qr-link' },
|
||||
img({ class: 'inhabitant-qr-small', src: `/qr/${encodeURIComponent(cv.contact || cv.author)}?size=120`, alt: 'QR' }))
|
||||
: null,
|
||||
cv.contact ? p(userLink(cv.contact)) : null,
|
||||
cv.description ? p(...renderUrl(`${cv.description}`)) : null,
|
||||
(cv.personalSkills && cv.personalSkills.length)
|
||||
? div(
|
||||
cv.personalSkills.map(tag =>
|
||||
a({
|
||||
href: `/search?query=%23${encodeURIComponent(tag)}`,
|
||||
class: "tag-link",
|
||||
style: "margin-right:0.8em;margin-bottom:0.5em;"
|
||||
}, `#${tag}`)
|
||||
)
|
||||
)
|
||||
: null
|
||||
]) : null,
|
||||
hasPersonal ? div({ class: "cv-box personal" }, ...[
|
||||
h2(i18n.cvLanguagesLabel),
|
||||
cv.languages ? p(`${cv.languages.toUpperCase()}`) : null
|
||||
]) : null,
|
||||
hasEducational ? div({ class: "cv-box education" }, ...[
|
||||
h2(i18n.cvEducationalView),
|
||||
cv.educationExperiences ? p(...renderUrl(`${cv.educationExperiences}`)) : null,
|
||||
(cv.educationalSkills && cv.educationalSkills.length)
|
||||
? div(
|
||||
cv.educationalSkills.map(tag =>
|
||||
a({
|
||||
href: `/search?query=%23${encodeURIComponent(tag)}`,
|
||||
class: "tag-link",
|
||||
style: "margin-right:0.8em;margin-bottom:0.5em;"
|
||||
}, `#${tag}`)
|
||||
)
|
||||
)
|
||||
: null
|
||||
]) : null,
|
||||
hasProfessional ? div({ class: "cv-box professional" }, ...[
|
||||
h2(i18n.cvProfessionalView),
|
||||
cv.professionalExperiences ? p(...renderUrl(`${cv.professionalExperiences}`)) : null,
|
||||
(cv.professionalSkills && cv.professionalSkills.length)
|
||||
? div(
|
||||
cv.professionalSkills.map(tag =>
|
||||
a({
|
||||
href: `/search?query=%23${encodeURIComponent(tag)}`,
|
||||
class: "tag-link",
|
||||
style: "margin-right:0.8em;margin-bottom:0.5em;"
|
||||
}, `#${tag}`)
|
||||
)
|
||||
)
|
||||
: null
|
||||
]) : null,
|
||||
hasOasis ? div({ class: "cv-box oasis" }, ...[
|
||||
h2(i18n.cvOasisContributorView),
|
||||
cv.oasisExperiences ? p(...renderUrl(`${cv.oasisExperiences}`)) : null,
|
||||
(cv.oasisSkills && cv.oasisSkills.length)
|
||||
? div(
|
||||
cv.oasisSkills.map(tag =>
|
||||
a({
|
||||
href: `/search?query=%23${encodeURIComponent(tag)}`,
|
||||
class: "tag-link",
|
||||
style: "margin-right:0.8em;margin-bottom:0.5em;"
|
||||
}, `#${tag}`)
|
||||
)
|
||||
)
|
||||
: null
|
||||
]) : null,
|
||||
hasAvailability ? div({ class: "cv-box availability" }, ...[
|
||||
h2(i18n.cvAvailabilityView),
|
||||
cv.location ? p(`${i18n.cvLocationLabel}: ${cv.location}`) : null,
|
||||
cv.status ? p(`${i18n.cvStatusLabel}: ${cv.status}`) : null,
|
||||
cv.preferences ? p(`${i18n.cvPreferencesLabel}: ${cv.preferences}`) : null
|
||||
]) : null
|
||||
])
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
465
src/views/document_view.js
Normal file
465
src/views/document_view.js
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
const { form, button, div, h2, p, section, input, label, br, a, span, textarea, select, option } =
|
||||
require("../server/node_modules/hyperaxe");
|
||||
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { template, i18n, renderOpinionsVoting, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions } = require("./main_views");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const { renderUrl } = require("../backend/renderUrl");
|
||||
|
||||
const userId = config.keys.id;
|
||||
|
||||
const safeArr = (v) => (Array.isArray(v) ? v : []);
|
||||
const safeText = (v) => String(v || "").trim();
|
||||
|
||||
const buildReturnTo = (filter, params = {}) => {
|
||||
const f = safeText(filter || "all");
|
||||
const q = safeText(params.q || "");
|
||||
const sort = safeText(params.sort || "recent");
|
||||
const parts = [`filter=${encodeURIComponent(f)}`];
|
||||
if (q) parts.push(`q=${encodeURIComponent(q)}`);
|
||||
if (sort) parts.push(`sort=${encodeURIComponent(sort)}`);
|
||||
return `/documents?${parts.join("&")}`;
|
||||
};
|
||||
|
||||
const safeDomId = (prefix, key) => `${prefix}${String(key || "").replace(/[^A-Za-z0-9_-]/g, "_")}`;
|
||||
|
||||
const renderTags = (tags) => {
|
||||
const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
|
||||
return list.length
|
||||
? div(
|
||||
{ class: "card-tags" },
|
||||
list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
|
||||
)
|
||||
: null;
|
||||
};
|
||||
|
||||
const renderFavoriteToggle = (doc, returnTo) =>
|
||||
form(
|
||||
{
|
||||
method: "POST",
|
||||
action: doc.isFavorite
|
||||
? `/documents/favorites/remove/${encodeURIComponent(doc.key)}`
|
||||
: `/documents/favorites/add/${encodeURIComponent(doc.key)}`
|
||||
},
|
||||
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
||||
button(
|
||||
{ type: "submit", class: "filter-btn" },
|
||||
doc.isFavorite ? i18n.documentRemoveFavoriteButton : i18n.documentAddFavoriteButton
|
||||
)
|
||||
);
|
||||
|
||||
const renderDocumentActions = (filter, doc, params = {}) => {
|
||||
const returnTo = buildReturnTo(filter, params);
|
||||
const isAuthor = String(doc.author) === String(userId);
|
||||
const hasOpinions = Object.keys(doc.opinions || {}).length > 0;
|
||||
|
||||
return isAuthor
|
||||
? div(
|
||||
{ class: "bookmark-actions" },
|
||||
!hasOpinions
|
||||
? form(
|
||||
{ method: "GET", action: `/documents/edit/${encodeURIComponent(doc.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "update-btn", type: "submit" }, i18n.documentUpdateButton)
|
||||
)
|
||||
: null,
|
||||
form(
|
||||
{ method: "POST", action: `/documents/delete/${encodeURIComponent(doc.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "delete-btn", type: "submit" }, i18n.documentDeleteButton)
|
||||
)
|
||||
)
|
||||
: null;
|
||||
};
|
||||
|
||||
const renderDocumentCommentsSection = (documentKey, rootId, comments = [], returnTo = null) => {
|
||||
const list = safeArr(comments).filter(c => {
|
||||
const t = c && c.value && c.value.content && c.value.content.text;
|
||||
return t && String(t).trim();
|
||||
});
|
||||
const commentsCount = list.length;
|
||||
|
||||
return div(
|
||||
{ class: "vote-comments-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
|
||||
span({ class: "card-value" }, String(commentsCount))
|
||||
),
|
||||
div(
|
||||
{ class: "comment-form-wrapper" },
|
||||
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
|
||||
form(
|
||||
{ method: "POST", action: `/documents/${encodeURIComponent(documentKey)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
|
||||
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
||||
rootId ? input({ type: "hidden", name: "rootId", value: rootId }) : null,
|
||||
textarea({
|
||||
id: "comment-text",
|
||||
name: "text",
|
||||
rows: 4,
|
||||
class: "comment-textarea",
|
||||
placeholder: i18n.voteNewCommentPlaceholder
|
||||
}),
|
||||
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
|
||||
br(),
|
||||
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
|
||||
)
|
||||
),
|
||||
list.length
|
||||
? div(
|
||||
{ class: "comments-list" },
|
||||
list.map((c) => {
|
||||
const author = c?.value?.author || "";
|
||||
const ts = c?.value?.timestamp || c?.timestamp;
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
|
||||
const relDate = ts ? moment(ts).fromNow() : "";
|
||||
|
||||
const content = c?.value?.content || {};
|
||||
const text = content.text || "";
|
||||
const threadRoot = content.fork || content.root || null;
|
||||
|
||||
return div(
|
||||
{ class: "votations-comment-card" },
|
||||
span(
|
||||
{ class: "created-at" },
|
||||
span(i18n.createdBy),
|
||||
author ? userLink(author) : span("(unknown)"),
|
||||
absDate ? span(" | ") : "",
|
||||
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
|
||||
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
|
||||
relDate && threadRoot
|
||||
? a({ href: `/thread/${encodeURIComponent(threadRoot)}#${encodeURIComponent(c.key)}` }, relDate)
|
||||
: ""
|
||||
),
|
||||
p({ class: "votations-comment-text" }, ...renderUrl(text))
|
||||
);
|
||||
})
|
||||
)
|
||||
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
|
||||
);
|
||||
};
|
||||
|
||||
const renderDocumentList = exports.renderDocumentList = (documents, filter, params = {}) => {
|
||||
const returnTo = buildReturnTo(filter, params);
|
||||
|
||||
return documents.length
|
||||
? documents.map((doc) => {
|
||||
const commentCount = typeof doc.commentCount === "number" ? doc.commentCount : 0;
|
||||
const title = safeText(doc.title);
|
||||
const pdfId = safeDomId("pdf-container-", doc.key);
|
||||
|
||||
const topbarLeft =
|
||||
doc.author && String(doc.author) !== String(userId)
|
||||
? form(
|
||||
{ method: "GET", action: "/pm" },
|
||||
input({ type: "hidden", name: "recipients", value: doc.author }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.documentMessageAuthorButton)
|
||||
)
|
||||
: null;
|
||||
|
||||
const isOwn = doc.author && String(doc.author) === String(userId);
|
||||
return div(
|
||||
{ class: "trending-card document-card" + (isOwn ? " own-content" : "") },
|
||||
div(
|
||||
{ class: "card-header activity-card-header" },
|
||||
span(),
|
||||
renderContentActions(doc.key, `/documents/${encodeURIComponent(doc.key)}`)
|
||||
),
|
||||
div(
|
||||
{ class: "card-section document-card-body" },
|
||||
div(
|
||||
{ class: "bookmark-topbar" },
|
||||
div(
|
||||
{ class: "bookmark-topbar-left" },
|
||||
renderFavoriteToggle(doc, returnTo),
|
||||
topbarLeft
|
||||
),
|
||||
renderDocumentActions(filter, doc, params)
|
||||
),
|
||||
title ? h2(title) : null,
|
||||
doc.lifetime ? div({ class: "card-chips-row" }, renderLifespanChip(doc.lifetime, i18n)) : null,
|
||||
doc?.url
|
||||
? div({ id: pdfId, class: "pdf-viewer-container", "data-pdf-url": `/blob/${encodeURIComponent(doc.url)}` })
|
||||
: p(i18n.documentNoFile),
|
||||
div(
|
||||
{ class: "card-comments-summary" },
|
||||
span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
|
||||
span({ class: "card-value" }, String(commentCount)),
|
||||
br(),
|
||||
br(),
|
||||
form(
|
||||
{ method: "GET", action: `/documents/${encodeURIComponent(doc.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
input({ type: "hidden", name: "filter", value: filter || "all" }),
|
||||
params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
|
||||
params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
|
||||
)
|
||||
),
|
||||
div(
|
||||
{ class: "card-comments-summary feed-opinions-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "),
|
||||
span({ class: "card-value" }, String(Object.values(doc.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)))
|
||||
),
|
||||
renderOpinionsVoting('/documents/opinions', doc.key, doc.opinions, returnTo, doc.opinions_inhabitants)
|
||||
),
|
||||
div({ class: "card-spread-left" }, renderSpreadButton(doc.key, (params.spreadMap && params.spreadMap.get(doc.key)) || params.spreads)),
|
||||
br(),
|
||||
(() => {
|
||||
const createdTs = doc.createdAt ? new Date(doc.createdAt).getTime() : NaN;
|
||||
const updatedTs = doc.updatedAt ? new Date(doc.updatedAt).getTime() : NaN;
|
||||
const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
|
||||
|
||||
return p(
|
||||
{ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${moment(doc.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
|
||||
userLink(doc.author),
|
||||
showUpdated
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
` | ${i18n.documentUpdatedAt}: ${moment(doc.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
|
||||
)
|
||||
: null
|
||||
);
|
||||
})()
|
||||
)
|
||||
);
|
||||
})
|
||||
: p(params.q ? i18n.documentNoMatch : i18n.noDocuments);
|
||||
};
|
||||
|
||||
const renderDocumentForm = (filter, documentId, docToEdit, params = {}) => {
|
||||
const returnFilter = filter === "create" ? "all" : params.filter || "all";
|
||||
const returnTo = safeText(params.returnTo) || buildReturnTo(returnFilter, params);
|
||||
const tagsValue = safeArr(docToEdit?.tags).join(", ");
|
||||
|
||||
return div(
|
||||
{ class: "div-center document-form" },
|
||||
form(
|
||||
{
|
||||
action: filter === "edit" ? `/documents/update/${encodeURIComponent(documentId)}` : "/documents/create",
|
||||
method: "POST",
|
||||
enctype: "multipart/form-data"
|
||||
},
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
label(i18n.documentFileLabel),
|
||||
br(),
|
||||
input({ type: "file", name: "document", accept: "application/pdf", required: filter !== "edit" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.documentTitleLabel),
|
||||
br(),
|
||||
input({ type: "text", name: "title", placeholder: i18n.documentTitlePlaceholder, value: docToEdit?.title || "" }),
|
||||
br(),
|
||||
label(i18n.documentDescriptionLabel),
|
||||
br(),
|
||||
textarea({ name: "description", placeholder: i18n.documentDescriptionPlaceholder, rows: "4" }, docToEdit?.description || ""),
|
||||
br(),
|
||||
label(i18n.documentTagsLabel),
|
||||
br(),
|
||||
input({ type: "text", name: "tags", placeholder: i18n.documentTagsPlaceholder, value: tagsValue }),
|
||||
br(),
|
||||
br(),
|
||||
button({ type: "submit" }, filter === "edit" ? i18n.documentUpdateButton : i18n.documentCreateButton)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.documentView = async (documents, filter = "all", documentId = null, params = {}) => {
|
||||
const title =
|
||||
filter === "mine"
|
||||
? i18n.documentMineSectionTitle
|
||||
: filter === "create"
|
||||
? i18n.documentCreateSectionTitle
|
||||
: filter === "edit"
|
||||
? i18n.documentUpdateSectionTitle
|
||||
: filter === "recent"
|
||||
? i18n.documentRecentSectionTitle
|
||||
: filter === "top"
|
||||
? i18n.documentTopSectionTitle
|
||||
: filter === "favorites"
|
||||
? i18n.documentFavoritesSectionTitle
|
||||
: i18n.documentAllSectionTitle;
|
||||
|
||||
const q = safeText(params.q || "");
|
||||
const sort = safeText(params.sort || "recent");
|
||||
|
||||
const list = safeArr(documents);
|
||||
const docToEdit = documentId ? list.find((d) => d.key === documentId) : null;
|
||||
|
||||
const tpl = template(
|
||||
title,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(title),
|
||||
p(i18n.documentDescription)
|
||||
),
|
||||
(() => {
|
||||
const { renderReachChip } = require('./clearnet_view');
|
||||
const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetDocuments);
|
||||
return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n));
|
||||
})(),
|
||||
br(),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/documents", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterTop),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
|
||||
i18n.documentFilterFavorites
|
||||
),
|
||||
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterRecent),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.documentCreateButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
filter === "create" || filter === "edit"
|
||||
? renderDocumentForm(filter, documentId, docToEdit || {}, { ...params, filter })
|
||||
: section(
|
||||
div(
|
||||
{ class: "documents-search" },
|
||||
form(
|
||||
{ method: "GET", action: "/documents", class: "filter-box" },
|
||||
input({ type: "hidden", name: "filter", value: filter }),
|
||||
input({ type: "text", name: "q", value: q, placeholder: i18n.documentSearchPlaceholder, class: "filter-box__input" }),
|
||||
div(
|
||||
{ class: "filter-box__controls" },
|
||||
select(
|
||||
{ name: "sort", class: "filter-box__select" },
|
||||
option({ value: "recent", selected: sort === "recent" }, i18n.documentSortRecent),
|
||||
option({ value: "oldest", selected: sort === "oldest" }, i18n.documentSortOldest),
|
||||
option({ value: "top", selected: sort === "top" }, i18n.documentSortTop)
|
||||
),
|
||||
button({ type: "submit", class: "filter-box__button" }, i18n.documentSearchButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
div({ class: "documents-list" }, renderDocumentList(list, filter, { q, sort }))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return `${tpl}<script type="module" src="/js/pdf.min.mjs"></script><script src="/js/pdf-viewer.js"></script>`;
|
||||
};
|
||||
|
||||
exports.singleDocumentView = async (doc, filter = "all", comments = [], params = {}) => {
|
||||
const q = safeText(params.q || "");
|
||||
const sort = safeText(params.sort || "recent");
|
||||
const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort });
|
||||
|
||||
const title = safeText(doc.title);
|
||||
const pdfId = safeDomId("pdf-container-", doc.key);
|
||||
const isAuthor = String(doc.author) === String(userId);
|
||||
const hasOpinions = Object.keys(doc.opinions || {}).length > 0;
|
||||
const { renderReachChip } = require('./clearnet_view');
|
||||
const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetDocuments);
|
||||
|
||||
const chips = [
|
||||
renderLifespanChip(doc.lifetime, i18n),
|
||||
doc.sizeBytes ? renderEcoTax(doc.sizeBytes, doc.key) : null
|
||||
].filter(Boolean);
|
||||
|
||||
const sideActions = [];
|
||||
sideActions.push(renderFavoriteToggle(doc, returnTo));
|
||||
if (doc.author && String(doc.author) !== String(userId)) {
|
||||
sideActions.push(form(
|
||||
{ method: "GET", action: "/pm" },
|
||||
input({ type: "hidden", name: "recipients", value: doc.author }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.documentMessageAuthorButton)
|
||||
));
|
||||
}
|
||||
if (isAuthor && !hasOpinions) {
|
||||
sideActions.push(form(
|
||||
{ method: "GET", action: `/documents/edit/${encodeURIComponent(doc.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "update-btn", type: "submit" }, i18n.documentUpdateButton)
|
||||
));
|
||||
}
|
||||
if (isAuthor) {
|
||||
sideActions.push(form(
|
||||
{ method: "POST", action: `/documents/delete/${encodeURIComponent(doc.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "delete-btn", type: "submit" }, i18n.documentDeleteButton)
|
||||
));
|
||||
}
|
||||
|
||||
const tagsNode = renderTags(doc.tags);
|
||||
|
||||
const docSide = div({ class: "tribe-side" },
|
||||
div({ class: "shop-title-row" },
|
||||
title ? h2({ class: "tribe-card-title" }, title) : null,
|
||||
renderReachChip(isClearnet, i18n)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
safeText(doc.description)
|
||||
? p({ class: "tribe-side-description" }, ...renderUrl(doc.description))
|
||||
: null,
|
||||
tagsNode,
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(doc.key, params.spreads)),
|
||||
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null
|
||||
);
|
||||
|
||||
const docMain = div({ class: "tribe-main" },
|
||||
doc?.url
|
||||
? div({ id: pdfId, class: "pdf-viewer-container", "data-pdf-url": `/blob/${encodeURIComponent(doc.url)}` })
|
||||
: p(i18n.documentNoFile),
|
||||
(() => {
|
||||
const createdTs = doc.createdAt ? new Date(doc.createdAt).getTime() : NaN;
|
||||
const updatedTs = doc.updatedAt ? new Date(doc.updatedAt).getTime() : NaN;
|
||||
const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
|
||||
|
||||
return p(
|
||||
{ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${moment(doc.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
|
||||
userLink(doc.author),
|
||||
showUpdated
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
` | ${i18n.documentUpdatedAt}: ${moment(doc.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
|
||||
)
|
||||
: null
|
||||
);
|
||||
})(),
|
||||
renderOpinionsVoting('/documents/opinions', doc.key, doc.opinions, returnTo, doc.opinions_inhabitants),
|
||||
renderDocumentCommentsSection(doc.key, doc.rootId || doc.key, comments, returnTo)
|
||||
);
|
||||
|
||||
const tpl = template(
|
||||
i18n.documentTitle,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.documentAllSectionTitle || i18n.documentTitle),
|
||||
p(i18n.documentDescription)
|
||||
),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/documents", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterTop),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
|
||||
i18n.documentFilterFavorites
|
||||
),
|
||||
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterRecent),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.documentCreateButton)
|
||||
)
|
||||
),
|
||||
div({ class: "tribe-details" }, docSide, docMain)
|
||||
)
|
||||
);
|
||||
|
||||
return `${tpl}<script type="module" src="/js/pdf.min.mjs"></script><script src="/js/pdf-viewer.js"></script>`;
|
||||
};
|
||||
|
||||
578
src/views/event_view.js
Normal file
578
src/views/event_view.js
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, table, tr, td } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, renderOpinionsVoting, userLink, renderStateChip, renderOpenClosedChip, renderPrivacyChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions } = require("./main_views");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const { renderUrl } = require("../backend/renderUrl");
|
||||
const { renderMapLocationUrl, renderMapEmbed, renderMapLocationVisitLabel } = require("./maps_view");
|
||||
|
||||
const userId = config.keys.id;
|
||||
|
||||
exports.renderEventInvitePage = (code) => {
|
||||
const pageContent = div({ class: "invite-page" },
|
||||
h2(i18n.tribeInviteCodeText, code),
|
||||
form({ method: "GET", action: "/events" },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.walletBack)
|
||||
)
|
||||
);
|
||||
return template(i18n.invitesEventsTitle || "Events", section(pageContent));
|
||||
};
|
||||
|
||||
const opt = (value, isSelected, text) =>
|
||||
option(Object.assign({ value }, isSelected ? { selected: "selected" } : {}), text);
|
||||
|
||||
const safeArray = (v) => (Array.isArray(v) ? v : []);
|
||||
|
||||
const normalizePrivacy = (v) => {
|
||||
const s = String(v || "public").toLowerCase();
|
||||
return s === "private" ? "private" : "public";
|
||||
};
|
||||
|
||||
const privacyLabel = (v) => (normalizePrivacy(v) === "private" ? i18n.eventPrivate : i18n.eventPublic);
|
||||
|
||||
const safeExternalHref = (url) => {
|
||||
const s = String(url || "").trim();
|
||||
const lower = s.toLowerCase();
|
||||
if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("mailto:")) return s;
|
||||
return "";
|
||||
};
|
||||
|
||||
const normalizeEventStatus = (v) => {
|
||||
const up = String(v || "").toUpperCase();
|
||||
if (up === "OPEN" || up === "CLOSED") return up;
|
||||
return up || "OPEN";
|
||||
};
|
||||
|
||||
const eventStatusLabel = (v) => {
|
||||
const st = normalizeEventStatus(v);
|
||||
if (st === "OPEN") return i18n.eventStatusOpen;
|
||||
if (st === "CLOSED") return i18n.eventStatusClosed;
|
||||
return st;
|
||||
};
|
||||
|
||||
const attendanceLabel = (isAttending) => (isAttending ? i18n.eventAttended : i18n.eventUnattended);
|
||||
|
||||
const renderEventOwnerActions = (e, returnTo) => {
|
||||
const st = normalizeEventStatus(e.status);
|
||||
if (e.organizer !== userId || st !== "OPEN") return [];
|
||||
const actions = [
|
||||
form(
|
||||
{ method: "GET", action: `/events/edit/${encodeURIComponent(e.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", class: "update-btn" }, i18n.eventUpdateButton)
|
||||
),
|
||||
form(
|
||||
{ method: "POST", action: `/events/delete/${encodeURIComponent(e.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", class: "delete-btn" }, i18n.eventDeleteButton)
|
||||
)
|
||||
];
|
||||
if (normalizePrivacy(e.isPublic) === "private") {
|
||||
actions.push(form(
|
||||
{ method: "POST", action: `/events/generate-invite/${encodeURIComponent(e.id)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeGenerateInvite)
|
||||
));
|
||||
if (e.openInviteCode) {
|
||||
actions.push(div({ class: "tribe-open-invite" },
|
||||
span({ class: "card-label" }, i18n.tribeInviteCodeText),
|
||||
span({ class: "tribe-open-invite-code" }, e.openInviteCode)
|
||||
));
|
||||
actions.push(form(
|
||||
{ method: "POST", action: `/events/open-invite/remove/${encodeURIComponent(e.id)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeRemoveInvitation)
|
||||
));
|
||||
} else {
|
||||
actions.push(form(
|
||||
{ method: "POST", action: `/events/open-invite/create/${encodeURIComponent(e.id)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeOpenInvitation)
|
||||
));
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
};
|
||||
|
||||
const renderEventAttendAction = (e, isAttending, returnTo) => {
|
||||
const st = normalizeEventStatus(e.status);
|
||||
if (st !== "OPEN") return null;
|
||||
if (e.organizer === userId) return null;
|
||||
if (normalizePrivacy(e.isPublic) === "private" && !isAttending) {
|
||||
return a({ class: "tribe-action-btn", href: "/invites#invites-events" }, i18n.tribeEnterInvite);
|
||||
}
|
||||
return form(
|
||||
{ method: "POST", action: `/events/attend/${encodeURIComponent(e.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", class: "filter-btn" }, attendanceLabel(isAttending))
|
||||
);
|
||||
};
|
||||
|
||||
const renderEventCommentsSection = (eventId, comments = [], currentFilter = "all") => {
|
||||
const commentsCount = Array.isArray(comments) ? comments.length : 0;
|
||||
const returnTo = `/events/${encodeURIComponent(eventId)}?filter=${encodeURIComponent(currentFilter || "all")}`;
|
||||
|
||||
return div(
|
||||
{ class: "vote-comments-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
|
||||
span({ class: "card-value" }, String(commentsCount))
|
||||
),
|
||||
div(
|
||||
{ class: "comment-form-wrapper" },
|
||||
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
|
||||
form(
|
||||
{ method: "POST", action: `/events/${encodeURIComponent(eventId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
textarea({
|
||||
id: "comment-text",
|
||||
name: "text",
|
||||
rows: 4,
|
||||
class: "comment-textarea",
|
||||
placeholder: i18n.voteNewCommentPlaceholder
|
||||
}),
|
||||
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
|
||||
br(),
|
||||
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
|
||||
)
|
||||
),
|
||||
(() => {
|
||||
const visibleComments = (comments || []).filter(c => {
|
||||
const t = c && c.value && c.value.content && c.value.content.text;
|
||||
return t && String(t).trim();
|
||||
});
|
||||
return visibleComments.length
|
||||
? div(
|
||||
{ class: "comments-list" },
|
||||
visibleComments.map((c) => {
|
||||
const author = c.value && c.value.author ? c.value.author : "";
|
||||
const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
|
||||
const relDate = ts ? moment(ts).fromNow() : "";
|
||||
|
||||
const content = c.value && c.value.content ? c.value.content : {};
|
||||
const root = content.fork || content.root || "";
|
||||
const text = content.text || "";
|
||||
|
||||
return div(
|
||||
{ class: "votations-comment-card" },
|
||||
span(
|
||||
{ class: "created-at" },
|
||||
span(i18n.createdBy),
|
||||
author ? userLink(author) : span("(unknown)"),
|
||||
absDate ? span(" | ") : "",
|
||||
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
|
||||
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
|
||||
relDate && root ? a({ href: `/thread/${encodeURIComponent(root)}#${encodeURIComponent(c.key)}` }, relDate) : ""
|
||||
),
|
||||
p({ class: "votations-comment-text" }, ...renderUrl(String(text)))
|
||||
);
|
||||
})
|
||||
)
|
||||
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet);
|
||||
})()
|
||||
);
|
||||
};
|
||||
|
||||
const renderEventStatusChip = (status) => {
|
||||
const localized = eventStatusLabel(status);
|
||||
return renderOpenClosedChip(status, { statusChipOPEN: localized, statusChipCLOSED: localized });
|
||||
};
|
||||
|
||||
const renderEventItem = exports.renderEventItem = (e, filter, spreadInfo) => {
|
||||
const currentFilter = filter || "all";
|
||||
const attendees = safeArray(e.attendees);
|
||||
const isPrivate = normalizePrivacy(e.isPublic) === "private";
|
||||
const isAttending = attendees.includes(userId);
|
||||
const price = parseFloat(e.price || 0);
|
||||
|
||||
const chips = [
|
||||
renderEventStatusChip(e.status),
|
||||
renderPrivacyChip(isPrivate, i18n),
|
||||
e.encrypted ? renderStateChip("encrypted", "🔒", i18n.encryptedChipLabel || "E2E") : null,
|
||||
isAttending ? renderStateChip("whole", "★", i18n.eventAttended) : null,
|
||||
renderLifespanChip(e.lifetime, i18n)
|
||||
].filter(Boolean);
|
||||
|
||||
const dateText = e.date ? moment(e.date).format("YYYY/MM/DD HH:mm") : "";
|
||||
|
||||
const isOwn = e.organizer && String(e.organizer) === String(userId);
|
||||
return div({ class: "trending-card event-card" + (isOwn ? " own-content" : "") },
|
||||
div(
|
||||
{ class: "card-header activity-card-header" },
|
||||
span(),
|
||||
renderContentActions(e.id, `/events/${encodeURIComponent(e.id)}?filter=${encodeURIComponent(currentFilter)}`)
|
||||
),
|
||||
div({ class: "card-section event-card-body" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" },
|
||||
a({ href: `/events/${encodeURIComponent(e.id)}` }, e.title || i18n.eventsTitle)
|
||||
)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
dateText ? p({ class: "card-date-highlight" }, dateText) : null,
|
||||
e.location && String(e.location).trim()
|
||||
? p({ class: "job-meta-line" }, String(e.location))
|
||||
: null,
|
||||
price > 0
|
||||
? div({ class: "job-price-line card-salary" }, `${price.toFixed(6)} ECO`)
|
||||
: null,
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.eventAttendees}: ${attendees.length}`)
|
||||
),
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(e.id, spreadInfo))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.eventView = async (events, filter, eventId, returnTo, params = {}) => {
|
||||
const list = Array.isArray(events) ? events : [events];
|
||||
const currentFilter = filter || "all";
|
||||
const { renderReachChip: renderReachChipEvents } = require('./clearnet_view');
|
||||
const viewerClearnetEvents = !!(params.viewerPrefs && params.viewerPrefs.clearnetEvents);
|
||||
|
||||
const title =
|
||||
currentFilter === "mine" ? i18n.eventMineSectionTitle :
|
||||
currentFilter === "create" ? i18n.eventCreateSectionTitle :
|
||||
currentFilter === "edit" ? i18n.eventUpdateSectionTitle :
|
||||
i18n.eventAllSectionTitle;
|
||||
|
||||
const eventToEdit = list.find((e) => e.id === eventId) || {};
|
||||
const editTags = Array.isArray(eventToEdit.tags) ? eventToEdit.tags.filter(Boolean) : [];
|
||||
|
||||
const canSee = (e) => {
|
||||
const isPub = normalizePrivacy(e.isPublic) === "public";
|
||||
if (isPub) return true;
|
||||
if (e.organizer === userId) return true;
|
||||
return safeArray(e.attendees).includes(userId);
|
||||
};
|
||||
|
||||
const visible = list.filter(canSee);
|
||||
|
||||
let filtered;
|
||||
if (currentFilter === "all") {
|
||||
filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public");
|
||||
} else if (currentFilter === "mine") {
|
||||
filtered = visible.filter((e) => e.organizer === userId);
|
||||
} else if (currentFilter === "today") {
|
||||
filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public" && moment(e.date).isSame(moment(), "day"));
|
||||
} else if (currentFilter === "week") {
|
||||
filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public" && moment(e.date).isBetween(moment(), moment().add(7, "days"), null, "[]"));
|
||||
} else if (currentFilter === "month") {
|
||||
filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public" && moment(e.date).isBetween(moment(), moment().add(1, "month"), null, "[]"));
|
||||
} else if (currentFilter === "year") {
|
||||
filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public" && moment(e.date).isBetween(moment(), moment().add(1, "year"), null, "[]"));
|
||||
} else if (currentFilter === "archived") {
|
||||
filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public" && normalizeEventStatus(e.status) === "CLOSED");
|
||||
} else {
|
||||
filtered = [];
|
||||
}
|
||||
|
||||
filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
|
||||
const minCreate = moment().add(1, "minute").format("YYYY-MM-DDTHH:mm");
|
||||
|
||||
const ret = typeof returnTo === "string" && returnTo.startsWith("/events") ? returnTo : "/events?filter=mine";
|
||||
const editPrivacy = normalizePrivacy(eventToEdit.isPublic);
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.eventsTitle),
|
||||
p(i18n.eventsDescription)
|
||||
),
|
||||
div({ class: "shop-title-row" }, renderReachChipEvents(viewerClearnetEvents, i18n)),
|
||||
br(),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/events" },
|
||||
button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "today", class: currentFilter === "today" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterToday),
|
||||
button({ type: "submit", name: "filter", value: "week", class: currentFilter === "week" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterWeek),
|
||||
button({ type: "submit", name: "filter", value: "month", class: currentFilter === "month" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMonth),
|
||||
button({ type: "submit", name: "filter", value: "year", class: currentFilter === "year" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterYear),
|
||||
button({ type: "submit", name: "filter", value: "archived", class: currentFilter === "archived" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterArchived),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.eventCreateButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
currentFilter === "edit" || currentFilter === "create"
|
||||
? div(
|
||||
{ class: "event-form" },
|
||||
form(
|
||||
{
|
||||
action: currentFilter === "edit" ? `/events/update/${encodeURIComponent(eventId)}` : "/events/create",
|
||||
method: "POST",
|
||||
enctype: "multipart/form-data"
|
||||
},
|
||||
input({ type: "hidden", name: "returnTo", value: ret }),
|
||||
label(i18n.eventTitleLabel),
|
||||
br(),
|
||||
input({
|
||||
type: "text",
|
||||
name: "title",
|
||||
id: "title",
|
||||
required: true,
|
||||
value: currentFilter === "edit" ? eventToEdit.title || "" : ""
|
||||
}),
|
||||
br(),
|
||||
label(i18n.eventDescriptionLabel),
|
||||
br(),
|
||||
textarea(
|
||||
{ name: "description", id: "description", placeholder: i18n.eventDescriptionPlaceholder, rows: "4" },
|
||||
currentFilter === "edit" ? eventToEdit.description || "" : ""
|
||||
),
|
||||
br(),
|
||||
label(i18n.uploadMedia),
|
||||
br(),
|
||||
input({ type: "file", name: "image", accept: "image/*" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.eventDateLabel),
|
||||
br(),
|
||||
input({
|
||||
type: "datetime-local",
|
||||
name: "date",
|
||||
id: "date",
|
||||
required: true,
|
||||
min: currentFilter === "create" ? minCreate : undefined,
|
||||
value: currentFilter === "edit" && eventToEdit.date ? moment(eventToEdit.date).format("YYYY-MM-DDTHH:mm") : ""
|
||||
}),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.eventPrivacyLabel),
|
||||
br(),
|
||||
select(
|
||||
{ name: "isPublic", id: "isPublic" },
|
||||
opt("public", editPrivacy !== "private", i18n.eventPublic),
|
||||
opt("private", editPrivacy === "private", i18n.eventPrivate)
|
||||
),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.eventLocationLabel),
|
||||
br(),
|
||||
input({
|
||||
type: "text",
|
||||
name: "location",
|
||||
id: "location",
|
||||
required: true,
|
||||
value: currentFilter === "edit" ? eventToEdit.location || "" : ""
|
||||
}),
|
||||
br(),
|
||||
label(i18n.mapLocationTitle || "Map Location"),
|
||||
br(),
|
||||
input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: eventToEdit?.mapUrl || "" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.eventUrlLabel),
|
||||
br(),
|
||||
input({ type: "url", name: "url", id: "url", value: currentFilter === "edit" ? eventToEdit.url || "" : "" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.eventPriceLabel),
|
||||
br(),
|
||||
input({
|
||||
type: "number",
|
||||
name: "price",
|
||||
id: "price",
|
||||
min: "0.000000",
|
||||
step: "0.000001",
|
||||
value: currentFilter === "edit" ? parseFloat(eventToEdit.price || 0).toFixed(6) : (0).toFixed(6)
|
||||
}),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.eventTagsLabel),
|
||||
br(),
|
||||
input({ type: "text", name: "tags", id: "tags", value: currentFilter === "edit" ? editTags.join(", ") : "" }),
|
||||
br(),
|
||||
br(),
|
||||
...(currentFilter === "create" ? [
|
||||
input({ type: "hidden", name: "addToCalendar", value: "0" }),
|
||||
label(i18n.eventAddToCalendar || "Add to Calendar"),
|
||||
br(),
|
||||
input({ id: "addToCalendar", type: "checkbox", name: "addToCalendar", value: "1", class: "meme-checkbox" }),
|
||||
br(),
|
||||
br()
|
||||
] : []),
|
||||
button({ type: "submit" }, currentFilter === "edit" ? i18n.eventUpdateButton : i18n.eventCreateButton)
|
||||
)
|
||||
)
|
||||
: filtered.length > 0
|
||||
? div({ class: "jobs-grid" }, filtered.map((e) => renderEventItem(e, currentFilter, params.spreadMap && params.spreadMap.get(e.id))))
|
||||
: p(i18n.noevents)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.singleEventView = async (event, filter, comments = [], params = {}) => {
|
||||
const currentFilter = filter || "all";
|
||||
const commentCount = typeof event.commentCount === "number" ? event.commentCount : 0;
|
||||
const attendees = safeArray(event.attendees);
|
||||
const urlHref = safeExternalHref(event.url);
|
||||
|
||||
const isPrivateNoAccess = normalizePrivacy(event.isPublic) === "private" &&
|
||||
String(event.organizer) !== String(userId) &&
|
||||
!attendees.includes(userId);
|
||||
|
||||
const filterBar = div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/events" },
|
||||
button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "today", class: currentFilter === "today" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterToday),
|
||||
button({ type: "submit", name: "filter", value: "week", class: currentFilter === "week" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterWeek),
|
||||
button({ type: "submit", name: "filter", value: "month", class: currentFilter === "month" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMonth),
|
||||
button({ type: "submit", name: "filter", value: "year", class: currentFilter === "year" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterYear),
|
||||
button({ type: "submit", name: "filter", value: "archived", class: currentFilter === "archived" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterArchived),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.eventCreateButton)
|
||||
)
|
||||
);
|
||||
|
||||
if (isPrivateNoAccess) {
|
||||
return template(
|
||||
event.title,
|
||||
section(filterBar, p({ class: "access-denied-msg" }, i18n.contentAccessDenied))
|
||||
);
|
||||
}
|
||||
|
||||
const { renderReachChip, renderEncryptedChip } = require('./clearnet_view');
|
||||
const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetEvents && normalizeEventStatus(event.status) !== 'CLOSED' && normalizePrivacy(event.isPublic) === 'public');
|
||||
const isPrivate = normalizePrivacy(event.isPublic) === 'private';
|
||||
const isEncrypted = !!event.encrypted || isPrivate;
|
||||
const isAttending = attendees.includes(userId);
|
||||
const isOrganizer = String(event.organizer) === String(userId);
|
||||
const returnToSelf = `/events/${encodeURIComponent(event.id)}?filter=${encodeURIComponent(currentFilter)}`;
|
||||
|
||||
const chips = [
|
||||
renderEventStatusChip(event.status),
|
||||
renderPrivacyChip(isPrivate, i18n),
|
||||
isEncrypted ? renderEncryptedChip(i18n) : renderReachChip(isClearnet, i18n),
|
||||
isAttending ? renderStateChip("whole", "★", i18n.eventAttended) : null,
|
||||
renderLifespanChip(event.lifetime, i18n),
|
||||
renderEcoTax(event.msgSize, event.id)
|
||||
].filter(Boolean);
|
||||
|
||||
const sideActions = [];
|
||||
if (event.organizer && event.organizer !== userId) {
|
||||
sideActions.push(form({ method: "GET", action: "/pm" },
|
||||
input({ type: "hidden", name: "recipients", value: event.organizer }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
|
||||
));
|
||||
}
|
||||
const attendNode = renderEventAttendAction(event, isAttending, returnToSelf);
|
||||
if (attendNode) sideActions.push(attendNode);
|
||||
if (params.linkedCalendarId) {
|
||||
sideActions.push(form({ method: "GET", action: `/calendars/${encodeURIComponent(params.linkedCalendarId)}` },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.eventVisitCalendar || "Visit calendar")
|
||||
));
|
||||
}
|
||||
if (isOrganizer) sideActions.push(...renderEventOwnerActions(event, returnToSelf));
|
||||
|
||||
const tagsNode = event.tags && event.tags.filter(Boolean).length
|
||||
? div({ class: "card-tags" },
|
||||
event.tags.filter(Boolean).map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
|
||||
)
|
||||
: null;
|
||||
|
||||
const infoRows = [];
|
||||
const pushRow = (labelText, valueNode) =>
|
||||
infoRows.push(tr(
|
||||
td({ class: "tribe-info-label" }, labelText),
|
||||
td({ class: "tribe-info-value" }, valueNode)
|
||||
));
|
||||
if (event.date) pushRow(i18n.eventDateLabel, moment(event.date).format("YYYY/MM/DD HH:mm"));
|
||||
pushRow(i18n.eventStatus, eventStatusLabel(event.status));
|
||||
pushRow(i18n.eventPrivacyLabel, privacyLabel(event.isPublic));
|
||||
if (event.location && String(event.location).trim()) pushRow(i18n.eventLocationLabel, event.location);
|
||||
if (urlHref) pushRow(i18n.eventUrlLabel, a({ href: urlHref, target: "_blank", rel: "noopener noreferrer" }, urlHref));
|
||||
const price = parseFloat(event.price || 0);
|
||||
if (price > 0) pushRow(i18n.eventPriceLabel, `${price.toFixed(6)} ECO`);
|
||||
|
||||
const attendeesListNode = attendees.length
|
||||
? div({ class: "card-assigned-list" },
|
||||
...attendees.filter(Boolean).map((id) => userLink(id))
|
||||
)
|
||||
: null;
|
||||
|
||||
const eventSide = div({ class: "tribe-side" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, event.title)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(event.id, params.spreads)),
|
||||
table({ class: "tribe-info-table jobs-info-table" }, ...infoRows),
|
||||
tagsNode,
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.eventAttendees}: ${attendees.length}`)
|
||||
),
|
||||
attendeesListNode,
|
||||
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null
|
||||
);
|
||||
|
||||
const returnToOpinions = `/events/${encodeURIComponent(event.id)}?filter=${encodeURIComponent(currentFilter)}`;
|
||||
const opinionsBar = renderOpinionsVoting('/events/opinions', event.id, event.opinions, returnToOpinions, event.opinions_inhabitants);
|
||||
|
||||
const eventMain = div({ class: "tribe-main" },
|
||||
event.description
|
||||
? div({ class: "job-section" },
|
||||
h2({ class: "job-section-title" }, i18n.eventDescriptionLabel),
|
||||
p({ class: "tribe-side-description" }, ...renderUrl(event.description))
|
||||
)
|
||||
: null,
|
||||
event.mapUrl ? div({ class: "job-section" }, renderMapEmbed(params.mapData, event.mapUrl)) : null,
|
||||
p({ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${moment(event.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
|
||||
userLink(event.organizer)
|
||||
),
|
||||
opinionsBar,
|
||||
renderEventCommentsSection(event.id, comments, currentFilter)
|
||||
);
|
||||
|
||||
return template(
|
||||
event.title,
|
||||
section(
|
||||
filterBar,
|
||||
div({ class: "tribe-details" }, eventSide, eventMain)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.clearnetEventView = async (event) => {
|
||||
const { escapeHtml: esc, renderClearnetPage } = require('./clearnet_view');
|
||||
const title = esc(event.title || 'Event');
|
||||
const desc = esc(event.description || '');
|
||||
const dateStr = event.date ? esc(moment(event.date).format("YYYY-MM-DD HH:mm")) : '';
|
||||
const loc = esc(event.location || '');
|
||||
const price = parseFloat(event.price || 0);
|
||||
const priceStr = price > 0 ? `${price.toFixed(2)} ECO` : '';
|
||||
const urlHref = safeExternalHref(event.url);
|
||||
const extraCss = `
|
||||
.cn-event-title{color:var(--fg);margin:0 0 16px 0;font-size:32px;font-weight:700}
|
||||
.cn-event-meta{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:20px}
|
||||
.cn-event-meta-item{background:var(--bg-sub);border:1px solid var(--border);border-radius:6px;padding:8px 14px;font-size:14px;color:var(--fg-soft);display:inline-flex;align-items:center;gap:6px}
|
||||
.cn-event-meta-id{font-family:monospace;font-size:11px;word-break:break-all;max-width:100%}
|
||||
.cn-event-desc{color:var(--fg-soft);white-space:pre-wrap;line-height:1.6;font-size:15px;margin:0 0 20px 0}
|
||||
.cn-event-link{display:inline-block;margin-top:12px;background:var(--bg-sub);border:1px solid var(--fg);color:var(--fg);padding:8px 16px;border-radius:6px;font-weight:600}
|
||||
`;
|
||||
const body = `
|
||||
<h1 class="cn-event-title">${title}</h1>
|
||||
<div class="cn-event-meta">
|
||||
${dateStr ? `<span class="cn-event-meta-item">📅 ${dateStr}</span>` : ''}
|
||||
${loc ? `<span class="cn-event-meta-item">📍 ${loc}</span>` : ''}
|
||||
${priceStr ? `<span class="cn-event-meta-item">💰 ${priceStr}</span>` : ''}
|
||||
</div>
|
||||
${desc ? `<p class="cn-event-desc">${desc}</p>` : ''}
|
||||
${urlHref ? `<a class="cn-event-link" href="${esc(urlHref)}" target="_blank" rel="noopener noreferrer">More info →</a>` : ''}
|
||||
`;
|
||||
return renderClearnetPage({
|
||||
title: `${event.title || 'Event'} — Oasis`,
|
||||
ogTitle: event.title || 'Event',
|
||||
ogDescription: event.description || '',
|
||||
extraCss,
|
||||
body,
|
||||
hubFeedId: event.organizer || null
|
||||
});
|
||||
};
|
||||
|
||||
182
src/views/favorites_view.js
Normal file
182
src/views/favorites_view.js
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
const { form, button, div, h2, p, section, input, a, span, img } = require("../server/node_modules/hyperaxe");
|
||||
|
||||
const { template, i18n, userLink, renderContentActions } = 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 safeArr = (v) => (Array.isArray(v) ? v : []);
|
||||
const safeText = (v) => String(v || "").trim();
|
||||
|
||||
const buildReturnTo = (filter) => {
|
||||
const f = safeText(filter || "all");
|
||||
return `/favorites?filter=${encodeURIComponent(f)}`;
|
||||
};
|
||||
|
||||
const renderTags = (tags) => {
|
||||
const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
|
||||
return list.length
|
||||
? div(
|
||||
{ class: "card-tags" },
|
||||
list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
|
||||
)
|
||||
: null;
|
||||
};
|
||||
|
||||
const renderBookmarkUrl = (item) => {
|
||||
if (item.kind !== "bookmarks") return null;
|
||||
if (!item.url) return null;
|
||||
return p(
|
||||
a(
|
||||
{ href: item.url, target: "_blank", rel: "noreferrer noopener", class: "bookmark-url" },
|
||||
item.url
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderImagePreview = (item) => {
|
||||
if (item.kind !== "images") return null;
|
||||
if (!item.url) return null;
|
||||
|
||||
return div(
|
||||
{ class: "image-container" },
|
||||
a(
|
||||
{ href: item.viewHref },
|
||||
img({
|
||||
src: `/image/256/${encodeURIComponent(item.url)}`,
|
||||
alt: item.title || "",
|
||||
class: "media-preview",
|
||||
loading: "lazy"
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderFavoriteCard = (item, filter) => {
|
||||
const returnTo = buildReturnTo(filter);
|
||||
|
||||
const title = safeText(item.title) || safeText(item.name) || safeText(item.category) || safeText(item.url) || "";
|
||||
|
||||
const ts = item.updatedAt || item.createdAt;
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
|
||||
|
||||
const isOwn = item.author && String(item.author) === String(userId);
|
||||
return div(
|
||||
{ class: "trending-card favorites-card" + (isOwn ? " own-content" : "") },
|
||||
div(
|
||||
{ class: "card-header activity-card-header" },
|
||||
div({ class: 'card-chips-row' },
|
||||
span({ class: 'pm-exposition-chip pm-exposition-whole' },
|
||||
span({ class: 'pm-exposition-text' }, String(item.kind || '').toUpperCase())
|
||||
)
|
||||
),
|
||||
renderContentActions(item.favId, item.viewHref)
|
||||
),
|
||||
div(
|
||||
{ class: "card-section favorites-card-body" },
|
||||
div(
|
||||
{ class: "bookmark-topbar" },
|
||||
div(
|
||||
{ class: "bookmark-topbar-left" },
|
||||
form(
|
||||
{
|
||||
method: "POST",
|
||||
action: `/favorites/remove/${encodeURIComponent(item.kind)}/${encodeURIComponent(item.favId)}`,
|
||||
class: "bookmark-favorite-form"
|
||||
},
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.favoritesRemoveButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
title ? h2(title) : null,
|
||||
renderImagePreview(item),
|
||||
renderBookmarkUrl(item),
|
||||
safeText(item.description) ? p(...renderUrl(item.description)) : null,
|
||||
renderTags(item.tags),
|
||||
p(
|
||||
{ class: "card-footer" },
|
||||
absDate ? span({ class: "date-link" }, `${absDate} ${i18n.performed} `) : "",
|
||||
item.author ? userLink(item.author) : ""
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.favoritesView = async (items, filter = "all", counts = {}) => {
|
||||
const c = counts || {};
|
||||
const total = typeof c.all === "number" ? c.all : safeArr(items).length;
|
||||
|
||||
return template(
|
||||
i18n.favoritesTitle,
|
||||
section(
|
||||
div({ class: "tags-header" }, h2(i18n.favoritesTitle), p(i18n.favoritesDescription)),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/favorites", class: "ui-toolbar ui-toolbar--filters" },
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterAll} (${total})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterRecent} (${total})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "audios", class: filter === "audios" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterAudios} (${c.audios || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "bookmarks", class: filter === "bookmarks" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterBookmarks} (${c.bookmarks || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "documents", class: filter === "documents" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterDocuments} (${c.documents || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "images", class: filter === "images" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterImages} (${c.images || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "maps", class: filter === "maps" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterMaps} (${c.maps || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "pads", class: filter === "pads" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterPads || "PADS"} (${c.pads || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "chats", class: filter === "chats" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterChats || "CHATS"} (${c.chats || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "calendars", class: filter === "calendars" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterCalendars || "CALENDARS"} (${c.calendars || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "videos", class: filter === "videos" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterVideos} (${c.videos || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "torrents", class: filter === "torrents" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterTorrents || "TORRENTS"} (${c.torrents || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "market", class: filter === "market" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterMarket || "MARKET"} (${c.market || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "shopProducts", class: filter === "shopProducts" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterShopProducts || "SHOP ITEMS"} (${c.shopProducts || 0})`
|
||||
)
|
||||
)
|
||||
),
|
||||
div({ class: "bookmark-list" }, safeArr(items).length ? safeArr(items).map((it) => renderFavoriteCard(it, filter)) : p(i18n.favoritesNoItems))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
229
src/views/fediverse_view.js
Normal file
229
src/views/fediverse_view.js
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
const { div, h2, h3, p, section, button, form, a, span, strong, input, label, img, textarea, br, hr, video: videoHyperaxe, audio: audioHyperaxe } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n } = require("./main_views");
|
||||
const { sanitizeHtml } = require('../backend/sanitizeHtml');
|
||||
const moment = require("../server/node_modules/moment");
|
||||
|
||||
const fmtDate = (ts) => {
|
||||
if (!ts) return "";
|
||||
const m = moment(ts);
|
||||
return m.isValid() ? m.format("YYYY-MM-DD HH:mm") : "";
|
||||
};
|
||||
|
||||
const renderMedia = (m) => {
|
||||
if (!m || !m.url) return "";
|
||||
if (m.type === 'video' || m.type === 'gifv') {
|
||||
return videoHyperaxe({ class: "post-video", src: m.url, controls: true, preload: "metadata" });
|
||||
}
|
||||
if (m.type === 'audio') {
|
||||
return audioHyperaxe({ class: "post-audio", src: m.url, controls: true });
|
||||
}
|
||||
return img({ class: "post-image", src: m.url, alt: m.description || "" });
|
||||
};
|
||||
|
||||
const renderComposeForm = (opts = {}) => {
|
||||
const { action, placeholder, replyToId, submitLabel } = opts;
|
||||
return div({ class: "publish-form" },
|
||||
form(
|
||||
{ method: "POST", action, enctype: "multipart/form-data" },
|
||||
replyToId ? input({ type: "hidden", name: "inReplyToId", value: replyToId }) : "",
|
||||
textarea({ name: "text", id: "text", rows: "6", class: "publish-textarea", placeholder: placeholder || i18n.fediverseComposePlaceholder }),
|
||||
br(),
|
||||
label({ for: "fediverse_media" }, i18n.fediverseAttach),
|
||||
br(),
|
||||
input({ type: "file", id: "fediverse_media", name: "media", accept: "image/*,video/*", multiple: true }),
|
||||
br(), br(),
|
||||
button({ type: "submit", class: "filter-btn" }, submitLabel || i18n.fediversePublish)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderActions = (post, returnTo) => {
|
||||
const id = encodeURIComponent(post.id);
|
||||
const rt = input({ type: "hidden", name: "returnTo", value: returnTo });
|
||||
const favAction = post.favourited ? `/fediverse/mastodon/unfav/${id}` : `/fediverse/mastodon/fav/${id}`;
|
||||
const favLabel = post.favourited ? `★ ${post.counts.favourites}` : `☆ ${post.counts.favourites}`;
|
||||
const boostAction = post.reblogged ? `/fediverse/mastodon/unboost/${id}` : `/fediverse/mastodon/boost/${id}`;
|
||||
const boostLabel = `🔁 ${post.counts.reblogs}`;
|
||||
return div(
|
||||
{ class: "fediverse-actions" },
|
||||
form({ method: "POST", action: favAction }, rt, button({ type: "submit", class: post.favourited ? "filter-btn active" : "filter-btn" }, favLabel)),
|
||||
form({ method: "POST", action: boostAction }, rt, button({ type: "submit", class: post.reblogged ? "filter-btn active" : "filter-btn" }, boostLabel)),
|
||||
form({ method: "GET", action: `/fediverse/mastodon/thread/${id}` }, button({ type: "submit", class: "filter-btn" }, `${i18n.fediverseReply} · ${post.counts.replies}`))
|
||||
);
|
||||
};
|
||||
|
||||
const renderPost = (post, returnTo, opts = {}) => {
|
||||
const acc = post.account || {};
|
||||
return div(
|
||||
{ class: "feed-card fediverse-card" },
|
||||
post.boostedBy ? div({ class: "fediverse-boosted" }, `🔁 ${post.boostedBy} ${i18n.fediverseBoosted}`) : "",
|
||||
div(
|
||||
{ class: "fediverse-head" },
|
||||
acc.avatar ? img({ class: "fediverse-avatar", src: acc.avatar, alt: acc.displayName }) : "",
|
||||
div(
|
||||
{ class: "fediverse-author" },
|
||||
span({ class: "fediverse-name" }, acc.displayName || acc.acct),
|
||||
span({ class: "fediverse-acct" }, acc.acct ? `@${acc.acct}` : "")
|
||||
),
|
||||
span({ class: "fediverse-date" }, fmtDate(post.createdAt))
|
||||
),
|
||||
div({ class: "feed-text", innerHTML: sanitizeHtml(post.html) }),
|
||||
post.media && post.media.length ? div({ class: "fediverse-media" }, post.media.map(renderMedia)) : "",
|
||||
opts.noActions ? "" : renderActions(post, returnTo)
|
||||
);
|
||||
};
|
||||
|
||||
const mastodonBox = (account, stats, actions, showLabel) => {
|
||||
const host = String(account.instance || "").replace(/^https?:\/\//, "");
|
||||
const handle = `${account.acct}@${host}`;
|
||||
const profileUrl = `${account.instance}/@${account.acct}`;
|
||||
const joined = stats && stats.createdAt && moment(stats.createdAt).isValid() ? moment(stats.createdAt).format("DD MMM YYYY") : "";
|
||||
const stat = (lbl, val) => div({ class: "fediverse-stat" }, span({ class: "fediverse-stat-label" }, lbl), strong(String(val)));
|
||||
return div({ class: "fediverse-network" },
|
||||
showLabel ? h3("Mastodon") : "",
|
||||
div({ class: "fediverse-profile" },
|
||||
div({ class: "fediverse-namerow" },
|
||||
account.avatar ? img({ class: "fediverse-avatar", src: account.avatar, alt: account.displayName }) : "",
|
||||
div({ class: "fediverse-profile-id" },
|
||||
div({ class: "fediverse-name" }, account.displayName || account.acct),
|
||||
a({ class: "fediverse-acct", href: profileUrl, target: "_blank", rel: "noopener noreferrer" }, handle)
|
||||
),
|
||||
actions && actions.length ? div({ class: "fediverse-compact-actions" }, actions) : ""
|
||||
),
|
||||
(stats && stats.bio) ? div({ class: "fediverse-bio", innerHTML: sanitizeHtml(stats.bio) }) : "",
|
||||
stats
|
||||
? div({ class: "fediverse-stats" },
|
||||
stat(i18n.fediverseFollowers, stats.followers),
|
||||
stat(i18n.fediverseFollowing, stats.following),
|
||||
stat(i18n.fediversePosts, stats.posts),
|
||||
joined ? stat(i18n.fediverseJoined, joined) : ""
|
||||
)
|
||||
: "",
|
||||
(stats && Array.isArray(stats.fields) && stats.fields.length)
|
||||
? div({ class: "fediverse-fields" }, stats.fields.map(f => div({ class: "fediverse-field" },
|
||||
span({ class: "fediverse-field-name" }, f.name, f.verified ? span({ class: "fediverse-field-verified", title: "verified" }, " ✓") : ""),
|
||||
span({ class: "fediverse-field-value", innerHTML: sanitizeHtml(f.value) })
|
||||
)))
|
||||
: ""
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const disconnectedBlock = () => {
|
||||
const text = String(i18n.fediverseDisconnected || "");
|
||||
const settingsLink = a({ href: "/settings#fediverse" }, i18n.fediverseEmptyLink || "settings");
|
||||
const parts = text.split("%LINK%");
|
||||
const para = parts.length > 1
|
||||
? p(parts[0], settingsLink, parts.slice(1).join("%LINK%"))
|
||||
: p(text, " ", settingsLink);
|
||||
return div({ class: "fediverse-disconnected" }, h2(i18n.fediverseStatus), para);
|
||||
};
|
||||
|
||||
exports.fediverseOverviewView = ({ account, stats } = {}) => {
|
||||
const box = account
|
||||
? mastodonBox(account, stats, [
|
||||
form({ method: "GET", action: "/fediverse/mastodon" }, button({ type: "submit", class: "filter-btn" }, i18n.fediverseManage))
|
||||
], true)
|
||||
: null;
|
||||
|
||||
return template(i18n.fediverse, section(
|
||||
div({ class: "tags-header" }, h2(i18n.fediverse), p(i18n.fediverseDescription)),
|
||||
account ? box : disconnectedBlock()
|
||||
));
|
||||
};
|
||||
|
||||
exports.fediverseView = ({ account, posts, error, stats } = {}) => {
|
||||
if (!account) return exports.fediverseOverviewView({ account: null });
|
||||
|
||||
const body = [];
|
||||
body.push(div({ class: "tags-header" }, h2(i18n.fediverse), p(i18n.fediverseDescription)));
|
||||
body.push(mastodonBox(account, stats, [
|
||||
a({ href: "/fediverse", class: "filter-btn" }, `← ${i18n.fediverse}`),
|
||||
a({ href: "/fediverse/mastodon?refresh=1", class: "filter-btn" }, i18n.fediverseRefresh),
|
||||
a({ href: "/fediverse/mastodon/preview", class: "filter-btn" }, i18n.fediverseWrite)
|
||||
], false));
|
||||
if (error) body.push(div({ class: "fediverse-error" }, i18n[error] || i18n.fediverseError));
|
||||
|
||||
const list = Array.isArray(posts) ? posts : [];
|
||||
if (!list.length && !error) {
|
||||
body.push(p({ class: "muted" }, i18n.fediverseNoPosts));
|
||||
} else {
|
||||
body.push(section({ class: "feed-container" }, list.map(pst => renderPost(pst, "/fediverse/mastodon"))));
|
||||
}
|
||||
|
||||
return template(i18n.fediverse, section(...body));
|
||||
};
|
||||
|
||||
exports.fediverseThreadView = ({ account, stats, thread, error } = {}) => {
|
||||
const header = section(
|
||||
div({ class: "tags-header" }, h2(i18n.fediverse), p(i18n.fediverseDescription)),
|
||||
account ? mastodonBox(account, stats, [
|
||||
a({ href: "/fediverse/mastodon", class: "filter-btn" }, `← ${i18n.fediverse}`)
|
||||
], true) : ""
|
||||
);
|
||||
|
||||
if (!thread || !thread.status) {
|
||||
return template(i18n.fediverse, header, section(div({ class: "fediverse-error" }, i18n[error] || i18n.fediverseError)));
|
||||
}
|
||||
|
||||
const body = [];
|
||||
if (error) body.push(div({ class: "fediverse-error" }, i18n[error] || i18n.fediverseError));
|
||||
const rt = `/fediverse/mastodon/thread/${encodeURIComponent(thread.status.id)}`;
|
||||
(thread.ancestors || []).forEach(pst => body.push(renderPost(pst, rt)));
|
||||
body.push(section({ class: "feed-container fediverse-focus" }, renderPost(thread.status, rt)));
|
||||
(thread.descendants || []).forEach(pst => body.push(renderPost(pst, rt)));
|
||||
body.push(br());
|
||||
body.push(hr());
|
||||
body.push(br());
|
||||
body.push(renderComposeForm({ action: `/fediverse/mastodon/reply/${encodeURIComponent(thread.status.id)}/preview`, placeholder: i18n.fediverseReplyPlaceholder, submitLabel: i18n.fediversePreview }));
|
||||
|
||||
return template(i18n.fediverse, header, section(...body));
|
||||
};
|
||||
|
||||
exports.fediversePreviewView = ({ account, stats, text, media, error, replyToId, parent } = {}) => {
|
||||
const mediaList = Array.isArray(media) ? media : [];
|
||||
const hasContent = (typeof text === "string" && text.trim().length > 0) || mediaList.length > 0;
|
||||
const isReply = !!replyToId;
|
||||
const sid = isReply ? encodeURIComponent(String(replyToId)) : "";
|
||||
const postAction = isReply ? `/fediverse/mastodon/reply/${sid}` : "/fediverse/mastodon/post";
|
||||
const previewAction = isReply ? `/fediverse/mastodon/reply/${sid}/preview` : "/fediverse/mastodon/preview";
|
||||
const backHref = isReply ? `/fediverse/mastodon/thread/${sid}` : "/fediverse/mastodon";
|
||||
|
||||
const previewCard = section({ class: "feed-container" },
|
||||
div({ class: "feed-card fediverse-card fediverse-preview-card" },
|
||||
text ? div({ class: "feed-text fediverse-pre" }, text) : "",
|
||||
mediaList.length ? div({ class: "fediverse-media" }, mediaList.map(m => renderMedia({ type: m.type, url: `/fediverse/tmp/${encodeURIComponent(m.name)}` }))) : ""
|
||||
)
|
||||
);
|
||||
|
||||
return template(i18n.fediverse,
|
||||
section(
|
||||
div({ class: "tags-header" }, h2(i18n.fediverse), p(i18n.fediverseDescription)),
|
||||
account ? mastodonBox(account, stats, [
|
||||
a({ href: backHref, class: "filter-btn" }, `← ${i18n.fediverse}`)
|
||||
], true) : ""
|
||||
),
|
||||
parent ? section({ class: "feed-container fediverse-focus" }, renderPost(parent, backHref, { noActions: true })) : "",
|
||||
error ? section(div({ class: "fediverse-error" }, i18n[error] || i18n.fediverseError)) : "",
|
||||
section(
|
||||
div({ class: "publish-form" },
|
||||
form(
|
||||
{ action: postAction, method: "POST", enctype: "multipart/form-data" },
|
||||
textarea({ name: "text", id: "text", rows: "6", class: "publish-textarea", placeholder: isReply ? i18n.fediverseReplyPlaceholder : i18n.fediverseComposePlaceholder }, text || ""),
|
||||
...mediaList.map(m => input({ type: "hidden", name: "tmp", value: m.name })),
|
||||
br(),
|
||||
label({ for: "fediverse_media" }, i18n.fediverseAttach),
|
||||
br(),
|
||||
input({ type: "file", id: "fediverse_media", name: "media", accept: "image/*,video/*", multiple: true }),
|
||||
br(),
|
||||
br(),
|
||||
...(hasContent ? [hr(), br(), previewCard, br(), br()] : []),
|
||||
div({ class: "fediverse-actions" },
|
||||
button({ type: "submit", class: "filter-btn", formaction: previewAction }, i18n.fediversePreview),
|
||||
...(hasContent ? [button({ type: "submit", class: "filter-btn" }, i18n.fediversePublish)] : [])
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
396
src/views/feed_view.js
Normal file
396
src/views/feed_view.js
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
const { div, h2, p, section, button, form, a, span, textarea, br, input, h1, label } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, renderOpinionsVoting, userLink, renderContentActions } = require("./main_views");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const { renderTextWithStyles } = require("../backend/renderTextWithStyles");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { sanitizeHtml } = require('../backend/sanitizeHtml');
|
||||
const { renderUrl } = require("../backend/renderUrl");
|
||||
|
||||
const FEED_TEXT_MIN = Number(config?.feed?.minLength ?? 1);
|
||||
const FEED_TEXT_MAX = Number(config?.feed?.maxLength ?? 280);
|
||||
|
||||
const normalizeOptions = (opts) => {
|
||||
if (typeof opts === "string") return { filter: String(opts || "ALL").toUpperCase(), q: "", tag: "", msg: "" };
|
||||
if (!opts || typeof opts !== "object") return { filter: "ALL", q: "", tag: "", msg: "" };
|
||||
return {
|
||||
filter: String(opts.filter || "ALL").toUpperCase(),
|
||||
q: typeof opts.q === "string" ? opts.q : "",
|
||||
tag: typeof opts.tag === "string" ? opts.tag : "",
|
||||
msg: typeof opts.msg === "string" ? opts.msg : ""
|
||||
};
|
||||
};
|
||||
|
||||
const formatDate = (feed) => {
|
||||
const ts = feed?.value?.timestamp || Date.parse(feed?.value?.content?.createdAt || "") || 0;
|
||||
return ts ? new Date(ts).toLocaleString() : "";
|
||||
};
|
||||
|
||||
const extractTags = (text) => {
|
||||
const list = (String(text || "").match(/#[A-Za-z0-9_]{1,32}/g) || []).map((t) => t.slice(1).toLowerCase());
|
||||
return Array.from(new Set(list));
|
||||
};
|
||||
|
||||
const rewriteHashtagLinks = (html) => {
|
||||
return String(html || '').replace(
|
||||
/href=(["'])\/hashtag\/([^"'?#\s<]+)\1/gi,
|
||||
(m, q, rawTag) => {
|
||||
let t = String(rawTag || '');
|
||||
try { t = decodeURIComponent(t); } catch {}
|
||||
t = t.replace(/[^A-Za-z0-9_]/g, '');
|
||||
const tag = t.toLowerCase();
|
||||
const query = encodeURIComponent(`#${tag}`);
|
||||
return `href=${q}/search?query=${query}${q}`;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const generateFilterButtons = (filters, currentFilter, action, extra = {}) => {
|
||||
const cur = String(currentFilter || "").toUpperCase();
|
||||
const hiddenInputs = (obj) =>
|
||||
Object.entries(obj)
|
||||
.filter(([, v]) => v !== undefined && v !== null && String(v).length > 0)
|
||||
.map(([k, v]) => input({ type: "hidden", name: k, value: String(v) }));
|
||||
|
||||
return filters.map((mode) =>
|
||||
form(
|
||||
{ method: "GET", action },
|
||||
input({ type: "hidden", name: "filter", value: mode }),
|
||||
...hiddenInputs(extra),
|
||||
button({ type: "submit", class: cur === mode ? "filter-btn active" : "filter-btn" }, i18n[mode + "Button"] || mode)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderVotesSummary = (opinions = {}) => {
|
||||
const entries = Object.entries(opinions).filter(([, v]) => Number(v) > 0);
|
||||
if (!entries.length) return null;
|
||||
entries.sort((a, b) => Number(b[1]) - Number(a[1]) || String(a[0]).localeCompare(String(b[0])));
|
||||
return div(
|
||||
{ class: "votes" },
|
||||
entries.map(([category, count]) => span({ class: "vote-category" }, `${category}: ${count}`))
|
||||
);
|
||||
};
|
||||
|
||||
const renderCardField = (labelText, value) =>
|
||||
div(
|
||||
{ class: "card-field" },
|
||||
span({ class: "card-label" }, labelText),
|
||||
span({ class: "card-value" }, value)
|
||||
);
|
||||
|
||||
const renderFeedCommentsSection = (feedKey, comments = []) => {
|
||||
const list = Array.isArray(comments) ? comments : [];
|
||||
const commentsCount = list.length;
|
||||
|
||||
return div(
|
||||
{ class: "vote-comments-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
|
||||
span({ class: "card-value" }, String(commentsCount))
|
||||
),
|
||||
div(
|
||||
{ class: "comment-form-wrapper" },
|
||||
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel || i18n.feedPostComment || "Post a comment"),
|
||||
form(
|
||||
{ method: "POST", action: `/feed/${encodeURIComponent(feedKey)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
|
||||
textarea({
|
||||
id: "comment-text",
|
||||
name: "text",
|
||||
rows: 4,
|
||||
class: "comment-textarea",
|
||||
placeholder: i18n.voteNewCommentPlaceholder || ""
|
||||
}),
|
||||
div({ class: "comment-file-upload" }, label(i18n.uploadMedia || "Upload media"), input({ type: "file", name: "blob" })),
|
||||
br(),
|
||||
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton || i18n.feedPostComment || "Send")
|
||||
)
|
||||
),
|
||||
list.length
|
||||
? div(
|
||||
{ class: "comments-list" },
|
||||
list.map((c) => {
|
||||
const author = c?.value?.author || "";
|
||||
const ts = c?.value?.timestamp || c?.timestamp;
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
|
||||
const relDate = ts ? moment(ts).fromNow() : "";
|
||||
|
||||
const content = c?.value?.content || {};
|
||||
const text = content.text || c?.value?.text || "";
|
||||
const threadRoot = content.fork || content.root || null;
|
||||
|
||||
return div(
|
||||
{ class: "votations-comment-card" },
|
||||
span(
|
||||
{ class: "created-at" },
|
||||
span(i18n.createdBy),
|
||||
author ? userLink(author) : span("(unknown)"),
|
||||
absDate ? span(" | ") : "",
|
||||
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
|
||||
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
|
||||
relDate && threadRoot
|
||||
? a({ href: `/thread/${encodeURIComponent(threadRoot)}#${encodeURIComponent(c.key)}` }, relDate)
|
||||
: ""
|
||||
),
|
||||
p({ class: "votations-comment-text" }, ...renderUrl(text))
|
||||
);
|
||||
})
|
||||
)
|
||||
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet || i18n.noComments || "")
|
||||
);
|
||||
};
|
||||
|
||||
const renderFeedCard = (feed) => {
|
||||
const content = feed.value.content || {};
|
||||
const rawText = typeof content.text === "string" ? content.text : "";
|
||||
const safeText = rawText.trim();
|
||||
if (!safeText) return null;
|
||||
|
||||
const voteEntries = Object.entries(content.opinions || {});
|
||||
const totalCount = voteEntries.reduce((sum, [, count]) => sum + (Number(count) || 0), 0);
|
||||
const createdAt = formatDate(feed);
|
||||
const me = config?.keys?.id;
|
||||
|
||||
const alreadyRefeeded = Array.isArray(content.refeeds_inhabitants) && me ? content.refeeds_inhabitants.includes(me) : false;
|
||||
|
||||
const authorId = feed.value.author || content.author || "";
|
||||
const refeedsNum = Number(content.refeeds || 0) || 0;
|
||||
const commentCount = Number(content.commentCount || 0);
|
||||
const styledHtml = rewriteHashtagLinks(renderTextWithStyles(safeText));
|
||||
|
||||
return div(
|
||||
{ class: "trending-card feed-card" + (authorId && String(authorId) === String(me) ? " own-content" : "") },
|
||||
div(
|
||||
{ class: "card-header activity-card-header" },
|
||||
span(),
|
||||
renderContentActions(feed.key, `/feed/${encodeURIComponent(feed.key)}`)
|
||||
),
|
||||
div(
|
||||
{ class: "card-section feed-card-body" },
|
||||
div(
|
||||
{ class: "feed-row" },
|
||||
div(
|
||||
{ class: "refeed-column" },
|
||||
h1(String(refeedsNum)),
|
||||
form(
|
||||
{ method: "POST", action: `/feed/refeed/${encodeURIComponent(feed.key)}` },
|
||||
button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", ...(alreadyRefeeded ? { disabled: true } : {}) }, i18n.refeedButton)
|
||||
),
|
||||
alreadyRefeeded ? p({ class: "muted" }, i18n.alreadyRefeeded) : null
|
||||
),
|
||||
div(
|
||||
{ class: "feed-main" },
|
||||
div({ class: "feed-text", innerHTML: sanitizeHtml(styledHtml) }),
|
||||
h2(
|
||||
`${i18n.totalOpinions}: ${totalCount}`,
|
||||
...(() => {
|
||||
const entries = voteEntries.filter(([, v]) => Number(v) > 0);
|
||||
if (!entries.length) return [];
|
||||
const maxVal = Math.max(...entries.map(([, v]) => Number(v)));
|
||||
const dominant = entries.filter(([, v]) => Number(v) === maxVal).map(([k]) => i18n['vote' + k.charAt(0).toUpperCase() + k.slice(1)] || k);
|
||||
return [
|
||||
span({ style: 'margin:0 8px;opacity:0.5;' }, '|'),
|
||||
span({ style: 'font-weight:700;' }, `${i18n.moreVoted || 'More Voted'}: ${dominant.join(' + ')}`)
|
||||
];
|
||||
})()
|
||||
),
|
||||
p(
|
||||
{ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${createdAt} ${i18n.performed} `),
|
||||
userLink(authorId),
|
||||
content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null
|
||||
)
|
||||
)
|
||||
),
|
||||
div(
|
||||
{ class: "card-comments-summary" },
|
||||
span({ class: "card-label" }, `${i18n.voteCommentsLabel || "Comments"}:`),
|
||||
span({ class: "card-value" }, String(commentCount)),
|
||||
br(),
|
||||
br(),
|
||||
form(
|
||||
{ method: "GET", action: `/feed/${encodeURIComponent(feed.key)}` },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton || i18n.feedOpenDiscussion || "Open Discussion")
|
||||
)
|
||||
),
|
||||
div(
|
||||
{ class: "card-comments-summary feed-opinions-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "),
|
||||
span({ class: "card-value" }, String(totalCount))
|
||||
),
|
||||
renderOpinionsVoting('/feed/opinions', feed.key, content.opinions, null, content.opinions_inhabitants)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.feedView = (feeds, opts = "ALL") => {
|
||||
const { filter, q, tag, msg } = normalizeOptions(opts);
|
||||
|
||||
const title =
|
||||
filter === "MINE"
|
||||
? i18n.MINEButton
|
||||
: filter === "TODAY"
|
||||
? i18n.TODAYButton
|
||||
: filter === "TOP"
|
||||
? i18n.TOPButton
|
||||
: filter === "CREATE"
|
||||
? i18n.createFeedTitle
|
||||
: tag
|
||||
? `${i18n.filteredByTag || i18n.filteredByTagTitle || "Filtered by tag"}: #${tag}`
|
||||
: q
|
||||
? `${i18n.searchTitle || "Search"}: “${q}”`
|
||||
: i18n.feedTitle;
|
||||
|
||||
const header = div({ class: "tags-header" }, h2(title), p(i18n.FeedshareYourOpinions));
|
||||
const successBanner = msg === 'feedPublished'
|
||||
? div({ class: 'feed-success-msg' }, p('✓ ' + (i18n.feedPublishedSuccess || 'Feed published successfully!')))
|
||||
: null;
|
||||
|
||||
const extra = { q, tag };
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
header,
|
||||
successBanner,
|
||||
div(
|
||||
{ class: "mode-buttons-row" },
|
||||
...generateFilterButtons(["ALL", "MINE", "TODAY", "TOP"], filter, "/feed", extra),
|
||||
form({ method: "GET", action: "/feed/create" }, button({ type: "submit", class: "create-button filter-btn" }, i18n.createFeedTitle || "Create Feed"))
|
||||
),
|
||||
div(
|
||||
{ class: "feed-tools-row" },
|
||||
form(
|
||||
{ method: "GET", action: "/feed", class: "feed-search-form" },
|
||||
input({ type: "hidden", name: "filter", value: filter }),
|
||||
tag ? input({ type: "hidden", name: "tag", value: tag }) : null,
|
||||
input({ type: "text", name: "q", value: q, placeholder: i18n.searchPlaceholder || "Search", class: "feed-search-input" }),
|
||||
button({ type: "submit", class: "filter-btn feed-search-btn" }, i18n.searchButton || "Search")
|
||||
)
|
||||
),
|
||||
section(
|
||||
filter === "CREATE"
|
||||
? form(
|
||||
{ method: "POST", action: "/feed/create" },
|
||||
textarea({
|
||||
name: "text",
|
||||
placeholder: i18n.feedPlaceholder,
|
||||
required: true,
|
||||
minlength: String(FEED_TEXT_MIN),
|
||||
maxlength: String(FEED_TEXT_MAX),
|
||||
rows: 4,
|
||||
cols: 50
|
||||
}),
|
||||
br(),
|
||||
button({ type: "submit", class: "create-button" }, i18n.createFeedButton)
|
||||
)
|
||||
: feeds && feeds.length > 0
|
||||
? div({ class: "feed-container" }, feeds.map((feed) => renderFeedCard(feed)).filter(Boolean))
|
||||
: div({ class: "no-results" }, p(i18n.noFeedsFound))
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.feedCreateView = (opts = {}) => {
|
||||
const { q, tag } = normalizeOptions(opts);
|
||||
|
||||
return template(
|
||||
i18n.createFeedTitle,
|
||||
section(
|
||||
div({ class: "tags-header" }, h2(i18n.createFeedTitle), p(i18n.FeedshareYourOpinions)),
|
||||
div({ class: "mode-buttons-row" }, ...generateFilterButtons(["ALL", "MINE", "TODAY", "TOP"], "CREATE", "/feed", { q, tag })),
|
||||
form(
|
||||
{ method: "POST", action: "/feed/create" },
|
||||
textarea({
|
||||
name: "text",
|
||||
required: true,
|
||||
minlength: String(FEED_TEXT_MIN),
|
||||
maxlength: String(FEED_TEXT_MAX),
|
||||
rows: 5,
|
||||
cols: 50,
|
||||
placeholder: i18n.feedPlaceholder
|
||||
}),
|
||||
br(),
|
||||
button({ type: "submit", class: "create-button" }, i18n.createFeedButton || "Send Feed!")
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.singleFeedView = (feed, comments = []) => {
|
||||
const content = feed.value?.content || {};
|
||||
const rawText = typeof content.text === "string" ? content.text : "";
|
||||
const safeText = rawText.trim();
|
||||
const authorId = feed.value?.author || content.author || "";
|
||||
const createdAt = formatDate(feed);
|
||||
const styledHtml = rewriteHashtagLinks(renderTextWithStyles(safeText));
|
||||
const me = config?.keys?.id;
|
||||
const alreadyRefeeded = Array.isArray(content.refeeds_inhabitants) && me ? content.refeeds_inhabitants.includes(me) : false;
|
||||
const refeedsNum = Number(content.refeeds || 0) || 0;
|
||||
const tags = extractTags(safeText);
|
||||
|
||||
return template(
|
||||
i18n.feedDetailTitle || "Feed",
|
||||
section(
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/feed", class: "ui-toolbar ui-toolbar--filters" },
|
||||
button({ type: "submit", name: "filter", value: "ALL", class: "filter-btn" }, i18n.ALLButton || "ALL"),
|
||||
button({ type: "submit", name: "filter", value: "MINE", class: "filter-btn" }, i18n.MINEButton || "MINE"),
|
||||
button({ type: "submit", name: "filter", value: "TODAY", class: "filter-btn" }, i18n.TODAYButton || "TODAY"),
|
||||
button({ type: "submit", name: "filter", value: "TOP", class: "filter-btn" }, i18n.TOPButton || "TOP"),
|
||||
form({ method: "GET", action: "/feed/create" }, button({ type: "submit", class: "create-button" }, i18n.createFeedTitle || "Create Feed"))
|
||||
)
|
||||
),
|
||||
div(
|
||||
{ class: "bookmark-item card feed-detail-card" },
|
||||
br,
|
||||
div(
|
||||
{ class: "feed-row" },
|
||||
div(
|
||||
{ class: "refeed-column" },
|
||||
h1(String(refeedsNum)),
|
||||
form(
|
||||
{ method: "POST", action: `/feed/refeed/${encodeURIComponent(feed.key)}` },
|
||||
button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", ...(alreadyRefeeded ? { disabled: true } : {}) }, i18n.refeedButton)
|
||||
),
|
||||
alreadyRefeeded ? p({ class: "muted" }, i18n.alreadyRefeeded) : null
|
||||
),
|
||||
div(
|
||||
{ class: "feed-main" },
|
||||
div({ class: "feed-text", innerHTML: sanitizeHtml(styledHtml) }),
|
||||
tags.length
|
||||
? div(
|
||||
{ class: "card-tags" },
|
||||
tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
|
||||
)
|
||||
: null,
|
||||
br,
|
||||
p(
|
||||
{ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${createdAt} ${i18n.performed} `),
|
||||
userLink(authorId),
|
||||
content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
renderFeedCommentsSection(feed.key, comments),
|
||||
div(
|
||||
{ class: "card-comments-summary feed-opinions-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "),
|
||||
span({ class: "card-value" }, String(Object.values(content.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)))
|
||||
),
|
||||
renderOpinionsVoting('/feed/opinions', feed.key, content.opinions, null, content.opinions_inhabitants)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
415
src/views/forum_view.js
Normal file
415
src/views/forum_view.js
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
const {
|
||||
div, a, span, form, button, section, p,
|
||||
input, label, br, select, option, h2, textarea
|
||||
} = require("../server/node_modules/hyperaxe");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { template, i18n, userLink, renderSpreadButton, renderPrivacyChip, renderLifespanChip } = require('./main_views');
|
||||
const { renderEncryptedChip: renderForumEncryptedChip } = require('./clearnet_view');
|
||||
const { config } = require('../server/SSB_server.js');
|
||||
const { renderUrl } = require('../backend/renderUrl');
|
||||
const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
|
||||
const { sanitizeHtml } = require('../backend/sanitizeHtml');
|
||||
|
||||
const userId = config.keys.id;
|
||||
|
||||
exports.renderForumInvitePage = (code) => {
|
||||
const pageContent = div({ class: 'invite-page' },
|
||||
h2(i18n.tribeInviteCodeText, code),
|
||||
form({ method: 'GET', action: '/forum' },
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.walletBack)
|
||||
)
|
||||
);
|
||||
return template(i18n.invitesForumsTitle || 'Forums', section(pageContent));
|
||||
};
|
||||
|
||||
const BASE_FILTERS = ['hot','all','mine','recent','top'];
|
||||
const CAT_BLOCK1 = ['GENERAL','OASIS','L.A.R.P.','POLITICS','TECH'];
|
||||
const CAT_BLOCK2 = ['SCIENCE','MUSIC','ART','GAMING','BOOKS','FILMS'];
|
||||
const CAT_BLOCK3 = ['PHILOSOPHY','SOCIETY','PRIVACY','CYBERWARFARE','SURVIVALISM'];
|
||||
const ALL_CATS = [...CAT_BLOCK1, ...CAT_BLOCK2, ...CAT_BLOCK3];
|
||||
|
||||
const catKey = (c) => 'forumCat' + String(c || '').replace(/\./g,'').replace(/[\s-]/g,'').toUpperCase();
|
||||
const catLabel = (c) => i18n[catKey(c)] || c;
|
||||
|
||||
const Z = 1.96;
|
||||
function wilsonScore(pos, neg) {
|
||||
const n = (pos||0)+(neg||0);
|
||||
if (n === 0) return 0;
|
||||
const phat = pos / n, z2 = Z * Z;
|
||||
return (phat + z2/(2*n) - Z*Math.sqrt((phat*(1-phat)+z2/(4*n))/n)) / (1+z2/n);
|
||||
}
|
||||
|
||||
function getFilteredForums(filter, forums) {
|
||||
const now = Date.now();
|
||||
if (filter === 'mine') return forums.filter(f => f.author === userId);
|
||||
if (filter === 'recent') return forums.filter(f => new Date(f.createdAt).getTime() >= now - 86400000);
|
||||
if (filter === 'top') return forums.slice().sort((a,b) => b.score - a.score);
|
||||
if (filter === 'hot') return forums
|
||||
.filter(f => new Date(f.createdAt).getTime() >= now - 86400000)
|
||||
.sort((a,b) => b.score - a.score);
|
||||
if (ALL_CATS.includes(filter))
|
||||
return forums.filter(f => f.category === filter);
|
||||
return forums;
|
||||
}
|
||||
|
||||
const generateFilterButtons = (filters, currentFilter, action, i18nMap = {}) =>
|
||||
div({ class: 'filter-group' },
|
||||
filters.map(mode =>
|
||||
form({ method: 'GET', action },
|
||||
input({ type: 'hidden', name: 'filter', value: mode }),
|
||||
button({ type: 'submit', class: currentFilter === mode ? 'filter-btn active' : 'filter-btn' },
|
||||
String(i18nMap[mode] || mode).toUpperCase()
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const renderCreateForumButton = () =>
|
||||
div({ class: 'forum-create-col' },
|
||||
form({ method: 'GET', action: '/forum' },
|
||||
button({ type: 'submit', name: 'filter', value: 'create', class: 'create-button' },
|
||||
i18n.forumCreateButton
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const renderVotes = (target, score, forumId) =>
|
||||
div({ class: 'forum-score-box' },
|
||||
form({ method: 'POST', action: `/forum/${encodeURIComponent(forumId)}/vote`, class: 'forum-score-form' },
|
||||
button({ name: 'value', value: 1, class: 'score-btn' }, '▲'),
|
||||
div({ class: 'score-total' }, String(score || 0)),
|
||||
button({ name: 'value', value: -1, class: 'score-btn' }, '▼'),
|
||||
input({ type: 'hidden', name: 'target', value: target }),
|
||||
input({ type: 'hidden', name: 'forumId', value: forumId })
|
||||
)
|
||||
);
|
||||
|
||||
const renderForumForm = () =>
|
||||
div({ class: 'forum-form' },
|
||||
form({ action: '/forum/create', method: 'POST' },
|
||||
label(i18n.forumCategoryLabel), br(),
|
||||
select({ name: 'category', required: true },
|
||||
ALL_CATS.map(cat => option({ value: cat }, catLabel(cat)))
|
||||
), br(), br(),
|
||||
label(i18n.eventPrivacyLabel), br(),
|
||||
select({ name: 'isPublic', id: 'isPublic' },
|
||||
option({ value: 'public', selected: true }, i18n.eventPublic),
|
||||
option({ value: 'private' }, i18n.eventPrivate)
|
||||
), br(), br(),
|
||||
label(i18n.forumTitleLabel), br(),
|
||||
input({
|
||||
type: 'text',
|
||||
name: 'title',
|
||||
required: true,
|
||||
placeholder: i18n.forumTitlePlaceholder
|
||||
}), br(), br(),
|
||||
label(i18n.forumMessageLabel), br(),
|
||||
textarea({
|
||||
name: 'text',
|
||||
required: true,
|
||||
rows: 4,
|
||||
placeholder: i18n.forumMessagePlaceholder
|
||||
}), br(), br(),
|
||||
button({ type: 'submit' }, i18n.forumCreateButton)
|
||||
)
|
||||
);
|
||||
|
||||
const renderThread = (nodes, level = 0, forumId) => {
|
||||
if (!Array.isArray(nodes)) return [];
|
||||
return [...nodes]
|
||||
.filter(m => (m && m.text && String(m.text).trim()) || (m && Array.isArray(m.children) && m.children.length))
|
||||
.sort((a, b) =>
|
||||
wilsonScore(b.positiveVotes, b.negativeVotes)
|
||||
- wilsonScore(a.positiveVotes, a.negativeVotes)
|
||||
)
|
||||
.flatMap((m, i) => {
|
||||
const isTopLevelWinner = level === 0 && i === 0;
|
||||
const classList = [
|
||||
'forum-comment',
|
||||
`level-${level}`,
|
||||
isTopLevelWinner ? 'highlighted-reply' : ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const commentBox = div(
|
||||
{ class: classList },
|
||||
div({ class: 'comment-header' },
|
||||
span({ class: 'date-link' },
|
||||
`${moment(m.timestamp).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
|
||||
userLink(m.author),
|
||||
div({ class: 'comment-votes' },
|
||||
span({ class: 'votes-count' }, `▲: ${m.positiveVotes || 0}`),
|
||||
span({ class: 'votes-count', style: 'margin-left:12px;' },
|
||||
`▼: ${m.negativeVotes || 0}`)
|
||||
)
|
||||
),
|
||||
div({ class: 'comment-body-row' },
|
||||
div({ class: 'comment-vote-col' },
|
||||
renderVotes(m.key, m.score, forumId)
|
||||
),
|
||||
div({ class: 'comment-text-col' },
|
||||
div(
|
||||
...(m.text || '').split('\n')
|
||||
.map(l => l.trim())
|
||||
.filter(l => l)
|
||||
.map(l => p(...renderUrl(l)))
|
||||
)
|
||||
)
|
||||
),
|
||||
div({ class: 'new-reply' },
|
||||
form({
|
||||
method: 'POST',
|
||||
action: `/forum/${encodeURIComponent(forumId)}/message`,
|
||||
class: 'comment-form'
|
||||
},
|
||||
input({ type: 'hidden', name: 'parentId', value: m.key }),
|
||||
textarea({
|
||||
name: 'message',
|
||||
rows: 2,
|
||||
required: true,
|
||||
placeholder: i18n.forumMessagePlaceholder,
|
||||
class: 'comment-textarea'
|
||||
}),
|
||||
button({ type: 'submit', class: 'forum-send-btn' }, 'Reply')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return [ commentBox, ...renderThread(m.children || [], level + 1, forumId) ];
|
||||
});
|
||||
};
|
||||
|
||||
const renderForumList = (forums, currentFilter, spreadMap = new Map()) => {
|
||||
const visibleForums = (Array.isArray(forums) ? forums : []).filter(f =>
|
||||
(f && f.title && String(f.title).trim()) || (f && f.text && String(f.text).trim())
|
||||
)
|
||||
return div({ class: 'forum-list' },
|
||||
visibleForums.length
|
||||
? visibleForums.map(f =>
|
||||
div({ class: 'forum-card' },
|
||||
div({ class: 'forum-score-col' },
|
||||
renderVotes(f.key, f.score, f.key)
|
||||
),
|
||||
div({ class: 'forum-main-col' },
|
||||
div({ class: 'forum-header-row' },
|
||||
a({
|
||||
class: 'forum-category',
|
||||
href: `/forum?filter=${encodeURIComponent(f.category)}`
|
||||
}, `[${catLabel(f.category)}]`),
|
||||
a({
|
||||
class: 'forum-title',
|
||||
href: `/forum/${encodeURIComponent(f.key)}`
|
||||
}, f.title),
|
||||
f.isPrivate ? renderPrivacyChip(true, i18n) : null,
|
||||
f.isPrivate ? renderForumEncryptedChip(i18n) : null,
|
||||
renderLifespanChip(f.lifetime, i18n)
|
||||
),
|
||||
div({
|
||||
class: 'forum-body',
|
||||
innerHTML: sanitizeHtml(renderTextWithStyles(f.text || ''))
|
||||
}),
|
||||
div({ class: 'forum-meta' },
|
||||
span({ class: 'forum-positive-votes' },
|
||||
`▲: ${f.positiveVotes || 0}`),
|
||||
span({ class: 'forum-negative-votes', style: 'margin-left:12px;' },
|
||||
`▼: ${f.negativeVotes || 0}`),
|
||||
span({ class: 'forum-participants' },
|
||||
`${i18n.forumParticipants.toUpperCase()}: ${f.participants?.length || 1}`),
|
||||
span({ class: 'forum-messages' },
|
||||
`${i18n.forumMessages.toUpperCase()}: ${(f.messagesCount || 1) - 1}`),
|
||||
form({ method: 'GET', action: `/forum/${encodeURIComponent(f.key)}`, class: 'visit-forum-form' },
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.forumVisitButton)
|
||||
)
|
||||
),
|
||||
(() => {
|
||||
const btn = renderSpreadButton(f.key, spreadMap.get(f.key));
|
||||
return btn ? div({ class: 'card-spread-left' }, btn) : null;
|
||||
})(),
|
||||
div({ class: 'forum-footer' },
|
||||
span({ class: 'date-link' },
|
||||
`${moment(f.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
|
||||
userLink(f.author)
|
||||
),
|
||||
currentFilter === 'mine' && f.author === userId
|
||||
? div({ class: 'forum-owner-actions' },
|
||||
form({
|
||||
method: 'POST',
|
||||
action: `/forum/delete/${encodeURIComponent(f.key)}`,
|
||||
class: 'forum-delete-form'
|
||||
},
|
||||
button({ type: 'submit', class: 'delete-btn' },
|
||||
i18n.forumDeleteButton)
|
||||
)
|
||||
)
|
||||
: null
|
||||
)
|
||||
)
|
||||
)
|
||||
: p(i18n.noForums)
|
||||
);
|
||||
}
|
||||
|
||||
exports.forumView = async (forums, currentFilter, params = {}) => {
|
||||
const CAT_I18N_MAP_UP = ALL_CATS.reduce((m,c)=>{ m[c]=(catLabel(c)||c).toUpperCase(); return m; },{});
|
||||
return template(i18n.forumTitle,
|
||||
section(
|
||||
div({ class: 'tags-header' },
|
||||
h2(currentFilter === 'create'
|
||||
? i18n.forumCreateSectionTitle
|
||||
: i18n.forumTitle),
|
||||
p(i18n.forumDescription)
|
||||
),
|
||||
div({ class: 'mode-buttons-cols' },
|
||||
generateFilterButtons(BASE_FILTERS, currentFilter, '/forum', {
|
||||
hot: i18n.forumFilterHot,
|
||||
all: i18n.forumFilterAll,
|
||||
mine: i18n.forumFilterMine,
|
||||
recent: i18n.forumFilterRecent,
|
||||
top: i18n.forumFilterTop
|
||||
}),
|
||||
generateFilterButtons(CAT_BLOCK1, currentFilter, '/forum', CAT_I18N_MAP_UP),
|
||||
generateFilterButtons(CAT_BLOCK2, currentFilter, '/forum', CAT_I18N_MAP_UP),
|
||||
generateFilterButtons(CAT_BLOCK3, currentFilter, '/forum', CAT_I18N_MAP_UP),
|
||||
renderCreateForumButton()
|
||||
),
|
||||
currentFilter === 'create'
|
||||
? renderForumForm()
|
||||
: renderForumList(
|
||||
getFilteredForums(currentFilter || 'all', forums),
|
||||
currentFilter,
|
||||
params.spreadMap
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.singleForumView = async (forum, messagesData, currentFilter) => {
|
||||
const CAT_I18N_MAP_UP = ALL_CATS.reduce((m,c)=>{ m[c]=(catLabel(c)||c).toUpperCase(); return m; },{});
|
||||
return template(forum.title,
|
||||
section(
|
||||
div({ class: 'tags-header' },
|
||||
h2(i18n.forumTitle),
|
||||
p(i18n.forumDescription)
|
||||
),
|
||||
div({ class: 'mode-buttons-cols' },
|
||||
generateFilterButtons(BASE_FILTERS, currentFilter, '/forum', {
|
||||
hot: i18n.forumFilterHot,
|
||||
all: i18n.forumFilterAll,
|
||||
mine: i18n.forumFilterMine,
|
||||
recent: i18n.forumFilterRecent,
|
||||
top: i18n.forumFilterTop
|
||||
}),
|
||||
generateFilterButtons(CAT_BLOCK1, currentFilter, '/forum', CAT_I18N_MAP_UP),
|
||||
generateFilterButtons(CAT_BLOCK2, currentFilter, '/forum', CAT_I18N_MAP_UP),
|
||||
generateFilterButtons(CAT_BLOCK3, currentFilter, '/forum', CAT_I18N_MAP_UP),
|
||||
renderCreateForumButton()
|
||||
)
|
||||
),
|
||||
div({ class: 'forum-thread-container' },
|
||||
div({
|
||||
class: 'forum-card forum-thread-header',
|
||||
style: 'display:flex;align-items:flex-start;'
|
||||
},
|
||||
div({
|
||||
class: 'root-vote-col',
|
||||
style: 'width:60px;text-align:center;'
|
||||
}, renderVotes(
|
||||
forum.key,
|
||||
messagesData.totalScore,
|
||||
forum.key
|
||||
)),
|
||||
div({
|
||||
class: 'forum-main-col',
|
||||
style: 'flex:1;padding-left:10px;'
|
||||
},
|
||||
div({ class: 'forum-header-row' },
|
||||
a({
|
||||
class: 'forum-category',
|
||||
href: `/forum?filter=${encodeURIComponent(forum.category)}`
|
||||
}, `[${catLabel(forum.category)}]`),
|
||||
a({
|
||||
class: 'forum-title',
|
||||
href: `/forum/${encodeURIComponent(forum.key)}`
|
||||
}, forum.title),
|
||||
forum.isPrivate ? renderPrivacyChip(true, i18n) : null,
|
||||
forum.isPrivate ? renderForumEncryptedChip(i18n) : null,
|
||||
renderLifespanChip(forum.lifetime, i18n),
|
||||
(forum.isPrivate && forum.author === userId)
|
||||
? form({ method: 'POST', action: `/forum/generate-invite/${encodeURIComponent(forum.key)}`, class: 'forum-invite-form' },
|
||||
button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeGenerateInvite))
|
||||
: null,
|
||||
(forum.isPrivate && forum.author === userId && !forum.openInviteCode)
|
||||
? form({ method: 'POST', action: `/forum/open-invite/create/${encodeURIComponent(forum.key)}`, class: 'forum-invite-form' },
|
||||
button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeOpenInvitation))
|
||||
: null,
|
||||
(forum.isPrivate && forum.author === userId && forum.openInviteCode)
|
||||
? span({ class: 'tribe-open-invite' },
|
||||
span({ class: 'card-label' }, i18n.tribeInviteCodeText),
|
||||
span({ class: 'tribe-open-invite-code' }, forum.openInviteCode))
|
||||
: null,
|
||||
(forum.isPrivate && forum.author === userId && forum.openInviteCode)
|
||||
? form({ method: 'POST', action: `/forum/open-invite/remove/${encodeURIComponent(forum.key)}`, class: 'forum-invite-form' },
|
||||
button({ type: 'submit', class: 'tribe-action-btn danger-btn' }, i18n.tribeRemoveInvitation))
|
||||
: null,
|
||||
(forum.isPrivate && forum.author !== userId)
|
||||
? a({ class: 'tribe-action-btn', href: '/invites#invites-forums' }, i18n.tribeEnterInvite)
|
||||
: null,
|
||||
(forum.author === userId)
|
||||
? form({ method: 'POST', action: `/forum/delete/${encodeURIComponent(forum.key)}`, class: 'forum-delete-form' },
|
||||
button({ type: 'submit', class: 'tribe-action-btn danger-btn' }, i18n.forumDeleteButton))
|
||||
: null
|
||||
),
|
||||
div({
|
||||
class: 'forum-body',
|
||||
innerHTML: sanitizeHtml(renderTextWithStyles(forum.text || ''))
|
||||
}),
|
||||
div({ class: 'forum-meta' },
|
||||
span({ class: 'votes-count' },
|
||||
`▲: ${messagesData.positiveVotes}`),
|
||||
span({
|
||||
class: 'votes-count',
|
||||
style: 'margin-left:12px;'
|
||||
}, `▼: ${messagesData.negativeVotes}`),
|
||||
span({ class: 'forum-participants' },
|
||||
`${i18n.forumParticipants.toUpperCase()}: ${forum.participants?.length || 1}`),
|
||||
span({ class: 'forum-messages' },
|
||||
`${i18n.forumMessages.toUpperCase()}: ${messagesData.total}`)
|
||||
),
|
||||
(() => {
|
||||
const btn = renderSpreadButton(forum.key);
|
||||
return btn ? div({ class: 'card-spread-left' }, btn) : null;
|
||||
})(),
|
||||
div({ class: 'forum-footer' },
|
||||
span({ class: 'date-link' },
|
||||
`${moment(forum.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
|
||||
userLink(forum.author)
|
||||
)
|
||||
)
|
||||
),
|
||||
div({
|
||||
class: 'new-message-wrapper',
|
||||
style: 'margin-top:12px;'
|
||||
},
|
||||
form({
|
||||
method: 'POST',
|
||||
action: `/forum/${encodeURIComponent(forum.key)}/message`,
|
||||
class: 'new-message-form'
|
||||
},
|
||||
textarea({
|
||||
name: 'message',
|
||||
rows: 4,
|
||||
required: true,
|
||||
placeholder: i18n.forumMessagePlaceholder,
|
||||
style: 'width:100%;'
|
||||
}), br(),
|
||||
button({
|
||||
type: 'submit',
|
||||
class: 'forum-send-btn',
|
||||
style: 'margin-top:4px;'
|
||||
}, i18n.forumSendButton)
|
||||
)
|
||||
),
|
||||
...renderThread(messagesData.messages, 0, forum.key)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
151
src/views/games_view.js
Normal file
151
src/views/games_view.js
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
const { div, h2, h3, p, section, form, input, button, a, img, table, tr, td, th, span, iframe } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, userLink} = require('./main_views');
|
||||
const moment = require("../server/node_modules/moment");
|
||||
|
||||
const getGames = () => [
|
||||
{ id: 'cocoland', title: () => i18n.gamesCocolandTitle, desc: () => i18n.gamesCocolandDesc },
|
||||
{ id: 'ecoinflow', title: () => i18n.gamesTheFlowTitle, desc: () => i18n.gamesTheFlowDesc },
|
||||
{ id: 'neoninfiltrator', title: () => i18n.gamesNeonInfiltratorTitle, desc: () => i18n.gamesNeonInfiltratorDesc },
|
||||
{ id: 'audiopendulum', title: () => i18n.gamesAudioPendulumTitle, desc: () => i18n.gamesAudioPendulumDesc },
|
||||
{ id: 'spaceinvaders', title: () => i18n.gamesSpaceInvadersTitle, desc: () => i18n.gamesSpaceInvadersDesc },
|
||||
{ id: 'arkanoid', title: () => i18n.gamesArkanoidTitle, desc: () => i18n.gamesArkanoidDesc },
|
||||
{ id: 'pingpong', title: () => i18n.gamesPingPongTitle, desc: () => i18n.gamesPingPongDesc },
|
||||
{ id: 'asteroids', title: () => i18n.gamesAsteroidsTitle, desc: () => i18n.gamesAsteroidsDesc },
|
||||
{ id: 'rockpaperscissors', title: () => i18n.gamesRockPaperScissorsTitle, desc: () => i18n.gamesRockPaperScissorsDesc },
|
||||
{ id: 'tiktaktoe', title: () => i18n.gamesTikTakToeTitle, desc: () => i18n.gamesTikTakToeDesc },
|
||||
{ id: 'flipflop', title: () => i18n.gamesFlipFlopTitle, desc: () => i18n.gamesFlipFlopDesc },
|
||||
{ id: '8ball', title: () => i18n.games8BallTitle, desc: () => i18n.games8BallDesc },
|
||||
{ id: 'artillery', title: () => i18n.gamesArtilleryTitle, desc: () => i18n.gamesArtilleryDesc },
|
||||
{ id: 'labyrinth', title: () => i18n.gamesLabyrinthTitle, desc: () => i18n.gamesLabyrinthDesc },
|
||||
{ id: 'cocoman', title: () => i18n.gamesCocomanTitle, desc: () => i18n.gamesCocomanDesc },
|
||||
{ id: 'tetris', title: () => i18n.gamesTetrisTitle, desc: () => i18n.gamesTetrisDesc }
|
||||
];
|
||||
|
||||
const shortId = (feedId) => feedId ? '@' + feedId.slice(1, 9) + '...' : '?';
|
||||
|
||||
const renderHallOfFame = (hall) => {
|
||||
const games = getGames();
|
||||
const gamesWithScores = games.filter(g => hall[g.id] && hall[g.id].length > 0);
|
||||
if (gamesWithScores.length === 0) {
|
||||
return p({ class: 'no-content' }, i18n.gamesNoScores || 'No scores yet.');
|
||||
}
|
||||
return div({ class: 'games-scoring-list' },
|
||||
gamesWithScores.map(game =>
|
||||
div({ class: 'game-scoring-section' },
|
||||
div({ class: 'game-scoring-header' },
|
||||
img({ src: `/game-assets/${game.id}/thumbnail.svg`, alt: game.title(), class: 'game-scoring-thumb', loading: 'lazy' }),
|
||||
div({ class: 'game-scoring-info' },
|
||||
h3({ class: 'game-card-title' }, game.title()),
|
||||
p({ class: 'game-card-desc game-desc-yellow' }, game.desc())
|
||||
)
|
||||
),
|
||||
table({ class: 'hall-of-fame-table' },
|
||||
tr(th('#'), th(i18n.gamesHallPlayer), th(i18n.gamesHallScore), th(i18n.gamesHallDate || 'Date')),
|
||||
...hall[game.id].map((entry, idx) =>
|
||||
tr(
|
||||
td(String(idx + 1)),
|
||||
td(userLink(entry.author)),
|
||||
td({ class: idx === 0 ? 'score-first' : '' }, String(entry.score)),
|
||||
td(entry.ts ? moment(entry.ts).format('YYYY-MM-DD') : '\u2014')
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const VALID_GAME_IDS = new Set(['cocoland','ecoinflow','neoninfiltrator','audiopendulum','spaceinvaders','arkanoid','pingpong','asteroids','rockpaperscissors','tiktaktoe','flipflop','8ball','artillery','labyrinth','cocoman','tetris']);
|
||||
|
||||
exports.gameShellView = (name) => {
|
||||
if (!VALID_GAME_IDS.has(name)) {
|
||||
return template(i18n.gamesTitle, section(p(i18n.notFound || 'Not found')));
|
||||
}
|
||||
const game = getGames().find(g => g.id === name);
|
||||
const filterBar = div({ class: 'filter-group' },
|
||||
form({ method: 'GET', action: '/games' },
|
||||
input({ type: 'hidden', name: 'filter', value: 'all' }),
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.gamesFilterAll)
|
||||
),
|
||||
form({ method: 'GET', action: '/games' },
|
||||
input({ type: 'hidden', name: 'filter', value: 'scoring' }),
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.gamesFilterScoring)
|
||||
)
|
||||
);
|
||||
return template(
|
||||
game ? game.title() : name,
|
||||
section(
|
||||
div({ class: 'tags-header' },
|
||||
h2(i18n.gamesTitle),
|
||||
p(i18n.gamesDescription || 'Discover and play some mini-games in your network.')
|
||||
),
|
||||
filterBar
|
||||
),
|
||||
section({ class: 'game-shell-section' },
|
||||
iframe({
|
||||
src: `/game-assets/${name}/index.html`,
|
||||
class: `game-iframe game-iframe-${name}`,
|
||||
scrolling: 'no',
|
||||
allowfullscreen: true
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.gamesView = (filter = 'all', hall = null) => {
|
||||
const games = getGames();
|
||||
|
||||
const filterBar = div({ class: 'filter-group' },
|
||||
form({ method: 'GET', action: '/games' },
|
||||
input({ type: 'hidden', name: 'filter', value: 'all' }),
|
||||
button({ type: 'submit', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' },
|
||||
i18n.gamesFilterAll
|
||||
)
|
||||
),
|
||||
form({ method: 'GET', action: '/games' },
|
||||
input({ type: 'hidden', name: 'filter', value: 'scoring' }),
|
||||
button({ type: 'submit', class: filter === 'scoring' ? 'filter-btn active' : 'filter-btn' },
|
||||
i18n.gamesFilterScoring
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const content = filter === 'scoring' && hall
|
||||
? renderHallOfFame(hall)
|
||||
: div({ class: 'games-single-col' },
|
||||
games.map(game => {
|
||||
const topScore = hall && hall[game.id] && hall[game.id].length > 0 ? hall[game.id][0] : null;
|
||||
return div({ class: 'game-row' },
|
||||
div({ class: 'game-row-media' },
|
||||
img({ src: `/game-assets/${game.id}/thumbnail.svg`, alt: game.title(), loading: 'lazy' })
|
||||
),
|
||||
div({ class: 'game-row-body' },
|
||||
h2({ class: 'game-card-title' }, game.title()),
|
||||
p({ class: 'game-card-desc game-desc-yellow' }, game.desc()),
|
||||
topScore
|
||||
? p({ class: 'game-top-score' },
|
||||
userLink(topScore.author),
|
||||
span({ class: 'game-new-record-label' }, ' - ' + (i18n.gamesNewRecord || 'New Record') + ': '),
|
||||
String(topScore.score)
|
||||
)
|
||||
: null
|
||||
),
|
||||
div({ class: 'game-row-actions' },
|
||||
a({ href: `/games/${game.id}`, class: 'filter-btn' }, i18n.gamesPlayButton)
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
return template(
|
||||
i18n.gamesTitle,
|
||||
section(
|
||||
div({ class: 'tags-header' },
|
||||
h2(i18n.gamesTitle),
|
||||
p(filter === 'scoring' ? i18n.gamesHallOfFame : (i18n.gamesDescription || 'Discover and play some mini-games in your network.'))
|
||||
),
|
||||
filterBar
|
||||
),
|
||||
section(content)
|
||||
);
|
||||
};
|
||||
153
src/views/graphos_view.js
Normal file
153
src/views/graphos_view.js
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
const h = require("../server/node_modules/hyperaxe");
|
||||
const { div, h2, p, section, button, form, input, span, a } = h;
|
||||
const { template, i18n } = require('./main_views');
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
const escAttr = (s) => String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const escText = (s) => String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const adaptiveLayout = (n) => {
|
||||
if (n <= 1) return { rRatio: 0.22, nodeR: 30, labelGap: 18 };
|
||||
if (n <= 3) return { rRatio: 0.28, nodeR: 26, labelGap: 16 };
|
||||
if (n <= 6) return { rRatio: 0.34, nodeR: 22, labelGap: 14 };
|
||||
if (n <= 12) return { rRatio: 0.40, nodeR: 18, labelGap: 14 };
|
||||
return { rRatio: 0.44, nodeR: 14, labelGap: 12 };
|
||||
};
|
||||
|
||||
const buildGraphSvg = (me, peers) => {
|
||||
const W = 900;
|
||||
const H = 600;
|
||||
const cx = W / 2;
|
||||
const cy = H / 2;
|
||||
const N = Math.max(1, peers.length);
|
||||
const { rRatio, nodeR, labelGap } = adaptiveLayout(N);
|
||||
const r = Math.min(W, H) * rRatio;
|
||||
const meR = nodeR + 6;
|
||||
|
||||
const positions = peers.map((peer, i) => {
|
||||
const angle = (i / N) * TAU - Math.PI / 2;
|
||||
return {
|
||||
peer,
|
||||
x: cx + r * Math.cos(angle),
|
||||
y: cy + r * Math.sin(angle)
|
||||
};
|
||||
});
|
||||
|
||||
const edges = positions.map(({ peer, x, y }) =>
|
||||
`<line x1="${cx}" y1="${cy}" x2="${x.toFixed(2)}" y2="${y.toFixed(2)}" class="graphos-edge graphos-edge-${peer.kind}" />`
|
||||
).join('');
|
||||
|
||||
const nodes = positions.map(({ peer, x, y }) => {
|
||||
const xs = x.toFixed(2);
|
||||
const ys = y.toFixed(2);
|
||||
const labelY = (y + nodeR + labelGap).toFixed(2);
|
||||
const enc = escAttr(encodeURIComponent(peer.key));
|
||||
const centerHref = `/graphos?center=${enc}`;
|
||||
const profileHref = `/author/${enc}`;
|
||||
const name = escText(peer.name);
|
||||
return `<g class="graphos-node graphos-node-${peer.kind}">`
|
||||
+ `<title>${name} (${peer.kind})</title>`
|
||||
+ `<a href="${centerHref}" class="graphos-node-link">`
|
||||
+ `<circle cx="${xs}" cy="${ys}" r="${nodeR}" class="graphos-node-circle graphos-node-circle-${peer.kind}" />`
|
||||
+ `</a>`
|
||||
+ `<a href="${profileHref}" class="graphos-node-link">`
|
||||
+ `<text x="${xs}" y="${labelY}" text-anchor="middle" class="graphos-node-label">${name}</text>`
|
||||
+ `</a>`
|
||||
+ `</g>`;
|
||||
}).join('');
|
||||
|
||||
const meLabelY = (cy + meR + labelGap + 2).toFixed(2);
|
||||
const meEnc = escAttr(encodeURIComponent(me.key));
|
||||
const meProfileHref = `/author/${meEnc}`;
|
||||
const meName = escText(me.name);
|
||||
const center = `<g class="graphos-node graphos-node-me">`
|
||||
+ `<title>${meName}</title>`
|
||||
+ `<a href="/graphos" class="graphos-node-link">`
|
||||
+ `<circle cx="${cx}" cy="${cy}" r="${(meR + 5).toFixed(2)}" class="graphos-node-circle graphos-node-circle-online graphos-me-online-ring" />`
|
||||
+ `<circle cx="${cx}" cy="${cy}" r="${meR}" class="graphos-node-circle graphos-node-circle-me" />`
|
||||
+ `</a>`
|
||||
+ `<a href="${meProfileHref}" class="graphos-node-link">`
|
||||
+ `<text x="${cx}" y="${meLabelY}" text-anchor="middle" class="graphos-node-label graphos-node-label-me">${meName}</text>`
|
||||
+ `</a>`
|
||||
+ `</g>`;
|
||||
|
||||
return `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" class="graphos-svg">`
|
||||
+ edges + nodes + center
|
||||
+ `</svg>`;
|
||||
};
|
||||
|
||||
const kpi = (label, value) => div({ class: 'stats-kpi' },
|
||||
div({ class: 'stats-kpi-label' }, label),
|
||||
div({ class: 'stats-kpi-value' }, String(value))
|
||||
);
|
||||
|
||||
const legendItem = (kind, label) =>
|
||||
span({ class: 'graphos-legend-item' },
|
||||
span({ class: `graphos-legend-dot graphos-node-circle-${kind}` }),
|
||||
span(label)
|
||||
);
|
||||
|
||||
exports.graphosView = ({ filter, me, peers, kpis, focus = null, focusId = null, shown = null, total = null }) => {
|
||||
const title = i18n.graphos || 'Graphos';
|
||||
const description = i18n.graphosDescription || 'Interactive map of the network around you.';
|
||||
const modes = ['ALL', 'MINE'];
|
||||
const capped = Number.isFinite(shown) && Number.isFinite(total) && total > shown;
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: 'tags-header' },
|
||||
h2(title),
|
||||
p(description)
|
||||
),
|
||||
focus
|
||||
? [
|
||||
form({ method: 'GET', action: '/graphos', class: 'graphos-backbar' },
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.graphosBackToMine || '← My network')
|
||||
),
|
||||
div({ class: 'graphos-focus-bar' },
|
||||
span({ class: 'graphos-focus-label' }, `${i18n.graphosViewingGraphOf || 'Viewing'}: `),
|
||||
a({ class: 'user-link', href: `/author/${encodeURIComponent(focusId || '')}` }, `@${focus}`)
|
||||
)
|
||||
]
|
||||
: div({ class: 'mode-buttons stats-mode-row' },
|
||||
modes.map(m =>
|
||||
form({ method: 'GET', action: '/graphos' },
|
||||
input({ type: 'hidden', name: 'filter', value: m }),
|
||||
button({ type: 'submit', class: filter === m ? 'filter-btn active' : 'filter-btn' }, i18n[m + 'Button'])
|
||||
)
|
||||
)
|
||||
),
|
||||
capped
|
||||
? p({ class: 'graphos-cap-note' }, `${i18n.graphosShowing || 'Showing'} ${shown} / ${total}`)
|
||||
: null,
|
||||
div({ class: 'graphos-legend' },
|
||||
legendItem('me', i18n.graphosYou || 'You'),
|
||||
legendItem('online', i18n.online || 'Online'),
|
||||
filter !== 'MINE' ? legendItem('discovered', i18n.discovered || 'Discovered') : null,
|
||||
filter !== 'MINE' ? legendItem('unknown', i18n.unknown || 'Unknown') : null
|
||||
),
|
||||
div({ class: 'graphos-canvas', innerHTML: buildGraphSvg(me, peers) }),
|
||||
div({ class: 'stats-block' },
|
||||
div({ class: 'stats-grid' },
|
||||
kpi(i18n.graphosTotalNodes || 'Total nodes', kpis.total),
|
||||
kpi(i18n.online || 'Online', kpis.online),
|
||||
filter !== 'MINE' ? kpi(i18n.discovered || 'Discovered', kpis.discovered) : null,
|
||||
filter !== 'MINE' ? kpi(i18n.unknown || 'Unknown', kpis.unknown) : null
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
541
src/views/image_view.js
Normal file
541
src/views/image_view.js
Normal file
|
|
@ -0,0 +1,541 @@
|
|||
const { form, button, div, h2, p, section, input, label, br, a, img, span, textarea, select, option } =
|
||||
require("../server/node_modules/hyperaxe");
|
||||
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { template, i18n, renderOpinionsVoting, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderStateChip, renderContentActions } = require("./main_views");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const { renderUrl } = require("../backend/renderUrl")
|
||||
const { renderMapLocationVisitLabel } = require("./maps_view");
|
||||
|
||||
const userId = config.keys.id;
|
||||
|
||||
const safeArr = (v) => (Array.isArray(v) ? v : []);
|
||||
const safeText = (v) => String(v || "").trim();
|
||||
|
||||
const buildReturnTo = (filter, params = {}) => {
|
||||
const f = safeText(filter || "all");
|
||||
const q = safeText(params.q || "");
|
||||
const sort = safeText(params.sort || "recent");
|
||||
const parts = [`filter=${encodeURIComponent(f)}`];
|
||||
if (q) parts.push(`q=${encodeURIComponent(q)}`);
|
||||
if (sort) parts.push(`sort=${encodeURIComponent(sort)}`);
|
||||
return `/images?${parts.join("&")}`;
|
||||
};
|
||||
|
||||
const renderPMButton = (recipient, className = "filter-btn") => {
|
||||
const r = safeText(recipient);
|
||||
if (!r) return null;
|
||||
if (String(r) === String(userId)) return null;
|
||||
|
||||
return form(
|
||||
{ method: "GET", action: "/pm" },
|
||||
input({ type: "hidden", name: "recipients", value: r }),
|
||||
button({ type: "submit", class: className }, i18n.privateMessage)
|
||||
);
|
||||
};
|
||||
|
||||
const renderTags = (tags) => {
|
||||
const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
|
||||
return list.length
|
||||
? div(
|
||||
{ class: "card-tags" },
|
||||
list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
|
||||
)
|
||||
: null;
|
||||
};
|
||||
|
||||
const renderImageFavoriteToggle = (imgObj, returnTo = "") =>
|
||||
form(
|
||||
{
|
||||
method: "POST",
|
||||
action: imgObj.isFavorite
|
||||
? `/images/favorites/remove/${encodeURIComponent(imgObj.key)}`
|
||||
: `/images/favorites/add/${encodeURIComponent(imgObj.key)}`
|
||||
},
|
||||
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
||||
button(
|
||||
{ type: "submit", class: "filter-btn" },
|
||||
imgObj.isFavorite ? i18n.imageRemoveFavoriteButton : i18n.imageAddFavoriteButton
|
||||
)
|
||||
);
|
||||
|
||||
const renderImageMedia = (imgObj, filter, params = {}) => {
|
||||
const src = imgObj?.url ? `/blob/${encodeURIComponent(imgObj.url)}` : "";
|
||||
|
||||
return imgObj?.url
|
||||
? div(
|
||||
{ class: "image-container", style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
|
||||
a(
|
||||
{
|
||||
href: `/images/${encodeURIComponent(imgObj.key)}?filter=${encodeURIComponent(filter || "all")}${
|
||||
params.q ? `&q=${encodeURIComponent(params.q)}` : ""
|
||||
}${params.sort ? `&sort=${encodeURIComponent(params.sort)}` : ""}`
|
||||
},
|
||||
img({ src, alt: imgObj.title || "", class: "media-preview", loading: "lazy" })
|
||||
)
|
||||
)
|
||||
: p(i18n.imageNoFile);
|
||||
};
|
||||
|
||||
const renderImageOwnerActions = (filter, imgObj, params = {}) => {
|
||||
const returnTo = buildReturnTo(filter, params);
|
||||
const isAuthor = String(imgObj.author) === String(userId);
|
||||
const hasOpinions = Object.keys(imgObj.opinions || {}).length > 0;
|
||||
|
||||
if (!isAuthor) return [];
|
||||
|
||||
const items = [];
|
||||
if (!hasOpinions) {
|
||||
items.push(
|
||||
form(
|
||||
{ method: "GET", action: `/images/edit/${encodeURIComponent(imgObj.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "update-btn", type: "submit" }, i18n.imageUpdateButton)
|
||||
)
|
||||
);
|
||||
}
|
||||
items.push(
|
||||
form(
|
||||
{ method: "POST", action: `/images/delete/${encodeURIComponent(imgObj.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "delete-btn", type: "submit" }, i18n.imageDeleteButton)
|
||||
)
|
||||
);
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const renderImageList = exports.renderImageList = (images, filter, params = {}) => {
|
||||
const returnTo = buildReturnTo(filter, params);
|
||||
return images.length
|
||||
? images.map((imgObj) => {
|
||||
const commentCount = typeof imgObj.commentCount === "number" ? imgObj.commentCount : 0;
|
||||
const title = safeText(imgObj.title);
|
||||
const ownerActions = renderImageOwnerActions(filter, imgObj, params);
|
||||
|
||||
const isOwn = imgObj.author && String(imgObj.author) === String(userId);
|
||||
return div(
|
||||
{ class: "trending-card image-card" + (isOwn ? " own-content" : "") },
|
||||
div(
|
||||
{ class: "card-header activity-card-header" },
|
||||
span(),
|
||||
renderContentActions(imgObj.key, `/images/${encodeURIComponent(imgObj.key)}`)
|
||||
),
|
||||
div(
|
||||
{ class: "card-section image-card-body" },
|
||||
div(
|
||||
{ class: "bookmark-topbar" },
|
||||
div(
|
||||
{ class: "bookmark-topbar-left" },
|
||||
renderImageFavoriteToggle(imgObj, returnTo),
|
||||
renderPMButton(imgObj.author)
|
||||
),
|
||||
ownerActions.length ? div({ class: "bookmark-actions" }, ...ownerActions) : null
|
||||
),
|
||||
title ? h2(title) : null,
|
||||
(imgObj.meme || imgObj.lifetime) ? div({ class: "card-chips-row" },
|
||||
imgObj.meme ? a({ href: "/images?filter=meme", class: "chip-link" }, renderStateChip("mutuals", null, i18n.imageFilterMeme || "MEME")) : null,
|
||||
imgObj.lifetime ? renderLifespanChip(imgObj.lifetime, i18n) : null
|
||||
) : null,
|
||||
renderImageMedia(imgObj, filter, params),
|
||||
div(
|
||||
{ class: "card-comments-summary" },
|
||||
span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
|
||||
span({ class: "card-value" }, String(commentCount)),
|
||||
br(),
|
||||
br(),
|
||||
form(
|
||||
{ method: "GET", action: `/images/${encodeURIComponent(imgObj.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
input({ type: "hidden", name: "filter", value: filter || "all" }),
|
||||
params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
|
||||
params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
|
||||
)
|
||||
),
|
||||
div(
|
||||
{ class: "card-comments-summary feed-opinions-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "),
|
||||
span({ class: "card-value" }, String(Object.values(imgObj.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)))
|
||||
),
|
||||
renderOpinionsVoting('/images/opinions', imgObj.key, imgObj.opinions, returnTo, imgObj.opinions_inhabitants)
|
||||
),
|
||||
div({ class: "card-spread-left" }, renderSpreadButton(imgObj.key, (params.spreadMap && params.spreadMap.get(imgObj.key)) || params.spreads)),
|
||||
renderMapLocationVisitLabel(imgObj.mapUrl),
|
||||
br(),
|
||||
(() => {
|
||||
const createdTs = imgObj.createdAt ? new Date(imgObj.createdAt).getTime() : NaN;
|
||||
const updatedTs = imgObj.updatedAt ? new Date(imgObj.updatedAt).getTime() : NaN;
|
||||
const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
|
||||
|
||||
return p(
|
||||
{ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${moment(imgObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
|
||||
userLink(imgObj.author),
|
||||
showUpdated
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
` | ${i18n.imageUpdatedAt}: ${moment(imgObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
|
||||
)
|
||||
: null
|
||||
);
|
||||
})()
|
||||
)
|
||||
);
|
||||
})
|
||||
: p(params.q ? i18n.imageNoMatch : i18n.noImages);
|
||||
};
|
||||
|
||||
const renderImageForm = (filter, imageId, imageToEdit, params = {}) => {
|
||||
const returnFilter = filter === "create" ? "all" : params.filter || "all";
|
||||
const returnTo = safeText(params.returnTo) || buildReturnTo(returnFilter, params);
|
||||
const tagsValue = safeArr(imageToEdit?.tags).join(", ");
|
||||
|
||||
return div(
|
||||
{ class: "div-center image-form" },
|
||||
form(
|
||||
{
|
||||
action: filter === "edit" ? `/images/update/${encodeURIComponent(imageId)}` : "/images/create",
|
||||
method: "POST",
|
||||
enctype: "multipart/form-data"
|
||||
},
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
label(i18n.imageFileLabel),
|
||||
br(),
|
||||
input({ type: "file", name: "image", required: filter !== "edit" }),
|
||||
br(),
|
||||
br(),
|
||||
imageToEdit?.url
|
||||
? img({ src: `/blob/${encodeURIComponent(imageToEdit.url)}`, class: "media-preview", alt: imageToEdit?.title || "" })
|
||||
: null,
|
||||
label(i18n.imageTitleLabel),
|
||||
br(),
|
||||
input({ type: "text", name: "title", placeholder: i18n.imageTitlePlaceholder, value: imageToEdit?.title || "" }),
|
||||
br(),
|
||||
label(i18n.imageDescriptionLabel),
|
||||
br(),
|
||||
textarea({ name: "description", placeholder: i18n.imageDescriptionPlaceholder, rows: "4" }, imageToEdit?.description || ""),
|
||||
br(),
|
||||
label(i18n.mapLocationTitle || "Map Location"),
|
||||
br(),
|
||||
input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: imageToEdit?.mapUrl || "" }),
|
||||
br(),
|
||||
input({ type: "hidden", name: "meme", value: "0" }),
|
||||
label(i18n.imageTagsLabel),
|
||||
br(),
|
||||
input({ type: "text", name: "tags", placeholder: i18n.imageTagsPlaceholder, value: tagsValue }),
|
||||
br(),
|
||||
label(i18n.imageMemeLabel),
|
||||
input({
|
||||
id: "meme-checkbox",
|
||||
type: "checkbox",
|
||||
name: "meme",
|
||||
value: "1",
|
||||
class: "meme-checkbox",
|
||||
...(imageToEdit?.meme ? { checked: true } : {})
|
||||
}),
|
||||
br(),
|
||||
br(),
|
||||
button({ type: "submit" }, filter === "edit" ? i18n.imageUpdateButton : i18n.imageCreateButton)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderGallery = (images) => {
|
||||
if (!images.length) return div(i18n.noImages);
|
||||
|
||||
return div(
|
||||
{ class: "gallery" },
|
||||
images.map((imgObj) => {
|
||||
const src = imgObj.url ? `/image/256/${encodeURIComponent(imgObj.url)}` : "";
|
||||
return a(
|
||||
{ href: `#img-${encodeURIComponent(imgObj.key)}`, class: "gallery-item" },
|
||||
img({ src, alt: imgObj.title || "", class: "gallery-image", loading: "lazy" })
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const renderLightbox = (images) =>
|
||||
images.map((imgObj) => {
|
||||
const src = imgObj.url ? `/blob/${encodeURIComponent(imgObj.url)}` : "";
|
||||
return div(
|
||||
{ id: `img-${encodeURIComponent(imgObj.key)}`, class: "lightbox" },
|
||||
a({ href: "#", class: "lightbox-close" }, "×"),
|
||||
img({ src, class: "lightbox-image", alt: imgObj.title || "" })
|
||||
);
|
||||
});
|
||||
|
||||
const renderImageCommentsSection = (imageKey, comments = [], returnTo = null) => {
|
||||
const list = safeArr(comments).filter(c => {
|
||||
const t = c && c.value && c.value.content && c.value.content.text;
|
||||
return t && String(t).trim();
|
||||
});
|
||||
const commentsCount = list.length;
|
||||
|
||||
return div(
|
||||
{ class: "vote-comments-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
|
||||
span({ class: "card-value" }, String(commentsCount))
|
||||
),
|
||||
div(
|
||||
{ class: "comment-form-wrapper" },
|
||||
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
|
||||
form(
|
||||
{ method: "POST", action: `/images/${encodeURIComponent(imageKey)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
|
||||
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
||||
textarea({
|
||||
id: "comment-text",
|
||||
name: "text",
|
||||
rows: 4,
|
||||
class: "comment-textarea",
|
||||
placeholder: i18n.voteNewCommentPlaceholder
|
||||
}),
|
||||
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
|
||||
br(),
|
||||
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
|
||||
)
|
||||
),
|
||||
list.length
|
||||
? div(
|
||||
{ class: "comments-list" },
|
||||
list.map((c) => {
|
||||
const author = c?.value?.author || "";
|
||||
const ts = c?.value?.timestamp || c?.timestamp;
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
|
||||
const relDate = ts ? moment(ts).fromNow() : "";
|
||||
|
||||
const content = c?.value?.content || {};
|
||||
const text = content.text || "";
|
||||
const threadRoot = content.fork || content.root || null;
|
||||
|
||||
return div(
|
||||
{ class: "votations-comment-card" },
|
||||
span(
|
||||
{ class: "created-at" },
|
||||
span(i18n.createdBy),
|
||||
author ? userLink(author) : span("(unknown)"),
|
||||
absDate ? span(" | ") : "",
|
||||
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
|
||||
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
|
||||
relDate && threadRoot ? a({ href: `/thread/${encodeURIComponent(threadRoot)}#${encodeURIComponent(c.key)}` }, relDate) : ""
|
||||
),
|
||||
p({ class: "votations-comment-text" }, ...renderUrl(text))
|
||||
);
|
||||
})
|
||||
)
|
||||
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
|
||||
);
|
||||
};
|
||||
|
||||
exports.imageView = async (images, filter = "all", imageId = null, params = {}) => {
|
||||
const title =
|
||||
filter === "mine"
|
||||
? i18n.imageMineSectionTitle
|
||||
: filter === "create"
|
||||
? i18n.imageCreateSectionTitle
|
||||
: filter === "edit"
|
||||
? i18n.imageUpdateSectionTitle
|
||||
: filter === "gallery"
|
||||
? i18n.imageGallerySectionTitle
|
||||
: filter === "meme"
|
||||
? i18n.imageMemeSectionTitle
|
||||
: filter === "recent"
|
||||
? i18n.imageRecentSectionTitle
|
||||
: filter === "top"
|
||||
? i18n.imageTopSectionTitle
|
||||
: filter === "favorites"
|
||||
? i18n.imageFavoritesSectionTitle
|
||||
: i18n.imageAllSectionTitle;
|
||||
|
||||
const q = safeText(params.q || "");
|
||||
const sort = safeText(params.sort || "recent");
|
||||
|
||||
const list = safeArr(images);
|
||||
const imageToEdit = imageId ? list.find((im) => im.key === imageId) : null;
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(title),
|
||||
p(i18n.imageDescription)
|
||||
),
|
||||
(() => {
|
||||
const { renderReachChip } = require('./clearnet_view');
|
||||
const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetImages);
|
||||
return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n));
|
||||
})(),
|
||||
br(),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/images", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterRecent),
|
||||
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterTop),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
|
||||
i18n.imageFilterFavorites
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "gallery", class: filter === "gallery" ? "filter-btn active" : "filter-btn" },
|
||||
i18n.imageFilterGallery
|
||||
),
|
||||
button({ type: "submit", name: "filter", value: "meme", class: filter === "meme" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterMeme),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.imageCreateButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
filter === "create" || filter === "edit"
|
||||
? renderImageForm(filter, imageId, imageToEdit, { ...params, filter })
|
||||
: section(
|
||||
div(
|
||||
{ class: "images-search" },
|
||||
form(
|
||||
{ method: "GET", action: "/images", class: "filter-box" },
|
||||
input({ type: "hidden", name: "filter", value: filter }),
|
||||
input({
|
||||
type: "text",
|
||||
name: "q",
|
||||
value: q,
|
||||
placeholder: i18n.imageSearchPlaceholder,
|
||||
class: "filter-box__input"
|
||||
}),
|
||||
div(
|
||||
{ class: "filter-box__controls" },
|
||||
select(
|
||||
{ name: "sort", class: "filter-box__select" },
|
||||
option({ value: "recent", selected: sort === "recent" }, i18n.imageSortRecent),
|
||||
option({ value: "oldest", selected: sort === "oldest" }, i18n.imageSortOldest),
|
||||
option({ value: "top", selected: sort === "top" }, i18n.imageSortTop)
|
||||
),
|
||||
button({ type: "submit", class: "filter-box__button" }, i18n.imageSearchButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
filter === "gallery" ? renderGallery(list) : div({ class: "images-list" }, renderImageList(list, filter, { q, sort }))
|
||||
)
|
||||
),
|
||||
...(filter === "gallery" ? renderLightbox(list) : [])
|
||||
);
|
||||
};
|
||||
|
||||
exports.singleImageView = async (imageObj, filter = "all", comments = [], params = {}) => {
|
||||
const q = safeText(params.q || "");
|
||||
const sort = safeText(params.sort || "recent");
|
||||
const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort });
|
||||
|
||||
const title = safeText(imageObj.title);
|
||||
const { renderReachChip } = require('./clearnet_view');
|
||||
const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetImages);
|
||||
|
||||
const memeChip = imageObj.meme
|
||||
? a({ href: "/images?filter=meme", class: "chip-link" }, renderStateChip("mutuals", null, i18n.imageFilterMeme || "MEME"))
|
||||
: null;
|
||||
const chips = [
|
||||
memeChip,
|
||||
renderLifespanChip(imageObj.lifetime, i18n),
|
||||
imageObj.sizeBytes ? renderEcoTax(imageObj.sizeBytes, imageObj.key) : null
|
||||
].filter(Boolean);
|
||||
|
||||
const ownerActions = renderImageOwnerActions(filter, imageObj, { q, sort });
|
||||
const sideActions = [];
|
||||
sideActions.push(renderImageFavoriteToggle(imageObj, returnTo));
|
||||
if (imageObj.author && String(imageObj.author) !== String(userId)) {
|
||||
sideActions.push(renderPMButton(imageObj.author));
|
||||
}
|
||||
for (const a of ownerActions) sideActions.push(a);
|
||||
|
||||
const tagsNode = renderTags(imageObj.tags);
|
||||
|
||||
const imageSide = div({ class: "tribe-side" },
|
||||
div({ class: "shop-title-row" },
|
||||
title ? h2({ class: "tribe-card-title" }, title) : null,
|
||||
renderReachChip(isClearnet, i18n)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
safeText(imageObj.description)
|
||||
? p({ class: "tribe-side-description" }, ...renderUrl(imageObj.description))
|
||||
: null,
|
||||
tagsNode,
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(imageObj.key, params.spreads)),
|
||||
renderMapLocationVisitLabel(imageObj.mapUrl),
|
||||
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null
|
||||
);
|
||||
|
||||
const imageMain = div({ class: "tribe-main" },
|
||||
imageObj?.url
|
||||
? div(
|
||||
{ class: "image-container" },
|
||||
img({
|
||||
src: `/blob/${encodeURIComponent(imageObj.url)}`,
|
||||
alt: imageObj.title || "",
|
||||
class: "media-preview",
|
||||
loading: "lazy"
|
||||
})
|
||||
)
|
||||
: p(i18n.imageNoFile),
|
||||
(() => {
|
||||
const createdTs = imageObj.createdAt ? new Date(imageObj.createdAt).getTime() : NaN;
|
||||
const updatedTs = imageObj.updatedAt ? new Date(imageObj.updatedAt).getTime() : NaN;
|
||||
const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
|
||||
|
||||
return p(
|
||||
{ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${moment(imageObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
|
||||
userLink(imageObj.author),
|
||||
showUpdated
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
` | ${i18n.imageUpdatedAt}: ${moment(imageObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
|
||||
)
|
||||
: null
|
||||
);
|
||||
})(),
|
||||
renderOpinionsVoting('/images/opinions', imageObj.key, imageObj.opinions, returnTo, imageObj.opinions_inhabitants),
|
||||
renderImageCommentsSection(imageObj.key, comments, returnTo)
|
||||
);
|
||||
|
||||
return template(
|
||||
i18n.imageTitle,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.imageAllSectionTitle || i18n.imageTitle),
|
||||
p(i18n.imageDescription)
|
||||
),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/images", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterRecent),
|
||||
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterTop),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
|
||||
i18n.imageFilterFavorites
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "gallery", class: filter === "gallery" ? "filter-btn active" : "filter-btn" },
|
||||
i18n.imageFilterGallery
|
||||
),
|
||||
button({ type: "submit", name: "filter", value: "meme", class: filter === "meme" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterMeme),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.imageCreateButton)
|
||||
)
|
||||
),
|
||||
div({ class: "tribe-details" }, imageSide, imageMain)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
39
src/views/indexing_view.js
Normal file
39
src/views/indexing_view.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
const { a, br, div, h2, p, progress, section, span, strong, meta, head, html, link, title, body, main } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n } = require('./main_views');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
|
||||
const doctypeString = '<!DOCTYPE html>';
|
||||
|
||||
exports.indexingView = ({ percent }) => {
|
||||
let metaRefresh;
|
||||
try {
|
||||
const cfg = getConfig() || {};
|
||||
const theme = cfg.themes?.current || 'Dark-SNH';
|
||||
void theme;
|
||||
} catch (_) {}
|
||||
|
||||
const pct = Math.max(0, Math.min(100, Number(percent) || 0));
|
||||
const headingText = i18n.indexingTitle || 'Synchronizing';
|
||||
const message = i18n.indexingMessage || 'Oasis is trying to syncronize a huge network of inhabitants. Just wait!';
|
||||
const refreshNote = i18n.indexingRefreshNote || 'This page refreshes every 10 seconds.';
|
||||
const editProfileLabel = i18n.indexingEditProfileLink || 'Set up my avatar';
|
||||
|
||||
return template(
|
||||
headingText,
|
||||
section(
|
||||
div({ class: 'tags-header' },
|
||||
h2(`❤ ${headingText}`),
|
||||
p(message)
|
||||
),
|
||||
div({ class: 'indexing-progress-block' },
|
||||
progress({ value: String(pct), max: '100', class: 'indexing-progress' }),
|
||||
p({ class: 'indexing-percent' }, strong(`${pct.toFixed(1)} %`)),
|
||||
p({ class: 'indexing-note' }, refreshNote)
|
||||
),
|
||||
div({ class: 'indexing-actions' },
|
||||
a({ href: '/profile/edit', class: 'filter-btn welcome-action-primary' }, editProfileLabel),
|
||||
a({ href: '/modules', class: 'filter-btn' }, i18n.modulesTitle || 'Modules')
|
||||
)
|
||||
)
|
||||
).replace('</head>', '<meta http-equiv="refresh" content="10"></head>');
|
||||
};
|
||||
422
src/views/inhabitants_view.js
Normal file
422
src/views/inhabitants_view.js
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
const { div, h2, p, section, button, form, img, a, textarea, input, br, span, strong } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, userLink, renderUserSensors, renderContentActions } = require('./main_views');
|
||||
const { renderContentStats } = require('./clearnet_view');
|
||||
const { renderUrl } = require('../backend/renderUrl');
|
||||
const { getConfig } = require('../configs/config-manager');
|
||||
|
||||
const DEFAULT_HASH_ENC = "%260000000000000000000000000000000000000000000%3D.sha256";
|
||||
const DEFAULT_HASH_PATH_RE = /\/image\/\d+\/%260000000000000000000000000000000000000000000%3D\.sha256$/;
|
||||
|
||||
const formatCarbonValue = (g) => {
|
||||
const n = Number(g) || 0;
|
||||
if (!n) return '0 µg CO₂';
|
||||
if (n >= 1) return `${n.toFixed(2)} g CO₂`;
|
||||
const mg = n * 1000;
|
||||
if (mg >= 1) return `${mg.toFixed(2)} mg CO₂`;
|
||||
return `${(mg * 1000).toFixed(2)} µg CO₂`;
|
||||
};
|
||||
|
||||
function isDefaultImageId(v){
|
||||
if (!v) return true;
|
||||
if (typeof v === 'string') {
|
||||
if (v === DEFAULT_HASH_ENC) return true;
|
||||
if (DEFAULT_HASH_PATH_RE.test(v)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function toImageUrl(imgId, size=256){
|
||||
if (!imgId || isDefaultImageId(imgId)) return '/assets/images/default-avatar.png';
|
||||
if (typeof imgId === 'string' && imgId.startsWith('/image/')) {
|
||||
return imgId.replace('/image/256/','/image/'+size+'/').replace('/image/512/','/image/'+size+'/');
|
||||
}
|
||||
return `/image/${size}/${encodeURIComponent(imgId)}`;
|
||||
}
|
||||
|
||||
function extractAboutImageId(about){
|
||||
if (!about || typeof about !== 'object') return null;
|
||||
const aimg = about.image;
|
||||
if (!aimg) return null;
|
||||
if (typeof aimg === 'string') return aimg;
|
||||
return aimg.link || aimg.url || null;
|
||||
}
|
||||
|
||||
function resolvePhoto(photoField, size = 256) {
|
||||
if (!photoField) return '/assets/images/default-avatar.png';
|
||||
if (typeof photoField === 'string') {
|
||||
if (photoField.startsWith('/assets/')) return photoField;
|
||||
if (photoField.startsWith('/blob/')) return photoField;
|
||||
if (photoField.startsWith('/image/')) {
|
||||
if (isDefaultImageId(photoField)) return '/assets/images/default-avatar.png';
|
||||
return photoField.replace('/image/256/','/image/'+size+'/').replace('/image/512/','/image/'+size+'/');
|
||||
}
|
||||
}
|
||||
return toImageUrl(photoField, size);
|
||||
}
|
||||
|
||||
const generateFilterButtons = (filters, currentFilter) =>
|
||||
filters.map(mode =>
|
||||
form({ method: 'GET', action: '/inhabitants' },
|
||||
input({ type: 'hidden', name: 'filter', value: mode }),
|
||||
button({
|
||||
type: 'submit',
|
||||
class: currentFilter === mode ? 'filter-btn active' : 'filter-btn'
|
||||
}, i18n[mode + 'Button'] || i18n[mode + 'SectionTitle'] || mode)
|
||||
)
|
||||
);
|
||||
|
||||
function lastActivityBadge(user, isMe) {
|
||||
const bucket = user && user.lastActivityBucket;
|
||||
const dotClass =
|
||||
bucket === 'green' ? 'green' : bucket === 'orange' ? 'orange' : bucket === 'red' ? 'red' : null;
|
||||
if (!dotClass) return [];
|
||||
const items = [
|
||||
span({ class: 'inhabitant-last-activity' },
|
||||
`${i18n.inhabitantActivityLevel}: `,
|
||||
span({ class: `activity-dot ${dotClass}` }, '●'))
|
||||
];
|
||||
const currentTheme = getConfig().themes.current;
|
||||
const src = isMe ? (currentTheme === 'OasisKIT' ? 'KIT' : (currentTheme === 'OasisMobile' || process.env.OASIS_MOBILE === '1') ? 'MOBILE' : 'DESKTOP') : (user && user.deviceSource) || null;
|
||||
if (src) {
|
||||
const upper = String(src).toUpperCase();
|
||||
const deviceClass = upper === 'KIT' ? 'device-kit' : upper === 'MOBILE' ? 'device-mobile' : 'device-desktop';
|
||||
items.push(span({ class: 'inhabitant-last-activity' },
|
||||
`${i18n.deviceLabel || 'Device'}: `,
|
||||
span({ class: deviceClass }, src)));
|
||||
}
|
||||
return [div({ class: 'inhabitant-activity-group' }, ...items)];
|
||||
}
|
||||
|
||||
const lightboxId = (id) => 'inhabitant_' + String(id || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
|
||||
const renderInhabitantCard = (user, filter, currentUserId, fediverseConfigured) => {
|
||||
const isMe = user.id === currentUserId;
|
||||
const raw = user.visibilityPrefs || {};
|
||||
const clearnetSubKeys = ['clearnetShops','clearnetJobs','clearnetEvents','clearnetProjects','clearnetPosts','clearnetAudios','clearnetVideos','clearnetImages','clearnetDocuments','clearnetTorrents','clearnetBookmarks'];
|
||||
const hasClearnet = raw.clearnet === true || clearnetSubKeys.some(k => raw[k] === true);
|
||||
const prefs = {
|
||||
activity: raw.activity === true,
|
||||
device: raw.device === true,
|
||||
karma: raw.karma !== false,
|
||||
ubi: raw.ubi === true,
|
||||
wallet: raw.wallet === true,
|
||||
ecoTax: raw.ecoTax !== false,
|
||||
larpSign: raw.larpSign === true,
|
||||
gpg: raw.gpg !== false,
|
||||
clearnet: hasClearnet,
|
||||
fediverse: raw.fediverse === true,
|
||||
fediverseHandle: typeof raw.fediverseHandle === 'string' ? raw.fediverseHandle : ''
|
||||
};
|
||||
return div({ class: 'trending-card inhabitants-card' + (isMe ? ' own-content' : '') },
|
||||
div(
|
||||
{ class: 'card-header activity-card-header' },
|
||||
span(),
|
||||
renderContentActions(user.id, `/inhabitant/${encodeURIComponent(user.id)}`)
|
||||
),
|
||||
div({ class: 'card-section inhabitants-card-body' },
|
||||
div({ class: 'inhabitant-card' },
|
||||
div({ class: 'inhabitant-left' },
|
||||
a(
|
||||
{ href: `/author/${encodeURIComponent(user.id)}` },
|
||||
img({ class: 'inhabitant-photo-details', src: resolvePhoto(user.photo, 256), alt: user.name || 'Anonymous' })
|
||||
),
|
||||
...renderUserSensors({
|
||||
isMe, fediverseConfigured, prefs, id: user.id,
|
||||
karmaScore: user.karmaScore, carbonGrams: user.carbonGrams,
|
||||
deviceSource: user.deviceSource, activityBucket: user.lastActivityBucket,
|
||||
gpgFingerprint: user.gpgFingerprint, ecoAddress: user.ecoAddress,
|
||||
estimatedUBI: user.estimatedUBI, lastClaimedDate: user.lastClaimedDate, totalClaimed: user.totalClaimed,
|
||||
larpHouse: user.larpHouse, stats: user.stats
|
||||
}, { relationshipNode: isMe ? span({ class: 'status you' }, i18n.relationshipYou) : null, excludeContent: true }),
|
||||
!isMe
|
||||
? div(
|
||||
{ class: 'cv-actions' },
|
||||
form(
|
||||
{ method: 'GET', action: '/pm' },
|
||||
input({ type: 'hidden', name: 'recipients', value: user.id }),
|
||||
button({ type: 'submit', class: 'btn' }, i18n.pmCreateButton)
|
||||
)
|
||||
)
|
||||
: null
|
||||
),
|
||||
div({ class: 'inhabitant-details' },
|
||||
h2(user.name || 'Anonymous'),
|
||||
user.description ? p(...renderUrl(user.description)) : null,
|
||||
filter === 'MATCHSKILLS' && user.commonSkills?.length
|
||||
? div({ class: 'matchskills' },
|
||||
p(`${i18n.commonSkills}: ${user.commonSkills.join(', ')}`),
|
||||
p(`${i18n.matchScore}: ${Math.round((user.matchScore || 0) * 100)}%`)
|
||||
)
|
||||
: null,
|
||||
filter === 'SUGGESTED' && (user.followsYou || user.commonSkills?.length || user.mutualCount)
|
||||
? div({ class: 'suggested-meta' },
|
||||
user.followsYou ? span({ class: 'suggested-badge' }, i18n.suggestedFollowsYou || 'Follows you') : null,
|
||||
user.commonSkills?.length
|
||||
? p(`${i18n.commonSkills || 'Common skills'}: ${user.commonSkills.join(', ')}`)
|
||||
: null,
|
||||
user.mutualCount ? p(`${i18n.mutualFollowers}: ${user.mutualCount}`) : null
|
||||
)
|
||||
: null,
|
||||
filter === 'blocked' && user.isBlocked
|
||||
? p(i18n.blockedLabel) : null,
|
||||
p(userLink(user.id)),
|
||||
!isMe ? (() => {
|
||||
const rel = user.relationship || {}
|
||||
const blockedBoth = rel.blocking && rel.blockedBy
|
||||
const mutual = rel.following && rel.followsMe
|
||||
const supportAction = rel.following ? 'unfollow' : (rel.blocking ? 'unblock' : 'follow')
|
||||
return div({ class: 'relationship-status inhabitant-relationship' },
|
||||
blockedBoth
|
||||
? span({ class: 'status blocked' }, i18n.relationshipMutualBlock)
|
||||
: [
|
||||
rel.blocking ? span({ class: 'status blocked' }, i18n.relationshipBlocking) : null,
|
||||
rel.blockedBy ? span({ class: 'status blocked-by' }, i18n.relationshipBlockedBy) : null,
|
||||
mutual
|
||||
? span({ class: 'status mutual' }, i18n.relationshipMutuals)
|
||||
: [
|
||||
span({ class: 'status supporting' }, rel.following ? i18n.relationshipFollowing : i18n.relationshipNone),
|
||||
span({ class: 'status supported-by' }, rel.followsMe ? i18n.relationshipTheyFollow : i18n.relationshipNotFollowing)
|
||||
]
|
||||
],
|
||||
div({ class: 'relationship-actions' },
|
||||
form({ method: 'POST', action: `/${supportAction}/${encodeURIComponent(user.id)}` },
|
||||
button({ type: 'submit', class: 'btn' }, i18n[supportAction])
|
||||
)
|
||||
)
|
||||
)
|
||||
})() : null,
|
||||
renderContentStats(user.stats, i18n)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderGalleryInhabitants = inhabitants =>
|
||||
div(
|
||||
{ class: "gallery" },
|
||||
inhabitants.length
|
||||
? inhabitants.map(u =>
|
||||
a({ href: `#${lightboxId(u.id)}`, class: "gallery-item" },
|
||||
img({ src: resolvePhoto(u.photo, 256), alt: u.name || "Anonymous", class: "gallery-image" })
|
||||
)
|
||||
)
|
||||
: p(i18n.noInhabitantsFound)
|
||||
);
|
||||
|
||||
const renderLightbox = inhabitants =>
|
||||
inhabitants.map(u =>
|
||||
div(
|
||||
{ id: lightboxId(u.id), class: "lightbox" },
|
||||
a({ href: "#", class: "lightbox-close" }, "×"),
|
||||
img({ src: resolvePhoto(u.photo, 256), class: "lightbox-image", alt: u.name || "Anonymous" })
|
||||
)
|
||||
);
|
||||
|
||||
function stripAndCollectImgs(text) {
|
||||
if (!text || typeof text !== 'string') return { clean: '', imgs: [] };
|
||||
const imgs = [];
|
||||
let clean = text;
|
||||
const rawImgRe = /<img[^>]*src="([^"]+)"[^>]*>/gi;
|
||||
clean = clean.replace(rawImgRe, (_, src) => { imgs.push(src); return ''; });
|
||||
const encImgRe = /<img[^&]*src="([^&]*)"[^&]*>/gi;
|
||||
clean = clean.replace(encImgRe, (_, src) => { imgs.push(src.replace(/&/g, '&')); return ''; });
|
||||
return { clean, imgs };
|
||||
}
|
||||
|
||||
function msgIdOf(m) {
|
||||
return m && (m.key || m.value?.key || m.value?.content?.root || m.value?.content?.branch || null);
|
||||
}
|
||||
|
||||
exports.inhabitantsView = (inhabitants, filter, query, currentUserId, fediverseConfigured) => {
|
||||
const title = filter === 'contacts' ? i18n.yourContacts
|
||||
: filter === 'CVs' ? i18n.allCVs
|
||||
: filter === 'MATCHSKILLS' ? i18n.matchSkills
|
||||
: filter === 'SUGGESTED' ? i18n.suggestedSectionTitle
|
||||
: filter === 'blocked' ? i18n.blockedSectionTitle
|
||||
: filter === 'GALLERY' ? i18n.gallerySectionTitle
|
||||
: filter === 'TOP KARMA' ? i18n.topkarmaSectionTitle
|
||||
: filter === 'TOP ECO' ? (i18n.topecoSectionTitle || 'Top Eco')
|
||||
: filter === 'TOP ACTIVITY' ? i18n.topactivitySectionTitle
|
||||
: i18n.allInhabitants;
|
||||
|
||||
const showCVFilters = filter === 'CVs' || filter === 'MATCHSKILLS';
|
||||
const filters = ['all', 'TOP ACTIVITY', 'TOP KARMA', 'TOP ECO', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'];
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: 'tags-header' },
|
||||
h2(title),
|
||||
p(i18n.discoverPeople)
|
||||
),
|
||||
div({ class: 'filters' },
|
||||
form({ method: 'GET', action: '/inhabitants' },
|
||||
input({ type: 'hidden', name: 'filter', value: filter }),
|
||||
input({
|
||||
type: 'text',
|
||||
name: 'search',
|
||||
placeholder: i18n.searchInhabitantsPlaceholder,
|
||||
value: (query && query.search) || ''
|
||||
}),
|
||||
showCVFilters
|
||||
? [
|
||||
input({ type: 'text', name: 'location', placeholder: i18n.filterLocation, value: (query && query.location) || '' }),
|
||||
input({ type: 'text', name: 'language', placeholder: i18n.filterLanguage, value: (query && query.language) || '' }),
|
||||
input({ type: 'text', name: 'skills', placeholder: i18n.filterSkills, value: (query && query.skills) || '' })
|
||||
]
|
||||
: null,
|
||||
br(),
|
||||
button({ type: 'submit' }, i18n.applyFilters)
|
||||
)
|
||||
),
|
||||
div({ class: 'inhabitant-action' },
|
||||
...generateFilterButtons(filters, filter)
|
||||
),
|
||||
filter === 'GALLERY'
|
||||
? renderGalleryInhabitants(inhabitants)
|
||||
: div({ class: 'inhabitants-list' },
|
||||
inhabitants.length
|
||||
? inhabitants.map(user => renderInhabitantCard(user, filter, currentUserId, fediverseConfigured))
|
||||
: p({ class: 'no-results' }, i18n.noInhabitantsFound)
|
||||
),
|
||||
...renderLightbox(inhabitants)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.inhabitantsProfileView = (payload, currentUserId, fediverseConfigured) => {
|
||||
const safe = payload && typeof payload === 'object' ? payload : {};
|
||||
const about = (safe.about && typeof safe.about === 'object') ? safe.about : {};
|
||||
const cv = (safe.cv && typeof safe.cv === 'object') ? safe.cv : {};
|
||||
const feed = Array.isArray(safe.feed) ? safe.feed : [];
|
||||
|
||||
const viewedId = typeof safe.viewedId === 'string' ? safe.viewedId : '';
|
||||
const id = (cv && cv.author) || (about && about.about) || viewedId || '';
|
||||
const baseName = ((cv && cv.name) || (about && about.name) || '').trim();
|
||||
const name = baseName || (i18n.unnamed || 'Anonymous');
|
||||
const description = (cv && cv.description) || (about && about.description) || '';
|
||||
|
||||
const listPhoto = (typeof safe.photo === 'string' && safe.photo.trim()) ? safe.photo : null;
|
||||
const rawCandidate = listPhoto || extractAboutImageId(about) || (cv && cv.photo) || null;
|
||||
const image = (
|
||||
typeof rawCandidate === 'string' &&
|
||||
rawCandidate.startsWith('/image/') &&
|
||||
!DEFAULT_HASH_PATH_RE.test(rawCandidate) &&
|
||||
rawCandidate.indexOf(DEFAULT_HASH_ENC) === -1
|
||||
)
|
||||
? rawCandidate.replace('/image/512/','/image/256/').replace('/image/1024/','/image/256/')
|
||||
: resolvePhoto(rawCandidate, 256);
|
||||
|
||||
const location = (cv && cv.location) || '';
|
||||
const languages = typeof (cv && cv.languages) === 'string'
|
||||
? (cv.languages || '').split(',').map(x => x.trim()).filter(Boolean)
|
||||
: Array.isArray(cv && cv.languages) ? cv.languages : [];
|
||||
const skills = [
|
||||
...((cv && cv.personalSkills) || []),
|
||||
...((cv && cv.oasisSkills) || []),
|
||||
...((cv && cv.educationalSkills) || []),
|
||||
...((cv && cv.professionalSkills) || [])
|
||||
];
|
||||
const status = (cv && cv.status) || '';
|
||||
const preferences = (cv && cv.preferences) || '';
|
||||
const createdAt = (cv && cv.createdAt) ? new Date(cv.createdAt).toLocaleString() : '';
|
||||
const isMe = id && id === currentUserId;
|
||||
const title = i18n.inhabitantProfileTitle || i18n.inhabitantviewDetails;
|
||||
const karmaScore = typeof safe.karmaScore === 'number' ? safe.karmaScore : 0;
|
||||
const estimatedUBI = typeof safe.estimatedUBI === 'number' ? safe.estimatedUBI : 0;
|
||||
const lastClaimedDate = safe.lastClaimedDate || null;
|
||||
const totalClaimed = typeof safe.totalClaimed === 'number' ? safe.totalClaimed : 0;
|
||||
const ecoAddress = typeof safe.ecoAddress === 'string' ? safe.ecoAddress : null;
|
||||
const rawPrefs = safe.visibilityPrefs || {};
|
||||
const clearnetSubKeys = ['clearnetShops','clearnetJobs','clearnetEvents','clearnetProjects','clearnetPosts','clearnetAudios','clearnetVideos','clearnetImages','clearnetDocuments','clearnetTorrents','clearnetBookmarks'];
|
||||
const prefs = {
|
||||
activity: rawPrefs.activity === true,
|
||||
device: rawPrefs.device === true,
|
||||
karma: rawPrefs.karma !== false,
|
||||
ubi: rawPrefs.ubi === true,
|
||||
wallet: rawPrefs.wallet === true,
|
||||
ecoTax: rawPrefs.ecoTax !== false,
|
||||
larpSign: rawPrefs.larpSign === true,
|
||||
gpg: rawPrefs.gpg !== false,
|
||||
clearnet: rawPrefs.clearnet === true || clearnetSubKeys.some(k => rawPrefs[k] === true),
|
||||
fediverse: rawPrefs.fediverse === true,
|
||||
fediverseHandle: typeof rawPrefs.fediverseHandle === 'string' ? rawPrefs.fediverseHandle : ''
|
||||
};
|
||||
const gpgFingerprint = String(safe.gpgFingerprint || '');
|
||||
const carbonGrams = typeof safe.carbonGrams === 'number' ? safe.carbonGrams : 0;
|
||||
const larpHouse = (safe.larpHouse && safe.larpHouse.key) ? safe.larpHouse : null;
|
||||
|
||||
const providedBucket = typeof safe.lastActivityBucket === 'string' ? safe.lastActivityBucket : null;
|
||||
const dotClass = providedBucket === 'green' ? 'green' : providedBucket === 'orange' ? 'orange' : 'red';
|
||||
|
||||
const detailNodes = [
|
||||
description ? p(...renderUrl(description)) : null,
|
||||
location ? p(`${i18n.locationLabel}: ${location}`) : null,
|
||||
languages.length ? p(`${i18n.languagesLabel}: ${languages.join(', ').toUpperCase()}`) : null,
|
||||
skills.length ? p(`${i18n.skillsLabel}: ${skills.join(', ')}`) : null,
|
||||
status ? p(`${i18n.statusLabel || 'Status'}: ${status}`) : null,
|
||||
preferences ? p(`${i18n.preferencesLabel || 'Preferences'}: ${preferences}`) : null,
|
||||
createdAt ? p(`${i18n.createdAtLabel || 'Created at'}: ${createdAt}`) : null
|
||||
].filter(Boolean);
|
||||
|
||||
return template(
|
||||
name,
|
||||
section(
|
||||
div({ class: 'tags-header' },
|
||||
h2(title),
|
||||
p(i18n.discoverPeople)
|
||||
),
|
||||
div({ class: 'mode-buttons' },
|
||||
...generateFilterButtons(['all', 'TOP ACTIVITY', 'TOP KARMA', 'TOP ECO', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'], 'all')
|
||||
),
|
||||
div({ class: 'inhabitant-card' },
|
||||
div({ class: 'inhabitant-left' },
|
||||
img({ class: 'inhabitant-photo-details', src: image, alt: name || 'Anonymous' }),
|
||||
h2(name || 'Anonymous'),
|
||||
safe.oasisVersion ? div({ class: 'profile-side-version' }, '🌴 ' + (i18n.oasisVersionLabel || 'Oasis Version') + ': ', strong(safe.oasisVersion)) : null,
|
||||
...renderUserSensors({
|
||||
isMe, fediverseConfigured, prefs, id: id || viewedId,
|
||||
karmaScore, carbonGrams, deviceSource: safe.deviceSource, activityBucket: providedBucket,
|
||||
gpgFingerprint, ecoAddress, estimatedUBI, lastClaimedDate, totalClaimed,
|
||||
larpHouse, stats: safe.stats
|
||||
}, { excludeContent: true }),
|
||||
(!isMe && (id || viewedId))
|
||||
? form(
|
||||
{ method: 'GET', action: '/pm' },
|
||||
input({ type: 'hidden', name: 'recipients', value: id || viewedId }),
|
||||
button({ type: 'submit', class: 'btn' }, i18n.pmCreateButton)
|
||||
)
|
||||
: null
|
||||
),
|
||||
(() => {
|
||||
const feedNodes = feed.length
|
||||
? [ section({ class: 'profile-feed' },
|
||||
h2(i18n.latestInteractions),
|
||||
...feed.map(m => {
|
||||
const raw = (m.value?.content?.text || '').replace(/<br\s*\/?>/g, '');
|
||||
const parts = stripAndCollectImgs(raw);
|
||||
const tid = msgIdOf(m);
|
||||
const visitBtn = tid
|
||||
? form({ method: 'GET', action: `/thread/${encodeURIComponent(tid)}#${encodeURIComponent(tid)}` },
|
||||
button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
|
||||
)
|
||||
: null;
|
||||
return div({ class: 'post' },
|
||||
visitBtn,
|
||||
parts.clean && parts.clean.trim() ? p(...renderUrl(parts.clean)) : null,
|
||||
...(parts.imgs || []).map(src => img({ src, class: 'post-image', alt: 'image' }))
|
||||
);
|
||||
})
|
||||
) ]
|
||||
: [];
|
||||
const contentStatsNode = renderContentStats(safe.stats, i18n);
|
||||
const rightNodes = [...detailNodes, contentStatsNode, ...feedNodes].filter(Boolean);
|
||||
return rightNodes.length ? div({ class: 'inhabitant-details' }, ...rightNodes) : null;
|
||||
})()
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.lastActivityBadge = lastActivityBadge;
|
||||
314
src/views/invites_view.js
Normal file
314
src/views/invites_view.js
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
const { form, button, div, h2, h3, p, section, ul, li, a, br, hr, input, label, span, table, tr, td, textarea } = require("../server/node_modules/hyperaxe");
|
||||
const path = require("path");
|
||||
const fs = require('fs');
|
||||
const { renderUrl } = require("../backend/renderUrl");
|
||||
const { template, i18n } = require('./main_views');
|
||||
|
||||
const homedir = require('os').homedir();
|
||||
const gossipPath = path.join(homedir, ".ssb", "gossip.json");
|
||||
const unfollowedPath = path.join(homedir, ".ssb", "gossip_unfollowed.json");
|
||||
|
||||
const encodePubLink = (key) => {
|
||||
let core = String(key).replace(/^@/, '').replace(/\.ed25519$/, '').replace(/-/g, '+').replace(/_/g, '/');
|
||||
if (!core.endsWith('=')) core += '=';
|
||||
return `/author/${encodeURIComponent('@' + core)}.ed25519`;
|
||||
};
|
||||
|
||||
const snhInvitePath = path.join(__dirname, '..', 'configs', 'snh-invite-code.json');
|
||||
|
||||
let snhInvite = null;
|
||||
try {
|
||||
snhInvite = JSON.parse(fs.readFileSync(snhInvitePath, 'utf8'));
|
||||
} catch {}
|
||||
|
||||
const deduplicateByHost = (list) => {
|
||||
const seen = new Set();
|
||||
return list.filter(p => {
|
||||
const host = (p.host || '').replace(/:\d+$/, '');
|
||||
if (!host || seen.has(host)) return false;
|
||||
seen.add(host);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const invitesView = ({ invitesEnabled, flash }) => {
|
||||
let pubs = [];
|
||||
let pubsValue = "false";
|
||||
let unfollowed = [];
|
||||
|
||||
try {
|
||||
pubs = fs.readFileSync(gossipPath, "utf8");
|
||||
} catch {
|
||||
pubs = '[]';
|
||||
}
|
||||
|
||||
try {
|
||||
pubs = JSON.parse(pubs);
|
||||
pubsValue = Array.isArray(pubs) && pubs.length > 0 ? "true" : "false";
|
||||
} catch {
|
||||
pubsValue = "false";
|
||||
pubs = [];
|
||||
}
|
||||
|
||||
try {
|
||||
unfollowed = JSON.parse(fs.readFileSync(unfollowedPath, "utf8") || "[]");
|
||||
} catch {
|
||||
unfollowed = [];
|
||||
}
|
||||
|
||||
const filteredPubs = pubsValue === "true"
|
||||
? deduplicateByHost(pubs.filter(pubItem => !unfollowed.find(u => u.key === pubItem.key)))
|
||||
: [];
|
||||
|
||||
const hasError = (pubItem) => pubItem && (pubItem.error || (typeof pubItem.failure === 'number' && pubItem.failure > 0));
|
||||
|
||||
const sanitizeError = (err) => {
|
||||
if (!err) return i18n.genericError || 'Unknown error';
|
||||
const firstLine = String(err).split('\n')[0].replace(/^Error:\s*/, '').trim();
|
||||
return firstLine || (i18n.genericError || 'Unknown error');
|
||||
};
|
||||
|
||||
const unreachableLabel = i18n.currentlyUnreachable || i18n.currentlyUnrecheable || 'ERROR!';
|
||||
|
||||
const pubTableHeader = () => tr(
|
||||
td({ class: 'card-label' }, 'PUB'),
|
||||
td({ class: 'card-label' }, i18n.invitesPort || 'Port'),
|
||||
td({ class: 'card-label' }, 'Key'),
|
||||
td({ class: 'card-label' }, '')
|
||||
);
|
||||
|
||||
const activePubs = filteredPubs.filter(pubItem => !hasError(pubItem));
|
||||
const unreachablePubs = pubs.filter(hasError);
|
||||
|
||||
const renderPubTable = (items, actionFn) => table({ class: 'block-info-table invites-pubs-table' },
|
||||
pubTableHeader(),
|
||||
items.map(pubItem => tr(
|
||||
td(pubItem.host || '—'),
|
||||
td(String(pubItem.port || 8008)),
|
||||
td(a({ href: encodePubLink(pubItem.key), class: 'user-link' }, pubItem.key)),
|
||||
td(actionFn(pubItem))
|
||||
))
|
||||
);
|
||||
|
||||
const title = i18n.invites;
|
||||
const description = i18n.invitesDescription;
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: 'tags-header' },
|
||||
h2(title),
|
||||
p(description)
|
||||
)
|
||||
),
|
||||
flash === 'alreadyFederated'
|
||||
? section(div({ class: 'message-banner' }, p(i18n.invitesAlreadyFederated || 'You are already federated with this pub.')))
|
||||
: null,
|
||||
section(
|
||||
div({ class: 'pubs-section' },
|
||||
h2(i18n.invitesPubsTitle),
|
||||
form(
|
||||
{ action: '/settings/invite/accept', method: 'post' },
|
||||
input({ name: 'invite', type: 'text', placeholder: i18n.invitesPubInviteCodePlaceholder, autofocus: true, required: true }),
|
||||
br(),
|
||||
button({ type: 'submit' }, i18n.invitesAcceptInvite)
|
||||
),
|
||||
br(),
|
||||
snhInvite ? div({ class: 'snh-invite-box' },
|
||||
h3({ class: 'snh-invite-name' }, snhInvite.name),
|
||||
p({ class: 'snh-invite-name' }, snhInvite.description),
|
||||
p({ class: 'snh-invite-name' }, renderUrl(snhInvite.url)),
|
||||
snhInvite.createdAt ? p({ class: 'snh-invite-date' }, `${i18n.statsCreatedAt || 'Created'}: ${new Date(snhInvite.createdAt).toLocaleDateString()}`) : null,
|
||||
form({ action: '/settings/invite/accept', method: 'post' },
|
||||
input({ type: 'hidden', name: 'invite', value: snhInvite.code }),
|
||||
button({ type: 'submit', class: 'filter-btn' }, snhInvite.code)
|
||||
)
|
||||
) : null
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: 'federations-section' },
|
||||
h2(i18n.invitesFederationsTitle || 'Federations'),
|
||||
div({ class: 'conn-actions invites-pubs-actions' },
|
||||
form({ action: '/invites/refresh-pubs', method: 'post' }, button({ type: 'submit' }, i18n.invitesPubsRefresh || 'Refresh')),
|
||||
form({ action: '/invites/clear-unreachable', method: 'post' }, button({ type: 'submit' }, i18n.invitesPubsClearUnreachable || 'Remove unreachable')),
|
||||
form({ action: '/invites/export-pubs', method: 'get' }, button({ type: 'submit' }, i18n.invitesPubsExport || 'Export'))
|
||||
),
|
||||
form(
|
||||
{ action: '/invites/import-pubs', method: 'post', enctype: 'multipart/form-data', class: 'peers-import-form' },
|
||||
label({ class: 'peers-import-label' }, i18n.invitesPubsImportTitle || 'Import pubs'),
|
||||
br(),
|
||||
textarea({ name: 'peerList', rows: '4', placeholder: i18n.invitesPubsImportPlaceholder || 'Paste one multiserver address or invite code per line…' }),
|
||||
br(),
|
||||
input({ type: 'file', name: 'peerFile', accept: '.txt,text/plain' }),
|
||||
br(),
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.invitesPubsImport || 'Import')
|
||||
),
|
||||
h2(`${i18n.invitesAcceptedInvites} (${activePubs.length})`),
|
||||
activePubs.length
|
||||
? renderPubTable(activePubs, pubItem =>
|
||||
form({ action: '/settings/invite/unfollow', method: 'post' },
|
||||
input({ type: 'hidden', name: 'key', value: pubItem.key }),
|
||||
button({ type: 'submit' }, i18n.invitesUnfollow)
|
||||
)
|
||||
)
|
||||
: p(i18n.invitesNoFederatedPubs),
|
||||
hr(),
|
||||
h2(`${i18n.invitesUnfollowedInvites} (${unfollowed.length})`),
|
||||
unfollowed.length
|
||||
? renderPubTable(unfollowed, pubItem =>
|
||||
form({ action: '/settings/invite/follow', method: 'post' },
|
||||
input({ type: 'hidden', name: 'key', value: pubItem.key }),
|
||||
input({ type: 'hidden', name: 'host', value: pubItem.host || '' }),
|
||||
input({ type: 'hidden', name: 'port', value: String(pubItem.port || 8008) }),
|
||||
button({ type: 'submit', disabled: hasError(pubItem) }, i18n.invitesFollow)
|
||||
)
|
||||
)
|
||||
: p(i18n.invitesNoUnfollowed),
|
||||
hr(),
|
||||
h2(`${i18n.invitesUnreachablePubs} (${unreachablePubs.length})`),
|
||||
unreachablePubs.length
|
||||
? renderPubTable(unreachablePubs, pubItem =>
|
||||
div({ class: 'error-box' },
|
||||
p({ class: 'error-title' }, i18n.errorDetails),
|
||||
p({ class: 'error-pre' }, sanitizeError(pubItem.error))
|
||||
)
|
||||
)
|
||||
: p(i18n.invitesNoUnreachablePubs)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: 'invites-houses', id: 'invites-houses' },
|
||||
h2(i18n.invitesHousesTitle || 'Houses'),
|
||||
form(
|
||||
{ action: '/larp/invite/redeem', method: 'post' },
|
||||
input({ type: 'hidden', name: 'returnTo', value: '/invites' }),
|
||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesHouseInviteCodePlaceholder || 'House invitation code', required: true, maxlength: 32 }),
|
||||
br(),
|
||||
button({ type: 'submit' }, i18n.invitesHouseJoinButton || 'Join House')
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: 'invites-tribes', id: 'invites-tribes' },
|
||||
h2(i18n.invitesTribesTitle),
|
||||
form(
|
||||
{ action: '/tribes/join-code', method: 'post' },
|
||||
input({ name: 'inviteCode', type: 'text', placeholder: i18n.invitesTribeInviteCodePlaceholder, required: true }),
|
||||
br(),
|
||||
button({ type: 'submit' }, i18n.invitesTribeJoinButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: 'invites-chats', id: 'invites-chats' },
|
||||
h2(i18n.invitesChatsTitle || 'Chats'),
|
||||
form(
|
||||
{ action: '/chats/join-code', method: 'post' },
|
||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesChatInviteCodePlaceholder || 'Enter chat invite code', required: true }),
|
||||
br(),
|
||||
button({ type: 'submit' }, i18n.invitesChatJoinButton || 'Join Chat')
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: 'invites-pads', id: 'invites-pads' },
|
||||
h2(i18n.invitesPadsTitle || 'Pads'),
|
||||
form(
|
||||
{ action: '/pads/join-code', method: 'post' },
|
||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesPadInviteCodePlaceholder || 'Enter pad invite code', required: true }),
|
||||
br(),
|
||||
button({ type: 'submit' }, i18n.invitesPadJoinButton || 'Join Pad')
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: 'invites-calendars', id: 'invites-calendars' },
|
||||
h2(i18n.invitesCalendarsTitle || 'Calendars'),
|
||||
form(
|
||||
{ action: '/calendars/join-code', method: 'post' },
|
||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesCalendarInviteCodePlaceholder || 'Enter calendar invite code', required: true }),
|
||||
br(),
|
||||
button({ type: 'submit' }, i18n.invitesCalendarJoinButton || 'Join Calendar')
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: 'invites-events', id: 'invites-events' },
|
||||
h2(i18n.invitesEventsTitle || 'Events'),
|
||||
form(
|
||||
{ action: '/events/join-code', method: 'post' },
|
||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesEventInviteCodePlaceholder || 'Enter event invite code', required: true }),
|
||||
br(),
|
||||
button({ type: 'submit' }, i18n.invitesEventJoinButton || 'Join Event')
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: 'invites-forums', id: 'invites-forums' },
|
||||
h2(i18n.invitesForumsTitle || 'Forums'),
|
||||
form(
|
||||
{ action: '/forum/join-code', method: 'post' },
|
||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesForumInviteCodePlaceholder || 'Enter forum invite code', required: true }),
|
||||
br(),
|
||||
button({ type: 'submit' }, i18n.invitesForumJoinButton || 'Join Forum')
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: 'invites-maps', id: 'invites-maps' },
|
||||
h2(i18n.invitesMapsTitle || 'Maps'),
|
||||
form(
|
||||
{ action: '/maps/join-code', method: 'post' },
|
||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesMapInviteCodePlaceholder || 'Enter map invite code', required: true }),
|
||||
br(),
|
||||
button({ type: 'submit' }, i18n.invitesMapJoinButton || 'Join Map')
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: 'invites-shops', id: 'invites-shops' },
|
||||
h2(i18n.invitesShopsTitle || 'Shops'),
|
||||
form(
|
||||
{ action: '/shops/join-code', method: 'post' },
|
||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesShopInviteCodePlaceholder || 'Enter shop invite code', required: true }),
|
||||
br(),
|
||||
button({ type: 'submit' }, i18n.invitesShopJoinButton || 'Join Shop')
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: 'invites-inhabitants', id: 'invites-inhabitants' },
|
||||
h2(i18n.invitesInhabitantsTitle || 'Inhabitants'),
|
||||
form(
|
||||
{ action: '/invites/inhabitant/follow', method: 'post' },
|
||||
input({ name: 'feedId', id: 'inh_oasis_id', type: 'text', placeholder: '@...=.ed25519', pattern: '@[A-Za-z0-9+/_\\-]{43}=\\.ed25519', required: true, maxlength: 56 }),
|
||||
br(),
|
||||
button({ type: 'submit' }, i18n.invitesInhabitantsFollow || 'Give Support')
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: 'invites-peers', id: 'invites-peers' },
|
||||
h2(i18n.peers || 'Peers'),
|
||||
p(i18n.directConnectDescription),
|
||||
form({ action: '/peers/connect', method: 'post' },
|
||||
input({ type: 'text', id: 'peer_host', name: 'host', required: true, placeholder: '192.168.1.100', pattern: '(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?)*)', title: i18n.peerHostValidation || 'Valid IPv4 (e.g. 192.168.1.100) or hostname (e.g. pub.example.com)', maxlength: 253 }),
|
||||
br(),
|
||||
label({ for: 'peer_port' }, i18n.peerPort),
|
||||
br(),
|
||||
input({ type: 'number', id: 'peer_port', name: 'port', placeholder: '8008', value: '8008', min: 1, max: 65535, required: true, title: i18n.peerPortValidation || 'Port 1-65535' }),
|
||||
br(), br(),
|
||||
label({ for: 'peer_key' }, i18n.peerPublicKey),
|
||||
br(),
|
||||
input({ type: 'text', id: 'peer_key', name: 'key', required: true, placeholder: '@...=.ed25519', pattern: '@[A-Za-z0-9+/_\\-]{43}=\\.ed25519', title: i18n.peerKeyValidation || 'SSB ed25519 public key (@<44 chars base64>=.ed25519)', maxlength: 56 }),
|
||||
br(), br(),
|
||||
button({ type: 'submit' }, i18n.connectAndFollow)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.invitesView = invitesView;
|
||||
|
||||
781
src/views/jobs_view.js
Normal file
781
src/views/jobs_view.js
Normal file
|
|
@ -0,0 +1,781 @@
|
|||
const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, progress, video, audio, table, tr, td } = require("../server/node_modules/hyperaxe")
|
||||
const { template, i18n, userLink, renderStateChip, renderOpenClosedChip, renderVisibilityChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions } = require("./main_views")
|
||||
const moment = require("../server/node_modules/moment")
|
||||
const { config } = require("../server/SSB_server.js")
|
||||
const { renderUrl } = require("../backend/renderUrl")
|
||||
const { renderMapLocationUrl, renderMapEmbed, renderMapLocationVisitLabel } = require("./maps_view")
|
||||
|
||||
const renderMediaBlob = (value, attrs = {}) => {
|
||||
if (!value) return null
|
||||
const s = String(value).trim()
|
||||
if (!s) return null
|
||||
if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}`, ...attrs })
|
||||
const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
|
||||
if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` })
|
||||
const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
|
||||
if (mAudio) return audio({ controls: true, class: attrs.class || 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` })
|
||||
const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
|
||||
if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, class: attrs.class || 'post-image' })
|
||||
return null
|
||||
}
|
||||
|
||||
const userId = config.keys.id
|
||||
|
||||
const FILTERS = [
|
||||
{ key: "ALL", i18n: "jobsFilterAll", title: "jobsAllTitle" },
|
||||
{ key: "MINE", i18n: "jobsFilterMine", title: "jobsMineTitle" },
|
||||
{ key: "APPLIED", i18n: "jobsFilterApplied", title: "jobsAppliedTitle" },
|
||||
{ key: "REMOTE", i18n: "jobsFilterRemote", title: "jobsRemoteTitle" },
|
||||
{ key: "PRESENCIAL", i18n: "jobsFilterPresencial", title: "jobsPresencialTitle" },
|
||||
{ key: "FREELANCER", i18n: "jobsFilterFreelancer", title: "jobsFreelancerTitle" },
|
||||
{ key: "EMPLOYEE", i18n: "jobsFilterEmployee", title: "jobsEmployeeTitle" },
|
||||
{ key: "EXCHANGE", i18n: "jobsFilterExchange", title: "jobsExchangeTitle" },
|
||||
{ key: "OPEN", i18n: "jobsFilterOpen", title: "jobsOpenTitle" },
|
||||
{ key: "CLOSED", i18n: "jobsFilterClosed", title: "jobsClosedTitle" },
|
||||
{ key: "RECENT", i18n: "jobsFilterRecent", title: "jobsRecentTitle" },
|
||||
{ key: "TOP", i18n: "jobsFilterTop", title: "jobsTopTitle" },
|
||||
{ key: "CV", i18n: "jobsCV", title: "jobsCVTitle" }
|
||||
]
|
||||
|
||||
function resolvePhoto(photoField, size = 256) {
|
||||
if (typeof photoField === "string" && photoField.startsWith("/image/")) return photoField
|
||||
if (typeof photoField === "string" && /^&[A-Za-z0-9+/=]+\.sha256$/.test(photoField)) return `/image/${size}/${encodeURIComponent(photoField)}`
|
||||
return "/assets/images/default-avatar.png"
|
||||
}
|
||||
|
||||
const safeArr = (v) => (Array.isArray(v) ? v : [])
|
||||
const safeText = (v) => String(v || "").trim()
|
||||
|
||||
const parseNum = (v) => {
|
||||
const n = parseFloat(String(v ?? "").replace(",", "."))
|
||||
return Number.isFinite(n) ? n : NaN
|
||||
}
|
||||
|
||||
const fmtSalary = (v) => {
|
||||
const n = parseNum(v)
|
||||
return Number.isFinite(n) ? n.toFixed(6) : String(v ?? "")
|
||||
}
|
||||
|
||||
const buildReturnTo = (filter, params = {}) => {
|
||||
const f = safeText(filter || "ALL")
|
||||
const q = safeText(params.search || params.q || "")
|
||||
const minSalary = params.minSalary ?? ""
|
||||
const maxSalary = params.maxSalary ?? ""
|
||||
const sort = safeText(params.sort || "")
|
||||
const parts = [`filter=${encodeURIComponent(f)}`]
|
||||
if (q) parts.push(`search=${encodeURIComponent(q)}`)
|
||||
if (String(minSalary) !== "") parts.push(`minSalary=${encodeURIComponent(String(minSalary))}`)
|
||||
if (String(maxSalary) !== "") parts.push(`maxSalary=${encodeURIComponent(String(maxSalary))}`)
|
||||
if (sort) parts.push(`sort=${encodeURIComponent(sort)}`)
|
||||
return `/jobs?${parts.join("&")}`
|
||||
}
|
||||
|
||||
const renderPmButton = (recipientId) =>
|
||||
recipientId && String(recipientId) !== String(userId)
|
||||
? form(
|
||||
{ method: "GET", action: "/pm" },
|
||||
input({ type: "hidden", name: "recipients", value: recipientId }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
|
||||
)
|
||||
: null
|
||||
|
||||
const renderTags = (tags = []) => {
|
||||
const arr = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean)
|
||||
return arr.length
|
||||
? div(
|
||||
{ class: "card-tags" },
|
||||
arr.map((tag) => a({ class: "tag-link", href: `/search?query=%23${encodeURIComponent(tag)}` }, `#${tag}`))
|
||||
)
|
||||
: null
|
||||
}
|
||||
|
||||
const renderApplicantsProgress = (subsCount, vacants) => {
|
||||
const s = Math.max(0, Number(subsCount || 0))
|
||||
const v = Math.max(1, Number(vacants || 1))
|
||||
const colorClass = s < v ? "applicants-under" : s === v ? "applicants-at" : "applicants-over"
|
||||
return div(
|
||||
{ class: "confirmations-block" },
|
||||
div(
|
||||
{ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.jobsApplicants}: `),
|
||||
span({ class: `card-value ${colorClass}` }, `${s}/${v}`)
|
||||
),
|
||||
progress({ class: "confirmations-progress", value: s, max: v })
|
||||
)
|
||||
}
|
||||
|
||||
const renderUpdatedLabel = (createdAt, updatedAt) => {
|
||||
const createdTs = createdAt ? new Date(createdAt).getTime() : NaN
|
||||
const updatedTs = updatedAt ? new Date(updatedAt).getTime() : NaN
|
||||
const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs)
|
||||
return showUpdated
|
||||
? span({ class: "votations-comment-date" }, ` | ${i18n.jobsUpdatedAt}: ${moment(updatedAt).format("YYYY/MM/DD HH:mm:ss")}`)
|
||||
: null
|
||||
}
|
||||
|
||||
const renderJobOwnerActions = (job, returnTo) => {
|
||||
const isAuthor = String(job.author) === String(userId)
|
||||
if (!isAuthor) return []
|
||||
const isOpen = String(job.status || "").toUpperCase() === "OPEN"
|
||||
return [
|
||||
form(
|
||||
{ method: "POST", action: `/jobs/status/${encodeURIComponent(job.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "status-btn", type: "submit", name: "status", value: isOpen ? "CLOSED" : "OPEN" }, isOpen ? i18n.jobSetClosed : i18n.jobSetOpen)
|
||||
),
|
||||
form(
|
||||
{ method: "GET", action: `/jobs/edit/${encodeURIComponent(job.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "update-btn", type: "submit" }, i18n.jobsUpdateButton)
|
||||
),
|
||||
form(
|
||||
{ method: "POST", action: `/jobs/delete/${encodeURIComponent(job.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "delete-btn", type: "submit" }, i18n.jobsDeleteButton)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
const renderJobStatusChip = (status) => {
|
||||
const s = String(status || "").toUpperCase()
|
||||
const localized = i18n["jobStatus" + s]
|
||||
return renderOpenClosedChip(s, { statusChipOPEN: localized || "OPEN", statusChipCLOSED: localized || "CLOSED" })
|
||||
}
|
||||
|
||||
const renderJobAppliedChip = () =>
|
||||
renderStateChip("whole", "★", i18n.jobsAppliedBadge)
|
||||
|
||||
const renderJobHiddenChip = () =>
|
||||
renderVisibilityChip("HIDDEN", i18n)
|
||||
|
||||
const renderJobApplyToggle = (job, returnTo) => {
|
||||
const isAuthor = String(job.author) === String(userId)
|
||||
const isOpen = String(job.status || "").toUpperCase() === "OPEN"
|
||||
if (isAuthor || !isOpen) return null
|
||||
const isSubscribed = safeArr(job.subscribers).includes(userId)
|
||||
return isSubscribed
|
||||
? form(
|
||||
{ method: "POST", action: `/jobs/unsubscribe/${encodeURIComponent(job.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.jobUnsubscribeButton)
|
||||
)
|
||||
: form(
|
||||
{ method: "POST", action: `/jobs/subscribe/${encodeURIComponent(job.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.jobSubscribeButton)
|
||||
)
|
||||
}
|
||||
|
||||
const renderJobInfoTable = (job, opts = {}) => {
|
||||
const rows = []
|
||||
const typeLabel = i18n["jobType" + String(job.job_type || "").toUpperCase()] || String(job.job_type || "").toUpperCase()
|
||||
const locLabel = String(job.location || "").toUpperCase()
|
||||
const timeLabel = i18n["jobTime" + String(job.job_time || "").toUpperCase()] || String(job.job_time || "").toUpperCase()
|
||||
const langLabel = String(job.languages || "").toUpperCase()
|
||||
const pushRow = (labelText, valueNode, valueClass) =>
|
||||
rows.push(tr(
|
||||
td({ class: "tribe-info-label" }, labelText),
|
||||
td({ class: `tribe-info-value${valueClass ? " " + valueClass : ""}` }, valueNode)
|
||||
))
|
||||
pushRow(i18n.jobType, typeLabel || "—")
|
||||
pushRow(i18n.jobLocation, locLabel || "—")
|
||||
pushRow(i18n.jobTime, timeLabel || "—")
|
||||
if (String(job.job_type || "").toLowerCase() !== "exchange") {
|
||||
pushRow(i18n.jobSalary, `${fmtSalary(job.salary)} ECO`, "card-salary")
|
||||
}
|
||||
if (opts.includeVacants) pushRow(i18n.jobVacants, String(job.vacants ?? 1))
|
||||
if (job.mapUrl) {
|
||||
const mapNode = renderMapLocationVisitLabel(job.mapUrl)
|
||||
if (mapNode) pushRow(i18n.mapLocationTitle || "Map", mapNode)
|
||||
}
|
||||
return table({ class: "tribe-info-table jobs-info-table" }, ...rows)
|
||||
}
|
||||
|
||||
const renderJobExtraDetails = (job) => {
|
||||
const langLabel = String(job.languages || "").toUpperCase()
|
||||
const isExchange = String(job.job_type || "").toLowerCase() === "exchange"
|
||||
const skill = safeText(job.exchangeSkill)
|
||||
if (!langLabel && !isExchange) return null
|
||||
const rows = []
|
||||
const pushRow = (labelText, valueNode) =>
|
||||
rows.push(tr(
|
||||
td({ class: "tribe-info-label" }, labelText),
|
||||
td({ class: "tribe-info-value" }, valueNode)
|
||||
))
|
||||
if (langLabel) pushRow(i18n.jobLanguages, langLabel)
|
||||
if (isExchange) {
|
||||
pushRow(i18n.jobsHoursOffered || "Hours offered", `${Number(job.hoursOffered) || 0} h`)
|
||||
pushRow(i18n.jobsHoursRequested || "Hours requested", `${Number(job.hoursRequested) || 0} h`)
|
||||
if (skill) pushRow(i18n.jobsExchangeSkill || "Skill", skill)
|
||||
}
|
||||
return table({ class: "tribe-info-table jobs-info-table" }, ...rows)
|
||||
}
|
||||
|
||||
const renderJobList = exports.renderJobList = (jobs, filter, params = {}) => {
|
||||
const list = safeArr(jobs)
|
||||
|
||||
if (!list.length) return p(i18n.noJobsMatch || i18n.noJobsFound)
|
||||
|
||||
return div({ class: "jobs-grid" },
|
||||
list.map((job) => {
|
||||
const subs = safeArr(job.subscribers)
|
||||
const isSubscribed = subs.includes(userId)
|
||||
const heroNode = job.image
|
||||
? div({ class: "tribe-card-image-wrapper" },
|
||||
a({ href: `/jobs/${encodeURIComponent(job.id)}` },
|
||||
renderMediaBlob(job.image, { class: "tribe-card-hero-image" })
|
||||
)
|
||||
)
|
||||
: null
|
||||
const isHidden = String(job.visibility || "PUBLIC").toUpperCase() === "HIDDEN"
|
||||
const chips = [
|
||||
renderJobStatusChip(job.status),
|
||||
isHidden ? renderJobHiddenChip() : null,
|
||||
isSubscribed ? renderJobAppliedChip() : null,
|
||||
renderLifespanChip(job.lifetime, i18n)
|
||||
].filter(Boolean)
|
||||
const isExchange = String(job.job_type || "").toLowerCase() === "exchange"
|
||||
const compensationText = isExchange
|
||||
? `${Number(job.hoursOffered) || 0}h ⇄ ${Number(job.hoursRequested) || 0}h`
|
||||
: `${fmtSalary(job.salary)} ECO`
|
||||
|
||||
const isOwn = job.author && String(job.author) === String(userId)
|
||||
return div({ class: "trending-card job-card" + (isOwn ? " own-content" : "") },
|
||||
div(
|
||||
{ class: "card-header activity-card-header" },
|
||||
span(),
|
||||
renderContentActions(job.id, `/jobs/${encodeURIComponent(job.id)}`)
|
||||
),
|
||||
div({ class: "card-section job-card-body" },
|
||||
heroNode,
|
||||
div({ class: "tribe-card-body" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" },
|
||||
a({ href: `/jobs/${encodeURIComponent(job.id)}` }, safeText(job.title) || i18n.jobsTitle)
|
||||
)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
div({ class: "card-date-highlight" }, compensationText),
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.jobSubscribers}: ${subs.length}`)
|
||||
),
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(job.id, params.spreadMap && params.spreadMap.get(job.id)))
|
||||
)
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const renderJobForm = (job = {}, mode = "create") => {
|
||||
const isEdit = mode === "edit"
|
||||
return div(
|
||||
{ class: "div-center job-form" },
|
||||
form(
|
||||
{
|
||||
action: isEdit ? `/jobs/update/${encodeURIComponent(job.id)}` : "/jobs/create",
|
||||
method: "POST",
|
||||
enctype: "multipart/form-data"
|
||||
},
|
||||
input({ type: "hidden", name: "returnTo", value: "/jobs?filter=MINE" }),
|
||||
label(i18n.jobType),
|
||||
br(),
|
||||
select(
|
||||
{ name: "job_type", required: true },
|
||||
option({ value: "freelancer", selected: job.job_type === "freelancer" ? "selected" : undefined }, i18n.jobTypeFreelance),
|
||||
option({ value: "employee", selected: job.job_type === "employee" ? "selected" : undefined }, i18n.jobTypeSalary),
|
||||
option({ value: "exchange", selected: job.job_type === "exchange" ? "selected" : undefined }, i18n.jobTypeExchange || "Hour exchange")
|
||||
),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.jobTitle),
|
||||
br(),
|
||||
input({ type: "text", name: "title", required: true, placeholder: i18n.jobTitlePlaceholder, value: job.title || "" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.jobImage),
|
||||
br(),
|
||||
input({ type: "file", name: "image" }),
|
||||
br(),
|
||||
job.image ? renderMediaBlob(job.image) : null,
|
||||
br(),
|
||||
label(i18n.jobDescription),
|
||||
br(),
|
||||
textarea({ name: "description", rows: "6", required: true, placeholder: i18n.jobDescriptionPlaceholder }, job.description || ""),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.jobRequirements),
|
||||
br(),
|
||||
textarea({ name: "requirements", rows: "6", placeholder: i18n.jobRequirementsPlaceholder }, job.requirements || ""),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.jobsTagsLabel),
|
||||
br(),
|
||||
input({ type: "text", name: "tags", value: Array.isArray(job.tags) ? job.tags.join(", ") : (job.tags || "") }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.jobLanguages),
|
||||
br(),
|
||||
input({ type: "text", name: "languages", placeholder: i18n.jobLanguagesPlaceholder, value: job.languages || "" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.jobTime),
|
||||
br(),
|
||||
select(
|
||||
{ name: "job_time", required: true },
|
||||
option({ value: "partial", selected: job.job_time === "partial" }, i18n.jobTimePartial),
|
||||
option({ value: "complete", selected: job.job_time === "complete" }, i18n.jobTimeComplete)
|
||||
),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.jobTasks),
|
||||
br(),
|
||||
textarea({ name: "tasks", rows: "6", placeholder: i18n.jobTasksPlaceholder }, job.tasks || ""),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.jobLocation),
|
||||
br(),
|
||||
select(
|
||||
{ name: "location", required: true },
|
||||
option({ value: "remote", selected: job.location === "remote" }, i18n.jobLocationRemote),
|
||||
option({ value: "presencial", selected: job.location === "presencial" }, i18n.jobLocationPresencial)
|
||||
),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.mapLocationTitle || "Map Location"),
|
||||
br(),
|
||||
input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: job.mapUrl || "" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.visibilityLabel || "Visibility"),
|
||||
br(),
|
||||
select(
|
||||
{ name: "visibility" },
|
||||
option({ value: "PUBLIC", selected: (job.visibility || "PUBLIC") === "PUBLIC" }, i18n.visibilityPublic || "Public"),
|
||||
option({ value: "HIDDEN", selected: job.visibility === "HIDDEN" }, i18n.visibilityHidden || "Hidden")
|
||||
),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.jobVacants),
|
||||
br(),
|
||||
input({ type: "number", name: "vacants", min: "1", placeholder: i18n.jobVacantsPlaceholder, value: job.vacants || 1, required: true }),
|
||||
br(),
|
||||
br(),
|
||||
String(job.job_type || "").toLowerCase() !== "exchange"
|
||||
? [
|
||||
label(i18n.jobSalary),
|
||||
br(),
|
||||
input({ type: "number", name: "salary", step: "0.000001", min: "0", value: job.salary || "" }),
|
||||
br(),
|
||||
br()
|
||||
]
|
||||
: null,
|
||||
String(job.job_type || "").toLowerCase() === "exchange"
|
||||
? [
|
||||
label(i18n.jobsHoursOffered || "Hours offered"),
|
||||
br(),
|
||||
input({ type: "number", name: "hoursOffered", step: "0.5", min: "0", value: job.hoursOffered || "" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.jobsHoursRequested || "Hours requested in return"),
|
||||
br(),
|
||||
input({ type: "number", name: "hoursRequested", step: "0.5", min: "0", value: job.hoursRequested || "" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.jobsExchangeSkill || "Skill wanted in exchange"),
|
||||
br(),
|
||||
input({ type: "text", name: "exchangeSkill", value: job.exchangeSkill || "" }),
|
||||
br(),
|
||||
br()
|
||||
]
|
||||
: null,
|
||||
button({ type: "submit" }, isEdit ? i18n.jobsUpdateButton : i18n.createJobButton)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const renderCVList = (inhabitants) =>
|
||||
div(
|
||||
{ class: "cv-list" },
|
||||
safeArr(inhabitants).length
|
||||
? safeArr(inhabitants).map((user) => {
|
||||
const isMe = String(user.id) === String(userId)
|
||||
return div(
|
||||
{ class: "inhabitant-card" },
|
||||
div(
|
||||
{ class: "inhabitant-left" },
|
||||
a({ href: `/author/${encodeURIComponent(user.id)}` },
|
||||
img({ class: "inhabitant-photo", src: resolvePhoto(user.photo) })
|
||||
),
|
||||
h2(user.name),
|
||||
user.id
|
||||
? a({ href: `/author/${encodeURIComponent(user.id)}`, class: 'inhabitant-qr-link' },
|
||||
img({ class: 'inhabitant-qr-small', src: `/qr/${encodeURIComponent(user.id)}?size=96`, alt: 'QR' }))
|
||||
: null
|
||||
),
|
||||
div(
|
||||
{ class: "inhabitant-details" },
|
||||
user.description ? p(...renderUrl(user.description)) : null,
|
||||
p(userLink(user.id)),
|
||||
div(
|
||||
{ class: "cv-actions" },
|
||||
form({ method: "GET", action: `/inhabitant/${encodeURIComponent(user.id)}` }, button({ type: "submit", class: "filter-btn" }, i18n.inhabitantviewDetails)),
|
||||
!isMe ? renderPmButton(user.id) : null
|
||||
)
|
||||
)
|
||||
)
|
||||
})
|
||||
: p({ class: "no-results" }, i18n.noInhabitantsFound)
|
||||
)
|
||||
|
||||
exports.jobsView = async (jobsOrCVs, filter = "ALL", params = {}) => {
|
||||
const search = safeText(params.search || "")
|
||||
const minSalary = params.minSalary ?? ""
|
||||
const maxSalary = params.maxSalary ?? ""
|
||||
const sort = safeText(params.sort || "recent")
|
||||
|
||||
const filterObj = FILTERS.find((f) => f.key === filter) || FILTERS[0]
|
||||
const sectionTitle = i18n[filterObj.title] || i18n.jobsTitle
|
||||
const { renderReachChip: renderReachChipJobs } = require('./clearnet_view');
|
||||
const viewerClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetJobs)
|
||||
|
||||
return template(
|
||||
i18n.jobsTitle,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(sectionTitle),
|
||||
p(i18n.jobsDescription)
|
||||
),
|
||||
div({ class: "shop-title-row" }, renderReachChipJobs(viewerClearnet, i18n)),
|
||||
br(),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/jobs", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "search", value: search }),
|
||||
input({ type: "hidden", name: "minSalary", value: String(minSalary ?? "") }),
|
||||
input({ type: "hidden", name: "maxSalary", value: String(maxSalary ?? "") }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
...FILTERS.map((f) =>
|
||||
button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, i18n[f.i18n])
|
||||
),
|
||||
button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.jobsCreateJob)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
filter === "CV"
|
||||
? section(
|
||||
form(
|
||||
{ method: "GET", action: "/jobs", class: "cv-filter-form" },
|
||||
input({ type: "hidden", name: "filter", value: "CV" }),
|
||||
input({ type: "text", name: "location", placeholder: i18n.filterLocation, value: params.location || "" }),
|
||||
input({ type: "text", name: "language", placeholder: i18n.filterLanguage, value: params.language || "" }),
|
||||
input({ type: "text", name: "skills", placeholder: i18n.filterSkills, value: params.skills || "" }),
|
||||
div({ class: "cv-filter-submit" },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.applyFilters)
|
||||
)
|
||||
),
|
||||
br(),
|
||||
renderCVList(jobsOrCVs)
|
||||
)
|
||||
: filter === "CREATE" || filter === "EDIT"
|
||||
? (() => {
|
||||
const jobToEdit = filter === "EDIT" ? (Array.isArray(jobsOrCVs) ? jobsOrCVs[0] : {}) : {}
|
||||
return renderJobForm(jobToEdit, filter === "EDIT" ? "edit" : "create")
|
||||
})()
|
||||
: section(
|
||||
div(
|
||||
{ class: "jobs-search" },
|
||||
form(
|
||||
{ method: "GET", action: "/jobs", class: "filter-box" },
|
||||
input({ type: "hidden", name: "filter", value: filter || "ALL" }),
|
||||
input({ type: "text", name: "search", value: search, placeholder: i18n.jobsSearchPlaceholder, class: "filter-box__input" }),
|
||||
div(
|
||||
{ class: "filter-box__controls" },
|
||||
div(
|
||||
{ class: "transfer-range" },
|
||||
input({ type: "number", name: "minSalary", step: "0.000001", min: "0", value: String(minSalary ?? ""), placeholder: i18n.jobsMinSalaryLabel, class: "filter-box__number transfer-amount-input" }),
|
||||
input({ type: "number", name: "maxSalary", step: "0.000001", min: "0", value: String(maxSalary ?? ""), placeholder: i18n.jobsMaxSalaryLabel, class: "filter-box__number transfer-amount-input" })
|
||||
),
|
||||
select(
|
||||
{ name: "sort", class: "filter-box__select" },
|
||||
option({ value: "recent", selected: sort === "recent" }, i18n.jobsSortRecent),
|
||||
option({ value: "salary", selected: sort === "salary" }, i18n.jobsSortSalary),
|
||||
option({ value: "subscribers", selected: sort === "subscribers" }, i18n.jobsSortSubscribers)
|
||||
),
|
||||
button({ type: "submit", class: "filter-box__button" }, i18n.jobsSearchButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
br(),
|
||||
div({ class: "jobs-list" }, renderJobList(jobsOrCVs, filter, { ...params, search, minSalary, maxSalary, sort }))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const renderJobCommentsSection = (jobId, returnTo, comments = []) => {
|
||||
const list = safeArr(comments).filter(c => {
|
||||
const t = c && c.value && c.value.content && c.value.content.text
|
||||
return t && String(t).trim()
|
||||
})
|
||||
const commentsCount = list.length
|
||||
|
||||
return div(
|
||||
{ id: "comments", class: "vote-comments-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
|
||||
span({ class: "card-value" }, String(commentsCount))
|
||||
),
|
||||
div(
|
||||
{ class: "comment-form-wrapper" },
|
||||
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
|
||||
form(
|
||||
{ method: "POST", action: `/jobs/${encodeURIComponent(jobId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
textarea({ id: "comment-text", name: "text", rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }),
|
||||
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
|
||||
br(),
|
||||
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
|
||||
)
|
||||
),
|
||||
list.length
|
||||
? div(
|
||||
{ class: "comments-list" },
|
||||
list.map((c) => {
|
||||
const author = c?.value?.author || ""
|
||||
const ts = c?.value?.timestamp || c?.timestamp
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""
|
||||
const relDate = ts ? moment(ts).fromNow() : ""
|
||||
const userName = author && author.includes("@") ? author.split("@")[1] : author
|
||||
const rootId = c?.value?.content ? (c.value.content.fork || c.value.content.root) : null
|
||||
const text = c?.value?.content?.text || ""
|
||||
|
||||
return div(
|
||||
{ class: "votations-comment-card" },
|
||||
span(
|
||||
{ class: "created-at" },
|
||||
span(i18n.createdBy),
|
||||
author ? userLink(author) : span("(unknown)"),
|
||||
absDate ? span(" | ") : "",
|
||||
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
|
||||
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
|
||||
relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : ""
|
||||
),
|
||||
p({ class: "votations-comment-text" }, ...renderUrl(text))
|
||||
)
|
||||
})
|
||||
)
|
||||
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
|
||||
)
|
||||
}
|
||||
|
||||
const renderCandidates = (candidates, jobId) => {
|
||||
if (!Array.isArray(candidates) || candidates.length === 0) return null;
|
||||
return div(
|
||||
{ class: "job-candidates" },
|
||||
h2(i18n.jobsCandidatesTitle || "Suggested candidates"),
|
||||
p(i18n.jobsCandidatesDescription || "Inhabitants with skills matching your job. Send them a private message to invite them."),
|
||||
div(
|
||||
{ class: "inhabitants-list" },
|
||||
candidates.map(c => div(
|
||||
{ class: "inhabitant-card" },
|
||||
div(
|
||||
{ class: "inhabitant-left" },
|
||||
a({ href: `/author/${encodeURIComponent(c.id)}` },
|
||||
img({ class: "inhabitant-photo", src: resolvePhoto(c.photo) })
|
||||
),
|
||||
h2(c.name || 'Anonymous'),
|
||||
c.id
|
||||
? a({ href: `/author/${encodeURIComponent(c.id)}`, class: 'inhabitant-qr-link' },
|
||||
img({ class: 'inhabitant-qr-small', src: `/qr/${encodeURIComponent(c.id)}?size=96`, alt: 'QR' }))
|
||||
: null
|
||||
),
|
||||
div(
|
||||
{ class: "inhabitant-details" },
|
||||
c.description ? p(...renderUrl(c.description)) : null,
|
||||
p(userLink(c.id)),
|
||||
div({ class: "matchskills" },
|
||||
p(`${i18n.matchScore || 'Match score'}: ${Math.round(c.matchScore * 100)}%`),
|
||||
p(`${i18n.commonSkills || 'Common skills'}: ${(c.commonSkills || []).join(', ')}`)
|
||||
),
|
||||
c.location ? p(`${i18n.locationLabel || 'Location'}: ${c.location}`) : null,
|
||||
c.status ? p(`${i18n.statusLabel || 'Status'}: ${c.status}`) : null,
|
||||
c.preferences ? p(`${i18n.preferencesLabel || 'Preferences'}: ${c.preferences}`) : null,
|
||||
div({ class: "cv-actions" },
|
||||
form({ method: 'GET', action: `/inhabitant/${encodeURIComponent(c.id)}` },
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.inhabitantviewDetails)
|
||||
),
|
||||
form({ method: 'GET', action: '/pm' },
|
||||
input({ type: 'hidden', name: 'recipients', value: c.id }),
|
||||
input({ type: 'hidden', name: 'subject', value: `${i18n.jobsTitle || 'Job'}: ${job.title || ''}`.slice(0, 150) }),
|
||||
input({ type: 'hidden', name: 'text', value: `${i18n.jobsCandidatesPmBody || 'Hi, I think your profile matches my job opening'}: /jobs/${jobId}` }),
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.pmCreateButton || 'Send PM')
|
||||
)
|
||||
)
|
||||
)
|
||||
))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {}) => {
|
||||
const returnTo = safeText(params.returnTo) || buildReturnTo(filter, params)
|
||||
const subs = safeArr(job.subscribers)
|
||||
const tagsNode = renderTags(job.tags)
|
||||
const isAuthor = String(job.author) === String(userId)
|
||||
const isSubscribed = subs.includes(userId)
|
||||
const { renderReachChip } = require('./clearnet_view')
|
||||
const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetJobs && String(job.status || '').toUpperCase() !== 'CLOSED' && String(job.visibility || 'PUBLIC').toUpperCase() !== 'HIDDEN')
|
||||
const candidatesBlock = isAuthor ? renderCandidates(params.candidates || [], job.id) : null
|
||||
|
||||
const ownerActions = renderJobOwnerActions(job, returnTo)
|
||||
const applyToggle = renderJobApplyToggle(job, returnTo)
|
||||
const pmBtn = renderPmButton(job.author)
|
||||
const visibility = (job.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC'
|
||||
const chips = [
|
||||
renderJobStatusChip(job.status),
|
||||
visibility === 'HIDDEN' ? renderJobHiddenChip() : null,
|
||||
isSubscribed ? renderJobAppliedChip() : null,
|
||||
renderLifespanChip(job.lifetime, i18n),
|
||||
renderEcoTax(job.msgSize, job.id),
|
||||
renderReachChip(isClearnet, i18n)
|
||||
].filter(Boolean)
|
||||
|
||||
const nextVisibility = visibility === 'PUBLIC' ? 'HIDDEN' : 'PUBLIC'
|
||||
const visibilityRow = isAuthor
|
||||
? div({ class: "tribe-side-actions" },
|
||||
span({ class: "card-label" }, `${i18n.visibilityLabel || 'Visibility'}: `),
|
||||
renderVisibilityChip(visibility, i18n),
|
||||
form({ method: "POST", action: `/jobs/visibility/${encodeURIComponent(job.id)}`, class: "inline-form" },
|
||||
input({ type: "hidden", name: "visibility", value: nextVisibility }),
|
||||
button({ type: "submit", class: "filter-btn" },
|
||||
nextVisibility === 'PUBLIC' ? (i18n.visibilityMakePublic || 'Make public') : (i18n.visibilityMakeHidden || 'Make hidden')
|
||||
)
|
||||
)
|
||||
)
|
||||
: null
|
||||
|
||||
const sideActions = []
|
||||
if (applyToggle) sideActions.push(applyToggle)
|
||||
if (pmBtn) sideActions.push(pmBtn)
|
||||
for (const a of ownerActions) sideActions.push(a)
|
||||
|
||||
const jobSide = div({ class: "tribe-side" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, safeText(job.title) || i18n.jobsTitle)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(job.id, params.spreads)),
|
||||
job.image
|
||||
? renderMediaBlob(job.image, { class: "tribe-detail-image" })
|
||||
: null,
|
||||
renderJobInfoTable(job, { includeVacants: true, includeSubscribers: false }),
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.jobSubscribers}: ${subs.length}`)
|
||||
),
|
||||
sideActions.length
|
||||
? div({ class: "tribe-side-actions" }, ...sideActions)
|
||||
: null,
|
||||
visibilityRow,
|
||||
tagsNode
|
||||
)
|
||||
|
||||
const renderJobSection = (titleText, bodyText) =>
|
||||
safeText(bodyText)
|
||||
? div({ class: "job-section" },
|
||||
h2({ class: "job-section-title" }, titleText),
|
||||
p({ class: "tribe-side-description" }, ...renderUrl(bodyText))
|
||||
)
|
||||
: null
|
||||
|
||||
const extraDetailsTable = renderJobExtraDetails(job)
|
||||
const jobMain = div({ class: "tribe-main" },
|
||||
renderJobSection(i18n.jobDescription, job.description),
|
||||
renderJobSection(i18n.jobRequirements, job.requirements),
|
||||
extraDetailsTable ? div({ class: "job-section" }, extraDetailsTable) : null,
|
||||
renderJobSection(i18n.jobTasks, job.tasks),
|
||||
job.mapUrl ? div({ class: "job-section" }, renderMapEmbed(params.mapData, job.mapUrl)) : null,
|
||||
p({ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${moment(job.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
|
||||
userLink(job.author),
|
||||
renderUpdatedLabel(job.createdAt, job.updatedAt)
|
||||
),
|
||||
candidatesBlock ? candidatesBlock : null,
|
||||
renderJobCommentsSection(job.id, returnTo, comments)
|
||||
)
|
||||
|
||||
return template(
|
||||
i18n.jobsTitle,
|
||||
section(
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/jobs", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "search", value: safeText(params.search || "") }),
|
||||
input({ type: "hidden", name: "minSalary", value: String(params.minSalary ?? "") }),
|
||||
input({ type: "hidden", name: "maxSalary", value: String(params.maxSalary ?? "") }),
|
||||
input({ type: "hidden", name: "sort", value: safeText(params.sort || "recent") }),
|
||||
...FILTERS.map((f) =>
|
||||
button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, i18n[f.i18n])
|
||||
),
|
||||
button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.jobsCreateJob)
|
||||
)
|
||||
),
|
||||
div({ class: "tribe-details" }, jobSide, jobMain)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
exports.clearnetJobView = async (job) => {
|
||||
const { escapeHtml: esc, blobUrl: cnBlob, renderClearnetPage } = require('./clearnet_view');
|
||||
const title = esc(job.title || 'Job');
|
||||
const desc = esc(job.description || '');
|
||||
const req = esc(job.requirements || '');
|
||||
const lang = esc(String(job.languages || '').toUpperCase());
|
||||
const loc = esc(String(job.location || '').toUpperCase());
|
||||
const jobType = String(job.job_type || '').toLowerCase();
|
||||
const jobTypeLabel = jobType === 'exchange' ? 'Hour exchange' : jobType === 'employee' ? 'Employee' : 'Freelancer';
|
||||
let compensation = '';
|
||||
if (jobType === 'exchange') {
|
||||
compensation = `${Number(job.hoursOffered || 0)}h offered · ${Number(job.hoursRequested || 0)}h requested`;
|
||||
if (job.exchangeSkill) compensation += ` · ${esc(job.exchangeSkill)}`;
|
||||
} else {
|
||||
compensation = `${parseFloat(job.salary || 0).toFixed(2)} ECO`;
|
||||
}
|
||||
const jobImg = cnBlob(job.image);
|
||||
const extraCss = `
|
||||
.cn-job-title{color:var(--fg);margin:0 0 16px 0;font-size:32px;font-weight:700}
|
||||
.cn-job-meta{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:20px}
|
||||
.cn-job-meta-item{background:var(--bg-sub);border:1px solid var(--border);border-radius:6px;padding:8px 14px;font-size:14px;color:var(--fg-soft);display:inline-flex;align-items:center;gap:6px}
|
||||
.cn-job-comp{background:var(--bg-sub);border:1px solid var(--fg);color:var(--fg);padding:8px 16px;border-radius:6px;font-weight:600;display:inline-block;margin-bottom:20px}
|
||||
.cn-job-img{display:block;max-width:100%;border:1px solid var(--border);border-radius:8px;margin-bottom:20px}
|
||||
.cn-job-section h2{color:var(--fg);font-size:18px;text-transform:uppercase;letter-spacing:2px;margin:24px 0 10px;padding-bottom:6px;border-bottom:1px solid var(--border)}
|
||||
.cn-job-section p{color:var(--fg-soft);white-space:pre-wrap;line-height:1.6;font-size:15px}
|
||||
`;
|
||||
const body = `
|
||||
<h1 class="cn-job-title">${title}</h1>
|
||||
<div class="cn-job-meta">
|
||||
<span class="cn-job-meta-item">💼 ${jobTypeLabel}</span>
|
||||
${job.createdAt ? `<span class="cn-job-meta-item">📅 ${esc(new Date(job.createdAt).toISOString().slice(0,10))}</span>` : ''}
|
||||
${loc ? `<span class="cn-job-meta-item">📍 ${loc}</span>` : ''}
|
||||
${lang ? `<span class="cn-job-meta-item">🗣 ${lang}</span>` : ''}
|
||||
</div>
|
||||
<div class="cn-job-comp">${compensation}</div>
|
||||
${jobImg ? `<img class="cn-job-img" src="${jobImg}" alt="${title}"/>` : ''}
|
||||
${desc ? `<div class="cn-job-section"><h2>Description</h2><p>${desc}</p></div>` : ''}
|
||||
${req ? `<div class="cn-job-section"><h2>Requirements</h2><p>${req}</p></div>` : ''}
|
||||
`;
|
||||
return renderClearnetPage({
|
||||
title: `${job.title || 'Job'} — Oasis`,
|
||||
ogTitle: job.title || 'Job',
|
||||
ogDescription: job.description || '',
|
||||
ogImage: jobImg,
|
||||
extraCss,
|
||||
body,
|
||||
hubFeedId: job.author || null
|
||||
});
|
||||
};
|
||||
|
||||
434
src/views/larp_view.js
Normal file
434
src/views/larp_view.js
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
const { div, h1, h2, h3, p, section, button, form, input, span, img, a, br, table, tr, td, textarea, label, strong } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, userLink } = require("./main_views");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
|
||||
const fmtCycle = (c) => c && c.formatted ? c.formatted : '';
|
||||
|
||||
const houseImageSrc = (house) => (house && house.image) ? house.image : '/assets/larp/images/default.jpg';
|
||||
|
||||
const renderCycleBanner = (cycle) => div({ class: 'larp-cycle-banner' },
|
||||
span({ class: 'larp-cycle-label' }, i18n.larpCycleLabel || 'Cycle'),
|
||||
span({ class: 'larp-cycle-value' }, fmtCycle(cycle))
|
||||
);
|
||||
|
||||
const renderHouseBadges = ({ myHouse, governingHouse }) => div({ class: 'larp-house-badges' },
|
||||
myHouse ? div({ class: 'larp-my-house' },
|
||||
span({ class: 'larp-my-house-label' }, i18n.larpMyHouse || 'My House'),
|
||||
a({ href: `/larp/${myHouse.key}`, class: 'larp-my-house-link' }, myHouse.name)
|
||||
) : null,
|
||||
governingHouse ? div({ class: 'larp-my-house' },
|
||||
span({ class: 'larp-my-house-label' }, i18n.larpGoverning || 'Governing'),
|
||||
a({ href: `/larp/${governingHouse.key}`, class: 'larp-my-house-link' }, governingHouse.name)
|
||||
) : null
|
||||
);
|
||||
|
||||
const renderHouseNav = (houses, currentKey) => div({ class: 'larp-house-nav' },
|
||||
span({ class: 'larp-house-nav-label' }, i18n.larpAllHouses || 'Houses:'),
|
||||
houses.map(h => a({
|
||||
href: `/larp/${h.key}`,
|
||||
class: h.key === currentKey ? 'larp-house-nav-chip active' : 'larp-house-nav-chip',
|
||||
title: h.name
|
||||
}, h.name))
|
||||
);
|
||||
|
||||
const renderHousePanel = (house, members, posts, { canPost, viewerHouseKey, cyclesUntilRuling }) => {
|
||||
const { renderEncryptedChip } = require('./clearnet_view');
|
||||
const isInHouse = viewerHouseKey === house.key;
|
||||
const isInLarp = viewerHouseKey !== null && viewerHouseKey !== undefined;
|
||||
const isMemberE2E = isInHouse && house.key !== 'academia';
|
||||
const isAcademia = house.key === 'academia';
|
||||
const canInvite = isInHouse && !isAcademia;
|
||||
const canLeaveToAcademia = isInHouse && !isAcademia;
|
||||
const canLeaveLarp = isInHouse && isAcademia;
|
||||
const canJoinAcademia = !isInLarp && isAcademia;
|
||||
const showInviteForm = !isAcademia && !isInHouse && (viewerHouseKey === 'academia' || !isInLarp);
|
||||
return div({ class: 'larp-detail' },
|
||||
div({ class: 'larp-detail-image-col' },
|
||||
img({ src: houseImageSrc(house), alt: house.name, class: 'larp-detail-image' })
|
||||
),
|
||||
div({ class: 'larp-detail-body' },
|
||||
div({ class: 'title-with-chip' },
|
||||
h2({ class: 'larp-detail-name' }, house.name),
|
||||
(cyclesUntilRuling === 0 && !isAcademia)
|
||||
? span({ class: 'larp-badge larp-badge-ruling' }, i18n.larpBadgeRuling || 'Ruling')
|
||||
: null,
|
||||
isMemberE2E ? renderEncryptedChip(i18n) : null
|
||||
),
|
||||
p({ class: 'larp-detail-motto' }, '“' + house.motto + '”'),
|
||||
table({ class: 'larp-info-table' },
|
||||
tr(td({ class: 'card-label' }, i18n.larpRolesLabel || 'Roles'), td({ class: 'card-value' }, house.roles)),
|
||||
tr(td({ class: 'card-label' }, i18n.larpFunctionLabel || 'Function'), td({ class: 'card-value' }, house.function)),
|
||||
tr(td({ class: 'card-label' }, i18n.larpMonthLabel || 'Governance cycle'), td({ class: 'card-value' }, String(house.month || ''))),
|
||||
(typeof cyclesUntilRuling === 'number' && cyclesUntilRuling > 0)
|
||||
? tr(td({ class: 'card-label' }, i18n.larpCyclesUntilRuling || 'Next governance cycle'), td({ class: 'card-value' }, String(cyclesUntilRuling)))
|
||||
: null,
|
||||
tr(td({ class: 'card-label' }, i18n.larpMembersCount || 'Members'), td({ class: 'card-value' }, String(Array.isArray(members) ? members.length : (house.memberCount || 0))))
|
||||
),
|
||||
p({ class: 'larp-detail-description' }, house.description),
|
||||
div({ class: 'larp-actions' },
|
||||
isInHouse
|
||||
? a({ href: `/larp/tribe/${encodeURIComponent(house.key)}`, class: 'filter-btn' }, i18n.larpVisitTribe || 'Visit Tribe')
|
||||
: null,
|
||||
canInvite
|
||||
? form({ method: 'POST', action: '/larp/invite/create', class: 'larp-invite-create-form' },
|
||||
input({ type: 'hidden', name: 'house', value: house.key }),
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.larpInviteCreate || 'Generate invitation code')
|
||||
)
|
||||
: null,
|
||||
canLeaveToAcademia
|
||||
? form({ method: 'POST', action: '/larp/join' },
|
||||
input({ type: 'hidden', name: 'house', value: 'academia' }),
|
||||
button({ type: 'submit', class: 'filter-btn danger-btn' }, i18n.larpLeaveToAcademia || 'Leave House')
|
||||
)
|
||||
: null,
|
||||
canLeaveLarp
|
||||
? form({ method: 'POST', action: '/larp/leave' },
|
||||
button({ type: 'submit', class: 'filter-btn danger-btn' }, i18n.larpLeaveToAcademia || 'Leave House')
|
||||
)
|
||||
: null,
|
||||
canJoinAcademia
|
||||
? form({ method: 'POST', action: '/larp/join' },
|
||||
input({ type: 'hidden', name: 'house', value: 'academia' }),
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.larpInviteRedeem || 'Join House')
|
||||
)
|
||||
: null,
|
||||
showInviteForm
|
||||
? form({ method: 'POST', action: '/larp/invite/redeem', class: 'larp-card-invite-form' },
|
||||
input({ type: 'hidden', name: 'returnTo', value: `/larp/${house.key}` }),
|
||||
input({ type: 'text', name: 'code', placeholder: i18n.larpInvitePlaceholder || 'Invitation code', maxlength: '32', required: 'required' }),
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.larpInviteRedeem || 'Join House')
|
||||
)
|
||||
: null
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const formatCooldown = (ms) => {
|
||||
if (ms <= 0) return '';
|
||||
const days = Math.ceil(ms / (24 * 60 * 60 * 1000));
|
||||
return String(days);
|
||||
};
|
||||
|
||||
const renderAcademiaJoinPanel = (allHouses, testStatus, housesById, questions, myHouseKey) => {
|
||||
const myKey = myHouseKey || 'academia';
|
||||
const ordered = [
|
||||
...allHouses.filter(h => h.key === myKey),
|
||||
...allHouses.filter(h => h.key !== myKey)
|
||||
];
|
||||
const last = testStatus && testStatus.last ? testStatus.last : null;
|
||||
const lastHouse = last && housesById ? housesById[last.house] : null;
|
||||
const lastHouseName = lastHouse ? lastHouse.name : (last ? last.house : null);
|
||||
const remainingMs = testStatus && !testStatus.allowed ? (testStatus.nextAt - Date.now()) : 0;
|
||||
const canTake = testStatus && testStatus.allowed;
|
||||
const qs = Array.isArray(questions) ? questions : [];
|
||||
return div({ class: 'larp-academia-join' },
|
||||
h2(i18n.larpAcademiaJoinTitle || 'Houses Current Status'),
|
||||
last
|
||||
? div({ class: 'larp-last-attempt' },
|
||||
h2({ class: 'larp-last-attempt-title' }, i18n.larpLastAttemptTitle || 'Your last attempt'),
|
||||
table({ class: 'larp-info-table' },
|
||||
tr(
|
||||
td({ class: 'card-label' }, i18n.larpLastAttemptHouse || 'House'),
|
||||
td({ class: 'card-value' }, lastHouse
|
||||
? a({ href: `/larp/${lastHouse.key}` }, lastHouseName)
|
||||
: lastHouseName || '—')
|
||||
),
|
||||
Number.isFinite(last.score)
|
||||
? tr(
|
||||
td({ class: 'card-label' }, i18n.larpTestScore || 'Score'),
|
||||
td({ class: 'card-value' }, String(last.score))
|
||||
)
|
||||
: null,
|
||||
last.ts
|
||||
? tr(
|
||||
td({ class: 'card-label' }, i18n.larpLastAttemptWhen || 'When'),
|
||||
td({ class: 'card-value' }, moment(last.ts).format('YYYY/MM/DD HH:mm:ss'))
|
||||
)
|
||||
: null,
|
||||
!testStatus.allowed
|
||||
? tr(
|
||||
td({ class: 'card-label' }, i18n.larpLastAttemptCooldown || 'Next attempt in'),
|
||||
td({ class: 'card-value' }, formatCooldown(remainingMs) + ' ' + (i18n.larpTestCooldownDays || 'cycles'))
|
||||
)
|
||||
: null
|
||||
)
|
||||
)
|
||||
: (testStatus && !testStatus.allowed
|
||||
? p({ class: 'larp-test-cooldown-banner' },
|
||||
(i18n.larpTestCooldownActive || 'Next test available in') + ' ' + formatCooldown(remainingMs) + ' ' + (i18n.larpTestCooldownDays || 'cycles')
|
||||
)
|
||||
: null),
|
||||
div({ class: 'larp-academia-grid' },
|
||||
ordered.map(h => a({ href: `/larp/${h.key}`, class: h.key === myKey ? 'larp-academia-thumb larp-academia-thumb-mine' : 'larp-academia-thumb' },
|
||||
img({ src: houseImageSrc(h), alt: h.name, class: 'larp-academia-thumb-image' }),
|
||||
span({ class: 'larp-academia-thumb-name' }, h.name)
|
||||
))
|
||||
),
|
||||
canTake && qs.length
|
||||
? [
|
||||
h2(i18n.larpWillTestTitle || 'WILL test'),
|
||||
p({ class: 'larp-will-test-hint' }, i18n.larpWillTestHint || 'Once completed, you will be assigned the house that best matches your answers. Your individual answers are processed locally and never stored — only the resulting house assignment is published to your feed.'),
|
||||
form({ method: 'POST', action: '/larp/test', class: 'larp-test-form larp-test-form-compact' },
|
||||
qs.map((q, idx) => div({ class: 'larp-test-question' },
|
||||
p({ class: 'larp-test-q-text' }, strong(`${idx + 1}. `), i18n[q.key] || q.question),
|
||||
div({ class: 'larp-test-options' },
|
||||
q.options.map((opt, oi) => label({ class: 'larp-test-option' },
|
||||
input({ type: 'radio', name: `q${idx}`, value: String(oi), required: 'required' }),
|
||||
' ', i18n[opt.key] || opt.text
|
||||
))
|
||||
)
|
||||
)),
|
||||
div({ class: 'larp-actions' },
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.larpTestSubmit || 'Join House')
|
||||
)
|
||||
)
|
||||
]
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const renderPostsBlock = (posts, house, canPost) => div({ class: 'larp-posts-block' },
|
||||
h2(i18n.larpPostsTitle || 'Wall'),
|
||||
canPost
|
||||
? form({ method: 'POST', action: '/larp/post', class: 'larp-post-form' },
|
||||
input({ type: 'hidden', name: 'house', value: house.key }),
|
||||
textarea({ id: 'larp_post_text', name: 'text', rows: '3', maxlength: '4000', placeholder: i18n.larpPostPlaceholder || 'What does this house need to say?' }),
|
||||
button({ type: 'submit', class: 'filter-btn larp-post-submit' }, i18n.larpPostSubmit || 'Publish')
|
||||
)
|
||||
: null,
|
||||
posts.length === 0
|
||||
? p({ class: 'empty' }, i18n.larpPostsEmpty || 'No posts yet.')
|
||||
: div({ class: 'larp-posts-list' },
|
||||
posts.map(post => div({ class: 'larp-post' },
|
||||
div({ class: 'larp-post-head' },
|
||||
userLink(post.author),
|
||||
span({ class: 'larp-post-time' }, moment(post.createdAt).format('YYYY-MM-DD HH:mm'))
|
||||
),
|
||||
p({ class: 'larp-post-text' }, post.text)
|
||||
))
|
||||
)
|
||||
);
|
||||
|
||||
const renderModeButtons = (filter) => div({ class: 'mode-buttons stats-mode-row' },
|
||||
['ruling', 'houses', 'rules'].map(m =>
|
||||
form({ method: 'GET', action: '/larp' },
|
||||
input({ type: 'hidden', name: 'filter', value: m }),
|
||||
button({
|
||||
type: 'submit',
|
||||
class: filter === m ? 'filter-btn active' : 'filter-btn'
|
||||
},
|
||||
m === 'ruling' ? (i18n.larpFilterRuling || 'RULING')
|
||||
: m === 'houses' ? (i18n.larpFilterHouses || 'HOUSES')
|
||||
: (i18n.larpFilterRules || 'FAQ')
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const renderRules = () => div({ class: 'larp-rules' },
|
||||
h2(i18n.larpRulesTitle || 'F.A.Q.'),
|
||||
div({ class: 'larp-rules-section' },
|
||||
h3(i18n.larpRulesEntryTitle || 'Starting house'),
|
||||
p(i18n.larpRulesEntryText || 'Every inhabitant begins in ACADEMIA by default.')
|
||||
),
|
||||
div({ class: 'larp-rules-section' },
|
||||
h3(i18n.larpRulesOneHouseTitle || 'One house at a time'),
|
||||
p(i18n.larpRulesOneHouseText || 'You can belong to at most one house at any given moment. If you belong to none, you are in ACADEMIA. Every non-ACADEMIA house page shows a "Leave House" button to its members that resets membership back to ACADEMIA. Leaving does NOT waive the test cooldown.')
|
||||
),
|
||||
div({ class: 'larp-rules-section' },
|
||||
h3(i18n.larpRulesTestEntry || 'WILL test'),
|
||||
p(i18n.larpRulesTestEntryText || 'Each option weights one or more houses; when you submit, the system tallies the scores and auto-admits you into the highest-scoring house.')
|
||||
),
|
||||
div({ class: 'larp-rules-section' },
|
||||
h3(i18n.larpRulesInviteEntry || 'Invitation code'),
|
||||
p(i18n.larpRulesInviteEntryText || 'House members can generate invitation codes that grant direct access to the house.')
|
||||
),
|
||||
div({ class: 'larp-rules-section' },
|
||||
h3(i18n.larpRulesGovernanceTitle || 'Governance Wall'),
|
||||
p(i18n.larpRulesGovernanceText || 'During its cycle, the governing house wears the "Ruling" badge and is the only one whose Wall is surfaced publicly under the RULING tab.')
|
||||
),
|
||||
div({ class: 'larp-rules-section' },
|
||||
h3(i18n.larpRulesSignTitle || 'L.A.R.P. Emblem'),
|
||||
p(i18n.larpRulesSignText || 'Inhabitants can choose whether to add a sensor on their avatar with the emblem of their current house.')
|
||||
)
|
||||
);
|
||||
|
||||
const renderHousesGrid = (houses, myHouseKey, governingKey) => {
|
||||
const inLarp = !!myHouseKey;
|
||||
const mine = inLarp ? houses.filter(h => h.key === myHouseKey) : [];
|
||||
const ruling = houses.filter(h => h.key === governingKey && h.key !== myHouseKey);
|
||||
const rest = houses.filter(h => h.key !== myHouseKey && h.key !== governingKey);
|
||||
const ordered = [...mine, ...ruling, ...rest];
|
||||
return div({ class: 'larp-grid' },
|
||||
ordered.map(h => {
|
||||
const isMine = h.key === myHouseKey;
|
||||
const isGoverning = h.key === governingKey;
|
||||
const highlight = isGoverning ? 'larp-card-ruling' : (isMine ? 'larp-card-mine' : '');
|
||||
return div({ class: `larp-card larp-card-${h.key}${highlight ? ' ' + highlight : ''}` },
|
||||
a({ href: `/larp/${h.key}`, class: 'larp-card-image-link' },
|
||||
img({ class: 'larp-card-image', src: houseImageSrc(h), alt: h.name })
|
||||
),
|
||||
div({ class: 'larp-card-body' },
|
||||
div({ class: 'larp-card-title-row' },
|
||||
a({ href: `/larp/${h.key}`, class: 'larp-card-title' }, h.name),
|
||||
isMine ? span({ class: 'larp-badge larp-badge-mine' }, i18n.larpBadgeYou || 'You') : null,
|
||||
isGoverning ? span({ class: 'larp-badge larp-badge-ruling' }, i18n.larpBadgeRuling || 'Ruling') : null
|
||||
),
|
||||
p({ class: 'larp-card-motto' }, '“' + h.motto + '”'),
|
||||
p({ class: 'larp-card-roles' }, h.roles),
|
||||
p({ class: 'larp-card-count' }, `${i18n.larpMembersCount || 'Members'}: ${h.memberCount || 0}`),
|
||||
h.openInviteCode ? p({ class: 'larp-card-count' }, i18n.tribeInviteCodeText, span({ class: 'tribe-open-invite-code' }, h.openInviteCode)) : null
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
exports.larpListView = ({ filter, houses, myHouseKey, cycle, governingKey, governingHouse, governingMembers, governingPosts, canPost }) => {
|
||||
const title = i18n.larpTitle || 'L.A.R.P.';
|
||||
const description = i18n.larpDescription || 'A live action role-playing layer for collaborative experimentation.';
|
||||
const myHouse = houses.find(h => h.key === myHouseKey) || null;
|
||||
const mode = filter === 'houses' ? 'houses' : filter === 'rules' ? 'rules' : 'ruling';
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: 'tags-header' },
|
||||
h1(title),
|
||||
p(description)
|
||||
),
|
||||
renderCycleBanner(cycle),
|
||||
renderHouseBadges({ myHouse, governingHouse }),
|
||||
renderModeButtons(mode),
|
||||
mode === 'houses'
|
||||
? renderHousesGrid(houses, myHouseKey, governingKey)
|
||||
: mode === 'rules'
|
||||
? renderRules()
|
||||
: [
|
||||
renderHousePanel(governingHouse, governingMembers, governingPosts, { canPost, viewerHouseKey: myHouseKey, cyclesUntilRuling: 0 }),
|
||||
renderPostsBlock(governingPosts, governingHouse, canPost)
|
||||
]
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const computeCyclesUntilRuling = (house, governingKey, now = new Date()) => {
|
||||
if (!house || house.key === governingKey) return 0;
|
||||
const houseIdx = (Number(house.month) || 1) - 1;
|
||||
const currentMonth = now.getMonth();
|
||||
for (let i = 1; i <= 12; i += 1) {
|
||||
const futureMonth = (currentMonth + i) % 12;
|
||||
if (futureMonth % 9 === houseIdx) return i;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
exports.larpHouseView = ({ house, members, myHouseKey, cycle, governingKey, houses, posts, canPost, testStatus, inviteCode, questions }) => {
|
||||
const myHouse = houses.find(h => h.key === myHouseKey) || null;
|
||||
const governingHouse = houses.find(h => h.key === governingKey) || null;
|
||||
const isAcademia = house.key === 'academia';
|
||||
const viewerInAcademia = myHouseKey === 'academia';
|
||||
const viewerIsMember = myHouseKey === house.key && house.key !== 'academia';
|
||||
const showWall = viewerIsMember || house.key === governingKey;
|
||||
const cycles = computeCyclesUntilRuling(house, governingKey);
|
||||
const housesById = Object.fromEntries(houses.map(h => [h.key, h]));
|
||||
|
||||
return template(
|
||||
house.name,
|
||||
section(
|
||||
div({ class: 'tags-header' },
|
||||
h1(house.name),
|
||||
p({ class: 'larp-detail-motto' }, '“' + house.motto + '”')
|
||||
),
|
||||
renderCycleBanner(cycle),
|
||||
renderHouseBadges({ myHouse, governingHouse }),
|
||||
renderHouseNav(houses, house.key),
|
||||
renderHousePanel(house, members, posts, { canPost, viewerHouseKey: myHouseKey, cyclesUntilRuling: cycles }),
|
||||
inviteCode
|
||||
? div({ class: 'larp-invite-banner' },
|
||||
p({ class: 'larp-invite-banner-title' }, strong(i18n.larpInviteBannerTitle || 'New invitation code')),
|
||||
p({ class: 'larp-invite-banner-hint' }, i18n.larpInviteBannerHint || 'Share this code with someone in ACADEMIA. It expires in 30 cycles or after one use.'),
|
||||
p({ class: 'larp-invite-banner-code' }, inviteCode)
|
||||
)
|
||||
: null,
|
||||
showWall
|
||||
? renderPostsBlock(posts, house, viewerIsMember)
|
||||
: null,
|
||||
isAcademia && viewerInAcademia ? renderAcademiaJoinPanel(houses, testStatus, housesById, questions, myHouseKey) : null
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.larpTestView = ({ questions, cycle, houses, myHouseKey, governingKey, testStatus }) => {
|
||||
const myHouse = houses.find(h => h.key === myHouseKey) || null;
|
||||
const governingHouse = houses.find(h => h.key === governingKey) || null;
|
||||
const titleText = i18n.larpTestTitle || 'Profile test';
|
||||
|
||||
return template(
|
||||
titleText,
|
||||
section(
|
||||
div({ class: 'tags-header' },
|
||||
h1(titleText),
|
||||
p(i18n.larpTestIntro || 'Answer the psychological questions. The system will pick the house that best fits your answers. You can attempt one test every 30 cycles.')
|
||||
),
|
||||
renderCycleBanner(cycle),
|
||||
renderHouseBadges({ myHouse, governingHouse }),
|
||||
testStatus && !testStatus.allowed
|
||||
? div({ class: 'larp-test-cooldown' },
|
||||
p((i18n.larpTestCooldownActive || 'Next test available in') + ' ' + formatCooldown(testStatus.nextAt - Date.now()) + ' ' + (i18n.larpTestCooldownDays || 'cycles')),
|
||||
p(a({ href: '/larp/academia', class: 'filter-btn' }, i18n.larpBackToAcademia || 'Back to ACADEMIA'))
|
||||
)
|
||||
: form({ method: 'POST', action: '/larp/test', class: 'larp-test-form' },
|
||||
questions.map((q, idx) => div({ class: 'larp-test-question' },
|
||||
p({ class: 'larp-test-q-text' }, strong(`${idx + 1}. `), i18n[q.key] || q.question),
|
||||
div({ class: 'larp-test-options' },
|
||||
q.options.map((opt, oi) => label({ class: 'larp-test-option' },
|
||||
input({ type: 'radio', name: `q${idx}`, value: String(oi), required: 'required' }),
|
||||
' ', i18n[opt.key] || opt.text
|
||||
))
|
||||
)
|
||||
)),
|
||||
div({ class: 'larp-actions' },
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.larpTestSubmit || 'Join House')
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.larpTestResultView = ({ house, result, cycle, houses, myHouseKey, governingKey }) => {
|
||||
const myHouse = houses.find(h => h.key === myHouseKey) || null;
|
||||
const governingHouse = houses.find(h => h.key === governingKey) || null;
|
||||
const titleText = (i18n.larpTestResultTitle || 'Test result') + (house && house.name ? ' — ' + house.name : '');
|
||||
const ranking = Array.isArray(result && result.ranking) ? result.ranking : [];
|
||||
const housesById = Object.fromEntries(houses.map(h => [h.key, h]));
|
||||
|
||||
return template(
|
||||
titleText,
|
||||
section(
|
||||
div({ class: 'tags-header' }, h1(titleText)),
|
||||
renderCycleBanner(cycle),
|
||||
renderHouseBadges({ myHouse, governingHouse }),
|
||||
div({ class: 'larp-test-result' },
|
||||
ranking.length
|
||||
? div({ class: 'larp-test-ranking' },
|
||||
h2({ class: 'larp-test-ranking-title' }, i18n.larpTestRankingTitle || 'Score by house'),
|
||||
table({ class: 'larp-info-table' },
|
||||
ranking.map(([key, score]) => tr(
|
||||
td({ class: 'card-label' }, housesById[key] ? a({ href: `/larp/${key}` }, housesById[key].name) : key),
|
||||
td({ class: 'card-value' }, String(score))
|
||||
))
|
||||
)
|
||||
)
|
||||
: null,
|
||||
p({ class: 'larp-test-next' }, i18n.larpTestNextAttempt || 'You can attempt another test in 30 cycles.'),
|
||||
div({ class: 'larp-actions' },
|
||||
a({ href: house ? `/larp/${house.key}` : '/larp/academia', class: 'filter-btn' },
|
||||
i18n.larpGoToHouse || 'Go to your house'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
65
src/views/legacy_view.js
Normal file
65
src/views/legacy_view.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
const { form, button, div, h2, p, section, input, label, br } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n } = require('./main_views');
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
function generateRandomPassword(length = 32) {
|
||||
return crypto.randomBytes(length).toString('hex').slice(0, length);
|
||||
}
|
||||
|
||||
const legacyView = async () => {
|
||||
const randomPassword = generateRandomPassword();
|
||||
|
||||
const header = div({ class: 'tags-header' },
|
||||
h2(i18n.legacyTitle),
|
||||
p(i18n.legacyDescription)
|
||||
);
|
||||
|
||||
return template(
|
||||
i18n.legacyTitle,
|
||||
section(
|
||||
header,
|
||||
p({ id: "randomPassword" }, h2({ class: "generated-password" }, randomPassword)),
|
||||
div({ class: "div-center legacy-section" },
|
||||
p(i18n.exportDescription),
|
||||
form(
|
||||
{
|
||||
action: "/legacy/export",
|
||||
method: "POST",
|
||||
id: "exportForm"
|
||||
},
|
||||
label(i18n.exportPasswordLabel),
|
||||
input({
|
||||
type: "password",
|
||||
name: "password",
|
||||
id: "password",
|
||||
required: true,
|
||||
placeholder: i18n.exportPasswordPlaceholder,
|
||||
minlength: 32
|
||||
}),
|
||||
p({ class: "file-info" }, i18n.fileInfo),
|
||||
button({ type: "submit" }, i18n.legacyExportButton)
|
||||
),
|
||||
br(),
|
||||
p(i18n.importDescription),
|
||||
form(
|
||||
{ action: "/legacy/import", method: "POST", enctype: "multipart/form-data" },
|
||||
input({ type: "file", name: "uploadedFile", required: true }),
|
||||
br(),
|
||||
p(i18n.passwordImport),
|
||||
input({
|
||||
type: "password",
|
||||
name: "importPassword",
|
||||
required: true,
|
||||
placeholder: i18n.importPasswordPlaceholder,
|
||||
minlength: 32
|
||||
}),
|
||||
br(),
|
||||
button({ type: "submit" }, i18n.legacyImportButton)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.legacyView = legacyView;
|
||||
249
src/views/logs_view.js
Normal file
249
src/views/logs_view.js
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
const { div, h2, p, section, button, form, span, table, thead, tbody, tr, th, td, input, textarea, br, option, select } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n } = require("./main_views");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { renderUrl } = require("../backend/renderUrl");
|
||||
|
||||
const safeArr = v => Array.isArray(v) ? v : [];
|
||||
|
||||
const FILTERS = ["today", "week", "month", "year", "always"];
|
||||
|
||||
const filterLabel = (f) => {
|
||||
const map = {
|
||||
today: i18n.logsFilterToday || 'TODAY',
|
||||
week: i18n.logsFilterWeek || 'LAST WEEK',
|
||||
month: i18n.logsFilterMonth || 'LAST MONTH',
|
||||
year: i18n.logsFilterYear || 'LAST YEAR',
|
||||
always: i18n.logsFilterAlways || 'ALWAYS'
|
||||
};
|
||||
return map[f] || f.toUpperCase();
|
||||
};
|
||||
|
||||
const renderFilterBar = (current, hasItems = false) =>
|
||||
div({ class: "logs-toolbar" },
|
||||
form({ method: "GET", action: "/logs", class: "logs-toolbar-inline" },
|
||||
FILTERS.map(f =>
|
||||
button({
|
||||
type: "submit", name: "filter", value: f,
|
||||
class: current === f ? "filter-btn active" : "filter-btn"
|
||||
}, filterLabel(f))
|
||||
)
|
||||
),
|
||||
form({ method: "GET", action: "/logs", class: "logs-toolbar-inline" },
|
||||
input({ type: "hidden", name: "view", value: "create" }),
|
||||
button({ type: "submit", class: "create-button" }, i18n.logsCreate || 'Create Log')
|
||||
),
|
||||
hasItems
|
||||
? form({ method: "GET", action: "/logs/export", class: "logs-toolbar-inline" },
|
||||
button({ type: "submit", class: "create-button" }, i18n.logsExport || 'Export Logs')
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const renderSearchBox = (current, search) => {
|
||||
const q = search || {};
|
||||
return div({ class: "logs-search" },
|
||||
form({ method: "GET", action: "/logs", class: "filter-box" },
|
||||
input({ type: "hidden", name: "filter", value: current || 'today' }),
|
||||
input({
|
||||
type: "text", name: "q", class: "filter-box__input",
|
||||
placeholder: i18n.logsSearchText || 'Search in logs...',
|
||||
value: q.q || ''
|
||||
}),
|
||||
div({ class: "filter-box__controls" },
|
||||
input({
|
||||
type: "date", name: "date", class: "filter-box__select",
|
||||
value: q.date || ''
|
||||
}),
|
||||
select({ name: "type", class: "filter-box__select" },
|
||||
option({ value: '', ...(q.type ? {} : { selected: true }) }, i18n.logsSearchAnyType || 'Any type'),
|
||||
option({ value: 'manual', ...(q.type === 'manual' ? { selected: true } : {}) }, i18n.logsModeManual || 'Manual'),
|
||||
option({ value: 'ai', ...(q.type === 'ai' ? { selected: true } : {}) }, i18n.logsModeAI || 'AI')
|
||||
),
|
||||
button({ type: "submit", class: "filter-box__button" }, i18n.logsSearchButton || 'Search')
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderToolbar = (current, search, hasItems) =>
|
||||
div({ class: "logs-toolbar-wrap" },
|
||||
renderFilterBar(current, hasItems),
|
||||
renderSearchBox(current, search)
|
||||
);
|
||||
|
||||
const MAX_PREVIEW = 140;
|
||||
|
||||
const truncate = (str) => {
|
||||
const s = String(str || '');
|
||||
if (s.length <= MAX_PREVIEW) return s;
|
||||
return s.slice(0, MAX_PREVIEW).replace(/\s+\S*$/, '') + '…';
|
||||
};
|
||||
|
||||
const renderLogPreview = (item) => {
|
||||
const text = truncate(item.text);
|
||||
return div({ class: "logs-entry-text" }, ...renderUrl(text));
|
||||
};
|
||||
|
||||
const renderTable = (items) => {
|
||||
if (!safeArr(items).length) return p({ class: "no-content" }, i18n.logsEmpty || 'No logs yet.');
|
||||
return table({ class: "logs-table" },
|
||||
thead(
|
||||
tr(
|
||||
th(i18n.logsColumnDate || 'Date'),
|
||||
th(i18n.logsColumnType || 'Type'),
|
||||
th(i18n.logsColumnLog || 'Log'),
|
||||
th(''),
|
||||
th('')
|
||||
)
|
||||
),
|
||||
tbody(
|
||||
items.map(item =>
|
||||
tr(
|
||||
td({ class: "logs-col-date" },
|
||||
span({ class: "logs-date-day" }, moment(item.ts).format("DD/MM/YYYY")),
|
||||
' ',
|
||||
span({ class: "logs-date-time" }, moment(item.ts).format("HH:mm"))
|
||||
),
|
||||
td({ class: "logs-col-type" },
|
||||
span({ class: item.mode === 'ai' ? "logs-type-text logs-type-ai" : "logs-type-text logs-type-manual" },
|
||||
item.mode === 'ai' ? (i18n.logsModeAI || 'AI') : (i18n.logsModeManual || 'Manual')
|
||||
)
|
||||
),
|
||||
td({ class: "logs-col-log" },
|
||||
item.label ? div({ class: "logs-entry-label" }, item.label) : null,
|
||||
renderLogPreview(item)
|
||||
),
|
||||
td({ class: "logs-col-actions" },
|
||||
form({ method: "GET", action: `/logs/view/${encodeURIComponent(item.key)}` },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.logsViewDetails || 'View Details')
|
||||
)
|
||||
),
|
||||
td({ class: "logs-col-actions" },
|
||||
form({ method: "GET", action: `/logs/export/${encodeURIComponent(item.key)}` },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.logsExportOne || 'Export')
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderModeToggle = (mode, aiModOn) => {
|
||||
const isAi = mode === 'ai';
|
||||
const isManual = !mode || mode === 'manual';
|
||||
return form({ method: "GET", action: "/logs", class: "logs-mode-form" },
|
||||
input({ type: "hidden", name: "view", value: "create" }),
|
||||
div({ class: "logs-mode-group" },
|
||||
button({
|
||||
type: "submit", name: "mode", value: "manual",
|
||||
class: isManual ? "filter-btn active" : "filter-btn"
|
||||
}, i18n.logsModeManual || 'Manual'),
|
||||
aiModOn
|
||||
? button({
|
||||
type: "submit", name: "mode", value: "ai",
|
||||
class: isAi ? "filter-btn active" : "filter-btn"
|
||||
}, i18n.logsModeAIWritten || 'AI-Assistant')
|
||||
: null
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderCreateForm = (mode, aiModOn) => {
|
||||
const isAi = mode === 'ai' && aiModOn;
|
||||
const inner = isAi
|
||||
? div({ class: "div-center audio-form" },
|
||||
form({ method: "POST", action: "/logs/create" },
|
||||
input({ type: "hidden", name: "mode", value: "ai" }),
|
||||
button({ type: "submit", class: "create-button" }, i18n.logsGenerateButton || 'Generate Text')
|
||||
)
|
||||
)
|
||||
: div({ class: "div-center audio-form" },
|
||||
form({ method: "POST", action: "/logs/create" },
|
||||
input({ type: "hidden", name: "mode", value: "manual" }),
|
||||
span(i18n.logsManualPrompt || 'Write your log'), br(),
|
||||
textarea({ name: "text", rows: "8", required: true, placeholder: i18n.logsTextPlaceholder || 'Describe your experiences...' }),
|
||||
br(), br(),
|
||||
button({ type: "submit", class: "create-button" }, i18n.logsWriteButton || 'Write')
|
||||
)
|
||||
);
|
||||
return div(renderModeToggle(mode, aiModOn), inner);
|
||||
};
|
||||
|
||||
const renderEditForm = (entry) => {
|
||||
return div({ class: "div-center audio-form" },
|
||||
h2(i18n.logsEditTitle || 'Edit Log'),
|
||||
form({ method: "POST", action: `/logs/edit/${encodeURIComponent(entry.key)}` },
|
||||
span(i18n.logsManualPrompt || 'Write your log...'), br(),
|
||||
textarea({ name: "text", rows: "8", required: true }, entry.text || ''),
|
||||
input({ type: "hidden", name: "label", value: entry.label || '' }),
|
||||
br(), br(),
|
||||
button({ type: "submit", class: "create-button" }, i18n.logsUpdateButton || 'Update')
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderDetail = (entry) => {
|
||||
const headerLine = `[${moment(entry.ts).format("DD/MM/YYYY HH:mm:ss")}]:`;
|
||||
return div({ class: "div-center audio-form logs-detail" },
|
||||
h2(headerLine),
|
||||
entry.label ? div({ class: "logs-entry-label" }, entry.label) : null,
|
||||
div({ class: "logs-detail-text" }, ...renderUrl(String(entry.text || ''))),
|
||||
div({ class: "logs-detail-actions" },
|
||||
form({ method: "GET", action: "/logs" },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.walletBack || 'Back')
|
||||
),
|
||||
form({ method: "GET", action: `/logs/export/${encodeURIComponent(entry.key)}` },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.logsExportOne || 'Export')
|
||||
),
|
||||
form({ method: "GET", action: "/logs" },
|
||||
input({ type: "hidden", name: "view", value: "edit" }),
|
||||
input({ type: "hidden", name: "id", value: entry.key }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.logsEdit || 'Edit')
|
||||
),
|
||||
form({ method: "POST", action: `/logs/delete/${encodeURIComponent(entry.key)}` },
|
||||
button({ type: "submit", class: "filter-btn danger-btn" }, i18n.logsDelete || 'Delete')
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.logsView = (items, filter, mode, opts = {}) => {
|
||||
const listTitle = i18n.logsTitle || 'Logs';
|
||||
const description = i18n.logsDescription || 'Record your experience in the network.';
|
||||
const view = opts.view || 'list';
|
||||
const aiModOn = !!opts.aiModOn;
|
||||
const hasItems = Array.isArray(items) && items.length > 0;
|
||||
|
||||
if (view === 'create') {
|
||||
const h = i18n.logsCreateTitle || 'Create Log';
|
||||
const body = section(
|
||||
div({ class: "tags-header" }, h2(h), p(description)),
|
||||
renderFilterBar(filter, hasItems),
|
||||
renderCreateForm(mode, aiModOn)
|
||||
);
|
||||
return template(h, body);
|
||||
}
|
||||
if (view === 'edit' && opts.entry) {
|
||||
const h = i18n.logsEditTitle || 'Edit Log';
|
||||
const body = section(
|
||||
div({ class: "tags-header" }, h2(h), p(description)),
|
||||
renderEditForm(opts.entry)
|
||||
);
|
||||
return template(h, body);
|
||||
}
|
||||
if (view === 'detail' && opts.entry) {
|
||||
const h = i18n.logsViewTitle || 'Log';
|
||||
const body = section(
|
||||
div({ class: "tags-header" }, h2(h), p(description)),
|
||||
renderDetail(opts.entry)
|
||||
);
|
||||
return template(h, body);
|
||||
}
|
||||
const body = section(
|
||||
div({ class: "tags-header" }, h2(listTitle), p(description)),
|
||||
renderToolbar(filter, opts.search || {}, hasItems),
|
||||
div({ class: "logs-list" }, renderTable(items))
|
||||
);
|
||||
return template(listTitle, body);
|
||||
};
|
||||
3940
src/views/main_views.js
Normal file
3940
src/views/main_views.js
Normal file
File diff suppressed because it is too large
Load diff
541
src/views/maps_view.js
Normal file
541
src/views/maps_view.js
Normal file
|
|
@ -0,0 +1,541 @@
|
|||
const { form, button, div, h2, h3, p, section, input, label, br, a, span, textarea, select, option, img, strong } =
|
||||
require("../server/node_modules/hyperaxe");
|
||||
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { template, i18n, userLink, renderLifespanChip, renderSpreadButton, renderContentActions } = require("./main_views");
|
||||
const { renderEncryptedChip } = require("./clearnet_view");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const { renderMapWithPins, renderZoomedMapWithPins, getViewportBounds, latLngToPx, pxToLatLng, MAP_W, MAP_H, getMaxTileZoom } = require("../maps/map_renderer");
|
||||
const { sanitizeHtml } = require('../backend/sanitizeHtml');
|
||||
|
||||
const userId = config.keys.id;
|
||||
const safeArr = (v) => (Array.isArray(v) ? v : []);
|
||||
const safeText = (v) => String(v || "").trim();
|
||||
|
||||
const buildReturnTo = (filter, params = {}) => {
|
||||
const f = safeText(filter || "all");
|
||||
const q = safeText(params.q || "");
|
||||
const parts = [`filter=${encodeURIComponent(f)}`];
|
||||
if (q) parts.push(`q=${encodeURIComponent(q)}`);
|
||||
return `/maps?${parts.join("&")}`;
|
||||
};
|
||||
|
||||
const renderPMButton = (recipient) => {
|
||||
const r = safeText(recipient);
|
||||
if (!r || String(r) === String(userId)) return null;
|
||||
return form({ method: "GET", action: "/pm" },
|
||||
input({ type: "hidden", name: "recipients", value: r }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.privateMessage));
|
||||
};
|
||||
|
||||
const renderTags = (tags) => {
|
||||
const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
|
||||
return list.length
|
||||
? div({ class: "card-tags" }, list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)))
|
||||
: null;
|
||||
};
|
||||
|
||||
const renderMapFavoriteToggle = (mapObj, returnTo = "") =>
|
||||
form({
|
||||
method: "POST",
|
||||
action: mapObj.isFavorite ? `/maps/favorites/remove/${encodeURIComponent(mapObj.key)}` : `/maps/favorites/add/${encodeURIComponent(mapObj.key)}`
|
||||
},
|
||||
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
||||
button({ type: "submit", class: "filter-btn" }, mapObj.isFavorite ? i18n.mapRemoveFavoriteButton : i18n.mapAddFavoriteButton));
|
||||
|
||||
let areaCounter = 0;
|
||||
|
||||
const buildAreas = (clickUrl, latParam = "lat", lngParam = "lng", viewport = null) => {
|
||||
const GRID = 16;
|
||||
const cellW = MAP_W / GRID;
|
||||
const cellH = MAP_H / GRID;
|
||||
const areas = [];
|
||||
for (let gy = 0; gy < GRID; gy++) {
|
||||
for (let gx = 0; gx < GRID; gx++) {
|
||||
let c;
|
||||
if (viewport) {
|
||||
const lat = viewport.latMax - (gy + 0.5) / GRID * (viewport.latMax - viewport.latMin);
|
||||
const lng = viewport.lngMin + (gx + 0.5) / GRID * (viewport.lngMax - viewport.lngMin);
|
||||
c = { lat: Math.round(lat * 10000) / 10000, lng: Math.round(lng * 10000) / 10000 };
|
||||
} else {
|
||||
const cx = Math.round(gx * cellW + cellW / 2);
|
||||
const cy = Math.round(gy * cellH + cellH / 2);
|
||||
c = pxToLatLng(cx, cy);
|
||||
}
|
||||
const x1 = Math.round(gx * cellW);
|
||||
const y1 = Math.round(gy * cellH);
|
||||
const x2 = Math.round((gx + 1) * cellW);
|
||||
const y2 = Math.round((gy + 1) * cellH);
|
||||
areas.push(`<area shape="rect" coords="${x1},${y1},${x2},${y2}" href="${clickUrl}${latParam}=${c.lat}&${lngParam}=${c.lng}" alt="${c.lat},${c.lng}">`);
|
||||
}
|
||||
}
|
||||
return areas;
|
||||
};
|
||||
|
||||
const renderMap = (markers, clickUrl, mainIdx, opts = {}) => {
|
||||
areaCounter++;
|
||||
const mapName = `m${areaCounter}`;
|
||||
const latParam = opts.latParam || "lat";
|
||||
const lngParam = opts.lngParam || "lng";
|
||||
const pinLabels = opts.pinLabels || [];
|
||||
const pinImages = opts.pinImages || [];
|
||||
const pfx = opts.pinPrefix || `pin${areaCounter}`;
|
||||
const zoom = parseInt(opts.zoom) || 2;
|
||||
const centerLat = typeof opts.centerLat === "number" ? opts.centerLat : 0;
|
||||
const centerLng = typeof opts.centerLng === "number" ? opts.centerLng : 0;
|
||||
|
||||
const pinList = safeArr(markers).filter((m) => m && typeof m.lat === "number" && typeof m.lng === "number");
|
||||
const useZoom = zoom > 2;
|
||||
const mapFile = useZoom
|
||||
? renderZoomedMapWithPins(centerLat, centerLng, zoom, pinList, mainIdx)
|
||||
: (pinList.length > 0 ? renderMapWithPins(pinList, mainIdx) : null);
|
||||
const imgSrc = mapFile ? `/mapcache/${mapFile}` : "/assets/images/worldmap-z2.png";
|
||||
const viewport = useZoom && clickUrl ? getViewportBounds(centerLat, centerLng, zoom) : null;
|
||||
|
||||
const useMap = clickUrl || pinLabels.length > 0;
|
||||
const mapTag = useMap ? mapName : "";
|
||||
|
||||
let gridAreasHtml = "";
|
||||
if (clickUrl) {
|
||||
const clickUrlWithZoom = zoom > 2 ? `${clickUrl}zoom=${zoom}&` : clickUrl;
|
||||
gridAreasHtml = buildAreas(clickUrlWithZoom, latParam, lngParam, viewport).join("");
|
||||
}
|
||||
let popupAreasHtml = "";
|
||||
let popupsHtml = "";
|
||||
if (pinLabels.length > 0) {
|
||||
const vp = useZoom ? getViewportBounds(centerLat, centerLng, zoom) : null;
|
||||
pinList.forEach((m, i) => {
|
||||
const lbl = pinLabels[i] || "";
|
||||
let px;
|
||||
if (vp) {
|
||||
px = {
|
||||
x: ((m.lng - vp.lngMin) / (vp.lngMax - vp.lngMin)) * MAP_W,
|
||||
y: ((vp.latMax - m.lat) / (vp.latMax - vp.latMin)) * MAP_H
|
||||
};
|
||||
} else {
|
||||
px = latLngToPx(m.lat, m.lng);
|
||||
}
|
||||
const escaped = lbl.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
const withLinks = escaped.replace(/https?:\/\/[^\s&"<>]+/g, (url) => {
|
||||
const clean = url.replace(/&/g, "&");
|
||||
return `<a href="${clean}" class="map-popup-link" target="_blank" rel="noopener">${url}</a>`;
|
||||
}).replace(/\n/g, "<br>");
|
||||
const sz = 20 * Math.pow(2, Math.max(0, zoom - getMaxTileZoom()));
|
||||
const x1 = Math.max(0, px.x - sz);
|
||||
const y1 = Math.max(0, px.y - sz);
|
||||
const x2 = Math.min(MAP_W, px.x + sz);
|
||||
const y2 = Math.min(MAP_H, px.y + sz);
|
||||
const popupId = `${pfx}_${i}`;
|
||||
const latStr = typeof m.lat === "number" ? m.lat.toFixed(4) : "";
|
||||
const lngStr = typeof m.lng === "number" ? m.lng.toFixed(4) : "";
|
||||
const imgBlobId = pinImages[i] && String(pinImages[i]).startsWith("&") ? pinImages[i] : "";
|
||||
const imgHtml = imgBlobId ? `<img src="/blob/${encodeURIComponent(imgBlobId)}" class="map-popup-img" alt="">` : "";
|
||||
popupAreasHtml += `<area shape="rect" coords="${x1},${y1},${x2},${y2}" title="${escaped}" alt="${escaped}" href="#${popupId}">`;
|
||||
popupsHtml += `<div id="${popupId}" class="map-popup"><div class="map-popup-box"><a href="#" class="map-popup-close">✕</a>${imgHtml}<div class="map-popup-label">${sanitizeHtml(withLinks)}</div><div class="map-popup-coords">${latStr}, ${lngStr}</div></div></div>`;
|
||||
});
|
||||
}
|
||||
const mapHtml = useMap ? `<map name="${mapTag}">${popupAreasHtml}${gridAreasHtml}</map>` : "";
|
||||
const useAttr = useMap ? ` usemap="#${mapTag}"` : "";
|
||||
|
||||
const mapWrapHtml = `<div class="map-wrap"><img src="${imgSrc}" class="map-img" alt="map"${useAttr}>${mapHtml}</div>`;
|
||||
const viewerEl = div({ class: "map-viewer" }, { innerHTML: mapWrapHtml });
|
||||
if (!popupsHtml) return viewerEl;
|
||||
return div({ class: "map-zone" }, viewerEl, div({ class: "map-popup-container", innerHTML: popupsHtml }));
|
||||
};
|
||||
|
||||
const renderCoordPreview = (lat, lng) => {
|
||||
if (!lat && !lng) return null;
|
||||
return span({ class: "map-coord-inline" },
|
||||
span({ class: "map-coord-pin" }, "📍"),
|
||||
strong(`${lat}, ${lng}`));
|
||||
};
|
||||
|
||||
const renderLocalEmbed = (lat, lng) => {
|
||||
const la = parseFloat(lat) || 0;
|
||||
const lo = parseFloat(lng) || 0;
|
||||
if (!la && !lo) return null;
|
||||
return renderMap([{ lat: la, lng: lo }], null, 0);
|
||||
};
|
||||
|
||||
const renderMapUrl = (mapObj) =>
|
||||
div({ class: "map-url-container" },
|
||||
span({ class: "card-label" }, i18n.mapUrlLabel + ": "),
|
||||
a({ href: `/maps/${encodeURIComponent(mapObj.key)}`, class: "map-url-link" },
|
||||
`/maps/${encodeURIComponent(mapObj.key)}`));
|
||||
|
||||
const renderMapOwnerActions = (filter, mapObj, params = {}) => {
|
||||
const returnTo = buildReturnTo(filter, params);
|
||||
if (String(mapObj.author) !== String(userId)) return [];
|
||||
const actions = [
|
||||
form({ method: "GET", action: `/maps/edit/${encodeURIComponent(mapObj.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "update-btn", type: "submit" }, i18n.mapUpdateButton)),
|
||||
form({ method: "POST", action: `/maps/delete/${encodeURIComponent(mapObj.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "delete-btn", type: "submit" }, i18n.mapDeleteButton))
|
||||
];
|
||||
const isPrivateMap = !mapObj.tribeId && mapObj.mapType !== "OPEN";
|
||||
if (isPrivateMap) {
|
||||
actions.push(form({ method: "POST", action: `/maps/generate-invite/${encodeURIComponent(mapObj.key)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeGenerateInvite)));
|
||||
}
|
||||
const openInvite = Array.isArray(mapObj.invites) ? mapObj.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code) : null;
|
||||
if (!mapObj.tribeId) {
|
||||
if (openInvite) {
|
||||
actions.push(div({ class: 'tribe-open-invite' },
|
||||
span({ class: 'card-label' }, i18n.tribeInviteCodeText),
|
||||
span({ class: 'tribe-open-invite-code' }, openInvite.code)
|
||||
));
|
||||
actions.push(form({ method: "POST", action: `/maps/open-invite/remove/${encodeURIComponent(mapObj.key)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeRemoveInvitation)));
|
||||
} else {
|
||||
actions.push(form({ method: "POST", action: `/maps/open-invite/create/${encodeURIComponent(mapObj.key)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeOpenInvitation)));
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
};
|
||||
|
||||
const renderFilters = (filter, q) =>
|
||||
div({ class: "filters" },
|
||||
form({ method: "GET", action: "/maps", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q || "" }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.mapFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.mapFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.mapFilterRecent),
|
||||
button({ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, i18n.mapFilterFavorites),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.mapUploadButton)));
|
||||
|
||||
const renderMapForm = (filter, mapId, mapToEdit, params = {}) => {
|
||||
const returnFilter = filter === "create" ? "all" : params.filter || "all";
|
||||
const returnTo = safeText(params.returnTo) || buildReturnTo(returnFilter, params);
|
||||
const latVal = params.lat !== undefined ? String(params.lat) : String(mapToEdit?.lat || "");
|
||||
const lngVal = params.lng !== undefined ? String(params.lng) : String(mapToEdit?.lng || "");
|
||||
const titleVal = params.title || mapToEdit?.title || "";
|
||||
const descVal = params.description || mapToEdit?.description || "";
|
||||
const markerLabelVal = params.markerLabel !== undefined ? params.markerLabel : (mapToEdit?.markerLabel || "");
|
||||
const tagsValue = params.tags !== undefined ? params.tags : safeArr(mapToEdit?.tags).join(", ");
|
||||
const mapTypeVal = params.mapType || mapToEdit?.mapType || "SINGLE";
|
||||
const maxTileZoom = getMaxTileZoom();
|
||||
const zoomVal = parseInt(params.zoom) || 2;
|
||||
const cleanUrl = `/maps?filter=create${params.tribeId ? '&tribeId=' + encodeURIComponent(params.tribeId) : ''}`;
|
||||
const pickerMarkers = latVal && lngVal ? [{ lat: parseFloat(latVal), lng: parseFloat(lngVal) }] : [];
|
||||
|
||||
return div({ class: "map-create-layout" },
|
||||
div({ class: "map-form map-form-full" },
|
||||
form({
|
||||
action: filter === "edit" ? `/maps/update/${encodeURIComponent(mapId)}` : "/maps/create",
|
||||
method: "POST",
|
||||
enctype: "multipart/form-data"
|
||||
},
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
input({ type: "hidden", name: "filter", value: "create" }),
|
||||
params.tribeId ? input({ type: "hidden", name: "tribeId", value: params.tribeId }) : null,
|
||||
label(i18n.title || "Title"),
|
||||
input({ type: "text", name: "title", placeholder: i18n.mapTitlePlaceholder || "Map title", value: titleVal }),
|
||||
label(i18n.mapDescriptionLabel),
|
||||
textarea({ name: "description", placeholder: i18n.mapDescriptionPlaceholder, rows: "3" }, descVal),
|
||||
label(i18n.mapTagsLabel),
|
||||
input({ type: "text", name: "tags", placeholder: i18n.mapTagsPlaceholder, value: tagsValue }),
|
||||
label(i18n.mapTypeLabel),
|
||||
select({ name: "mapType" },
|
||||
option({ value: "SINGLE", ...(mapTypeVal === "SINGLE" ? { selected: true } : {}) }, "SINGLE"),
|
||||
option({ value: "OPEN", ...(mapTypeVal === "OPEN" ? { selected: true } : {}) }, "OPEN"),
|
||||
option({ value: "CLOSED", ...(mapTypeVal === "CLOSED" ? { selected: true } : {}) }, "CLOSED")),
|
||||
br(),br(),
|
||||
label(i18n.mapMarkerLabelField),
|
||||
textarea({ name: "markerLabel", placeholder: i18n.mapMarkerLabelPlaceholder, rows: "3" }, markerLabelVal),
|
||||
label(i18n.markerImageLabel || "Marker Image"),
|
||||
input({ type: "file", name: "image", accept: "image/*" }),
|
||||
br(), br(),
|
||||
label(i18n.mapLatLabel),
|
||||
input({ type: "text", name: "lat", placeholder: i18n.mapLatPlaceholder, value: latVal }),
|
||||
label(i18n.mapLngLabel),
|
||||
input({ type: "text", name: "lng", placeholder: i18n.mapLngPlaceholder, value: lngVal }),
|
||||
div({ class: "map-form-row" },
|
||||
button({ type: "submit", attrs: { formmethod: "GET" }, formaction: "/maps", class: "filter-btn" }, i18n.mapAddMarkerButton || "Add Marker"),
|
||||
a({ href: cleanUrl, class: "filter-btn" }, i18n.mapCleanMarkerButton || "Clean Marker")),
|
||||
renderCoordPreview(latVal, lngVal),
|
||||
label(i18n.mapZoomLabel || "Zoom"),
|
||||
select({ name: "zoom" },
|
||||
[2, 3, 4, 5, 6, 7, 8].map(z =>
|
||||
option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))),
|
||||
br(),br(),
|
||||
button({ type: "submit", attrs: { formmethod: "GET" }, formaction: "/maps", class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom"),
|
||||
div({ class: "map-form-map-slot" },
|
||||
renderMap(pickerMarkers, null, 0, { zoom: zoomVal, centerLat: parseFloat(latVal) || 0, centerLng: parseFloat(lngVal) || 0 })),
|
||||
button({ type: "submit", class: "create-button" }, filter === "edit" ? i18n.mapUpdateButton : i18n.mapCreateButton))));
|
||||
};
|
||||
|
||||
const renderMarkerForm = (mapObj, returnTo, params = {}, tribeMembers = []) => {
|
||||
if (mapObj.mapType === "SINGLE") return null;
|
||||
if (mapObj.mapType === "CLOSED" && String(mapObj.author) !== String(userId)) return null;
|
||||
if (mapObj.mapType === "OPEN" && mapObj.tribeId && !tribeMembers.includes(userId)) return null;
|
||||
const mkLat = params.mkLat || "";
|
||||
const mkLng = params.mkLng || "";
|
||||
const zoomVal = parseInt(params.zoom) || 2;
|
||||
|
||||
const existingMarkers = [{ lat: mapObj.lat, lng: mapObj.lng }].concat(
|
||||
safeArr(mapObj.markers).map((m) => ({ lat: m.lat, lng: m.lng })));
|
||||
if (mkLat && mkLng) existingMarkers.push({ lat: parseFloat(mkLat), lng: parseFloat(mkLng) });
|
||||
|
||||
const pinLabels = [mapObj.markerLabel || mapObj.description || mapObj.title || ""].concat(
|
||||
safeArr(mapObj.markers).map((m) => m.label || ""));
|
||||
|
||||
const mkCleanUrl = `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(params.filter || "all")}`;
|
||||
const clickUrl = `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(params.filter || "all")}&zoom=${zoomVal}&`;
|
||||
return div({ class: "map-marker-form", id: "add-marker" },
|
||||
h3(i18n.mapAddMarkerTitle),
|
||||
form({ method: "POST", action: `/maps/${encodeURIComponent(mapObj.key)}/marker`, class: "map-form", enctype: "multipart/form-data" },
|
||||
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
||||
label(i18n.mapMarkerLabelField),
|
||||
textarea({ name: "label", placeholder: i18n.mapMarkerLabelPlaceholder, rows: "3" }, params.mkMarkerLabel || ""),
|
||||
label(i18n.markerImageLabel || "Marker Image"),
|
||||
input({ type: "file", name: "image", accept: "image/*" }),
|
||||
br(),br(),
|
||||
label(i18n.mapMarkerLatLabel),
|
||||
input({ type: "text", name: "mkLat", placeholder: i18n.mapLatPlaceholder, value: String(mkLat) }),
|
||||
label(i18n.mapMarkerLngLabel),
|
||||
input({ type: "text", name: "mkLng", placeholder: i18n.mapLngPlaceholder, value: String(mkLng) }),
|
||||
div({ class: "map-form-row" },
|
||||
button({ type: "submit", attrs: { formmethod: "GET" }, formaction: `/maps/${encodeURIComponent(mapObj.key)}`, class: "filter-btn" }, i18n.mapAddMarkerButton || "Add Marker"),
|
||||
a({ href: mkCleanUrl, class: "filter-btn" }, i18n.mapCleanMarkerButton || "Clean Marker")),
|
||||
renderCoordPreview(mkLat, mkLng),
|
||||
label(i18n.mapZoomLabel || "Zoom"),
|
||||
select({ name: "zoom" },
|
||||
[2, 3, 4, 5, 6, 7, 8].map(z =>
|
||||
option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))),
|
||||
br(),br(),
|
||||
button({ type: "submit", attrs: { formmethod: "GET" }, formaction: `/maps/${encodeURIComponent(mapObj.key)}`, class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom"),
|
||||
div({ class: "map-form-map-slot" },
|
||||
renderMap(existingMarkers, clickUrl, 0, { latParam: "mkLat", lngParam: "mkLng", pinLabels, pinPrefix: `mk${areaCounter}`, zoom: zoomVal, centerLat: parseFloat(mkLat) || parseFloat(mapObj.lat) || 0, centerLng: parseFloat(mkLng) || parseFloat(mapObj.lng) || 0 })),
|
||||
button({ type: "submit", class: "create-button" }, i18n.mapAddMarkerButton)));
|
||||
};
|
||||
|
||||
const renderMarkersList = (markers, mapObj) => {
|
||||
const allMarkers = [];
|
||||
if (mapObj) {
|
||||
allMarkers.push({
|
||||
lat: mapObj.lat,
|
||||
lng: mapObj.lng,
|
||||
label: mapObj.markerLabel || mapObj.description || mapObj.title || i18n.mapMarkerDefault,
|
||||
author: mapObj.author,
|
||||
createdAt: mapObj.createdAt
|
||||
});
|
||||
}
|
||||
allMarkers.push(...safeArr(markers));
|
||||
if (!allMarkers.length) return null;
|
||||
return div({ class: "map-markers-list" },
|
||||
h3(i18n.mapMarkersTitle),
|
||||
br(),
|
||||
div(allMarkers.flatMap((mk, i) => [
|
||||
...(i > 0 ? [br()] : []),
|
||||
div({ class: "map-marker-info" },
|
||||
span({ class: "map-marker-dot" }, "ꔌ"),
|
||||
span({ class: "map-marker-coords" }, `${(typeof mk.lat === 'number' ? mk.lat : 0).toFixed(4)}, ${(typeof mk.lng === 'number' ? mk.lng : 0).toFixed(4)}`),
|
||||
span({ class: "map-marker-meta" },
|
||||
userLink(mk.author),
|
||||
` · ${moment(mk.createdAt).fromNow()}`))
|
||||
])));
|
||||
};
|
||||
|
||||
const renderMapCard = (mapObj, filter, params = {}) => {
|
||||
const returnTo = buildReturnTo(filter, params);
|
||||
const ownerActions = renderMapOwnerActions(filter, mapObj, params);
|
||||
const markerCount = safeArr(mapObj.markers).length;
|
||||
|
||||
const thumbMarkers = [{ lat: mapObj.lat, lng: mapObj.lng }].concat(
|
||||
safeArr(mapObj.markers).map((m) => ({ lat: m.lat, lng: m.lng })));
|
||||
const thumbFile = renderMapWithPins(thumbMarkers, 0);
|
||||
const thumbSrc = thumbFile ? `/mapcache/${thumbFile}` : "/assets/images/worldmap-z2.png";
|
||||
|
||||
const chips = [
|
||||
renderEncryptedChip(i18n),
|
||||
renderLifespanChip(mapObj.lifetime, i18n)
|
||||
].filter(Boolean);
|
||||
|
||||
const isOwn = mapObj.author && String(mapObj.author) === String(userId);
|
||||
return div({ class: "trending-card map-card" + (isOwn ? " own-content" : "") },
|
||||
div({ class: "card-header activity-card-header" },
|
||||
span({ class: "map-type-badge" }, mapObj.mapType),
|
||||
renderContentActions(mapObj.key, `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(filter)}`)),
|
||||
div({ class: "card-section maps-card-body" },
|
||||
a({ href: `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(filter)}`, class: "map-card-thumb-link" },
|
||||
{ innerHTML: `<img src="${thumbSrc}" class="map-card-thumb" alt="map">` }),
|
||||
div({ class: "map-card-body" },
|
||||
div({ class: "shop-title-row" },
|
||||
mapObj.title ? h2({ class: "tribe-card-title" }, a({ href: `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(filter)}` }, mapObj.title)) : null
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
div({ class: "map-card-header" },
|
||||
div({ class: "map-card-info" },
|
||||
span({ class: "map-coords" }, `📍 ${mapObj.lat.toFixed(4)}, ${mapObj.lng.toFixed(4)}`),
|
||||
markerCount > 0 ? span({ class: "map-marker-count" }, `▾ ${markerCount}`) : null,
|
||||
mapObj.key ? renderMapUrl(mapObj) : null),
|
||||
div({ class: "map-card-actions" },
|
||||
renderMapFavoriteToggle(mapObj, returnTo),
|
||||
renderPMButton(mapObj.author),
|
||||
...ownerActions)),
|
||||
safeText(mapObj.description) ? p({ class: "map-description" }, mapObj.description) : null,
|
||||
(() => {
|
||||
const btn = renderSpreadButton(mapObj.key, params.spreadMap && params.spreadMap.get(mapObj.key));
|
||||
return btn ? div({ class: "card-spread-left" }, btn) : null;
|
||||
})(),
|
||||
p({ class: "card-footer" },
|
||||
span({ class: "date-link" }, moment(mapObj.createdAt).fromNow()),
|
||||
span(" · "),
|
||||
userLink(mapObj.author)))));
|
||||
};
|
||||
|
||||
const renderMapList = (maps, filter, params = {}) =>
|
||||
maps.length
|
||||
? maps.map((mapObj) => renderMapCard(mapObj, filter, params))
|
||||
: p(params.q ? i18n.mapNoMatch : i18n.noMaps);
|
||||
|
||||
exports.mapsView = async (maps, filter = "all", mapId = null, params = {}) => {
|
||||
const title = filter === "mine" ? i18n.mapMineSectionTitle
|
||||
: filter === "create" ? i18n.mapCreateSectionTitle
|
||||
: filter === "edit" ? i18n.mapUpdateSectionTitle
|
||||
: filter === "recent" ? i18n.mapRecentSectionTitle
|
||||
: filter === "favorites" ? i18n.mapFavoritesSectionTitle
|
||||
: i18n.mapAllSectionTitle;
|
||||
|
||||
const q = safeText(params.q || "");
|
||||
const list = safeArr(maps);
|
||||
const mapToEdit = mapId ? list.find((m) => m.key === mapId) : null;
|
||||
const allMarkers = list.map((m) => ({ lat: m.lat, lng: m.lng, href: `/maps/${encodeURIComponent(m.key)}` }));
|
||||
|
||||
return template(title,
|
||||
section(
|
||||
div({ class: "tags-header" }, h2(title), p(i18n.mapDescription)),
|
||||
renderFilters(filter, q)),
|
||||
section(
|
||||
filter === "create" || filter === "edit"
|
||||
? renderMapForm(filter, mapId, mapToEdit, { ...params, filter })
|
||||
: section(
|
||||
div({ class: "maps-search" },
|
||||
form({ method: "GET", action: "/maps", class: "filter-box" },
|
||||
input({ type: "hidden", name: "filter", value: filter }),
|
||||
input({ type: "text", name: "q", value: q, placeholder: i18n.mapSearchPlaceholder, class: "filter-box__input" }),
|
||||
div({ class: "filter-box__controls" }, button({ type: "submit", class: "filter-box__button" }, i18n.mapSearchButton)))),
|
||||
div({ class: "maps-list" }, renderMapList(list, filter, { q })))));
|
||||
};
|
||||
|
||||
exports.singleMapView = async (mapObj, filter = "all", params = {}) => {
|
||||
const q = safeText(params.q || "");
|
||||
const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q });
|
||||
const ownerActions = renderMapOwnerActions(filter, mapObj, { q });
|
||||
const tribeMembers = safeArr(params.tribeMembers);
|
||||
const zoomVal = parseInt(params.zoom) || 2;
|
||||
|
||||
const allMarkers = [{ lat: mapObj.lat, lng: mapObj.lng }].concat(
|
||||
safeArr(mapObj.markers).map((m) => ({ lat: m.lat, lng: m.lng })));
|
||||
|
||||
const pinLabels = [mapObj.markerLabel || mapObj.description || mapObj.title || ""].concat(
|
||||
safeArr(mapObj.markers).map((m) => m.label || ""));
|
||||
const pinImages = [mapObj.image || ""].concat(safeArr(mapObj.markers).map((m) => m.image || ""));
|
||||
|
||||
return template(mapObj.title || i18n.mapTitle,
|
||||
section(renderFilters(filter, q)),
|
||||
section(
|
||||
div({ class: "map-detail" },
|
||||
mapObj.title ? div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, mapObj.title)
|
||||
) : null,
|
||||
mapObj.title ? div({ class: "card-chips-row" },
|
||||
renderEncryptedChip(i18n),
|
||||
renderLifespanChip(mapObj.lifetime, i18n)
|
||||
) : null,
|
||||
safeText(mapObj.description) ? p({ class: "map-description" }, mapObj.description) : null,
|
||||
div({ class: "map-detail-header" },
|
||||
div({ class: "map-detail-info" },
|
||||
span({ class: "map-type-badge" }, mapObj.mapType),
|
||||
span({ class: "map-coords-detail" }, `📍 ${mapObj.lat.toFixed(6)}, ${mapObj.lng.toFixed(6)}`)),
|
||||
div({ class: "map-detail-actions" },
|
||||
renderMapFavoriteToggle(mapObj, returnTo),
|
||||
renderPMButton(mapObj.author),
|
||||
(String(mapObj.author) !== String(userId) && !mapObj.tribeId && mapObj.mapType !== "OPEN")
|
||||
? a({ class: "tribe-action-btn", href: "/invites#invites-maps" }, i18n.tribeEnterInvite)
|
||||
: null,
|
||||
...ownerActions)),
|
||||
renderMapUrl(mapObj),
|
||||
br(),
|
||||
form({ method: "GET", action: `/maps/${encodeURIComponent(mapObj.key)}` },
|
||||
label(i18n.mapZoomLabel || "Zoom"),
|
||||
br(),
|
||||
select({ name: "zoom" },
|
||||
[2, 3, 4, 5, 6, 7, 8].map(z =>
|
||||
option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))),
|
||||
br(), br(),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom")),
|
||||
br(),
|
||||
renderMap(allMarkers, null, 0, { pinLabels, pinImages, pinPrefix: `detail${areaCounter}`, zoom: zoomVal, centerLat: parseFloat(mapObj.lat) || 0, centerLng: parseFloat(mapObj.lng) || 0 }),
|
||||
renderMarkersList(mapObj.markers, mapObj),
|
||||
renderTags(mapObj.tags),
|
||||
(() => {
|
||||
const btn = renderSpreadButton(mapObj.key);
|
||||
return btn ? div({ class: "card-spread-centered" }, btn) : null;
|
||||
})(),
|
||||
br(),
|
||||
p({ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${moment(mapObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
|
||||
userLink(mapObj.author),
|
||||
mapObj.updatedAt && mapObj.updatedAt !== mapObj.createdAt
|
||||
? span({ class: "votations-comment-date" }, ` · ${i18n.mapUpdatedAt}: ${moment(mapObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`)
|
||||
: null),
|
||||
renderMarkerForm(mapObj, returnTo, params, tribeMembers))));
|
||||
};
|
||||
|
||||
exports.renderMapLocationUrl = (mapUrl) => {
|
||||
if (!mapUrl) return null;
|
||||
return span({ class: "map-location-inline" },
|
||||
span({ class: "map-location-icon" }, "ꔌ"),
|
||||
a({ href: mapUrl, class: "map-location-link" }, mapUrl));
|
||||
};
|
||||
|
||||
exports.renderMapLocationVisitLabel = (mapUrl) => {
|
||||
if (!mapUrl) return null;
|
||||
return div({ class: "card-field" },
|
||||
span({ class: "card-label" }, (i18n.mapLocationTitle || "Map Location") + ":"),
|
||||
span({ class: "card-value" },
|
||||
a({ href: mapUrl, class: "map-location-link" }, i18n.mapVisitLabel || "Visit map")));
|
||||
};
|
||||
|
||||
exports.renderMapEmbed = (mapData, mapUrl) => {
|
||||
if (!mapData || (parseFloat(mapData.lat) === 0 && parseFloat(mapData.lng) === 0))
|
||||
return exports.renderMapLocationVisitLabel(mapUrl);
|
||||
return div({ class: "map-embed-section" },
|
||||
span({ class: "card-label" }, (i18n.mapLocationTitle || "Map Location") + ":"),
|
||||
span({ class: "card-value map-zoom-info" }, "Zoom: 2"),
|
||||
renderLocalEmbed(mapData.lat, mapData.lng),
|
||||
mapUrl ? div({ class: "map-embed-url" },
|
||||
a({ href: mapUrl, class: "map-location-link" }, mapUrl)) : null);
|
||||
};
|
||||
|
||||
exports.renderMapEmbedWithZoom = (mapData, mapUrl, detailUrl, zoom) => {
|
||||
if (!mapData || (parseFloat(mapData.lat) === 0 && parseFloat(mapData.lng) === 0))
|
||||
return exports.renderMapLocationVisitLabel(mapUrl);
|
||||
const zoomVal = parseInt(zoom) || 2;
|
||||
const la = parseFloat(mapData.lat) || 0;
|
||||
const lo = parseFloat(mapData.lng) || 0;
|
||||
return div({ class: "map-embed-section" },
|
||||
span({ class: "card-label" }, (i18n.mapLocationTitle || "Map Location") + ":"),
|
||||
form({ method: "GET", action: detailUrl },
|
||||
label(i18n.mapZoomLabel || "Zoom"),
|
||||
br(),
|
||||
select({ name: "zoom" },
|
||||
[2, 3, 4, 5, 6, 7, 8].map(z =>
|
||||
option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))),
|
||||
br(), br(),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom")),
|
||||
br(),
|
||||
renderMap([{ lat: la, lng: lo }], null, 0, { zoom: zoomVal, centerLat: la, centerLng: lo }),
|
||||
mapUrl ? div({ class: "map-embed-url" },
|
||||
a({ href: mapUrl, class: "map-location-link" }, mapUrl)) : null);
|
||||
};
|
||||
|
||||
exports.renderMapLocationGrid = (lat, lng) => {
|
||||
if (lat === undefined || lng === undefined) return null;
|
||||
return div({ class: "map-location-embed" },
|
||||
renderMap([{ lat: parseFloat(lat) || 0, lng: parseFloat(lng) || 0 }], null, 0));
|
||||
};
|
||||
43
src/views/markdown.js
Normal file
43
src/views/markdown.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"use strict";
|
||||
|
||||
const md = require("../server/node_modules/ssb-markdown");
|
||||
const ssbMessages = require("../server/node_modules/ssb-msgs");
|
||||
const ssbRef = require("../server/node_modules/ssb-ref");
|
||||
const { span } = require("../server/node_modules/hyperaxe");
|
||||
|
||||
const toUrl = (mentions) => {
|
||||
const mentionNames = [];
|
||||
|
||||
const handleLink = ({ name, link }) => {
|
||||
if (typeof name === "string") {
|
||||
const atName = name.charAt(0) === "@" ? name : `@${name}`;
|
||||
mentionNames.push({ name: atName, link });
|
||||
}
|
||||
};
|
||||
|
||||
ssbMessages.links(mentions, "feed").forEach(handleLink);
|
||||
|
||||
const urlHandler = (ref) => {
|
||||
const found = mentionNames.find(({ name }) => name === ref);
|
||||
if (found) return `/author/${encodeURIComponent(found.link)}`;
|
||||
|
||||
if (ssbRef.isFeedId(ref)) return `/author/${encodeURIComponent(ref)}`;
|
||||
if (ssbRef.isMsgId(ref)) return `/thread/${encodeURIComponent(ref)}`;
|
||||
|
||||
const splitIndex = ref.indexOf("?");
|
||||
const blobRef = splitIndex === -1 ? ref : ref.slice(0, splitIndex);
|
||||
|
||||
if (ssbRef.isBlobId(blobRef)) return `/blob/${encodeURIComponent(blobRef)}`;
|
||||
if (ref && ref[0] === "#") return `/hashtag/${encodeURIComponent(ref.substr(1))}`;
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
return urlHandler;
|
||||
};
|
||||
|
||||
module.exports = (input, mentions = []) =>
|
||||
md.block(input, {
|
||||
toUrl: toUrl(mentions),
|
||||
emoji: (character) => span({ class: "emoji" }, character).outerHTML,
|
||||
});
|
||||
737
src/views/market_view.js
Normal file
737
src/views/market_view.js
Normal file
|
|
@ -0,0 +1,737 @@
|
|||
const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, th, td, progress, video, audio } = require("../server/node_modules/hyperaxe")
|
||||
const { template, i18n, userLink, renderStateChip, renderVisibilityChip, renderLifespanChip, renderEcoTax, renderSpreadButton } = require("./main_views")
|
||||
const moment = require("../server/node_modules/moment")
|
||||
const { config } = require("../server/SSB_server.js")
|
||||
const { renderUrl } = require("../backend/renderUrl")
|
||||
const { renderMapLocationUrl, renderMapEmbed, renderMapLocationVisitLabel, renderMapEmbedWithZoom } = require("./maps_view")
|
||||
|
||||
const renderMediaBlob = (value, fallbackSrc = null, attrs = {}) => {
|
||||
if (!value) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
|
||||
const s = String(value).trim()
|
||||
if (!s) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
|
||||
if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}`, ...attrs })
|
||||
const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
|
||||
if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` })
|
||||
const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
|
||||
if (mAudio) return audio({ controls: true, class: attrs.class || 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` })
|
||||
const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
|
||||
if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, class: attrs.class || 'post-image' })
|
||||
return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
|
||||
}
|
||||
|
||||
const userId = config.keys.id
|
||||
|
||||
const parseBidEntry = (raw) => {
|
||||
const s = String(raw || "").trim()
|
||||
if (!s) return null
|
||||
|
||||
if (s.includes("|")) {
|
||||
const parts = s.split("|")
|
||||
if (parts.length < 3) return null
|
||||
const bidder = parts[0] || ""
|
||||
const amount = parseFloat(String(parts[1] || "").replace(",", "."))
|
||||
const time = parts.slice(2).join("|")
|
||||
if (!bidder || !Number.isFinite(amount) || !time) return null
|
||||
return { bidder, amount, time }
|
||||
}
|
||||
|
||||
const first = s.indexOf(":")
|
||||
const second = s.indexOf(":", first + 1)
|
||||
if (first === -1 || second === -1) return null
|
||||
|
||||
const bidder = s.slice(0, first)
|
||||
const amountStr = s.slice(first + 1, second)
|
||||
const time = s.slice(second + 1)
|
||||
const amount = parseFloat(String(amountStr || "").replace(",", "."))
|
||||
if (!bidder || !Number.isFinite(amount) || !time) return null
|
||||
return { bidder, amount, time }
|
||||
}
|
||||
|
||||
const toNum = (v) => {
|
||||
if (v === null || v === undefined) return NaN
|
||||
const n = parseFloat(String(v).replace(",", "."))
|
||||
return Number.isFinite(n) ? n : NaN
|
||||
}
|
||||
|
||||
const normalizeQ = (q) => String(q || "").trim().toLowerCase()
|
||||
|
||||
const matchesQuery = (item, q) => {
|
||||
const qq = normalizeQ(q)
|
||||
if (!qq) return true
|
||||
const title = String(item.title || "").toLowerCase()
|
||||
const tags = Array.isArray(item.tags) ? item.tags : []
|
||||
const tagStr = tags.map((t) => String(t || "").toLowerCase()).join(" ")
|
||||
return title.includes(qq) || tagStr.includes(qq)
|
||||
}
|
||||
|
||||
const withinPrice = (item, minP, maxP) => {
|
||||
const p = toNum(item.price)
|
||||
if (!Number.isFinite(p)) return false
|
||||
if (Number.isFinite(minP) && p < minP) return false
|
||||
if (Number.isFinite(maxP) && p > maxP) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const sortItems = (items, sort) => {
|
||||
const s = String(sort || "recent")
|
||||
if (s === "price") return items.slice().sort((a, b) => toNum(a.price) - toNum(b.price))
|
||||
if (s === "deadline") {
|
||||
return items.slice().sort((a, b) => {
|
||||
const ad = a.deadline ? new Date(a.deadline).getTime() : Number.POSITIVE_INFINITY
|
||||
const bd = b.deadline ? new Date(b.deadline).getTime() : Number.POSITIVE_INFINITY
|
||||
return ad - bd
|
||||
})
|
||||
}
|
||||
return items.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
}
|
||||
|
||||
const buildReturnTo = (filter, q, minPrice, maxPrice, sort) => {
|
||||
const params = []
|
||||
if (filter) params.push(`filter=${encodeURIComponent(filter)}`)
|
||||
if (q) params.push(`q=${encodeURIComponent(q)}`)
|
||||
if (minPrice !== undefined && minPrice !== null && String(minPrice) !== "") params.push(`minPrice=${encodeURIComponent(String(minPrice))}`)
|
||||
if (maxPrice !== undefined && maxPrice !== null && String(maxPrice) !== "") params.push(`maxPrice=${encodeURIComponent(String(maxPrice))}`)
|
||||
if (sort) params.push(`sort=${encodeURIComponent(sort)}`)
|
||||
return `/market${params.length ? `?${params.join("&")}` : ""}`
|
||||
}
|
||||
|
||||
const renderCardField = (labelText, value = "") =>
|
||||
div({ class: "card-field" }, span({ class: "card-label" }, labelText), span({ class: "card-value" }, ...renderUrl(String(value))))
|
||||
|
||||
const renderCardFieldRich = (labelText, parts) =>
|
||||
div({ class: "card-field" }, span({ class: "card-label" }, labelText), span({ class: "card-value" }, ...(Array.isArray(parts) ? parts : [parts])))
|
||||
|
||||
const renderPmButton = (recipientId) =>
|
||||
recipientId && String(recipientId) !== String(userId)
|
||||
? form({ method: "GET", action: "/pm" }, input({ type: "hidden", name: "recipients", value: recipientId }), button({ type: "submit", class: "filter-btn" }, i18n.privateMessage))
|
||||
: null
|
||||
|
||||
const renderStockBar = (stockValue, maxValue) => {
|
||||
const s = Math.max(0, Number(stockValue || 0))
|
||||
const m = Math.max(1, Number(maxValue || s || 1))
|
||||
return div(
|
||||
{ class: "confirmations-block stock-block" },
|
||||
div(
|
||||
{ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.marketItemStock}: `),
|
||||
span({ class: "card-value" }, s > 0 ? `${s}/${m}` : i18n.marketOutOfStock)
|
||||
),
|
||||
progress({ class: "confirmations-progress stock-progress", value: Math.min(s, m), max: m })
|
||||
)
|
||||
}
|
||||
|
||||
const renderMarketCommentsSection = (itemId, returnTo, comments = []) => {
|
||||
const commentsCount = Array.isArray(comments) ? comments.length : 0
|
||||
|
||||
return div(
|
||||
{ class: "vote-comments-section market-comments" },
|
||||
div({ class: "comments-count" }, span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), span({ class: "card-value" }, String(commentsCount))),
|
||||
div(
|
||||
{ class: "comment-form-wrapper" },
|
||||
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
|
||||
form(
|
||||
{ method: "POST", action: `/market/${encodeURIComponent(itemId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
textarea({ id: "comment-text", name: "text", rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }),
|
||||
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
|
||||
br(),
|
||||
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
|
||||
)
|
||||
),
|
||||
(() => {
|
||||
const visibleComments = (comments || []).filter(c => {
|
||||
const t = c && c.value && c.value.content && c.value.content.text
|
||||
return t && String(t).trim()
|
||||
})
|
||||
return visibleComments.length
|
||||
? div(
|
||||
{ class: "comments-list" },
|
||||
visibleComments.map((c) => {
|
||||
const author = c.value && c.value.author ? c.value.author : ""
|
||||
const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""
|
||||
const relDate = ts ? moment(ts).fromNow() : ""
|
||||
const userName = author && author.includes("@") ? author.split("@")[1] : author
|
||||
const rootId = c.value && c.value.content ? c.value.content.fork || c.value.content.root : null
|
||||
const text = c.value && c.value.content && c.value.content.text ? c.value.content.text : ""
|
||||
|
||||
return div(
|
||||
{ class: "votations-comment-card" },
|
||||
span(
|
||||
{ class: "created-at" },
|
||||
span(i18n.createdBy),
|
||||
author ? userLink(author) : span("(unknown)"),
|
||||
absDate ? span(" | ") : "",
|
||||
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
|
||||
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
|
||||
relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : ""
|
||||
),
|
||||
p({ class: "votations-comment-text" }, ...renderUrl(text))
|
||||
)
|
||||
})
|
||||
)
|
||||
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
|
||||
})()
|
||||
)
|
||||
}
|
||||
|
||||
const isMyBidItem = (item) => {
|
||||
const polls = Array.isArray(item.auctions_poll) ? item.auctions_poll : []
|
||||
for (const x of polls) {
|
||||
const b = parseBidEntry(x)
|
||||
if (b && b.bidder === userId) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const auctionCountdownParts = (deadline) => {
|
||||
if (!deadline) return null
|
||||
const dl = moment(deadline)
|
||||
if (!dl.isValid()) return null
|
||||
const now = moment()
|
||||
const rel = dl.fromNow()
|
||||
if (dl.isAfter(now)) return { label: i18n.marketAuctionEndsIn, rel }
|
||||
return { label: i18n.marketAuctionEnded, rel }
|
||||
}
|
||||
|
||||
const renderCountdownField = (item) => {
|
||||
const cd = item && (item.item_type === "auction" || item.item_type === "exchange") ? auctionCountdownParts(item.deadline) : null
|
||||
if (!cd) return null
|
||||
return renderCardFieldRich(`${cd.label}:`, [span({ class: "countdown-strong" }, cd.rel)])
|
||||
}
|
||||
|
||||
const normStatus = (s) => String(s || "").toUpperCase().replace(/_/g, " ").replace(/\s+/g, " ").trim()
|
||||
|
||||
const renderMarketOwnerActions = (item, returnTo) => {
|
||||
const polls = Array.isArray(item.auctions_poll) ? item.auctions_poll : []
|
||||
const canUpdate = item.status !== "SOLD" && item.status !== "DISCARDED" && polls.length === 0
|
||||
const cur = normStatus(item.status || "FOR SALE")
|
||||
const canChange = cur !== "SOLD" && cur !== "DISCARDED"
|
||||
const out = []
|
||||
if (canUpdate) {
|
||||
out.push(
|
||||
form(
|
||||
{ method: "GET", action: `/market/edit/${encodeURIComponent(item.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "update-btn", type: "submit" }, i18n.marketActionsUpdate)
|
||||
)
|
||||
)
|
||||
}
|
||||
out.push(
|
||||
form(
|
||||
{ method: "POST", action: `/market/delete/${encodeURIComponent(item.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "delete-btn", type: "submit" }, i18n.marketActionsDelete)
|
||||
)
|
||||
)
|
||||
if (canChange) {
|
||||
out.push(
|
||||
form(
|
||||
{ method: "POST", action: `/market/status/${encodeURIComponent(item.id)}`, class: "project-control-form project-control-form--status" },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
select(
|
||||
{ name: "status", class: "project-control-select" },
|
||||
option({ value: "FOR SALE", selected: cur === "FOR SALE" }, i18n.marketFilterForSale),
|
||||
option({ value: "SOLD", selected: cur === "SOLD" }, i18n.marketFilterSold),
|
||||
option({ value: "DISCARDED", selected: cur === "DISCARDED" }, i18n.marketFilterDiscarded)
|
||||
),
|
||||
button({ class: "status-btn project-control-btn", type: "submit" }, i18n.marketActionsChangeStatus)
|
||||
)
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const renderMarketTopbar = (item, returnTo) => {
|
||||
const left = [renderPmButton(item && item.seller)].filter(Boolean)
|
||||
const right = item && String(item.seller) === String(userId) ? renderMarketOwnerActions(item, returnTo) : []
|
||||
const leftNode = left.length ? div({ class: "bookmark-topbar-left transfer-topbar-left" }, ...left) : null
|
||||
const rightNode = right.length ? div({ class: "bookmark-actions transfer-actions" }, ...right) : null
|
||||
const children = []
|
||||
if (leftNode) children.push(leftNode)
|
||||
if (rightNode) children.push(rightNode)
|
||||
return children.length ? div({ class: "bookmark-topbar transfer-topbar-single" }, ...children) : null
|
||||
}
|
||||
|
||||
exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
|
||||
const list = Array.isArray(items) ? items : []
|
||||
const q = params.q || ""
|
||||
const minPrice = params.minPrice
|
||||
const maxPrice = params.maxPrice
|
||||
const sort = params.sort || "recent"
|
||||
|
||||
let title = i18n.marketAllSectionTitle
|
||||
switch (filter) {
|
||||
case "mine":
|
||||
title = i18n.marketMineSectionTitle
|
||||
break
|
||||
case "create":
|
||||
title = i18n.marketCreateSectionTitle
|
||||
break
|
||||
case "edit":
|
||||
title = i18n.marketUpdateSectionTitle
|
||||
break
|
||||
case "mybids":
|
||||
title = i18n.marketFilterMyBids
|
||||
break
|
||||
}
|
||||
|
||||
let filtered = []
|
||||
switch (filter) {
|
||||
case "all":
|
||||
filtered = list
|
||||
break
|
||||
case "mine":
|
||||
filtered = list.filter((e) => e.seller === userId)
|
||||
break
|
||||
case "exchange":
|
||||
filtered = list.filter((e) => e.item_type === "exchange" && e.status === "FOR SALE")
|
||||
break
|
||||
case "auctions":
|
||||
filtered = list.filter((e) => e.item_type === "auction" && e.status === "FOR SALE")
|
||||
break
|
||||
case "new":
|
||||
filtered = list.filter((e) => e.item_status === "NEW" && e.status === "FOR SALE")
|
||||
break
|
||||
case "used":
|
||||
filtered = list.filter((e) => e.item_status === "USED" && e.status === "FOR SALE")
|
||||
break
|
||||
case "broken":
|
||||
filtered = list.filter((e) => e.item_status === "BROKEN" && e.status === "FOR SALE")
|
||||
break
|
||||
case "for sale":
|
||||
filtered = list.filter((e) => e.status === "FOR SALE")
|
||||
break
|
||||
case "sold":
|
||||
filtered = list.filter((e) => e.status === "SOLD")
|
||||
break
|
||||
case "discarded":
|
||||
filtered = list.filter((e) => e.status === "DISCARDED")
|
||||
break
|
||||
case "recent": {
|
||||
const oneDayAgo = moment().subtract(1, "days").toISOString()
|
||||
filtered = list.filter((e) => e.status === "FOR SALE" && String(e.createdAt || "") >= oneDayAgo)
|
||||
break
|
||||
}
|
||||
case "mybids":
|
||||
filtered = list.filter((e) => String(e.item_type || "").toLowerCase() === "auction").filter(isMyBidItem)
|
||||
break
|
||||
default:
|
||||
filtered = list
|
||||
break
|
||||
}
|
||||
|
||||
const minP = toNum(minPrice)
|
||||
const maxP = toNum(maxPrice)
|
||||
filtered = filtered.filter((it) => matchesQuery(it, q)).filter((it) => withinPrice(it, minP, maxP))
|
||||
filtered = sortItems(filtered, sort)
|
||||
|
||||
const returnTo = buildReturnTo(filter, q, minPrice, maxPrice, sort)
|
||||
const itemEdit = itemToEdit || {}
|
||||
|
||||
const hiddenCtx = [
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "minPrice", value: minPrice ?? "" }),
|
||||
input({ type: "hidden", name: "maxPrice", value: maxPrice ?? "" }),
|
||||
input({ type: "hidden", name: "sort", value: sort })
|
||||
]
|
||||
|
||||
const isFormMode = filter === "create" || filter === "edit"
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: "tags-header" }, h2(i18n.marketTitle), p(i18n.marketDescription)),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/market", class: "ui-toolbar ui-toolbar--filters" },
|
||||
...hiddenCtx,
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "exchange", class: filter === "exchange" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterItems),
|
||||
button({ type: "submit", name: "filter", value: "auctions", class: filter === "auctions" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterAuctions),
|
||||
button({ type: "submit", name: "filter", value: "mybids", class: filter === "mybids" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterMyBids),
|
||||
button({ type: "submit", name: "filter", value: "new", class: filter === "new" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterNew),
|
||||
button({ type: "submit", name: "filter", value: "used", class: filter === "used" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterUsed),
|
||||
button({ type: "submit", name: "filter", value: "broken", class: filter === "broken" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterBroken),
|
||||
button({ type: "submit", name: "filter", value: "for sale", class: filter === "for sale" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterForSale),
|
||||
button({ type: "submit", name: "filter", value: "sold", class: filter === "sold" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterSold),
|
||||
button({ type: "submit", name: "filter", value: "discarded", class: filter === "discarded" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterDiscarded),
|
||||
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterRecent),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.marketCreateButton)
|
||||
)
|
||||
),
|
||||
!isFormMode
|
||||
? div(
|
||||
{ class: "market-search" },
|
||||
form(
|
||||
{ method: "GET", action: "/market", class: "filter-box" },
|
||||
input({ type: "hidden", name: "filter", value: filter || "all" }),
|
||||
input({ type: "text", name: "q", value: q, placeholder: i18n.marketSearchPlaceholder, class: "filter-box__input" }),
|
||||
div(
|
||||
{ class: "filter-box__controls" },
|
||||
div(
|
||||
{ class: "transfer-range" },
|
||||
input({
|
||||
type: "number",
|
||||
name: "minPrice",
|
||||
step: "0.000001",
|
||||
min: "0",
|
||||
value: String(minPrice ?? ""),
|
||||
placeholder: i18n.marketMinPriceLabel,
|
||||
class: "filter-box__number transfer-amount-input"
|
||||
}),
|
||||
input({
|
||||
type: "number",
|
||||
name: "maxPrice",
|
||||
step: "0.000001",
|
||||
min: "0",
|
||||
value: String(maxPrice ?? ""),
|
||||
placeholder: i18n.marketMaxPriceLabel,
|
||||
class: "filter-box__number transfer-amount-input"
|
||||
})
|
||||
),
|
||||
select(
|
||||
{ name: "sort", class: "filter-box__select" },
|
||||
option({ value: "recent", selected: sort === "recent" }, i18n.marketSortRecent),
|
||||
option({ value: "price", selected: sort === "price" }, i18n.marketSortPrice),
|
||||
option({ value: "deadline", selected: sort === "deadline" }, i18n.marketSortDeadline)
|
||||
),
|
||||
button({ type: "submit", class: "filter-box__button" }, i18n.marketSearchButton)
|
||||
)
|
||||
)
|
||||
)
|
||||
: null
|
||||
),
|
||||
section(
|
||||
isFormMode
|
||||
? div(
|
||||
{ class: "market-form" },
|
||||
form(
|
||||
{ action: filter === "edit" ? `/market/update/${encodeURIComponent(itemEdit.id)}` : "/market/create", method: "POST", enctype: "multipart/form-data" },
|
||||
input({ type: "hidden", name: "returnTo", value: "/market?filter=mine" }),
|
||||
label(i18n.marketItemType),
|
||||
br(),
|
||||
select(
|
||||
{ name: "item_type", id: "item_type", required: true },
|
||||
option({ value: "auction", selected: itemEdit && itemEdit.item_type === "auction" }, "Auction"),
|
||||
option({ value: "exchange", selected: itemEdit && itemEdit.item_type === "exchange" }, "Exchange")
|
||||
),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.marketItemTitle),
|
||||
br(),
|
||||
input({ type: "text", name: "title", id: "title", value: (itemEdit && itemEdit.title) || "", required: true }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.marketItemDescription),
|
||||
br(),
|
||||
textarea({ name: "description", id: "description", placeholder: i18n.marketItemDescriptionPlaceholder, rows: "6", required: true }, (itemEdit && itemEdit.description) || ""),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.marketCreateFormImageLabel),
|
||||
br(),
|
||||
input({ type: "file", name: "image", id: "image" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.marketItemStatus),
|
||||
br(),
|
||||
select(
|
||||
{ name: "item_status", id: "item_status" },
|
||||
option({ value: "BROKEN", selected: itemEdit && itemEdit.item_status === "BROKEN" }, "BROKEN"),
|
||||
option({ value: "USED", selected: itemEdit && itemEdit.item_status === "USED" }, "USED"),
|
||||
option({ value: "NEW", selected: itemEdit && itemEdit.item_status === "NEW" }, "NEW")
|
||||
),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.marketItemStock),
|
||||
br(),
|
||||
input({ type: "number", name: "stock", id: "stock", value: (itemEdit && itemEdit.stock) || 1, required: true, min: "1", step: "1" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.mapLocationTitle || "Map Location"),
|
||||
br(),
|
||||
input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: itemEdit?.mapUrl || "" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.visibilityLabel || "Visibility"),
|
||||
br(),
|
||||
select(
|
||||
{ name: "visibility" },
|
||||
option({ value: "PUBLIC", selected: (itemEdit?.visibility || "PUBLIC") === "PUBLIC" }, i18n.visibilityPublic || "Public"),
|
||||
option({ value: "HIDDEN", selected: itemEdit?.visibility === "HIDDEN" }, i18n.visibilityHidden || "Hidden")
|
||||
),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.marketItemPrice),
|
||||
br(),
|
||||
input({ type: "number", name: "price", id: "price", value: (itemEdit && itemEdit.price) || "", required: true, step: "0.000001", min: "0.000001" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.marketItemTags),
|
||||
br(),
|
||||
input({ type: "text", name: "tags", id: "tags", placeholder: i18n.marketItemTagsPlaceholder, value: (itemEdit && itemEdit.tags && itemEdit.tags.join(", ")) || "" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.marketItemDeadline),
|
||||
br(),
|
||||
input({
|
||||
type: "datetime-local",
|
||||
name: "deadline",
|
||||
id: "deadline",
|
||||
required: true,
|
||||
min: moment().format("YYYY-MM-DDTHH:mm"),
|
||||
value: itemEdit && itemEdit.deadline ? moment(itemEdit.deadline).format("YYYY-MM-DDTHH:mm") : ""
|
||||
}),
|
||||
br(),
|
||||
br(),
|
||||
input({ type: "hidden", name: "includesShipping", value: "0" }),
|
||||
label(i18n.marketItemIncludesShipping),
|
||||
br(),
|
||||
input({
|
||||
id: "includesShipping-checkbox",
|
||||
type: "checkbox",
|
||||
name: "includesShipping",
|
||||
value: "1",
|
||||
class: "meme-checkbox",
|
||||
...(itemEdit && itemEdit.includesShipping ? { checked: true } : {})
|
||||
}),
|
||||
br(),br(),
|
||||
button({ type: "submit" }, filter === "edit" ? i18n.marketUpdateButton : i18n.marketCreateButton)
|
||||
)
|
||||
)
|
||||
: div(
|
||||
{ class: "market-grid" },
|
||||
filtered.length > 0
|
||||
? filtered.map((item) => {
|
||||
const polls = Array.isArray(item.auctions_poll) ? item.auctions_poll : []
|
||||
const parsedBids = polls.map(parseBidEntry).filter(Boolean).sort((a, b) => new Date(b.time) - new Date(a.time))
|
||||
const myBid = item.item_type === "auction" ? parsedBids.some((b) => b.bidder === userId) : false
|
||||
const maxStock = item.initialStock || item.stockMax || item.stock || 1
|
||||
const stockLeft = Number(item.stock || 0)
|
||||
const isOwner = String(item.seller) === String(userId)
|
||||
|
||||
const actionNodesRaw = isOwner
|
||||
? renderMarketOwnerActions(item, "/market?filter=mine")
|
||||
: [
|
||||
item.status !== "SOLD" && item.status !== "DISCARDED" && item.item_type === "auction"
|
||||
? form(
|
||||
{ method: "POST", action: `/market/bid/${encodeURIComponent(item.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
input({ type: "number", name: "bidAmount", step: "0.000001", min: "0.000001", placeholder: i18n.marketYourBid, required: true }),
|
||||
br(),
|
||||
button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton)
|
||||
)
|
||||
: null,
|
||||
item.status === "FOR SALE" && item.item_type !== "auction" && !isOwner && stockLeft > 0
|
||||
? form(
|
||||
{ method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: "/inbox?filter=sent" }),
|
||||
input({ type: "hidden", name: "buyerId", value: userId }),
|
||||
button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
|
||||
)
|
||||
: null
|
||||
].filter(Boolean)
|
||||
|
||||
return div({ class: "tribe-card market-tribe-card" },
|
||||
div({ class: "tribe-card-image-wrapper" },
|
||||
a({ href: `/market/${encodeURIComponent(item.id)}` },
|
||||
renderMediaBlob(item.image, '/assets/images/default-market.png', { class: 'tribe-card-hero-image' })
|
||||
)
|
||||
),
|
||||
div({ class: "tribe-card-body" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, a({ href: `/market/${encodeURIComponent(item.id)}` }, item.title))
|
||||
),
|
||||
div({ class: "card-chips-row" },
|
||||
renderStateChip("encrypted", "", String(item.item_type || "").toUpperCase()),
|
||||
item.item_status ? renderStateChip("whole", "", String(item.item_status).toUpperCase()) : null,
|
||||
String(item.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? renderVisibilityChip("HIDDEN", i18n) : null,
|
||||
item.includesShipping ? renderStateChip("mutuals", "📦", String(i18n.marketItemIncludesShipping || "Shipping").replace(/\?$/, "").toUpperCase()) : null,
|
||||
renderLifespanChip(item.lifetime, i18n)
|
||||
),
|
||||
div({ class: "market-card-price card-date-highlight" }, `${item.price} ECO`),
|
||||
div({ class: "card-visit-btn-centered" },
|
||||
form({ method: 'GET', action: `/market/${encodeURIComponent(item.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
input({ type: "hidden", name: "filter", value: filter || "all" }),
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "minPrice", value: String(minPrice ?? "") }),
|
||||
input({ type: "hidden", name: "maxPrice", value: String(maxPrice ?? "") }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.viewItem || i18n.marketVisitItem || 'View Item')
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
})
|
||||
: p(i18n.marketNoItemsMatch || i18n.marketNoItems)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
exports.singleMarketView = async (item, filter, comments = [], params = {}) => {
|
||||
const polls = Array.isArray(item.auctions_poll) ? item.auctions_poll : []
|
||||
const parsedBids = polls.map(parseBidEntry).filter(Boolean).sort((a, b) => new Date(b.time) - new Date(a.time))
|
||||
const q = params.q || ""
|
||||
const minPrice = params.minPrice
|
||||
const maxPrice = params.maxPrice
|
||||
const sort = params.sort || "recent"
|
||||
const returnTo = params.returnTo || buildReturnTo(filter, q, minPrice, maxPrice, sort)
|
||||
const topbar = renderMarketTopbar(item, returnTo)
|
||||
const stockLeft = Number(item.stock || 0)
|
||||
const showBuy = item.status === "FOR SALE" && item.item_type !== "auction" && String(item.seller) !== String(userId) && stockLeft > 0
|
||||
const maxStock = item.initialStock || item.stockMax || item.stock || 1
|
||||
|
||||
return template(
|
||||
item.title,
|
||||
section(
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/market", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "minPrice", value: minPrice ?? "" }),
|
||||
input({ type: "hidden", name: "maxPrice", value: maxPrice ?? "" }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "exchange", class: filter === "exchange" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterItems),
|
||||
button({ type: "submit", name: "filter", value: "auctions", class: filter === "auctions" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterAuctions),
|
||||
button({ type: "submit", name: "filter", value: "mybids", class: filter === "mybids" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterMyBids),
|
||||
button({ type: "submit", name: "filter", value: "new", class: filter === "new" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterNew),
|
||||
button({ type: "submit", name: "filter", value: "used", class: filter === "used" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterUsed),
|
||||
button({ type: "submit", name: "filter", value: "broken", class: filter === "broken" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterBroken),
|
||||
button({ type: "submit", name: "filter", value: "for sale", class: filter === "for sale" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterForSale),
|
||||
button({ type: "submit", name: "filter", value: "sold", class: filter === "sold" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterSold),
|
||||
button({ type: "submit", name: "filter", value: "discarded", class: filter === "discarded" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterDiscarded),
|
||||
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterRecent),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.marketCreateButton)
|
||||
)
|
||||
),
|
||||
(() => {
|
||||
const isHidden = String(item.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN'
|
||||
const chips = [
|
||||
renderStateChip("encrypted", "", String(item.item_type || "").toUpperCase()),
|
||||
item.item_status ? renderStateChip("whole", "", String(item.item_status).toUpperCase()) : null,
|
||||
item.includesShipping ? renderStateChip("mutuals", "📦", String(i18n.marketItemIncludesShipping || "Shipping").replace(/\?$/, "").toUpperCase()) : null,
|
||||
isHidden ? renderVisibilityChip("HIDDEN", i18n) : null,
|
||||
renderLifespanChip(item.lifetime, i18n),
|
||||
renderEcoTax(item.msgSize, item.id)
|
||||
].filter(Boolean)
|
||||
|
||||
const tagsNode = item.tags && item.tags.length
|
||||
? div({ class: "card-tags" }, item.tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)))
|
||||
: null
|
||||
|
||||
const infoRows = []
|
||||
const pushRow = (labelText, valueNode) =>
|
||||
infoRows.push(tr(
|
||||
td({ class: "tribe-info-label" }, labelText),
|
||||
td({ class: "tribe-info-value" }, valueNode)
|
||||
))
|
||||
pushRow(i18n.marketItemSeller, userLink(item.seller))
|
||||
if (item.shopId && item.shopTitle) pushRow(i18n.marketShopLabel || "Shop", a({ href: `/shops/${encodeURIComponent(item.shopId)}`, class: "user-link" }, item.shopTitle))
|
||||
if (item.deadline) pushRow(i18n.marketItemAvailable, moment(item.deadline).format("YYYY/MM/DD HH:mm:ss"))
|
||||
|
||||
const itemSide = div({ class: "tribe-side" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, item.title)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(item.id, params.spreads)),
|
||||
renderMediaBlob(item.image, "/assets/images/default-market.png"),
|
||||
div({ class: "card-date-highlight" }, `${item.price} ECO`),
|
||||
renderStockBar(item.stock, maxStock),
|
||||
table({ class: "tribe-info-table jobs-info-table" }, ...infoRows),
|
||||
tagsNode
|
||||
)
|
||||
|
||||
const visibilityRow = String(item.seller) === String(userId)
|
||||
? (() => {
|
||||
const vis = isHidden ? 'HIDDEN' : 'PUBLIC'
|
||||
const next = vis === 'PUBLIC' ? 'HIDDEN' : 'PUBLIC'
|
||||
return div({ class: "tribe-side-actions" },
|
||||
span({ class: "card-label" }, `${i18n.visibilityLabel || 'Visibility'}: `),
|
||||
renderVisibilityChip(vis, i18n),
|
||||
form({ method: "POST", action: `/market/visibility/${encodeURIComponent(item.id)}`, class: "inline-form" },
|
||||
input({ type: "hidden", name: "visibility", value: next }),
|
||||
button({ type: "submit", class: "filter-btn" },
|
||||
next === 'PUBLIC' ? (i18n.visibilityMakePublic || 'Make public') : (i18n.visibilityMakeHidden || 'Make hidden')
|
||||
)
|
||||
)
|
||||
)
|
||||
})()
|
||||
: null
|
||||
|
||||
const marketActions = []
|
||||
if (item.seller && String(item.seller) !== String(userId)) {
|
||||
marketActions.push(form({ method: "GET", action: "/pm" },
|
||||
input({ type: "hidden", name: "recipients", value: item.seller }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
|
||||
))
|
||||
}
|
||||
if (String(item.seller) === String(userId)) {
|
||||
marketActions.push(form({ method: "GET", action: `/market/edit/${encodeURIComponent(item.id)}` },
|
||||
button({ type: "submit", class: "update-btn" }, i18n.marketUpdateButton || "Update")
|
||||
))
|
||||
marketActions.push(form({ method: "POST", action: `/market/delete/${encodeURIComponent(item.id)}` },
|
||||
button({ type: "submit", class: "delete-btn" }, i18n.marketDeleteButton || "Delete")
|
||||
))
|
||||
}
|
||||
const itemMain = div({ class: "tribe-main" },
|
||||
marketActions.length ? div({ class: "tribe-side-actions" }, ...marketActions) : null,
|
||||
visibilityRow,
|
||||
item.description
|
||||
? div({ class: "job-section" },
|
||||
h2({ class: "job-section-title" }, i18n.marketItemDescription),
|
||||
p({ class: "tribe-side-description" }, ...renderUrl(item.description))
|
||||
)
|
||||
: null,
|
||||
renderCountdownField(item),
|
||||
item.mapUrl ? div({ class: "job-section" }, renderMapEmbedWithZoom(params.mapData, item.mapUrl, `/market/${encodeURIComponent(item.id)}`, params.zoom)) : null,
|
||||
item.item_type === "auction"
|
||||
? div(
|
||||
{ class: "auction-info job-section" },
|
||||
h2({ class: "job-section-title" }, i18n.marketAuctionBids),
|
||||
parsedBids.length
|
||||
? table(
|
||||
{ class: "auction-bid-table" },
|
||||
tr(th(i18n.marketAuctionBidTime), th(i18n.marketAuctionUser), th(i18n.marketAuctionBidAmount)),
|
||||
parsedBids.map((bid) =>
|
||||
tr(td(moment(bid.time).format("YYYY-MM-DD HH:mm:ss")), td(a({ href: `/author/${encodeURIComponent(bid.bidder)}` }, bid.bidder)), td(`${parseFloat(bid.amount).toFixed(6)} ECO`))
|
||||
)
|
||||
)
|
||||
: p({ class: "tribe-side-description" }, i18n.marketNoBids || "No bids yet"),
|
||||
item.status !== "SOLD" && item.status !== "DISCARDED" && String(item.seller) !== String(userId)
|
||||
? form(
|
||||
{ method: "POST", action: `/market/bid/${encodeURIComponent(item.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
input({ type: "number", name: "bidAmount", step: "0.000001", min: "0.000001", placeholder: i18n.marketYourBid, required: true }),
|
||||
br(),
|
||||
button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton)
|
||||
)
|
||||
: null
|
||||
)
|
||||
: null,
|
||||
showBuy
|
||||
? div(
|
||||
{ class: "market-item actions" },
|
||||
form(
|
||||
{ method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: "/inbox?filter=sent" }),
|
||||
button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
|
||||
)
|
||||
)
|
||||
: null,
|
||||
renderMarketCommentsSection(item.id, returnTo, comments)
|
||||
)
|
||||
|
||||
return div({ class: "tribe-details" }, itemSide, itemMain)
|
||||
})()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
144
src/views/melody_view.js
Normal file
144
src/views/melody_view.js
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"use strict";
|
||||
|
||||
const { div, h2, p, section, audio, source, span, a, form, input, button, br, textarea, label } = require("../server/node_modules/hyperaxe");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { template, i18n, userLink, renderEcoTax } = require("./main_views");
|
||||
|
||||
const renderSequenceList = (sequence, availableIds = null) => {
|
||||
if (!sequence || sequence.length === 0) {
|
||||
return p({ class: "empty" }, i18n.melodyEmpty || "No notes yet — your blockchain has not produced any block.");
|
||||
}
|
||||
const checkAvailability = availableIds && typeof availableIds.has === 'function';
|
||||
return div({ class: "melody-composition" },
|
||||
h2({ class: "melody-section-title" }, i18n.melodyCompositionTitle || "Blockchain Composition Map"),
|
||||
div({ class: "melody-notes-grid" },
|
||||
sequence.slice(0, 256).map(n => {
|
||||
const noteName = n.name || n.n;
|
||||
const noteType = n.type || n.t;
|
||||
const noteDur = n.durMs || n.d || 0;
|
||||
const blockId = typeof n.id === 'string' ? n.id : null;
|
||||
const reachable = blockId && (!checkAvailability || availableIds.has(blockId));
|
||||
const unavailableNote = checkAvailability && blockId && !reachable
|
||||
? ' · ' + (i18n.melodyNoteUnavailable || 'block not in your local feed')
|
||||
: '';
|
||||
const titleAttr = `${noteType} · ${noteName} · ${noteDur}ms` + (blockId ? ` · ${blockId}` : '') + unavailableNote;
|
||||
const inner = [
|
||||
span({ class: "melody-note-name" }, noteName),
|
||||
span({ class: "melody-note-type" }, noteType)
|
||||
];
|
||||
if (reachable) {
|
||||
return a({ href: `/blockexplorer/block/${encodeURIComponent(blockId)}`, class: "melody-note-chip melody-note-chip-link", title: titleAttr }, ...inner);
|
||||
}
|
||||
const cls = checkAvailability && blockId && !reachable
|
||||
? "melody-note-chip melody-note-chip-unavailable"
|
||||
: "melody-note-chip";
|
||||
return div({ class: cls, title: titleAttr }, ...inner);
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.renderCompositionSequence = renderSequenceList;
|
||||
|
||||
const renderFilters = (activeFilter) => div({ class: "filters" },
|
||||
form({ method: "GET", action: "/melody", class: "ui-toolbar ui-toolbar--filters" },
|
||||
button({ type: "submit", name: "filter", value: "mine", class: activeFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.melodyFilterMine || "MINE"),
|
||||
button({ type: "submit", name: "filter", value: "all", class: activeFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.melodyFilterAll || "ALL")
|
||||
)
|
||||
);
|
||||
|
||||
const renderOwnPanel = ({ feedId, total, sequence }) => {
|
||||
const audioHref = `/melody/audio.wav?_=${Date.now()}`;
|
||||
return div(
|
||||
div({ class: "melody-player-card" },
|
||||
div({ class: "melody-meta" },
|
||||
userLink(feedId),
|
||||
span({ class: "melody-meta-sep" }, " · "),
|
||||
span({ class: "card-label" }, `${i18n.melodyTotalBlocks || "Notes"}: `),
|
||||
span({ class: "card-value" }, String(total))
|
||||
),
|
||||
sequence && sequence.length > 0
|
||||
? div(
|
||||
audio({ controls: true, preload: "none", class: "melody-audio" },
|
||||
source({ src: audioHref, type: "audio/wav" })
|
||||
),
|
||||
div({ class: "melody-regen-form" },
|
||||
form({ method: "GET", action: "/melody", class: "inline-form" },
|
||||
input({ type: "hidden", name: "filter", value: "mine" }),
|
||||
input({ type: "hidden", name: "r", value: String(Date.now()) }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.melodyRegenerate || "Regenerate")
|
||||
),
|
||||
a({ href: "/melody/audio.wav?download=1", download: `${feedId || 'oasis'}.wav`, class: "filter-btn melody-download-btn" }, i18n.melodyDownload || "Download Melody")
|
||||
),
|
||||
div({ class: "melody-upload-block" },
|
||||
h2({ class: "melody-section-title" }, i18n.melodyUploadBcsTitle || "Publish"),
|
||||
p({ class: "melody-upload-name-note" }, (i18n.melodyUploadBcsNameNote || "The audio will be published with the auto-generated name") + ' ', span({ class: "melody-upload-name-value" }, `BCS-${feedId || 'OASISID'}`), '.'),
|
||||
form({ method: "POST", action: "/melody/upload", class: "melody-upload-form" },
|
||||
textarea({
|
||||
id: "melody_upload_stego",
|
||||
name: "stegoMessage",
|
||||
rows: "3",
|
||||
maxlength: "280",
|
||||
placeholder: `${i18n.melodyUploadStegoPlaceholder || "Optional."} ${i18n.melodyUploadStegoLabel || "Hidden message (steganography)"} — ${i18n.melodyUploadStegoMaxLabel || "max 280 characters"}`
|
||||
}),
|
||||
br(),
|
||||
button({ type: "submit", class: "filter-btn melody-upload-submit" }, i18n.melodyUploadBcsButton || "Publish")
|
||||
)
|
||||
)
|
||||
)
|
||||
: p({ class: "empty" }, i18n.melodyEmpty || "No notes yet")
|
||||
),
|
||||
renderSequenceList(sequence)
|
||||
);
|
||||
};
|
||||
|
||||
const renderBcsList = (bcsAudios) => {
|
||||
if (!bcsAudios || bcsAudios.length === 0) {
|
||||
return p({ class: "empty" }, i18n.melodyAllEmpty || "No BCS compositions from other inhabitants yet.");
|
||||
}
|
||||
return div({ class: "melody-bcs-list" },
|
||||
bcsAudios.map(a => div({ class: "melody-bcs-card" },
|
||||
div({ class: "melody-bcs-head" },
|
||||
a.title ? h2({ class: "melody-bcs-title" }, a.title) : null,
|
||||
div({ class: "melody-bcs-meta" },
|
||||
span({ class: "card-label" }, `${i18n.melodyByLabel || "By"}: `),
|
||||
userLink(a.author),
|
||||
span({ class: "melody-meta-sep" }, " · "),
|
||||
span({ class: "card-value" }, moment(a.createdAt).format("YYYY/MM/DD HH:mm:ss"))
|
||||
)
|
||||
),
|
||||
a.url
|
||||
? div({ class: "audio-container melody-bcs-player" },
|
||||
audio({ controls: true, preload: "metadata", src: `/blob/${encodeURIComponent(a.url)}` })
|
||||
)
|
||||
: null,
|
||||
a.description ? p({ class: "melody-bcs-desc" }, a.description) : null,
|
||||
div({ class: "melody-bcs-actions" },
|
||||
form({ method: "GET", action: `/melody/transcode/${encodeURIComponent(a.key)}` },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.audioTranscodeButton || "TRANSCODE")
|
||||
)
|
||||
)
|
||||
))
|
||||
);
|
||||
};
|
||||
|
||||
exports.melodyView = ({ feedId, total, sequence, filter, bcsAudios }) => {
|
||||
const title = i18n.melodyTitle || "Melody";
|
||||
const description = i18n.melodyDescription || "Play the melody of your blockchain — each block becomes a note.";
|
||||
const activeFilter = filter === "all" ? "all" : "mine";
|
||||
const body = activeFilter === "all"
|
||||
? renderBcsList(bcsAudios)
|
||||
: renderOwnPanel({ feedId, total, sequence });
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(title),
|
||||
p(description)
|
||||
),
|
||||
renderFilters(activeFilter),
|
||||
body
|
||||
)
|
||||
);
|
||||
};
|
||||
150
src/views/modules_view.js
Normal file
150
src/views/modules_view.js
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
const { form, button, div, h2, p, section, table, tr, td, input, a, br, ul, li, span, strong } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n } = require('./main_views');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
|
||||
const modulesView = () => {
|
||||
const config = getConfig().modules;
|
||||
const modules = [
|
||||
{ name: 'agenda', label: i18n.modulesAgendaLabel, description: i18n.modulesAgendaDescription },
|
||||
{ name: 'ai', label: i18n.modulesAILabel, description: i18n.modulesAIDescription },
|
||||
{ name: 'aiNav', label: i18n.modulesAINavLabel, description: i18n.modulesAINavDescription },
|
||||
{ name: 'audios', label: i18n.modulesAudiosLabel, description: i18n.modulesAudiosDescription },
|
||||
{ name: 'banking', label: i18n.modulesBankingLabel, description: i18n.modulesBankingDescription },
|
||||
{ name: 'bookmarks', label: i18n.modulesBookmarksLabel, description: i18n.modulesBookmarksDescription },
|
||||
{ name: 'calendars', label: i18n.modulesCalendarsLabel, description: i18n.modulesCalendarsDescription },
|
||||
{ name: 'chats', label: i18n.modulesChatsLabel, description: i18n.modulesChatsDescription },
|
||||
{ name: 'cipher', label: i18n.modulesCipherLabel, description: i18n.modulesCipherDescription },
|
||||
{ name: 'courts', label: i18n.modulesCourtsLabel, description: i18n.modulesCourtsDescription },
|
||||
{ name: 'docs', label: i18n.modulesDocsLabel, description: i18n.modulesDocsDescription },
|
||||
{ name: 'events', label: i18n.modulesEventsLabel, description: i18n.modulesEventsDescription },
|
||||
{ name: 'favorites', label: i18n.modulesFavoritesLabel, description: i18n.modulesFavoritesDescription },
|
||||
{ name: 'fediverse', label: i18n.modulesFediverseLabel, description: i18n.modulesFediverseDescription },
|
||||
{ name: 'feed', label: i18n.modulesFeedLabel, description: i18n.modulesFeedDescription },
|
||||
{ name: 'forum', label: i18n.modulesForumLabel, description: i18n.modulesForumDescription },
|
||||
{ name: 'games', label: i18n.modulesGamesLabel, description: i18n.modulesGamesDescription },
|
||||
{ name: 'graphos', label: i18n.modulesGraphosLabel, description: i18n.modulesGraphosDescription },
|
||||
{ name: 'images', label: i18n.modulesImagesLabel, description: i18n.modulesImagesDescription },
|
||||
{ name: 'invites', label: i18n.modulesInvitesLabel, description: i18n.modulesInvitesDescription },
|
||||
{ name: 'jobs', label: i18n.modulesJobsLabel, description: i18n.modulesJobsDescription },
|
||||
{ name: 'legacy', label: i18n.modulesLegacyLabel, description: i18n.modulesLegacyDescription },
|
||||
{ name: 'latest', label: i18n.modulesLatestLabel, description: i18n.modulesLatestDescription },
|
||||
{ name: 'logs', label: i18n.modulesLogsLabel, description: i18n.modulesLogsDescription },
|
||||
{ name: 'maps', label: i18n.modulesMapLabel, description: i18n.modulesMapDescription },
|
||||
{ name: 'market', label: i18n.modulesMarketLabel, description: i18n.modulesMarketDescription },
|
||||
{ name: 'melody', label: i18n.modulesMelodyLabel, description: i18n.modulesMelodyDescription },
|
||||
{ name: 'multiverse', label: i18n.modulesMultiverseLabel, description: i18n.modulesMultiverseDescription },
|
||||
{ name: 'opinions', label: i18n.modulesOpinionsLabel, description: i18n.modulesOpinionsDescription },
|
||||
{ name: 'pads', label: i18n.modulesPadsLabel, description: i18n.modulesPadsDescription },
|
||||
{ name: 'parliament', label: i18n.modulesParliamentLabel, description: i18n.modulesParliamentDescription },
|
||||
{ name: 'pixelia', label: i18n.modulesPixeliaLabel, description: i18n.modulesPixeliaDescription },
|
||||
{ name: 'projects', label: i18n.modulesProjectsLabel, description: i18n.modulesProjectsDescription },
|
||||
{ name: 'popular', label: i18n.modulesPopularLabel, description: i18n.modulesPopularDescription },
|
||||
{ name: 'reports', label: i18n.modulesReportsLabel, description: i18n.modulesReportsDescription },
|
||||
{ name: 'shops', label: i18n.modulesShopsLabel, description: i18n.modulesShopsDescription },
|
||||
{ name: 'summaries', label: i18n.modulesSummariesLabel, description: i18n.modulesSummariesDescription },
|
||||
{ name: 'tags', label: i18n.modulesTagsLabel, description: i18n.modulesTagsDescription },
|
||||
{ name: 'tasks', label: i18n.modulesTasksLabel, description: i18n.modulesTasksDescription },
|
||||
{ name: 'threads', label: i18n.modulesThreadsLabel, description: i18n.modulesThreadsDescription },
|
||||
{ name: 'torrents', label: i18n.modulesTorrentsLabel, description: i18n.modulesTorrentsDescription },
|
||||
{ name: 'transfers', label: i18n.modulesTransfersLabel, description: i18n.modulesTransfersDescription },
|
||||
{ name: 'trending', label: i18n.modulesTrendingLabel, description: i18n.modulesTrendingDescription },
|
||||
{ name: 'tribes', label: i18n.modulesTribesLabel, description: i18n.modulesTribesDescription },
|
||||
{ name: 'larp', label: i18n.modulesLarpLabel, description: i18n.modulesLarpDescription },
|
||||
{ name: 'videos', label: i18n.modulesVideosLabel, description: i18n.modulesVideosDescription },
|
||||
{ name: 'votes', label: i18n.modulesVotationsLabel, description: i18n.modulesVotationsDescription },
|
||||
{ name: 'wallet', label: i18n.modulesWalletLabel, description: i18n.modulesWalletDescription },
|
||||
{ name: 'topics', label: i18n.modulesTopicsLabel, description: i18n.modulesTopicsDescription }
|
||||
];
|
||||
|
||||
const moduleStates = modules.reduce((acc, mod) => {
|
||||
acc[`${mod.name}Mod`] = config[`${mod.name}Mod`] === 'on' ? 'on' : 'off';
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const activeModulesCount = modules.filter(mod => moduleStates[`${mod.name}Mod`] === 'on').length;
|
||||
const totalModulesCount = modules.length;
|
||||
const disabledModulesCount = totalModulesCount - activeModulesCount;
|
||||
|
||||
const header = div({ class: 'tags-header' },
|
||||
h2(i18n.modulesViewTitle),
|
||||
p(i18n.modulesViewDescription),
|
||||
div({ style: 'margin-bottom:16px;' },
|
||||
`${i18n.modulesTotalModulesLabel}: `,
|
||||
strong({ style: 'color:#888;' }, totalModulesCount),
|
||||
ul({ style: 'list-style-type:none; padding:0; margin:0;' },
|
||||
li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
|
||||
`${i18n.modulesEnabledModulesLabel}: `,
|
||||
span({ style: 'color:#888;' }, activeModulesCount)
|
||||
),
|
||||
li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
|
||||
`${i18n.modulesDisabledModulesLabel}: `,
|
||||
span({ style: 'color:#888;' }, disabledModulesCount)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const PRESETS = {
|
||||
minimal: ['feed', 'forum', 'games', 'images', 'videos', 'audios', 'bookmarks', 'tags', 'trending', 'popular', 'latest', 'threads', 'opinions', 'cipher', 'legacy'],
|
||||
social: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'fediverse', 'feed', 'forum', 'games', 'images', 'invites', 'larp', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'melody', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes'],
|
||||
economy: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'fediverse', 'feed', 'forum', 'games', 'images', 'invites', 'larp', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'melody', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'jobs', 'shops'],
|
||||
full: modules.map(m => m.name)
|
||||
};
|
||||
|
||||
const presetButtons = div({ class: 'preset-group', style: 'display:flex;gap:8px;flex-wrap:nowrap;margin-bottom:16px;' },
|
||||
Object.entries(PRESETS).map(([key, mods]) => {
|
||||
const presetLabel = (i18n[`modulesPreset_${key}`] || key).toUpperCase();
|
||||
const isActive = modules.every(m => mods.includes(m.name) === (moduleStates[`${m.name}Mod`] === 'on'));
|
||||
return form({ action: "/modules/preset", method: "post", style: "display:inline;margin:0;" },
|
||||
input({ type: "hidden", name: "preset", value: key }),
|
||||
button({
|
||||
type: 'submit',
|
||||
class: isActive ? 'filter-btn active' : 'filter-btn',
|
||||
}, presetLabel)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
return template(
|
||||
i18n.modules,
|
||||
section(header),
|
||||
section(
|
||||
h2(i18n.modulesPresetTitle || "Common Configurations"),
|
||||
presetButtons,
|
||||
form(
|
||||
{ action: "/save-modules", method: "post" },
|
||||
table(
|
||||
{ class: "module-table" },
|
||||
tr(
|
||||
td(i18n.modulesModuleName),
|
||||
td(i18n.modulesModuleDescription),
|
||||
td({ style: 'text-align: center;' }, i18n.modulesModuleStatus)
|
||||
),
|
||||
modules.map(mod =>
|
||||
tr(
|
||||
td(a({ href: `/${mod.name}` }, mod.label)),
|
||||
td(p(mod.description)),
|
||||
td(
|
||||
input({
|
||||
type: "checkbox",
|
||||
id: `${mod.name}Mod`,
|
||||
name: `${mod.name}Form`,
|
||||
class: "input-checkbox",
|
||||
value: 'on',
|
||||
checked: moduleStates[`${mod.name}Mod`] === 'on' ? true : undefined
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
div(
|
||||
{ class: "save-button-container", style: "margin-top: 20px; text-align: center;" },
|
||||
button({ type: "submit", class: "submit-button" }, i18n.saveSettings)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.modulesView = modulesView;
|
||||
|
||||
397
src/views/opinions_view.js
Normal file
397
src/views/opinions_view.js
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
const { div, h2, p, section, button, form, a, img, video: videoHyperaxe, audio: audioHyperaxe, input, table, tr, th, td, br, span, details, summary } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, userLink, renderSpreadButton, renderContentActions } = require('./main_views');
|
||||
const { config } = require('../server/SSB_server.js');
|
||||
const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
|
||||
const { renderUrl } = require('../backend/renderUrl');
|
||||
const opinionCategories = require('../backend/opinion_categories');
|
||||
const { sanitizeHtml } = require('../backend/sanitizeHtml');
|
||||
|
||||
const seenDocumentTitles = new Set();
|
||||
|
||||
const detailHref = (type, key) => {
|
||||
switch (type) {
|
||||
case 'bookmark': return `/bookmarks/${encodeURIComponent(key)}`;
|
||||
case 'image': return `/images/${encodeURIComponent(key)}`;
|
||||
case 'video': return `/videos/${encodeURIComponent(key)}`;
|
||||
case 'audio': return `/audios/${encodeURIComponent(key)}`;
|
||||
case 'torrent': return `/torrents/${encodeURIComponent(key)}`;
|
||||
case 'document': return `/documents/${encodeURIComponent(key)}`;
|
||||
case 'feed': return `/feed/${encodeURIComponent(key)}`;
|
||||
case 'votes': return `/votes/${encodeURIComponent(key)}`;
|
||||
case 'transfer': return `/transfers/${encodeURIComponent(key)}`;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderContentHtml = (content, key) => {
|
||||
switch (content.type) {
|
||||
case 'bookmark':
|
||||
return div({ class: 'opinion-bookmark' },
|
||||
div({ class: 'card-section bookmark' },
|
||||
h2(content.url ? div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, p(a({ href: content.url, target: '_blank', class: "bookmark-url" }, content.url)))
|
||||
) : ""),
|
||||
content.lastVisit ? div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.bookmarkLastVisitLabel + ':'),
|
||||
span({ class: 'card-value' }, new Date(content.lastVisit).toLocaleString())
|
||||
) : "",
|
||||
content.description
|
||||
? [
|
||||
span({ class: 'card-label' }, i18n.bookmarkDescriptionLabel + ":"),
|
||||
p(...renderUrl(content.description))
|
||||
]
|
||||
: null
|
||||
)
|
||||
);
|
||||
case 'image':
|
||||
return div({ class: 'opinion-image' },
|
||||
div({ class: 'card-section image' },
|
||||
content.title ? div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.imageTitleLabel + ':'),
|
||||
span({ class: 'card-value' }, content.title)
|
||||
) : "",
|
||||
content.description
|
||||
? [
|
||||
span({ class: 'card-label' }, i18n.imageDescriptionLabel + ":"),
|
||||
p(...renderUrl(content.description))
|
||||
]
|
||||
: null,
|
||||
content.meme ? div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.trendingCategory + ':'),
|
||||
span({ class: 'card-value' }, i18n.meme)
|
||||
) : "",
|
||||
br(),
|
||||
div({ class: 'card-field' },
|
||||
img({ src: `/blob/${encodeURIComponent(content.url)}`, class: 'feed-image' })
|
||||
)
|
||||
)
|
||||
);
|
||||
case 'video':
|
||||
return div({ class: 'opinion-video' },
|
||||
div({ class: 'card-section video' },
|
||||
content.title ? div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.videoTitleLabel + ':'),
|
||||
span({ class: 'card-value' }, content.title)
|
||||
) : "",
|
||||
content.description
|
||||
? [
|
||||
span({ class: 'card-label' }, i18n.videoDescriptionLabel + ":"),
|
||||
p(...renderUrl(content.description))
|
||||
]
|
||||
: null,
|
||||
div({ class: 'card-field' },
|
||||
videoHyperaxe({
|
||||
controls: true,
|
||||
src: `/blob/${encodeURIComponent(content.url)}`,
|
||||
type: content.mimeType || 'video/mp4',
|
||||
width: '640',
|
||||
height: '360'
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
case 'audio':
|
||||
return div({ class: 'opinion-audio' },
|
||||
div({ class: 'card-section audio' },
|
||||
content.title ? div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.audioTitleLabel + ':'),
|
||||
span({ class: 'card-value' }, content.title)
|
||||
) : "",
|
||||
content.description
|
||||
? [
|
||||
span({ class: 'card-label' }, i18n.audioDescriptionLabel + ":"),
|
||||
p(...renderUrl(content.description))
|
||||
]
|
||||
: null,
|
||||
div({ class: 'card-field' },
|
||||
audioHyperaxe({
|
||||
controls: true,
|
||||
src: `/blob/${encodeURIComponent(content.url)}`,
|
||||
type: content.mimeType,
|
||||
preload: 'metadata'
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
case 'torrent':
|
||||
return div({ class: 'opinion-torrent' },
|
||||
div({ class: 'card-section' },
|
||||
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || 'Title') + ':'), span({ class: 'card-value' }, content.title)) : ""
|
||||
)
|
||||
);
|
||||
case 'document': {
|
||||
const t = content.title?.trim();
|
||||
if (t && seenDocumentTitles.has(t)) return null;
|
||||
if (t) seenDocumentTitles.add(t);
|
||||
return div({ class: 'opinion-document' },
|
||||
div({ class: 'card-section document' },
|
||||
t ? div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.documentTitleLabel + ':'),
|
||||
span({ class: 'card-value' }, t)
|
||||
) : "",
|
||||
content.description
|
||||
? [
|
||||
span({ class: 'card-label' }, i18n.documentDescriptionLabel + ":"),
|
||||
p(...renderUrl(content.description))
|
||||
]
|
||||
: null,
|
||||
div({ class: 'card-field' },
|
||||
div({ class: 'pdf-viewer-container', 'data-pdf-url': `/blob/${encodeURIComponent(content.url)}` })
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
case 'feed':
|
||||
return div({ class: 'opinion-feed' },
|
||||
div({ class: 'card-section feed' },
|
||||
div({ class: 'feed-text', innerHTML: sanitizeHtml(renderTextWithStyles(content.text)) }),
|
||||
content.refeeds
|
||||
? h2({ class: 'card-field' }, span({ class: 'card-label' }, `${i18n.tribeFeedRefeeds}: `), span({ class: 'card-value' }, content.refeeds))
|
||||
: ""
|
||||
)
|
||||
);
|
||||
case 'votes': {
|
||||
const votesList = content.votes && typeof content.votes === 'object'
|
||||
? Object.entries(content.votes).map(([option, count]) => ({ option, count }))
|
||||
: [];
|
||||
return div({ class: 'opinion-votes' },
|
||||
div({ class: 'card-section votes' },
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.voteQuestionLabel + ':'),
|
||||
span({ class: 'card-value' }, content.question)
|
||||
),
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.voteDeadline + ':'),
|
||||
span({ class: 'card-value' }, content.deadline ? new Date(content.deadline).toLocaleString() : '')
|
||||
),
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.voteTotalVotes + ':'),
|
||||
span({ class: 'card-value' }, content.totalVotes)
|
||||
),
|
||||
table(
|
||||
tr(...votesList.map(({ option }) => th(i18n[option] || option))),
|
||||
tr(...votesList.map(({ count }) => td(count)))
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
case 'transfer':
|
||||
return div({ class: 'opinion-transfer' },
|
||||
div({ class: 'card-section transfer' },
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.concept + ':'),
|
||||
span({ class: 'card-value' }, content.concept)
|
||||
),
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.deadline + ':'),
|
||||
span({ class: 'card-value' }, content.deadline ? new Date(content.deadline).toLocaleString() : '')
|
||||
),
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.status + ':'),
|
||||
span({ class: 'card-value' }, content.status)
|
||||
),
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.amount + ':'),
|
||||
span({ class: 'card-value' }, content.amount)
|
||||
),
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.from + ':'),
|
||||
span({ class: 'card-value' }, userLink(content.from))
|
||||
),
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.to + ':'),
|
||||
span({ class: 'card-value' }, userLink(content.to))
|
||||
),
|
||||
h2({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, `${i18n.transfersConfirmations}: `),
|
||||
span({ class: 'card-value' }, `${content.confirmedBy.length}/2`)
|
||||
)
|
||||
)
|
||||
);
|
||||
default:
|
||||
return div({ class: 'styled-text' },
|
||||
div({ class: 'card-section styled-text-content' },
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-value', innerHTML: sanitizeHtml(content.text || content.description || content.title || '[no content]') })
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
exports.opinionsView = (items, filter, spreadMap = new Map()) => {
|
||||
seenDocumentTitles.clear();
|
||||
items = items
|
||||
.filter(item => {
|
||||
const c = item.value?.content || item.content;
|
||||
return c && typeof c === 'object' && c.type !== 'tombstone';
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (filter === 'TOP') {
|
||||
const aVotes = (a.value.content.opinions_inhabitants || []).length;
|
||||
const bVotes = (b.value.content.opinions_inhabitants || []).length;
|
||||
return bVotes !== aVotes ? bVotes - aVotes : b.value.timestamp - a.value.timestamp;
|
||||
}
|
||||
return b.value.timestamp - a.value.timestamp;
|
||||
});
|
||||
|
||||
const title = i18n.opinionsTitle;
|
||||
const baseFilters = ['TOP', 'ALL', 'MINE', 'RECENT'];
|
||||
|
||||
const cards = items
|
||||
.map(item => {
|
||||
const c = item.value.content;
|
||||
const key = item.key;
|
||||
const contentHtml = renderContentHtml(c, key);
|
||||
if (!contentHtml) return null;
|
||||
const voteEntries = Object.entries(c.opinions || {});
|
||||
const total = voteEntries.reduce((sum, [, v]) => sum + v, 0);
|
||||
const voted = c.opinions_inhabitants?.includes(config.keys.id);
|
||||
const created = new Date(item.value.timestamp).toLocaleString();
|
||||
const allCats = opinionCategories;
|
||||
|
||||
const isOwn = item.value.author && String(item.value.author) === String(config.keys.id);
|
||||
return div(
|
||||
{ class: 'trending-card opinions-card' + (isOwn ? ' own-content' : '') },
|
||||
div({ class: 'card-header activity-card-header' },
|
||||
span({ class: 'pm-exposition-chip pm-exposition-whole' },
|
||||
span({ class: 'pm-exposition-text' }, String(c.type || '').toUpperCase())
|
||||
),
|
||||
renderContentActions(key, detailHref(c.type, key))
|
||||
),
|
||||
div(
|
||||
{ class: 'card-section opinions-card-body' },
|
||||
contentHtml,
|
||||
div({ class: 'card-spread-left' }, renderSpreadButton(key, spreadMap.get(key))),
|
||||
p({ class: 'card-footer' },
|
||||
span({ class: 'date-link' }, `${created} ${i18n.performed} `),
|
||||
userLink(item.value.author)
|
||||
),
|
||||
(() => {
|
||||
const entries = voteEntries.filter(([, v]) => v > 0);
|
||||
const dominantPart = (() => {
|
||||
if (!entries.length) return null;
|
||||
const maxVal = Math.max(...entries.map(([, v]) => v));
|
||||
const dominant = entries.filter(([, v]) => v === maxVal).map(([k]) => i18n['vote' + k.charAt(0).toUpperCase() + k.slice(1)] || k);
|
||||
return [
|
||||
span({ class: 'trending-dominant-sep' }, '|'),
|
||||
span({ class: 'trending-dominant-text' }, `${i18n.moreVoted || 'More Voted'}: ${dominant.join(' + ')}`)
|
||||
];
|
||||
})();
|
||||
return h2(
|
||||
`${i18n.totalOpinions || i18n.opinionsTotalCount}: `,
|
||||
span({ class: 'trending-total-count' }, String(total)),
|
||||
...(dominantPart || [])
|
||||
);
|
||||
})(),
|
||||
details({ class: 'opinions-voting-collapse' },
|
||||
summary({ class: 'opinions-summary' }, `${i18n.opinionsTitle || 'Opinions'} (${Object.values(c.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)})`),
|
||||
div({ class: 'voting-buttons' },
|
||||
allCats.map(cat => {
|
||||
const label = `${i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${c.opinions?.[cat] || 0}]`;
|
||||
if (voted) {
|
||||
return button({ class: 'vote-btn', type: 'button' }, label);
|
||||
}
|
||||
return form({ method: 'POST', action: `/opinions/${encodeURIComponent(key)}/${cat}` },
|
||||
button({ class: 'vote-btn' }, label)
|
||||
);
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const hasDocuments = items.some(item => item.value.content?.type === 'document');
|
||||
const header = div({ class: 'tags-header' },
|
||||
h2(title),
|
||||
p(i18n.shareYourOpinions)
|
||||
);
|
||||
|
||||
const html = template(
|
||||
title,
|
||||
section(
|
||||
header,
|
||||
div({ class: 'mode-buttons' },
|
||||
div({ class: 'column' },
|
||||
baseFilters.map(mode =>
|
||||
form({ method: 'GET', action: '/opinions' },
|
||||
input({ type: 'hidden', name: 'filter', value: mode }),
|
||||
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
|
||||
)
|
||||
)
|
||||
),
|
||||
div({ class: 'column' },
|
||||
opinionCategories.positive.slice(0, 5).map(mode =>
|
||||
form({ method: 'GET', action: '/opinions' },
|
||||
input({ type: 'hidden', name: 'filter', value: mode }),
|
||||
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
|
||||
)
|
||||
)
|
||||
),
|
||||
div({ class: 'column' },
|
||||
opinionCategories.positive.slice(5, 10).map(mode =>
|
||||
form({ method: 'GET', action: '/opinions' },
|
||||
input({ type: 'hidden', name: 'filter', value: mode }),
|
||||
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
|
||||
)
|
||||
)
|
||||
),
|
||||
div({ class: 'column' },
|
||||
opinionCategories.positive.slice(10, 15).map(mode =>
|
||||
form({ method: 'GET', action: '/opinions' },
|
||||
input({ type: 'hidden', name: 'filter', value: mode }),
|
||||
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
div({ class: 'mode-buttons' },
|
||||
div({ class: 'column' },
|
||||
opinionCategories.constructive.slice(0, 5).map(mode =>
|
||||
form({ method: 'GET', action: '/opinions' },
|
||||
input({ type: 'hidden', name: 'filter', value: mode }),
|
||||
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
|
||||
)
|
||||
)
|
||||
),
|
||||
div({ class: 'column' },
|
||||
opinionCategories.constructive.slice(5, 11).map(mode =>
|
||||
form({ method: 'GET', action: '/opinions' },
|
||||
input({ type: 'hidden', name: 'filter', value: mode }),
|
||||
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
|
||||
)
|
||||
)
|
||||
),
|
||||
div({ class: 'column' },
|
||||
opinionCategories.moderation.slice(0, 5).map(mode =>
|
||||
form({ method: 'GET', action: '/opinions' },
|
||||
input({ type: 'hidden', name: 'filter', value: mode }),
|
||||
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
|
||||
)
|
||||
)
|
||||
),
|
||||
div({ class: 'column' },
|
||||
opinionCategories.moderation.slice(5, 10).map(mode =>
|
||||
form({ method: 'GET', action: '/opinions' },
|
||||
input({ type: 'hidden', name: 'filter', value: mode }),
|
||||
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
cards.length
|
||||
? div({ class: 'opinions-container' }, ...cards)
|
||||
: div({ class: 'no-results' }, p(i18n.noOpinionsFound))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return `${html}${hasDocuments
|
||||
? `<script type="module" src="/js/pdf.min.mjs"></script>
|
||||
<script src="/js/pdf-viewer.js"></script>`
|
||||
: ''}`;
|
||||
};
|
||||
|
||||
392
src/views/pads_view.js
Normal file
392
src/views/pads_view.js
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
const { div, h2, h3, h4, p, section, button, form, a, span, br, textarea, input, label, select, option, table, tr, td } = require("../server/node_modules/hyperaxe")
|
||||
const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton } = require("./main_views")
|
||||
const { renderEncryptedChip } = require("./clearnet_view")
|
||||
const moment = require("../server/node_modules/moment")
|
||||
const { config } = require("../server/SSB_server.js")
|
||||
|
||||
const userId = config.keys.id
|
||||
|
||||
const PAD_COLOR_CLASSES = ["pad-author-color-0","pad-author-color-1","pad-author-color-2","pad-author-color-3","pad-author-color-4","pad-author-color-5","pad-author-color-6","pad-author-color-7","pad-author-color-8","pad-author-color-9"]
|
||||
const memberColorClass = (members, feedId) => {
|
||||
const idx = members.indexOf(feedId)
|
||||
return idx >= 0 ? PAD_COLOR_CLASSES[idx % PAD_COLOR_CLASSES.length] : "pad-author-color-none"
|
||||
}
|
||||
|
||||
const sliceChunksByOffset = (chunks, from, to) => {
|
||||
const out = []
|
||||
let pos = 0
|
||||
for (const c of chunks) {
|
||||
const cStart = pos
|
||||
const cEnd = pos + c.text.length
|
||||
if (cEnd <= from) { pos = cEnd; continue }
|
||||
if (cStart >= to) break
|
||||
const sliceStart = Math.max(0, from - cStart)
|
||||
const sliceEnd = Math.min(c.text.length, to - cStart)
|
||||
if (sliceEnd > sliceStart) out.push({ text: c.text.slice(sliceStart, sliceEnd), author: c.author })
|
||||
pos = cEnd
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const mergeAdjacent = (chunks) => {
|
||||
const out = []
|
||||
for (const c of chunks) {
|
||||
if (!c.text) continue
|
||||
if (out.length > 0 && out[out.length - 1].author === c.author) {
|
||||
out[out.length - 1].text += c.text
|
||||
} else {
|
||||
out.push({ ...c })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const computeAttributedChunks = (entries) => {
|
||||
if (!entries || entries.length === 0) return []
|
||||
let chunks = [{ text: entries[0].text || "", author: entries[0].author }]
|
||||
for (let i = 1; i < entries.length; i++) {
|
||||
const prev = entries[i - 1].text || ""
|
||||
const curr = entries[i].text || ""
|
||||
const author = entries[i].author
|
||||
let start = 0
|
||||
const maxStart = Math.min(prev.length, curr.length)
|
||||
while (start < maxStart && prev.charCodeAt(start) === curr.charCodeAt(start)) start++
|
||||
let endPrev = prev.length
|
||||
let endCurr = curr.length
|
||||
while (endPrev > start && endCurr > start && prev.charCodeAt(endPrev - 1) === curr.charCodeAt(endCurr - 1)) {
|
||||
endPrev--
|
||||
endCurr--
|
||||
}
|
||||
const inserted = curr.slice(start, endCurr)
|
||||
const headChunks = sliceChunksByOffset(chunks, 0, start)
|
||||
const tailChunks = sliceChunksByOffset(chunks, endPrev, prev.length)
|
||||
const middle = inserted ? [{ text: inserted, author }] : []
|
||||
chunks = mergeAdjacent([...headChunks, ...middle, ...tailChunks])
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
const renderStatus = (status, isClosed) => {
|
||||
if (isClosed) return span({ class: "pad-status-closed" }, i18n.padStatusClosed || "CLOSED")
|
||||
if (status === "INVITE-ONLY") return span({ class: "pad-status-invite" }, i18n.padStatusInviteOnly || "INVITE-ONLY")
|
||||
return span({ class: "pad-status-open" }, i18n.padStatusOpen || "OPEN")
|
||||
}
|
||||
|
||||
const renderPadStatusChip = (status, isClosed) => {
|
||||
const s = isClosed ? "CLOSED" : String(status || "OPEN").toUpperCase()
|
||||
const variant = s === "CLOSED" ? "closed" : s === "INVITE-ONLY" ? "whole" : "mutuals"
|
||||
const icon = s === "CLOSED" ? "✗" : s === "INVITE-ONLY" ? "🔑" : "✓"
|
||||
const label = s === "CLOSED" ? (i18n.padStatusClosed || "CLOSED")
|
||||
: s === "INVITE-ONLY" ? (i18n.padStatusInviteOnly || "INVITE-ONLY")
|
||||
: (i18n.padStatusOpen || "OPEN")
|
||||
return renderStateChip(variant, icon, label)
|
||||
}
|
||||
|
||||
const renderModeButtons = (currentFilter) =>
|
||||
div({ class: "tribe-mode-buttons" },
|
||||
["all", "mine", "recent", "open", "closed"].map(f =>
|
||||
form({ method: "GET", action: "/pads" },
|
||||
input({ type: "hidden", name: "filter", value: f }),
|
||||
button({ type: "submit", class: currentFilter === f ? "filter-btn active" : "filter-btn" },
|
||||
i18n[`padFilter${f.charAt(0).toUpperCase() + f.slice(1)}`] || f.toUpperCase())
|
||||
)
|
||||
),
|
||||
form({ method: "GET", action: "/pads" },
|
||||
input({ type: "hidden", name: "filter", value: "create" }),
|
||||
button({ type: "submit", class: "create-button" }, i18n.padCreate || "Create Pad")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
const renderPadCard = (pad, filter, spreadInfo) => {
|
||||
const chips = [
|
||||
renderPadStatusChip(pad.status, pad.isClosed),
|
||||
renderEncryptedChip(i18n),
|
||||
renderLifespanChip(pad.lifetime, i18n)
|
||||
].filter(Boolean)
|
||||
return div({ class: "tribe-card" },
|
||||
div({ class: "tribe-card-body" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" },
|
||||
a({ href: `/pads/${encodeURIComponent(pad.rootId)}` }, pad.title || "\u2014")
|
||||
)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
pad.deadline
|
||||
? p({ class: "job-meta-line" }, `${i18n.padDeadlineLabel || "Deadline"}: ${moment(pad.deadline).format("YYYY-MM-DD HH:mm")}`)
|
||||
: null,
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.padMembersLabel || "Members"}: ${pad.members.length}`)
|
||||
),
|
||||
(() => {
|
||||
const btn = renderSpreadButton(pad.rootId, spreadInfo);
|
||||
return btn ? div({ class: "card-spread-left" }, btn) : null;
|
||||
})(),
|
||||
div({ class: "visit-btn-centered" },
|
||||
a({ href: `/pads/${encodeURIComponent(pad.rootId)}`, class: "filter-btn" }, i18n.padVisitPad || "Visit Pad")
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const renderCreateForm = (padToEdit, params) => {
|
||||
const tribeId = (params && params.tribeId) || ""
|
||||
return div({ class: "div-center audio-form" },
|
||||
h2(padToEdit ? (i18n.padUpdateSectionTitle || "Update Pad") : (i18n.padCreateSectionTitle || "Create New Pad")),
|
||||
form({
|
||||
method: "POST",
|
||||
action: padToEdit ? `/pads/update/${encodeURIComponent(padToEdit.rootId)}` : "/pads/create"
|
||||
},
|
||||
tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null,
|
||||
span(i18n.padTitleLabel || "Title"), require("../server/node_modules/hyperaxe").br(),
|
||||
input({ type: "text", name: "title", value: padToEdit ? padToEdit.title : "", placeholder: i18n.padTitlePlaceholder || "Enter pad title...", required: true }),
|
||||
require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(),
|
||||
span(i18n.padStatusLabel || "Status"), require("../server/node_modules/hyperaxe").br(),
|
||||
select({ name: "status" },
|
||||
["OPEN", "INVITE-ONLY"].map(s =>
|
||||
option({ value: s, ...(padToEdit && padToEdit.status === s ? { selected: true } : {}) }, s)
|
||||
)
|
||||
),
|
||||
require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(),
|
||||
span(i18n.padDeadlineLabel || "Deadline"), require("../server/node_modules/hyperaxe").br(),
|
||||
input({
|
||||
type: "datetime-local",
|
||||
name: "deadline",
|
||||
value: padToEdit && padToEdit.deadline ? moment(padToEdit.deadline).format("YYYY-MM-DDTHH:mm") : "",
|
||||
min: moment().format("YYYY-MM-DDTHH:mm")
|
||||
}),
|
||||
require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(),
|
||||
span(i18n.padTagsLabel || "Tags"), require("../server/node_modules/hyperaxe").br(),
|
||||
input({ type: "text", name: "tags", value: padToEdit ? padToEdit.tags.join(", ") : "", placeholder: i18n.padTagsPlaceholder || "tag1, tag2, ..." }),
|
||||
require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(),
|
||||
button({ type: "submit", class: "create-button" }, padToEdit ? (i18n.padUpdate || "Update Pad") : (i18n.padCreate || "Create Pad"))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
exports.renderPadInvitePage = (code) => {
|
||||
const pageContent = div({ class: "invite-page" },
|
||||
h2(i18n.tribeInviteCodeText, code),
|
||||
form({ method: "GET", action: "/pads" },
|
||||
input({ type: "hidden", name: "filter", value: "all" }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.walletBack)
|
||||
)
|
||||
)
|
||||
return template(i18n.padInviteMode || "Invite", section(pageContent))
|
||||
}
|
||||
|
||||
exports.padsView = async (pads, filter, padToEdit, params) => {
|
||||
const q = String((params && params.q) || "").trim()
|
||||
const isForm = filter === "create" || filter === "edit"
|
||||
const headerMap = {
|
||||
all: i18n.padAllSectionTitle || "Pads",
|
||||
mine: i18n.padMineSectionTitle || "Your Pads",
|
||||
recent: i18n.padRecentSectionTitle || "Recent Pads",
|
||||
open: i18n.padOpenSectionTitle || "Open Pads",
|
||||
closed: i18n.padClosedSectionTitle || "Closed Pads"
|
||||
}
|
||||
const headerText = headerMap[filter] || headerMap.all
|
||||
|
||||
const filteredPads = q
|
||||
? pads.filter(pd => String(pd.title || "").toLowerCase().includes(q.toLowerCase()))
|
||||
: pads
|
||||
|
||||
const body = div({ class: "main-column" },
|
||||
div({ class: "tags-header" },
|
||||
h2(headerText),
|
||||
p(i18n.padsDescription || "Manage collaborative encrypted text editors in your network.")
|
||||
),
|
||||
renderModeButtons(filter),
|
||||
!isForm
|
||||
? div({ class: "filters" },
|
||||
form({ method: "GET", action: "/pads" },
|
||||
input({ type: "hidden", name: "filter", value: filter }),
|
||||
input({ type: "text", name: "q", placeholder: i18n.padSearchPlaceholder || "Search pads...", value: q }),
|
||||
br(),
|
||||
button({ type: "submit" }, i18n.search),
|
||||
br()
|
||||
)
|
||||
)
|
||||
: null,
|
||||
isForm
|
||||
? renderCreateForm(padToEdit, params)
|
||||
: div(
|
||||
filteredPads.length === 0
|
||||
? p({ class: "no-content" }, i18n.padsNoItems || "No pads found.")
|
||||
: div({ class: "tribe-grid" }, ...filteredPads.map(pd => renderPadCard(pd, filter, params && params.spreadMap && params.spreadMap.get(pd.rootId))))
|
||||
)
|
||||
)
|
||||
|
||||
return template(i18n.padsTitle || "Pads", body)
|
||||
}
|
||||
|
||||
exports.singlePadView = async (pad, entries, params) => {
|
||||
const isAuthor = String(pad.author) === String(userId)
|
||||
const isMember = pad.members.includes(userId) || (!!pad.tribeId && !!pad.isTribeMember)
|
||||
const padClosed = pad.isClosed
|
||||
const returnTo = `/pads/${encodeURIComponent(pad.rootId)}`
|
||||
const shareUrl = `/pads/${encodeURIComponent(pad.rootId)}`
|
||||
const isRestrictedInviteOnly = !isMember && !isAuthor && pad.status === "INVITE-ONLY"
|
||||
|
||||
const tags = !isRestrictedInviteOnly && Array.isArray(pad.tags) && pad.tags.length > 0
|
||||
? div({ class: "tribe-side-tags" }, ...pad.tags.map(t => a({ href: `/search?query=%23${encodeURIComponent(t)}` }, `#${t}`)))
|
||||
: null
|
||||
|
||||
const detailChips = [
|
||||
renderPadStatusChip(pad.status, padClosed),
|
||||
renderEncryptedChip(i18n),
|
||||
renderLifespanChip(pad.lifetime, i18n)
|
||||
].filter(Boolean)
|
||||
const padSide = div({ class: "tribe-side" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, pad.title || "\u2014")
|
||||
),
|
||||
detailChips.length ? div({ class: "card-chips-row" }, ...detailChips) : null,
|
||||
div({ class: "shop-share" },
|
||||
span({ class: "tribe-info-label" }, i18n.padShareUrl || "Share URL"),
|
||||
input({ type: "text", readonly: true, value: shareUrl, class: "shop-share-input" })
|
||||
),
|
||||
(() => {
|
||||
const btn = renderSpreadButton(pad.rootId);
|
||||
return btn ? div({ class: "card-spread-centered" }, btn) : null;
|
||||
})(),
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.padMembersLabel || "Members"}: ${pad.members.length}`)
|
||||
),
|
||||
table({ class: "tribe-info-table" },
|
||||
tr(td({ class: "tribe-info-label" }, i18n.padCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(pad.createdAt).format("YYYY-MM-DD"))),
|
||||
isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-value pad-author-cell", colspan: "4" }, userLink(pad.author))),
|
||||
isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-label" }, i18n.padDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, pad.deadline ? moment(pad.deadline).format("YYYY-MM-DD HH:mm") : "\u2014"))
|
||||
),
|
||||
isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions" },
|
||||
isAuthor && pad.status === "INVITE-ONLY"
|
||||
? form({ method: "POST", action: `/pads/generate-invite/${encodeURIComponent(pad.rootId)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeGenerateInvite)
|
||||
)
|
||||
: null,
|
||||
(() => {
|
||||
if (!(isAuthor && pad.status === "INVITE-ONLY")) return null
|
||||
const invs = Array.isArray(pad.invites) ? pad.invites : []
|
||||
const openInvite = invs.find(inv => typeof inv === "object" && inv && inv.public === true && inv.code)
|
||||
if (openInvite) return [
|
||||
div({ class: "tribe-open-invite" },
|
||||
span({ class: "card-label" }, i18n.tribeInviteCodeText),
|
||||
span({ class: "tribe-open-invite-code" }, openInvite.code)
|
||||
),
|
||||
form({ method: "POST", action: `/pads/open-invite/remove/${encodeURIComponent(pad.rootId)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeRemoveInvitation)
|
||||
)
|
||||
]
|
||||
return form({ method: "POST", action: `/pads/open-invite/create/${encodeURIComponent(pad.rootId)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeOpenInvitation)
|
||||
)
|
||||
})(),
|
||||
form(
|
||||
{ method: "POST", action: pad.isFavorite ? `/pads/favorites/remove/${encodeURIComponent(pad.key)}` : `/pads/favorites/add/${encodeURIComponent(pad.key)}` },
|
||||
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
||||
button({ type: "submit", class: "tribe-action-btn" }, pad.isFavorite ? (i18n.padRemoveFavorite || "Remove Favorite") : (i18n.padAddFavorite || "Add Favorite"))
|
||||
),
|
||||
!isAuthor
|
||||
? a({ href: `/pm?to=${encodeURIComponent(pad.author)}`, class: "tribe-action-btn" }, "PM")
|
||||
: null,
|
||||
isAuthor
|
||||
? form({ method: "GET", action: "/pads" },
|
||||
input({ type: "hidden", name: "filter", value: "edit" }),
|
||||
input({ type: "hidden", name: "id", value: pad.rootId }),
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.padUpdate || "Update")
|
||||
)
|
||||
: null,
|
||||
isAuthor && pad.status !== "CLOSED" && !padClosed
|
||||
? form({ method: "POST", action: `/pads/close/${encodeURIComponent(pad.rootId)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.padClose || "Close Pad")
|
||||
)
|
||||
: null,
|
||||
isAuthor
|
||||
? form({ method: "POST", action: `/pads/delete/${encodeURIComponent(pad.rootId)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.padDelete || "Delete")
|
||||
)
|
||||
: null
|
||||
),
|
||||
!isAuthor && pad.status === "INVITE-ONLY" && !isMember
|
||||
? div({ class: "pad-invite-section" },
|
||||
a({ class: "tribe-action-btn", href: "/invites#invites-pads" }, i18n.tribeEnterInvite)
|
||||
)
|
||||
: null,
|
||||
!isRestrictedInviteOnly && !isAuthor && !isMember && pad.status === "OPEN" && !padClosed
|
||||
? form({ method: "POST", action: `/pads/join/${encodeURIComponent(pad.rootId)}` },
|
||||
button({ type: "submit", class: "create-button" }, i18n.padStartEditing || "START EDITING!")
|
||||
)
|
||||
: null,
|
||||
tags
|
||||
)
|
||||
|
||||
let canonicalEntries = entries
|
||||
if (params.selectedVersion) {
|
||||
const idx = entries.findIndex(e => e.key === params.selectedVersion.key)
|
||||
if (idx >= 0) canonicalEntries = entries.slice(0, idx + 1)
|
||||
}
|
||||
const chunks = computeAttributedChunks(canonicalEntries)
|
||||
const lastEntry = canonicalEntries.length > 0 ? canonicalEntries[canonicalEntries.length - 1] : null
|
||||
const currentText = lastEntry ? lastEntry.text : ""
|
||||
|
||||
const coloredView = chunks.length > 0
|
||||
? div({ class: "pad-readonly-colored" },
|
||||
...chunks.map(c =>
|
||||
span({ class: "pad-author-span " + memberColorClass(pad.members, c.author) }, c.text)
|
||||
)
|
||||
)
|
||||
: p(i18n.padNoEntries || "No entries yet.")
|
||||
|
||||
const visibleEntries = entries.filter(e => e.text && String(e.text).trim())
|
||||
const versionList = visibleEntries.length > 0
|
||||
? div({ class: "pad-version-list" },
|
||||
h4(i18n.padVersionHistory || "Version History"),
|
||||
...visibleEntries.slice().reverse().map((e, idx) =>
|
||||
div({ class: "pad-version-item" },
|
||||
span({ class: "pad-version-date" }, moment(e.createdAt).format("YYYY-MM-DD HH:mm")),
|
||||
span({ class: "pad-version-author" },
|
||||
span({ class: "pad-author-swatch " + memberColorClass(pad.members, e.author) }),
|
||||
userLink(e.author)
|
||||
),
|
||||
a({ href: `/pads/${encodeURIComponent(pad.rootId)}?version=${encodeURIComponent(e.key || idx)}`, class: "pad-version-link" }, i18n.padVersionView || "View")
|
||||
)
|
||||
)
|
||||
)
|
||||
: null
|
||||
|
||||
const editorArea = isMember && !padClosed && !params.selectedVersion
|
||||
? div({ class: "pad-editor-area" },
|
||||
coloredView,
|
||||
form({ method: "POST", action: `/pads/entry/${encodeURIComponent(pad.rootId)}` },
|
||||
textarea({ name: "text", rows: "12", class: "pad-editor-white", placeholder: i18n.padEditorPlaceholder || "Start writing..." }, currentText),
|
||||
button({ type: "submit", class: "create-button" }, i18n.padSubmitEntry || "Submit")
|
||||
),
|
||||
versionList ? div({ class: "pad-version-section" }, versionList) : null
|
||||
)
|
||||
: div({ class: "pad-editor-area" },
|
||||
params.selectedVersion
|
||||
? div({ class: "pad-viewer-back" },
|
||||
a({ href: `/pads/${encodeURIComponent(pad.rootId)}`, class: "filter-btn" },
|
||||
"\u2190 " + (i18n.padBackToEditor || "Back to editor"))
|
||||
)
|
||||
: null,
|
||||
coloredView,
|
||||
versionList ? div({ class: "pad-version-section" }, versionList) : null
|
||||
)
|
||||
|
||||
const padMain = isRestrictedInviteOnly
|
||||
? div({ class: "tribe-main" }, p({ class: "access-denied-msg" }, i18n.padAccessDenied))
|
||||
: div({ class: "tribe-main" }, editorArea)
|
||||
|
||||
return template(
|
||||
pad.title || i18n.padsTitle || "Pad",
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.padsTitle || "Pads"),
|
||||
p(i18n.padsDescription || "Manage collaborative encrypted text editors in your network.")
|
||||
),
|
||||
renderModeButtons("all")
|
||||
),
|
||||
section(div({ class: "tribe-details" }, padSide, padMain))
|
||||
)
|
||||
}
|
||||
986
src/views/parliament_view.js
Normal file
986
src/views/parliament_view.js
Normal file
|
|
@ -0,0 +1,986 @@
|
|||
const { form, button, div, h2, p, section, input, label, br, a, span, table, thead, tbody, tr, th, td, textarea, select, option, ul, li, img } = require('../server/node_modules/hyperaxe');
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { template, i18n, userLink} = require('./main_views');
|
||||
|
||||
const TERM_DAYS = 60;
|
||||
|
||||
const fmt = (d) => moment(d).format('YYYY-MM-DD HH:mm:ss');
|
||||
const timeLeft = (end) => {
|
||||
const diff = moment(end).diff(moment());
|
||||
if (diff <= 0) return '0d 00:00:00';
|
||||
const dur = moment.duration(diff);
|
||||
const d = Math.floor(dur.asDays());
|
||||
const h = String(dur.hours()).padStart(2,'0');
|
||||
const m = String(dur.minutes()).padStart(2,'0');
|
||||
const s = String(dur.seconds()).padStart(2,'0');
|
||||
return `${d}d ${h}:${m}:${s}`;
|
||||
};
|
||||
const reqVotes = (method, total) => {
|
||||
const m = String(method || '').toUpperCase();
|
||||
if (m === 'DEMOCRACY' || m === 'ANARCHY') return Math.floor(Number(total || 0) / 2) + 1;
|
||||
if (m === 'MAJORITY') return Math.ceil(Number(total || 0) * 0.8);
|
||||
if (m === 'MINORITY') return Math.ceil(Number(total || 0) * 0.2);
|
||||
return 0;
|
||||
};
|
||||
const showVoteMetrics = (method) => {
|
||||
const m = String(method || '').toUpperCase();
|
||||
return !(m === 'DICTATORSHIP' || m === 'KARMATOCRACY');
|
||||
};
|
||||
const applyEl = (fn, attrs, kids) => fn.apply(null, [attrs || {}].concat(kids || []));
|
||||
const methodImageSrc = (method) => `assets/images/${String(method || '').toUpperCase().toLowerCase()}.png`;
|
||||
const MethodBadge = (method) => {
|
||||
const m = String(method || '').toUpperCase();
|
||||
const labelTxt = String(i18n[`parliamentMethod${m}`] || m).toUpperCase();
|
||||
return span(
|
||||
{ class: 'method-badge' },
|
||||
labelTxt,
|
||||
br(),br(),
|
||||
img({ src: methodImageSrc(m), alt: labelTxt, class: 'method-badge__icon' })
|
||||
);
|
||||
};
|
||||
const MethodHero = (method) => {
|
||||
const m = String(method || '').toUpperCase();
|
||||
const labelTxt = String(i18n[`parliamentMethod${m}`] || m).toUpperCase();
|
||||
return span(
|
||||
{ class: 'method-hero' },
|
||||
labelTxt,
|
||||
br(),br(),
|
||||
img({ src: methodImageSrc(m), alt: labelTxt, class: 'method-hero__icon' })
|
||||
);
|
||||
};
|
||||
const KPI = (labelTxt, value) =>
|
||||
div({ class: 'kpi' },
|
||||
span({ class: 'kpi__label' }, labelTxt),
|
||||
span({ class: 'kpi__value' }, value)
|
||||
);
|
||||
const CycleInfo = (start, end, labels = {
|
||||
since: i18n.parliamentLegSince,
|
||||
end: i18n.parliamentLegEnd,
|
||||
remaining: i18n.parliamentTimeRemaining
|
||||
}) =>
|
||||
div({ class: 'cycle-info' },
|
||||
KPI((labels.since + ': ').toUpperCase(), fmt(start)),
|
||||
KPI((labels.end + ': ').toUpperCase(), fmt(end)),
|
||||
KPI((labels.remaining + ': ').toUpperCase(), timeLeft(end))
|
||||
);
|
||||
const Tabs = (active) =>
|
||||
div(
|
||||
{ class: 'filters' },
|
||||
form(
|
||||
{ method: 'GET', action: '/parliament' },
|
||||
['government', 'candidatures', 'proposals', 'laws', 'revocations', 'historical', 'leaders', 'rules'].map(f =>
|
||||
button({ type: 'submit', name: 'filter', value: f, class: active === f ? 'filter-btn active' : 'filter-btn' }, i18n[`parliamentFilter${f.charAt(0).toUpperCase()+f.slice(1)}`])
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const GovHeader = (g) => {
|
||||
const termStart = g && g.since ? g.since : moment().toISOString();
|
||||
const termEnd = g && g.end ? g.end : moment(termStart).add(TERM_DAYS, 'days').toISOString();
|
||||
const methodKeyRaw = g && g.method ? String(g.method) : 'ANARCHY';
|
||||
const methodKey = methodKeyRaw.toUpperCase();
|
||||
const i18nMeth = i18n[`parliamentMethod${methodKey}`];
|
||||
const methodLabel = (i18nMeth && String(i18nMeth).trim() ? String(i18nMeth) : methodKey).toUpperCase();
|
||||
const isAnarchy = methodKey === 'ANARCHY';
|
||||
const population = String(Number(g.inhabitantsTotal || 0));
|
||||
const votesReceivedNum = Number.isFinite(Number(g.votesReceived)) ? Number(g.votesReceived) : 0;
|
||||
const totalVotesNum = Number.isFinite(Number(g.totalVotes)) ? Number(g.totalVotes) : 0;
|
||||
const votesDisplay = `${votesReceivedNum} (${totalVotesNum})`;
|
||||
return div(
|
||||
{ class: 'cycle-info' },
|
||||
div({ class: 'kpi' },
|
||||
span({ class: 'kpi__label' }, (i18n.parliamentLegSince + ': ').toUpperCase()),
|
||||
span({ class: 'kpi__value' }, fmt(termStart))
|
||||
),
|
||||
div({ class: 'kpi' },
|
||||
span({ class: 'kpi__label' }, (i18n.parliamentLegEnd + ': ').toUpperCase()),
|
||||
span({ class: 'kpi__value' }, fmt(termEnd))
|
||||
),
|
||||
div({ class: 'kpi' },
|
||||
span({ class: 'kpi__label' }, (i18n.parliamentTimeRemaining + ': ').toUpperCase()),
|
||||
span({ class: 'kpi__value' }, timeLeft(termEnd))
|
||||
),
|
||||
div({ class: 'kpi' },
|
||||
span({ class: 'kpi__label' }, (i18n.parliamentPopulation + ': ').toUpperCase()),
|
||||
span({ class: 'kpi__value' }, population)
|
||||
),
|
||||
div({ class: 'kpi' },
|
||||
span({ class: 'kpi__label' }, (i18n.parliamentGovMethod + ': ').toUpperCase()),
|
||||
span({ class: 'kpi__value' }, methodLabel)
|
||||
),
|
||||
!isAnarchy
|
||||
? div({ class: 'kpi' },
|
||||
span({ class: 'kpi__label' }, (i18n.parliamentVotesReceived + ': ').toUpperCase()),
|
||||
span({ class: 'kpi__value' }, votesDisplay)
|
||||
)
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const GovernmentCard = (g, meta) => {
|
||||
const termStart = g && g.since ? g.since : moment().toISOString();
|
||||
const termEnd = g && g.end ? g.end : moment(termStart).add(TERM_DAYS, 'days').toISOString();
|
||||
const actorLabel =
|
||||
g.powerType === 'tribe'
|
||||
? (i18n.parliamentActorInPowerTribe || i18n.parliamentActorInPower || 'TRIBE RULING')
|
||||
: (i18n.parliamentActorInPowerInhabitant || i18n.parliamentActorInPower || 'INHABITANT RULING');
|
||||
const methodKeyRaw = g && g.method ? String(g.method) : 'ANARCHY';
|
||||
const methodKey = methodKeyRaw.toUpperCase();
|
||||
const i18nMeth = i18n[`parliamentMethod${methodKey}`];
|
||||
const methodLabel = (i18nMeth && String(i18nMeth).trim() ? String(i18nMeth) : methodKey).toUpperCase();
|
||||
const actorLink =
|
||||
g.powerType === 'tribe'
|
||||
? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId)
|
||||
: userLink(g.powerId, g.powerTitle);
|
||||
const actorBio = meta && meta.bio ? meta.bio : '';
|
||||
const memberIds = Array.isArray(g.membersList) ? g.membersList : (Array.isArray(g.members) ? g.members : []);
|
||||
const membersRow =
|
||||
g.powerType === 'tribe'
|
||||
? tr(
|
||||
{ class: 'parliament-members-row' },
|
||||
td(
|
||||
{ colspan: 2 },
|
||||
div(
|
||||
span({ class: 'card-label' }, (i18n.parliamentMembers + ': ').toUpperCase()),
|
||||
memberIds && memberIds.length
|
||||
? ul({ class: 'parliament-members-list' }, ...memberIds.map(id => li(userLink(id))))
|
||||
: span({ class: 'card-value' }, String(g.members || 0))
|
||||
)
|
||||
)
|
||||
)
|
||||
: null;
|
||||
return div(
|
||||
{ class: 'card' },
|
||||
h2(i18n.parliamentGovernmentCard),
|
||||
GovHeader(g),
|
||||
div({ class: 'method-image-centered' }, img({ src: methodImageSrc(methodKey), alt: methodLabel })),
|
||||
div(
|
||||
{ class: 'table-wrap' },
|
||||
applyEl(table, { class: 'table table--centered gov-overview' }, [
|
||||
thead(tr(
|
||||
th(i18n.parliamentGovMethod),
|
||||
th(i18n.parliamentThPresented || 'CANDIDATURES'),
|
||||
th(i18n.parliamentPoliciesProposal || 'LAWS PROPOSAL'),
|
||||
th(i18n.parliamentPoliciesApproved || 'LAWS APPROVED'),
|
||||
th(i18n.parliamentPoliciesDeclined || 'LAWS DECLINED'),
|
||||
th(i18n.parliamentPoliciesDiscarded || 'LAWS DISCARDED'),
|
||||
th(i18n.parliamentPoliciesRevocated || 'LAWS REVOCATED'),
|
||||
th(i18n.parliamentEfficiency || '% EFFICIENCY')
|
||||
)),
|
||||
tbody(tr(
|
||||
td({ 'data-label': i18n.parliamentGovMethod }, methodLabel),
|
||||
td({ 'data-label': i18n.parliamentThPresented || 'CANDIDATURES' }, String(g.presented || 0)),
|
||||
td({ 'data-label': i18n.parliamentPoliciesProposal || 'LAWS PROPOSAL' }, String(g.proposed || 0)),
|
||||
td({ 'data-label': i18n.parliamentPoliciesApproved || 'LAWS APPROVED' }, String(g.approved || 0)),
|
||||
td({ 'data-label': i18n.parliamentPoliciesDeclined || 'LAWS DECLINED' }, String(g.declined || 0)),
|
||||
td({ 'data-label': i18n.parliamentPoliciesDiscarded || 'LAWS DISCARDED' }, String(g.discarded || 0)),
|
||||
td({ 'data-label': i18n.parliamentPoliciesRevocated || 'LAWS REVOCATED' }, String(g.revocated || 0)),
|
||||
td({ 'data-label': i18n.parliamentEfficiency || '% EFFICIENCY' }, `${String(g.efficiency || 0)} %`)
|
||||
))
|
||||
])
|
||||
),
|
||||
(g.powerType === 'tribe' || g.powerType === 'inhabitant')
|
||||
? div(
|
||||
{ class: 'table-wrap mt-2' },
|
||||
applyEl(table, { class: 'table parliament-actor-table' }, [
|
||||
thead(tr(
|
||||
th({ class: 'parliament-actor-col' }, String(actorLabel).toUpperCase()),
|
||||
th({ class: 'parliament-description-col' }, i18n.description.toUpperCase())
|
||||
)),
|
||||
tbody(
|
||||
tr(
|
||||
td({ class: 'parliament-actor-col' }, div({ class: 'leader-cell' }, actorLink)),
|
||||
td({ class: 'parliament-description-col' }, p(actorBio || '-'))
|
||||
),
|
||||
membersRow
|
||||
)
|
||||
])
|
||||
)
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const NoGovernment = () => div({ class: 'empty' }, p(i18n.parliamentNoStableGov));
|
||||
const NoProposals = () => div({ class: 'empty' }, p(i18n.parliamentNoProposals));
|
||||
const NoLaws = () => div({ class: 'empty' }, p(i18n.parliamentNoLaws));
|
||||
const NoGovernments = () => div({ class: 'empty' }, p(i18n.parliamentNoGovernments));
|
||||
const NoRevocations = () => null;
|
||||
|
||||
const CandidatureForm = () =>
|
||||
div(
|
||||
{ class: 'div-center' },
|
||||
h2(i18n.parliamentCandidatureFormTitle),
|
||||
form(
|
||||
{ method: 'POST', action: '/parliament/candidatures/propose' },
|
||||
label(i18n.parliamentCandidatureId), br(),
|
||||
input({ type: 'text', name: 'candidateId', placeholder: i18n.parliamentCandidatureIdPh, required: true }), br(), br(),
|
||||
label(i18n.parliamentCandidatureMethod), br(),
|
||||
select({ name: 'method' },
|
||||
['DEMOCRACY','MAJORITY','MINORITY','DICTATORSHIP','KARMATOCRACY'].map(m => option({ value: m }, i18n[`parliamentMethod${m}`] || m))
|
||||
), br(), br(),
|
||||
button({ type: 'submit', class: 'create-button' }, i18n.parliamentCandidatureProposeBtn)
|
||||
)
|
||||
);
|
||||
|
||||
const pickLeader = (arr) => {
|
||||
if (!arr || !arr.length) return null;
|
||||
const sorted = [...arr].sort((a, b) => {
|
||||
const va = Number(a.votes || 0), vb = Number(b.votes || 0);
|
||||
if (vb !== va) return vb - va;
|
||||
const ka = Number(a.karma || 0), kb = Number(b.karma || 0);
|
||||
if (kb !== ka) return kb - ka;
|
||||
const sa = Number(a.profileSince || 0), sb = Number(b.profileSince || 0);
|
||||
if (sa !== sb) return sa - sb;
|
||||
const ca = new Date(a.createdAt).getTime(), cb = new Date(b.createdAt).getTime();
|
||||
if (ca !== cb) return ca - cb;
|
||||
return String(a.targetId).localeCompare(String(b.targetId));
|
||||
});
|
||||
return sorted[0];
|
||||
};
|
||||
|
||||
const CandidatureStats = (cands, govCard, leaderMeta, electionQuorum = 2) => {
|
||||
if (!cands || !cands.length) return null;
|
||||
const leader = pickLeader(cands || []);
|
||||
if (!leader) return null;
|
||||
const methodKey = String(leader.method || '').toUpperCase();
|
||||
const methodLabel = String(i18n[`parliamentMethod${methodKey}`] || methodKey).toUpperCase();
|
||||
const votes = String(leader.votes || 0);
|
||||
const hasQuorum = Number(leader.votes || 0) >= electionQuorum;
|
||||
const avatarSrc = (leaderMeta && leaderMeta.avatarUrl) ? leaderMeta.avatarUrl : '/assets/images/default-avatar.png';
|
||||
const winLbl = (i18n.parliamentWinningCandidature || i18n.parliamentCurrentLeader || 'WINNING CANDIDATURE').toUpperCase();
|
||||
const idLink = leader
|
||||
? (leader.targetType === 'inhabitant'
|
||||
? userLink(leader.targetId)
|
||||
: a({ class: 'tag-link', href: `/tribe/${encodeURIComponent(leader.targetId)}?` }, leader.targetTitle || leader.targetId))
|
||||
: null;
|
||||
const avatarHref = leader
|
||||
? (leader.targetType === 'inhabitant'
|
||||
? `/author/${encodeURIComponent(leader.targetId)}`
|
||||
: `/tribe/${encodeURIComponent(leader.targetId)}?`)
|
||||
: null;
|
||||
return div(
|
||||
{ class: 'card' },
|
||||
h2(i18n.parliamentElectionsStatusTitle),
|
||||
div({ class: 'card-field card-field--spaced' },
|
||||
span({ class: 'card-label' }, winLbl + ': '),
|
||||
span({ class: 'card-value' }, hasQuorum ? idLink : span({ style: 'color:#ffcc00;font-weight:bold;' }, i18n.voteNoQuorum || 'NO QUORUM'))
|
||||
),
|
||||
hasQuorum
|
||||
? div({ class: 'card-field card-field--spaced' },
|
||||
span({ class: 'card-label' }, (i18n.parliamentGovMethod + ': ').toUpperCase()),
|
||||
span({ class: 'card-value' }, methodLabel)
|
||||
)
|
||||
: null,
|
||||
hasQuorum
|
||||
? div(
|
||||
{ class: 'table-wrap mt-2' },
|
||||
applyEl(table, { class: 'parliament-gov-table' }, [
|
||||
thead(tr(
|
||||
th(i18n.parliamentThLeader),
|
||||
th({ class: 'parliament-method-col' }, i18n.parliamentGovMethod),
|
||||
th({ class: 'parliament-votes-col' }, i18n.parliamentVotesReceived)
|
||||
)),
|
||||
tbody(tr(
|
||||
td(
|
||||
div({ class: 'parliament-avatar-cell' },
|
||||
avatarHref
|
||||
? a({ href: avatarHref }, img({ src: avatarSrc, class: 'parliament-leader-avatar' }))
|
||||
: img({ src: avatarSrc, class: 'parliament-leader-avatar' }),
|
||||
idLink ? div({ class: 'parliament-avatar-name' }, idLink) : null
|
||||
)
|
||||
),
|
||||
td({ class: 'parliament-method-col' },
|
||||
img({ src: methodImageSrc(methodKey), alt: methodLabel, class: 'parliament-method-img' })
|
||||
),
|
||||
td({ class: 'parliament-votes-col' }, span({ class: 'votes-value' }, votes))
|
||||
))
|
||||
])
|
||||
)
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const CandidaturesTable = (candidatures) => {
|
||||
const rows = (candidatures || []).map(c => {
|
||||
const idLink =
|
||||
c.targetType === 'inhabitant'
|
||||
? p(userLink(c.targetId))
|
||||
: p(a({ class: 'tag-link', href: `/tribe/${encodeURIComponent(c.targetId)}?` }, c.targetTitle || c.targetId));
|
||||
const voters = Array.isArray(c.voters) ? c.voters : [];
|
||||
return tr(
|
||||
td(idLink),
|
||||
td(fmt(c.createdAt)),
|
||||
td({ class: 'nowrap' }, c.method),
|
||||
td(c.targetType === 'inhabitant' ? String(c.karma || 0) : '-'),
|
||||
td(
|
||||
span({ class: 'candidature-votes-count' }, String(c.votes || 0)),
|
||||
voters.length ? div({ class: 'candidature-voters' }, ...voters.map(v => div({ class: 'candidature-voter' }, userLink(v)))) : null
|
||||
),
|
||||
td(form({ method: 'POST', action: `/parliament/candidatures/${encodeURIComponent(c.id)}/vote` }, button({ class: 'vote-btn' }, i18n.parliamentVoteBtn)))
|
||||
);
|
||||
});
|
||||
return div(
|
||||
{ class: 'table-wrap' },
|
||||
h2(i18n.parliamentCandidaturesListTitle),
|
||||
applyEl(table, { class: 'table table--centered' }, [
|
||||
thead(tr(
|
||||
th(i18n.parliamentThId),
|
||||
th(i18n.parliamentThProposalDate),
|
||||
th(i18n.parliamentThMethod),
|
||||
th(i18n.parliamentThKarma),
|
||||
th(i18n.parliamentThSupports),
|
||||
th(i18n.parliamentThVote)
|
||||
)),
|
||||
applyEl(tbody, null, rows)
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
const ProposalForm = () =>
|
||||
div(
|
||||
{ class: 'div-center' },
|
||||
h2(i18n.parliamentProposalFormTitle),
|
||||
form(
|
||||
{ method: 'POST', action: '/parliament/proposals/create' },
|
||||
label(i18n.parliamentProposalTitle), br(),
|
||||
input({ type: 'text', name: 'title', required: true }), br(), br(),
|
||||
label(i18n.parliamentProposalDescription), br(),
|
||||
textarea({ name: 'description', rows: 5, maxlength: 1000 }), br(), br(),
|
||||
button({ type: 'submit', class: 'create-button' }, i18n.parliamentProposalPublish)
|
||||
)
|
||||
);
|
||||
|
||||
const ProposalsList = (proposals) => {
|
||||
if (!proposals || !proposals.length) return null;
|
||||
const cards = proposals.map(pItem => {
|
||||
const titleNode = pItem && pItem.voteId
|
||||
? a({ class: 'proposal-title-link', href: `/votes/${encodeURIComponent(pItem.voteId)}` }, pItem.title || '')
|
||||
: (pItem.title || '');
|
||||
const onTrackLabel = pItem && pItem.onTrack
|
||||
? (i18n.parliamentProposalOnTrackYes || 'THRESHOLD REACHED')
|
||||
: (i18n.parliamentProposalOnTrackNo || 'BELOW THRESHOLD');
|
||||
return div(
|
||||
{ class: 'card' },
|
||||
br(),
|
||||
div(
|
||||
{ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '),
|
||||
span({ class: 'card-value' }, fmt(pItem.createdAt))
|
||||
),
|
||||
div(
|
||||
{ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '),
|
||||
span({ class: 'card-value' }, userLink(pItem.proposer))
|
||||
),
|
||||
div(
|
||||
{ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '),
|
||||
span({ class: 'card-value' }, pItem.method)
|
||||
),
|
||||
br(),
|
||||
div(
|
||||
h2(titleNode),
|
||||
p(pItem.description || '')
|
||||
),
|
||||
pItem.deadline
|
||||
? div(
|
||||
{ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentProposalDeadlineLabel.toUpperCase() + ': '),
|
||||
span({ class: 'card-value' }, fmt(pItem.deadline))
|
||||
)
|
||||
: null,
|
||||
pItem.deadline
|
||||
? div(
|
||||
{ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentProposalTimeLeft.toUpperCase() + ': '),
|
||||
span({ class: 'card-value' }, timeLeft(pItem.deadline))
|
||||
)
|
||||
: null,
|
||||
showVoteMetrics(pItem.method)
|
||||
? div(
|
||||
{ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentVotesNeeded.toUpperCase() + ': '),
|
||||
span({ class: 'card-value' }, String(pItem.needed || reqVotes(pItem.method, pItem.total)))
|
||||
)
|
||||
: null,
|
||||
showVoteMetrics(pItem.method)
|
||||
? div(
|
||||
{ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentVotesSlashTotal.toUpperCase() + ': '),
|
||||
span({ class: 'card-value' }, `${Number(pItem.total || 0)}/${Number(pItem.needed || 0)}`)
|
||||
)
|
||||
: null,
|
||||
showVoteMetrics(pItem.method)
|
||||
? div(
|
||||
{ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentProposalVoteStatusLabel.toUpperCase() + ': '),
|
||||
span({ class: 'card-value' }, onTrackLabel)
|
||||
)
|
||||
: null,
|
||||
pItem && pItem.voteId
|
||||
? form(
|
||||
{ method: 'GET', action: `/votes/${encodeURIComponent(pItem.voteId)}` },
|
||||
button({ type: 'submit', class: 'vote-btn' }, i18n.parliamentVoteAction)
|
||||
)
|
||||
: null
|
||||
);
|
||||
});
|
||||
return div(
|
||||
{ class: 'cards' },
|
||||
h2(i18n.parliamentCurrentProposalsTitle),
|
||||
applyEl(div, null, cards)
|
||||
);
|
||||
};
|
||||
|
||||
const FutureLawsList = (rows) => {
|
||||
if (!rows || !rows.length) return null;
|
||||
const cards = rows.map(pItem =>
|
||||
div(
|
||||
{ class: 'card' },
|
||||
br(),
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '), span({ class: 'card-value' }, pItem.method)),
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), span({ class: 'card-value' }, fmt(pItem.createdAt))),
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, userLink(pItem.proposer))),
|
||||
h2(pItem.title || ''),
|
||||
p(pItem.description || '')
|
||||
)
|
||||
);
|
||||
return div(
|
||||
{ class: 'cards' },
|
||||
h2(i18n.parliamentFutureLawsTitle),
|
||||
applyEl(div, null, cards)
|
||||
);
|
||||
};
|
||||
|
||||
const RevocationForm = (laws = []) =>
|
||||
div(
|
||||
{ class: 'div-center' },
|
||||
h2(i18n.parliamentRevocationFormTitle),
|
||||
form(
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/parliament/revocations/create'
|
||||
},
|
||||
label(i18n.parliamentRevocationLaw), br(),
|
||||
select(
|
||||
{ name: 'lawId', required: true },
|
||||
...(laws || []).map(l =>
|
||||
option(
|
||||
{ value: l.id },
|
||||
`${l.question || l.title || l.id}`
|
||||
)
|
||||
)
|
||||
),
|
||||
br(), br(),
|
||||
label(i18n.parliamentRevocationReasons), br(),
|
||||
textarea({ name: 'reasons', rows: 4, maxlength: 1000 }),
|
||||
br(), br(),
|
||||
button({ type: 'submit', class: 'create-button' }, i18n.parliamentRevocationPublish || 'Publish Revocation')
|
||||
)
|
||||
);
|
||||
|
||||
const RevocationsList = (revocations) => {
|
||||
if (!revocations || !revocations.length) return null;
|
||||
const cards = revocations.map(pItem => {
|
||||
const titleNode = pItem && pItem.voteId
|
||||
? a({ class: 'revocation-title-link', href: `/votes/${encodeURIComponent(pItem.voteId)}` }, pItem.title || pItem.lawTitle || '')
|
||||
: (pItem.title || pItem.lawTitle || '');
|
||||
const onTrackLabel = pItem && pItem.onTrack
|
||||
? (i18n.parliamentProposalOnTrackYes || 'THRESHOLD REACHED')
|
||||
: (i18n.parliamentProposalOnTrackNo || 'BELOW THRESHOLD');
|
||||
return div(
|
||||
{ class: 'card' },
|
||||
br(),
|
||||
div(
|
||||
{ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '),
|
||||
span({ class: 'card-value' }, fmt(pItem.createdAt))
|
||||
),
|
||||
div(
|
||||
{ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '),
|
||||
span(
|
||||
{ class: 'card-value' },
|
||||
userLink(pItem.proposer)
|
||||
)
|
||||
),
|
||||
div(
|
||||
{ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentGovMethod + ': '),
|
||||
span({ class: 'card-value' }, pItem.method)
|
||||
),
|
||||
br(),
|
||||
div(
|
||||
h2(titleNode),
|
||||
p(pItem.reasons || '')
|
||||
),
|
||||
pItem.deadline
|
||||
? div(
|
||||
{ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentProposalDeadlineLabel.toUpperCase() + ': '),
|
||||
span({ class: 'card-value' }, fmt(pItem.deadline))
|
||||
)
|
||||
: null,
|
||||
pItem.deadline
|
||||
? div(
|
||||
{ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentProposalTimeLeft.toUpperCase() + ': '),
|
||||
span({ class: 'card-value' }, timeLeft(pItem.deadline))
|
||||
)
|
||||
: null,
|
||||
showVoteMetrics(pItem.method)
|
||||
? div(
|
||||
{ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentVotesNeeded.toUpperCase() + ': '),
|
||||
span({ class: 'card-value' }, String(pItem.needed || reqVotes(pItem.method, pItem.total)))
|
||||
)
|
||||
: null,
|
||||
showVoteMetrics(pItem.method)
|
||||
? div(
|
||||
{ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentVotesSlashTotal.toUpperCase() + ': '),
|
||||
span({ class: 'card-value' }, `${Number(pItem.total || 0)}/${Number(pItem.needed || 0)}`)
|
||||
)
|
||||
: null,
|
||||
showVoteMetrics(pItem.method)
|
||||
? div(
|
||||
{ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentProposalVoteStatusLabel.toUpperCase() + ': '),
|
||||
span({ class: 'card-value' }, onTrackLabel)
|
||||
)
|
||||
: null,
|
||||
pItem && pItem.voteId
|
||||
? form(
|
||||
{ method: 'GET', action: `/votes/${encodeURIComponent(pItem.voteId)}` },
|
||||
button({ type: 'submit', class: 'vote-btn' }, i18n.parliamentVoteAction)
|
||||
)
|
||||
: null
|
||||
);
|
||||
});
|
||||
return div(
|
||||
{ class: 'cards' },
|
||||
h2(i18n.parliamentCurrentRevocationsTitle),
|
||||
applyEl(div, null, cards)
|
||||
);
|
||||
};
|
||||
|
||||
const FutureRevocationsList = (rows) => {
|
||||
if (!rows || !rows.length) return null;
|
||||
const cards = rows.map(pItem =>
|
||||
div(
|
||||
{ class: 'card' },
|
||||
br(),
|
||||
pItem.method ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '), span({ class: 'card-value' }, pItem.method)) : null,
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), span({ class: 'card-value' }, fmt(pItem.createdAt))),
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, userLink(pItem.proposer))),
|
||||
h2(pItem.title || pItem.lawTitle || ''),
|
||||
p(pItem.reasons || '')
|
||||
)
|
||||
);
|
||||
return div(
|
||||
{ class: 'cards' },
|
||||
h2(i18n.parliamentFutureRevocationsTitle),
|
||||
applyEl(div, null, cards)
|
||||
);
|
||||
};
|
||||
|
||||
const LawsStats = (laws = [], revocatedCount = 0) => {
|
||||
const proposed = laws.length;
|
||||
const approved = laws.length;
|
||||
const declined = 0;
|
||||
const discarded = 0;
|
||||
const revocated = Number(revocatedCount || 0);
|
||||
return div(
|
||||
{ class: 'table-wrap' },
|
||||
h2(i18n.parliamentPoliciesTitle || 'POLICIES'),
|
||||
applyEl(table, { class: 'table table--centered' }, [
|
||||
thead(tr(
|
||||
th(i18n.parliamentThProposed),
|
||||
th(i18n.parliamentThApproved),
|
||||
th(i18n.parliamentThDeclined),
|
||||
th(i18n.parliamentThDiscarded),
|
||||
th(i18n.parliamentPoliciesRevocated)
|
||||
)),
|
||||
tbody(
|
||||
tr(
|
||||
td(String(proposed)),
|
||||
td(String(approved)),
|
||||
td(String(declined)),
|
||||
td(String(discarded)),
|
||||
td(String(revocated))
|
||||
)
|
||||
)
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
const LawsList = (laws) => {
|
||||
if (!laws || !laws.length) return NoLaws();
|
||||
const cards = laws.map(l => {
|
||||
const total = Number((l.votes && (l.votes.total || l.votes.TOTAL)) || 0);
|
||||
const yes = Number((l.votes && (l.votes.YES || l.votes.Yes || l.votes.yes)) || 0);
|
||||
const needed = reqVotes(l.method, total);
|
||||
const showMetricsFlag = showVoteMetrics(l.method);
|
||||
return div(
|
||||
{ class: 'card' },
|
||||
br(),
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentGovMethod + ': ').toUpperCase()), span({ class: 'card-value' }, l.method)),
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawEnacted + ': ').toUpperCase()), span({ class: 'card-value' }, fmt(l.enactedAt))),
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, userLink(l.proposer))),
|
||||
h2(l.question || ''),
|
||||
p(l.description || ''),
|
||||
showMetricsFlag ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentVotesNeeded + ': ').toUpperCase()), span({ class: 'card-value' }, String(needed))) : null,
|
||||
showMetricsFlag ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentVotesSlashTotal + ': ').toUpperCase()), span({ class: 'card-value' }, `${yes}/${total}`)) : null
|
||||
);
|
||||
});
|
||||
return div(
|
||||
{ class: 'cards' },
|
||||
h2(i18n.parliamentLawsTitle || 'LAWS'),
|
||||
applyEl(div, null, cards)
|
||||
);
|
||||
};
|
||||
|
||||
const HistoricalGovsSummary = (rows = []) => {
|
||||
const byMethod = new Map();
|
||||
for (const g of rows) {
|
||||
const k = String(g.method || 'ANARCHY').toUpperCase();
|
||||
if (!byMethod.has(k)) byMethod.set(k, { cycles: 0, proposed: 0, approved: 0, declined: 0, discarded: 0, revocated: 0 });
|
||||
const rec = byMethod.get(k);
|
||||
rec.cycles += 1;
|
||||
rec.proposed += Number(g.proposed || 0);
|
||||
rec.approved += Number(g.approved || 0);
|
||||
rec.declined += Number(g.declined || 0);
|
||||
rec.discarded += Number(g.discarded || 0);
|
||||
rec.revocated += Number(g.revocated || 0);
|
||||
}
|
||||
const entries = Array.from(byMethod.entries()).sort((a,b) => String(a[0]).localeCompare(String(b[0])));
|
||||
const lines = entries.map(([method, rec]) =>
|
||||
tr(
|
||||
td(method),
|
||||
td(String(rec.proposed)),
|
||||
td(String(rec.approved)),
|
||||
td(String(rec.declined)),
|
||||
td(String(rec.discarded)),
|
||||
td(String(rec.revocated)),
|
||||
td(String(rec.cycles))
|
||||
)
|
||||
);
|
||||
return div(
|
||||
{ class: 'table-wrap' },
|
||||
h2(i18n.parliamentHistoricalGovernmentsTitle || 'Governments'),
|
||||
applyEl(table, { class: 'table table--centered' }, [
|
||||
thead(tr(
|
||||
th(String(i18n.parliamentGovMethod).toUpperCase()),
|
||||
th(i18n.parliamentPoliciesProposal),
|
||||
th(i18n.parliamentPoliciesApproved),
|
||||
th(i18n.parliamentPoliciesDeclined),
|
||||
th(i18n.parliamentPoliciesDiscarded),
|
||||
th(i18n.parliamentPoliciesRevocated),
|
||||
th(i18n.parliamentThCycles)
|
||||
)),
|
||||
applyEl(tbody, null, lines)
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
const HistoricalList = (rows, metasByKey = {}) => {
|
||||
if (!rows || !rows.length) return NoGovernments();
|
||||
const cards = rows.map(g => {
|
||||
const key = `${g.powerType}:${g.powerId}`;
|
||||
const meta = metasByKey[key];
|
||||
const showActor = g.powerType === 'tribe' || g.powerType === 'inhabitant';
|
||||
const showMembers = g.powerType === 'tribe';
|
||||
const actorLabel =
|
||||
g.powerType === 'tribe'
|
||||
? (i18n.parliamentActorInPowerTribe || 'TRIBE RULING')
|
||||
: (i18n.parliamentActorInPowerInhabitant || 'INHABITANT RULING');
|
||||
return div(
|
||||
{ class: 'card' },
|
||||
h2(g.method),
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, (i18n.parliamentLegSince + ': ').toUpperCase()),
|
||||
span({ class: 'card-value' }, fmt(g.since))
|
||||
),
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, (i18n.parliamentLegEnd + ': ').toUpperCase()),
|
||||
span({ class: 'card-value' }, fmt(g.end))
|
||||
),
|
||||
showActor ? div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, String(actorLabel).toUpperCase() + ': '),
|
||||
span({ class: 'card-value' },
|
||||
g.powerType === 'tribe'
|
||||
? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId)
|
||||
: userLink(g.powerId, g.powerTitle)
|
||||
)
|
||||
) : null,
|
||||
(g.method !== 'ANARCHY')
|
||||
? div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentVotesReceived + ': '),
|
||||
span({ class: 'card-value' }, `${g.votesReceived} (${g.totalVotes})`)
|
||||
)
|
||||
: null,
|
||||
br(),
|
||||
showActor && meta && (meta.avatarUrl || meta.bio)
|
||||
? div(
|
||||
{ class: 'actor-meta' },
|
||||
meta.avatarUrl ? img({ src: meta.avatarUrl, alt: '', class: 'avatar--lg' }) : null,
|
||||
meta.bio ? p({ class: 'bio' }, meta.bio) : null
|
||||
)
|
||||
: null,
|
||||
showMembers
|
||||
? div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentMembers + ': '),
|
||||
span({ class: 'card-value' }, String(g.members || 0))
|
||||
)
|
||||
: null,
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentPoliciesProposal + ': '),
|
||||
span({ class: 'card-value' }, String(g.proposed || 0))
|
||||
),
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentPoliciesApproved + ': '),
|
||||
span({ class: 'card-value' }, String(g.approved || 0))
|
||||
),
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentPoliciesDeclined + ': '),
|
||||
span({ class: 'card-value' }, String(g.declined || 0))
|
||||
),
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentPoliciesDiscarded + ': '),
|
||||
span({ class: 'card-value' }, String(g.discarded || 0))
|
||||
),
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentPoliciesRevocated + ': '),
|
||||
span({ class: 'card-value' }, String(g.revocated || 0))
|
||||
),
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.parliamentEfficiency + ': '),
|
||||
span({ class: 'card-value' }, `${g.efficiency || 0} %`)
|
||||
)
|
||||
);
|
||||
});
|
||||
return div(
|
||||
{ class: 'cards' },
|
||||
h2(i18n.parliamentHistoricalElectionsTitle || 'ELECTION CYCLES'),
|
||||
applyEl(div, null, cards)
|
||||
);
|
||||
};
|
||||
|
||||
const LeadersSummary = (leaders = [], candidatures = [], allGovCards = [], totalCandidatures = null) => {
|
||||
const timesInPower = leaders.reduce((s, l) => s + Number(l.inPower || 0), 0);
|
||||
const totals = (allGovCards || []).reduce((acc, g) => {
|
||||
acc.proposed += Number(g.proposed || 0);
|
||||
acc.approved += Number(g.approved || 0);
|
||||
acc.declined += Number(g.declined || 0);
|
||||
acc.discarded += Number(g.discarded || 0);
|
||||
acc.revocated += Number(g.revocated || 0);
|
||||
return acc;
|
||||
}, { proposed:0, approved:0, declined:0, discarded:0, revocated:0 });
|
||||
const presented = totalCandidatures != null ? Number(totalCandidatures) : (candidatures || []).length;
|
||||
const efficiencyPct = totals.proposed > 0 ? Math.round((totals.approved / totals.proposed) * 100) : 0;
|
||||
return div(
|
||||
{ class: 'table-wrap' },
|
||||
h2(i18n.parliamentHistoricalLawsTitle || 'Actions'),
|
||||
applyEl(table, { class: 'table table--centered' }, [
|
||||
thead(tr(
|
||||
th(i18n.parliamentThTotalCandidatures),
|
||||
th(i18n.parliamentThTimesInPower),
|
||||
th(i18n.parliamentThProposed),
|
||||
th(i18n.parliamentThApproved),
|
||||
th(i18n.parliamentThDeclined),
|
||||
th(i18n.parliamentThDiscarded),
|
||||
th(i18n.parliamentPoliciesRevocated),
|
||||
th(i18n.parliamentEfficiency)
|
||||
)),
|
||||
tbody(
|
||||
tr(
|
||||
td(String(presented)),
|
||||
td(String(timesInPower)),
|
||||
td(String(totals.proposed)),
|
||||
td(String(totals.approved)),
|
||||
td(String(totals.declined)),
|
||||
td(String(totals.discarded)),
|
||||
td(String(totals.revocated)),
|
||||
td(`${efficiencyPct} %`)
|
||||
)
|
||||
)
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
const LeadersList = (leaders, metas = {}, candidatures = []) => {
|
||||
if (!leaders || !leaders.length) return div({ class: 'empty' }, p(i18n.parliamentNoLeaders));
|
||||
const rows = leaders.map(l => {
|
||||
const key = `${l.powerType}:${l.powerId}`;
|
||||
const meta = metas[key] || {};
|
||||
const avatarHref = l.powerType === 'tribe'
|
||||
? `/tribe/${encodeURIComponent(l.powerId)}`
|
||||
: `/author/${encodeURIComponent(l.powerId)}`;
|
||||
const avatar = meta.avatarUrl
|
||||
? a({ href: avatarHref }, img({ src: meta.avatarUrl, alt: '', class: 'leader-table__avatar' }))
|
||||
: null;
|
||||
const link = l.powerType === 'tribe'
|
||||
? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(l.powerId)}` }, l.powerTitle || l.powerId)
|
||||
: userLink(l.powerId, l.powerTitle);
|
||||
const leaderCell = div({ class: 'leader-cell parliament-avatar-cell' }, avatar, div({ class: 'parliament-avatar-name' }, link));
|
||||
return tr(
|
||||
td(leaderCell),
|
||||
td(String(l.proposed || 0)),
|
||||
td(String(l.approved || 0)),
|
||||
td(String(l.declined || 0)),
|
||||
td(String(l.discarded || 0)),
|
||||
td(String(l.revocated || 0)),
|
||||
td(`${(l.efficiency != null ? Math.round(l.efficiency * 100) : (l.proposed > 0 ? Math.round((l.approved / l.proposed) * 100) : 0))} %`)
|
||||
);
|
||||
});
|
||||
return div(
|
||||
{ class: 'table-wrap' },
|
||||
h2(i18n.parliamentHistoricalLeadersTitle),
|
||||
applyEl(table, { class: 'table table--centered gov-overview' }, [
|
||||
thead(tr(
|
||||
th(i18n.parliamentThLeader),
|
||||
th(i18n.parliamentPoliciesProposal),
|
||||
th(i18n.parliamentPoliciesApproved),
|
||||
th(i18n.parliamentPoliciesDeclined),
|
||||
th(i18n.parliamentPoliciesDiscarded),
|
||||
th(i18n.parliamentPoliciesRevocated),
|
||||
th(i18n.parliamentEfficiency)
|
||||
)),
|
||||
applyEl(tbody, null, rows)
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
const RulesContent = () => {
|
||||
const points = [
|
||||
i18n.parliamentRulesIntro,
|
||||
i18n.parliamentRulesTerm,
|
||||
i18n.parliamentRulesMethods,
|
||||
i18n.parliamentRulesAnarchy,
|
||||
i18n.parliamentRulesCandidates,
|
||||
i18n.parliamentRulesElection,
|
||||
i18n.parliamentRulesTies,
|
||||
i18n.parliamentRulesProposals,
|
||||
i18n.parliamentRulesLimit,
|
||||
i18n.parliamentRulesLaws,
|
||||
i18n.parliamentRulesRevocations,
|
||||
i18n.parliamentRulesHistorical,
|
||||
i18n.parliamentRulesLeaders
|
||||
];
|
||||
return div(
|
||||
{ class: 'card' },
|
||||
h2(i18n.parliamentRulesTitle),
|
||||
div({ class: 'rules-points' },
|
||||
points.filter(Boolean).map((t, i) =>
|
||||
div({ class: 'rules-point' },
|
||||
span({ class: 'rules-point-num' }, String(i + 1)),
|
||||
span({ class: 'rules-point-text' }, t)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const CandidaturesSection = (governmentCard, candidatures, leaderMeta, electionQuorum = 2) => {
|
||||
return div(
|
||||
h2(i18n.parliamentGovernmentCard),
|
||||
GovHeader(governmentCard || {}),
|
||||
CandidatureStats(candidatures || [], governmentCard || null, leaderMeta || null, electionQuorum),
|
||||
CandidatureForm(),
|
||||
candidatures && candidatures.length ? CandidaturesTable(candidatures) : null
|
||||
);
|
||||
};
|
||||
|
||||
const ProposalsSection = (governmentCard, proposals, futureLaws, canPropose) => {
|
||||
const has = proposals && proposals.length > 0;
|
||||
const fl = FutureLawsList(futureLaws || []);
|
||||
if (!has && canPropose) return div(h2(i18n.parliamentGovernmentCard), GovHeader(governmentCard || {}), ProposalForm(), fl);
|
||||
if (!has && !canPropose) return div(h2(i18n.parliamentGovernmentCard), GovHeader(governmentCard || {}), NoProposals(), fl);
|
||||
return div(h2(i18n.parliamentGovernmentCard), GovHeader(governmentCard || {}), ProposalForm(), ProposalsList(proposals), fl);
|
||||
};
|
||||
|
||||
const RevocationsSection = (governmentCard, laws, revocations, futureRevocations) =>
|
||||
div(
|
||||
h2(i18n.parliamentGovernmentCard),
|
||||
GovHeader(governmentCard || {}),
|
||||
RevocationForm(laws || []),
|
||||
RevocationsList(revocations || []) || '',
|
||||
FutureRevocationsList(futureRevocations || []) || ''
|
||||
);
|
||||
|
||||
const normalizeGovCard = (governmentCard, inhabitantsTotal) => {
|
||||
const pop = Number(inhabitantsTotal ?? governmentCard?.inhabitantsTotal ?? 0) || 0;
|
||||
if (governmentCard && (governmentCard.method || governmentCard.since || governmentCard.end || governmentCard.powerType)) {
|
||||
return { ...governmentCard, inhabitantsTotal: pop };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parliamentView = async (state) => {
|
||||
const {
|
||||
filter,
|
||||
governmentCard,
|
||||
candidatures,
|
||||
proposals,
|
||||
futureLaws,
|
||||
canPropose,
|
||||
laws,
|
||||
historical,
|
||||
leaders,
|
||||
leaderMeta,
|
||||
powerMeta,
|
||||
revocations,
|
||||
futureRevocations,
|
||||
revocationsEnactedCount,
|
||||
historicalMetas = {},
|
||||
leadersMetas = {},
|
||||
inhabitantsTotal
|
||||
} = state;
|
||||
|
||||
const fallbackGov = {
|
||||
method: 'ANARCHY',
|
||||
votesReceived: 0,
|
||||
totalVotes: 0,
|
||||
proposed: 0,
|
||||
approved: 0,
|
||||
declined: 0,
|
||||
discarded: 0,
|
||||
revocated: 0,
|
||||
efficiency: 0,
|
||||
powerType: 'none',
|
||||
powerId: null,
|
||||
powerTitle: 'ANARCHY',
|
||||
since: moment().toISOString(),
|
||||
end: moment().add(TERM_DAYS, 'days').toISOString(),
|
||||
inhabitantsTotal: Number(inhabitantsTotal ?? 0) || 0
|
||||
};
|
||||
|
||||
const gov = normalizeGovCard(governmentCard, inhabitantsTotal) || fallbackGov;
|
||||
const electionQuorum = Math.max(2, Math.ceil((Number(inhabitantsTotal) || 0) * 0.25));
|
||||
const leadersGovCards = [...(historical || []), ...(governmentCard ? [governmentCard] : [])];
|
||||
const leadersTotalCandidatures = governmentCard && governmentCard.presented != null ? governmentCard.presented : null;
|
||||
|
||||
const LawsSectionWrap = () =>
|
||||
div(
|
||||
LawsStats(laws || [], revocationsEnactedCount || 0),
|
||||
LawsList(laws || [])
|
||||
);
|
||||
|
||||
return template(
|
||||
i18n.parliamentTitle,
|
||||
section(div({ class: 'tags-header' }, h2(i18n.parliamentTitle), p(i18n.parliamentDescription)), Tabs(filter)),
|
||||
section(
|
||||
filter === 'government' ? GovernmentCard(gov, powerMeta) : null,
|
||||
filter === 'candidatures' ? CandidaturesSection(gov, candidatures, leaderMeta, electionQuorum) : null,
|
||||
filter === 'proposals' ? ProposalsSection(gov, proposals, futureLaws, canPropose) : null,
|
||||
filter === 'laws' ? LawsSectionWrap() : null,
|
||||
filter === 'revocations' ? RevocationsSection(gov, laws, revocations, futureRevocations) : null,
|
||||
filter === 'historical' ? div(HistoricalGovsSummary(historical || []), HistoricalList(historical || [], historicalMetas)) : null,
|
||||
filter === 'leaders' ? div(LeadersSummary(leaders || [], candidatures || [], leadersGovCards, leadersTotalCandidatures), LeadersList(leaders || [], leadersMetas, candidatures || [])) : null,
|
||||
filter === 'rules' ? RulesContent() : null
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = { parliamentView, pickLeader };
|
||||
|
||||
144
src/views/peers_view.js
Normal file
144
src/views/peers_view.js
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadcastActive = false, technicalPeers = [] }) => {
|
||||
const { form, button, div, h2, p, section, a, hr, input, label, br, span, table, tr, td, textarea } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n } = require('./main_views');
|
||||
|
||||
const startButton = form({ action: "/settings/conn/start", method: "post" }, button({ type: "submit" }, i18n.startNetworking));
|
||||
const restartButton = form({ action: "/settings/conn/restart", method: "post" }, button({ type: "submit" }, i18n.restartNetworking));
|
||||
const stopButton = form({ action: "/settings/conn/stop", method: "post" }, button({ type: "submit" }, i18n.stopNetworking));
|
||||
const syncButton = form({ action: "/settings/conn/sync", method: "post" }, button({ type: "submit" }, i18n.sync));
|
||||
const connButtons = [startButton, restartButton, stopButton, syncButton];
|
||||
|
||||
const deduplicatePeers = (peers) => {
|
||||
const seen = new Set();
|
||||
return peers.filter(p => {
|
||||
const key = p[1]?.key;
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const sourceLabel = (src) => {
|
||||
const s = String(src || '').toLowerCase();
|
||||
if (s === 'rpc') return i18n.peerSourceRpc || 'RPC';
|
||||
if (s === 'gossip') return i18n.peerSourceGossip || 'Gossip';
|
||||
if (s === 'ebt') return i18n.peerSourceEbt || 'EBT';
|
||||
if (s === 'recent') return i18n.peerSourceRecent || 'Recent';
|
||||
if (s === 'lan') return i18n.peerSourceLan || 'LAN';
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderPeerRow = (peerData) => {
|
||||
const peer = peerData[1];
|
||||
const { name, users, key } = peer;
|
||||
const peerUrl = `/author/${encodeURIComponent(key)}`;
|
||||
const filteredUsers = (users || []).filter(u => u.id !== key);
|
||||
const userCount = filteredUsers.length;
|
||||
return tr(
|
||||
td({ 'data-label': i18n.peerHost || 'Pub' }, a({ href: peerUrl, class: "user-link" }, name || key.slice(0, 20) + '…')),
|
||||
td({ 'data-label': 'Key' }, a({ href: peerUrl, class: 'user-link peer-key' }, key)),
|
||||
td({ 'data-label': i18n.peersReplicatedFeeds || 'Feeds' }, String(userCount))
|
||||
);
|
||||
};
|
||||
|
||||
const dedupOnline = deduplicatePeers(onlinePeers);
|
||||
const dedupDiscovered = deduplicatePeers(discoveredPeers);
|
||||
const dedupUnknown = deduplicatePeers(unknownPeers);
|
||||
|
||||
const onlineCount = dedupOnline.length;
|
||||
const discoveredCount = dedupDiscovered.length;
|
||||
const unknownCount = dedupUnknown.length;
|
||||
|
||||
const renderPeerTable = (peers, emptyKey) => {
|
||||
if (peers.length === 0) return p(i18n[emptyKey] || i18n.noConnections);
|
||||
return table({ class: 'block-info-table' },
|
||||
tr(
|
||||
td({ class: 'card-label' }, i18n.peerHost || 'Pub'),
|
||||
td({ class: 'card-label' }, 'Key'),
|
||||
td({ class: 'card-label' }, i18n.peersReplicatedFeeds || 'Replicated feeds')
|
||||
),
|
||||
...peers.map(renderPeerRow)
|
||||
);
|
||||
};
|
||||
|
||||
const technicalRows = (technicalPeers || []).map(tp => {
|
||||
const k = tp.key || '';
|
||||
const connected = tp.state === 'connected';
|
||||
const action = connected ? 'disconnect' : 'connect';
|
||||
const btnLabel = connected ? (i18n.peerDisconnect || 'Disconnect') : (i18n.peerConnect || 'Connect');
|
||||
return tr(
|
||||
td({ 'data-label': 'Key' }, a({ href: `/author/${encodeURIComponent(k)}`, class: 'user-link peer-key' }, k ? k.slice(0, 20) + '…' : '—')),
|
||||
td({ 'data-label': 'Host' }, String(tp.host || '—')),
|
||||
td({ 'data-label': i18n.peerPort || 'Port' }, String(tp.port || '—')),
|
||||
td({ 'data-label': 'State' }, String(tp.state || tp.source || '—')),
|
||||
td({ 'data-label': i18n.peerLastChange || 'Last change' }, String(tp.stateChange ? new Date(tp.stateChange).toISOString().slice(0, 16).replace('T', ' ') : '—')),
|
||||
td({ 'data-label': '' },
|
||||
form({ method: "POST", action: `/peers/${action}`, class: "inline-form" },
|
||||
input({ type: "hidden", name: "key", value: k }),
|
||||
input({ type: "hidden", name: "host", value: String(tp.host || '') }),
|
||||
input({ type: "hidden", name: "port", value: String(tp.port || 8008) }),
|
||||
button({ type: "submit", class: "filter-btn" }, btnLabel)
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
const refreshButton = form({ action: "/peers/refresh", method: "post" }, button({ type: "submit" }, i18n.peerRefresh || 'Refresh'));
|
||||
const pruneButton = form({ action: "/peers/prune", method: "post" }, button({ type: "submit" }, i18n.peerPruneIdle || 'Remove idle'));
|
||||
const exportButton = form({ action: "/peers/export", method: "get" }, button({ type: "submit" }, i18n.peerExport || 'Export'));
|
||||
const importForm = form(
|
||||
{ action: "/peers/import", method: "post", enctype: "multipart/form-data", class: "peers-import-form" },
|
||||
label({ class: 'peers-import-label' }, i18n.peerImportTitle || 'Import peer list'),
|
||||
br(),
|
||||
textarea({ name: "peerList", rows: "4", placeholder: i18n.peerImportPlaceholder || 'Paste one multiserver address per line…' }),
|
||||
br(),
|
||||
input({ type: "file", name: "peerFile", accept: ".txt,text/plain" }),
|
||||
br(),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.peerImport || 'Import')
|
||||
);
|
||||
|
||||
const peersTechnicalBlock = div({ class: 'tags-header peers-technical-block' },
|
||||
h2(i18n.peerConnectionsTitle || 'Connections'),
|
||||
div({ class: "conn-actions peers-conn-actions" }, refreshButton, pruneButton, exportButton),
|
||||
technicalPeers.length
|
||||
? table({ class: 'block-info-table' },
|
||||
tr(
|
||||
td({ class: 'card-label' }, 'Key'),
|
||||
td({ class: 'card-label' }, 'Host'),
|
||||
td({ class: 'card-label' }, i18n.peerPort || 'Port'),
|
||||
td({ class: 'card-label' }, 'State'),
|
||||
td({ class: 'card-label' }, 'Last change'),
|
||||
td({ class: 'card-label' }, '')
|
||||
),
|
||||
...technicalRows
|
||||
)
|
||||
: p(i18n.peersTechnicalEmpty || 'No peers registered yet.'),
|
||||
importForm
|
||||
);
|
||||
|
||||
return template(
|
||||
i18n.peers,
|
||||
section(
|
||||
div({ class: 'tags-header' },
|
||||
h2(i18n.peers),
|
||||
p(i18n.peerConnectionsIntro)
|
||||
),
|
||||
div({ class: "conn-actions" }, ...connButtons),
|
||||
(onlineCount + discoveredCount + unknownCount) > 0
|
||||
? div({ class: "peers-list" },
|
||||
div({ class: "tags-header" }, h2(`${i18n.online} (${onlineCount})`)),
|
||||
renderPeerTable(dedupOnline, 'noConnections'),
|
||||
hr(),
|
||||
div({ class: "tags-header" }, h2(`${i18n.discovered} (${discoveredCount})`)),
|
||||
renderPeerTable(dedupDiscovered, 'noDiscovered'),
|
||||
hr(),
|
||||
div({ class: "tags-header" }, h2(`${i18n.unknown} (${unknownCount})`)),
|
||||
renderPeerTable(dedupUnknown, 'noUnknownPeers')
|
||||
)
|
||||
: null,
|
||||
peersTechnicalBlock,
|
||||
p(i18n.connectionActionIntro)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.peersView = peersView;
|
||||
92
src/views/pixelia_view.js
Normal file
92
src/views/pixelia_view.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
const { div, h2, p, section, form, input, label, select, option, button, table, tr, td, hr, ul, li, a, br } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, userLink} = require('./main_views');
|
||||
|
||||
exports.pixeliaView = (pixelArt, errorMessage) => {
|
||||
const title = i18n.pixeliaTitle;
|
||||
const description = i18n.pixeliaDescription;
|
||||
const gridWidth = 50;
|
||||
const gridHeight = 200;
|
||||
|
||||
const grid = table(
|
||||
{ class: "pixelia-grid" },
|
||||
...Array.from({ length: gridHeight }, (_, rowIndex) =>
|
||||
tr(
|
||||
...Array.from({ length: gridWidth }, (_, colIndex) => {
|
||||
const pixel = pixelArt.find(p => p.x === colIndex + 1 && p.y === rowIndex + 1);
|
||||
const colorClass = pixel ? `pixel-color-${pixel.color.slice(1)}` : 'pixel-empty';
|
||||
const cellId = `cell-${rowIndex + 1}-${colIndex + 1}`;
|
||||
return td(
|
||||
{
|
||||
id: cellId,
|
||||
title: pixel ? `By: ${pixel.author}` : "",
|
||||
class: `pixel-cell ${colorClass}`
|
||||
},
|
||||
""
|
||||
);
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const contributors = pixelArt.length > 0 ? [...new Set(pixelArt.flatMap(p => p.contributors_inhabitants || []))] : [];
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(title),
|
||||
p(description)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: "pixelia-form-wrap" },
|
||||
form({ method: "POST", action: "/pixelia/paint", class: "pixelia-paint-form" },
|
||||
label({ for: "x" }, "X (1-50):"),
|
||||
input({ type: "number", id: "x", name: "x", min: 1, max: gridWidth, required: true }),
|
||||
label({ for: "y" }, "Y (1-200):"),
|
||||
input({ type: "number", id: "y", name: "y", min: 1, max: gridHeight, required: true }),
|
||||
label({ for: "color" }, i18n.colorLabel),
|
||||
select({ id: "color", name: "color", required: true },
|
||||
option({ value: "#000000", style: "background-color:#000000;" }, "Black"),
|
||||
option({ value: "#ffffff", style: "background-color:#ffffff;" }, "White"),
|
||||
option({ value: "#17f018", style: "background-color:#17f018;" }, "Green"),
|
||||
option({ value: "#ffbb00", style: "background-color:#ffbb00;" }, "Yellow"),
|
||||
option({ value: "#ff0000", style: "background-color:#ff0000;" }, "Red"),
|
||||
option({ value: "#0000ff", style: "background-color:#0000ff;" }, "Blue"),
|
||||
option({ value: "#ffff00", style: "background-color:#ffff00;" }, "Lime"),
|
||||
option({ value: "#00ff00", style: "background-color:#00ff00;" }, "Spring Green"),
|
||||
option({ value: "#00ffff", style: "background-color:#00ffff;" }, "Aqua"),
|
||||
option({ value: "#ff00ff", style: "background-color:#ff00ff;" }, "Fuchsia"),
|
||||
option({ value: "#a52a2a", style: "background-color:#a52a2a;" }, "Brown"),
|
||||
option({ value: "#800080", style: "background-color:#800080;" }, "Purple"),
|
||||
option({ value: "#808000", style: "background-color:#808000;" }, "Olive"),
|
||||
option({ value: "#00bfff", style: "background-color:#00bfff;" }, "Deep Sky Blue"),
|
||||
option({ value: "#d3d3d3", style: "background-color:#d3d3d3;" }, "Light Grey"),
|
||||
option({ value: "#ff6347", style: "background-color:#ff6347;" }, "Tomato")
|
||||
),
|
||||
button({ type: "submit" }, i18n.paintButton)
|
||||
)
|
||||
),
|
||||
errorMessage ? div({ class: "error-message" }, errorMessage) : null,
|
||||
div({ class: "total-pixels" },
|
||||
h2(`${i18n.totalPixels}: ${pixelArt.length}`)
|
||||
)
|
||||
),
|
||||
hr(),
|
||||
section(
|
||||
div({ class: "main_content" },
|
||||
div({ class: "pixelia-grid-wrap" }, grid),
|
||||
pixelArt.length > 0 ?
|
||||
div({ class: "contributors" },
|
||||
h2(i18n.contributorsTitle),
|
||||
ul(
|
||||
...contributors.map(author =>
|
||||
li(userLink(author))
|
||||
)
|
||||
)
|
||||
) : null
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
97
src/views/pm_view.js
Normal file
97
src/views/pm_view.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
const { div, h2, p, section, button, form, input, textarea, br, label, pre, span, strong } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n } = require('./main_views');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
|
||||
exports.pmView = async (initialRecipients = '', initialSubject = '', initialText = '', showPreview = false, sentKey = '', crypterError = false, crypterPreview = null, recipientError = false) => {
|
||||
const title = i18n.pmSendTitle;
|
||||
const description = i18n.pmDescription;
|
||||
const textLen = (initialText || '').length;
|
||||
|
||||
const { renderEncryptedChip, renderDoubleEncryptionChip } = require('./clearnet_view');
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
div({ class: "title-with-chip" }, h2(title), renderEncryptedChip(i18n)),
|
||||
p(description)
|
||||
),
|
||||
crypterError
|
||||
? div({ class: "pm-form-error-msg" }, p('✗ ' + i18n.pmCrypterTooLong))
|
||||
: null,
|
||||
recipientError
|
||||
? div({ class: "pm-form-error-msg" }, p('✗ ' + i18n.pmInvalidRecipients))
|
||||
: null,
|
||||
sentKey
|
||||
? div({ class: "pm-sent-key" },
|
||||
input({ type: "text", readonly: true, value: sentKey, class: "pm-sent-key-value" })
|
||||
)
|
||||
: null,
|
||||
section(
|
||||
div({ class: "pm-form" },
|
||||
form({ method: "POST", action: "/pm", id: "pm-form" },
|
||||
label({ for: "recipients" }, i18n.pmLimitsHint),
|
||||
br(),
|
||||
input({
|
||||
type: "text",
|
||||
name: "recipients",
|
||||
placeholder: i18n.pmRecipientsHint,
|
||||
required: true,
|
||||
value: initialRecipients,
|
||||
maxlength: "511"
|
||||
}),
|
||||
br(),
|
||||
label({ for: "subject" }, i18n.pmSubject),
|
||||
br(),
|
||||
input({ type: "text", name: "subject", placeholder: i18n.pmSubjectHint, value: initialSubject, maxlength: "150" }),
|
||||
br(),
|
||||
label({ for: "text" }, i18n.pmText),
|
||||
br(),
|
||||
textarea({ name: "text", rows: "6", cols: "50", id: "pm-text", maxlength: "8096" }, initialText),
|
||||
div({ class: "pm-crypter-row" },
|
||||
label({ for: "pm-crypter" },
|
||||
input({ type: "checkbox", name: "crypter", value: "1", id: "pm-crypter" }),
|
||||
renderDoubleEncryptionChip(i18n)
|
||||
)
|
||||
),
|
||||
div({ class: "pm-actions-block" },
|
||||
div({ class: "pm-actions" },
|
||||
button({ type: "submit", formaction: "/pm/preview", formmethod: "POST" }, i18n.pmPreview),
|
||||
button({ type: "submit", class: "btn-compact" }, i18n.pmSend)
|
||||
)
|
||||
)
|
||||
),
|
||||
showPreview
|
||||
? (crypterPreview
|
||||
? div({ class: "pm-preview pm-crypter-preview" },
|
||||
div({ class: "title-with-chip" }, h2(i18n.pmPreviewTitle), renderEncryptedChip(i18n)),
|
||||
p({ class: "pm-preview-count" }, `${(initialText || '').length} ${i18n.pmCrypterCharsLabel}`),
|
||||
div({ class: "pm-preview-content" }, pre({ class: "pm-pre" }, initialText || '')),
|
||||
div({ class: "pm-sent-key" },
|
||||
input({ type: "text", readonly: true, value: crypterPreview.key, class: "pm-sent-key-value" })
|
||||
),
|
||||
div({ class: "title-with-chip" }, h2(i18n.pmCrypterCipherLabel), renderDoubleEncryptionChip(i18n)),
|
||||
p({ class: "pm-preview-count" }, `${(crypterPreview.cipher || '').length} ${i18n.pmCrypterCharsLabel}`),
|
||||
div({ class: "pm-preview-content" }, pre({ class: "pm-pre" }, crypterPreview.cipher || '')),
|
||||
form({ method: "POST", action: "/pm", class: "pm-crypter-send-form" },
|
||||
input({ type: "hidden", name: "recipients", value: initialRecipients }),
|
||||
input({ type: "hidden", name: "subject", value: initialSubject }),
|
||||
input({ type: "hidden", name: "text", value: initialText }),
|
||||
input({ type: "hidden", name: "crypter", value: "1" }),
|
||||
input({ type: "hidden", name: "crypterKey", value: crypterPreview.key }),
|
||||
input({ type: "hidden", name: "precomputed", value: crypterPreview.cipher }),
|
||||
button({ type: "submit", class: "btn-compact" }, i18n.pmSend)
|
||||
)
|
||||
)
|
||||
: div({ id: "pm-preview-area", class: "pm-preview" },
|
||||
div({ class: "title-with-chip" }, h2(i18n.pmPreviewTitle), renderEncryptedChip(i18n)),
|
||||
p({ id: "pm-preview-count", class: "pm-preview-count" }, `${textLen}/8096`),
|
||||
div({ id: "pm-preview-content", class: "pm-preview-content" },
|
||||
pre({ class: "pm-pre" }, initialText || '')
|
||||
)
|
||||
))
|
||||
: null
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
760
src/views/projects_view.js
Normal file
760
src/views/projects_view.js
Normal file
|
|
@ -0,0 +1,760 @@
|
|||
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")
|
||||
const { template, i18n, renderOpinionsVoting, userLink, renderStateChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions } = require("./main_views")
|
||||
const moment = require("../server/node_modules/moment")
|
||||
const { config } = require("../server/SSB_server.js")
|
||||
const { renderUrl } = require("../backend/renderUrl")
|
||||
const { renderMapLocationUrl, renderMapEmbed, renderMapLocationVisitLabel, renderMapEmbedWithZoom } = require("./maps_view")
|
||||
|
||||
const renderMediaBlob = (value, attrs = {}) => {
|
||||
if (!value) return null
|
||||
const s = String(value).trim()
|
||||
if (!s) return null
|
||||
if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}`, ...attrs })
|
||||
const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
|
||||
if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` })
|
||||
const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
|
||||
if (mAudio) return audio({ controls: true, class: attrs.class || 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` })
|
||||
const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
|
||||
if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, class: attrs.class || 'post-image' })
|
||||
return null
|
||||
}
|
||||
|
||||
const userId = config.keys.id
|
||||
|
||||
const FILTERS = [
|
||||
{ key: "ALL", i18n: "projectFilterAll", title: "projectAllTitle" },
|
||||
{ key: "MINE", i18n: "projectFilterMine", title: "projectMineTitle" },
|
||||
{ key: "APPLIED", i18n: "projectFilterApplied", title: "projectAppliedTitle" },
|
||||
{ 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 safeArr = (v) => (Array.isArray(v) ? v : [])
|
||||
const safeText = (v) => String(v || "").trim()
|
||||
|
||||
const toNum = (v) => {
|
||||
const s = v === null || v === undefined ? "" : String(v)
|
||||
const n = parseFloat(s.replace(",", "."))
|
||||
return Number.isFinite(n) ? n : NaN
|
||||
}
|
||||
|
||||
const clamp = (n, a, b) => Math.max(a, Math.min(b, n))
|
||||
|
||||
const sumAmounts = (list) => safeArr(list).reduce((s, x) => s + (toNum(x && x.amount) || 0), 0)
|
||||
|
||||
const followersCount = (p) => safeArr(p && p.followers).length
|
||||
const backersCount = (p) => safeArr(p && p.backers).length
|
||||
const backersTotal = (p) => sumAmounts((p && p.backers) || [])
|
||||
|
||||
const budgetSummary = (project) => {
|
||||
const goal = Math.max(0, toNum(project && project.goal) || 0)
|
||||
const assigned = Math.max(0, sumAmounts((project && project.bounties) || []))
|
||||
const remaining = Math.max(0, goal - assigned)
|
||||
const exceeded = assigned > goal
|
||||
return { goal, assigned, remaining, exceeded }
|
||||
}
|
||||
|
||||
const buildReturnTo = (filter) => `/projects?filter=${encodeURIComponent(String(filter || "ALL").toUpperCase())}`
|
||||
|
||||
const renderCardField = (labelText, valueNode) =>
|
||||
div(
|
||||
{ class: "card-field" },
|
||||
span({ class: "card-label" }, labelText),
|
||||
span({ class: "card-value" }, valueNode)
|
||||
)
|
||||
|
||||
const renderCardFieldRich = (labelText, children) =>
|
||||
div(
|
||||
{ class: "card-field card-field-rich" },
|
||||
span({ class: "card-label" }, labelText),
|
||||
span({ class: "card-value" }, ...children)
|
||||
)
|
||||
|
||||
const renderProgressBlock = (labelText, valueText, value, maxValue) =>
|
||||
div(
|
||||
{ class: "confirmations-block progress-block" },
|
||||
div(
|
||||
{ class: "card-field" },
|
||||
span({ class: "card-label" }, labelText),
|
||||
span({ class: "card-value" }, valueText)
|
||||
),
|
||||
progress({ class: "confirmations-progress", value: value, max: maxValue })
|
||||
)
|
||||
|
||||
const aggregateTopBackers = (projects) => {
|
||||
const map = new Map()
|
||||
for (const pr of safeArr(projects)) {
|
||||
const backers = safeArr(pr && pr.backers)
|
||||
for (const b of backers) {
|
||||
const uid = b && b.userId
|
||||
const amt = Math.max(0, toNum(b && b.amount) || 0)
|
||||
if (!uid) continue
|
||||
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) => ({ userId: r.userId, total: r.total, pledges: r.pledges, projects: r.projects.size }))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
}
|
||||
|
||||
const renderBackersLeaderboard = (projects) => {
|
||||
const rows = aggregateTopBackers(projects)
|
||||
return rows.length
|
||||
? div(
|
||||
{ class: "backers-leaderboard" },
|
||||
h2(i18n.projectBackersLeaderboardTitle),
|
||||
...rows.slice(0, 50).map((r) =>
|
||||
div(
|
||||
{ class: "backer-row" },
|
||||
renderCardField(i18n.projectBackerAuthor + ":", a({ href: `/author/${encodeURIComponent(r.userId)}`, class: "user-link user-pill" }, r.userId)),
|
||||
renderCardField(i18n.projectBackerAmount + ":", span({ class: "chip chip-amt" }, `${r.total} ECO`)),
|
||||
renderCardField(i18n.projectBackerPledges + ":", span({ class: "chip chip-pledges" }, String(r.pledges))),
|
||||
renderCardField(i18n.projectBackerProjects + ":", span({ class: "chip chip-projects" }, String(r.projects)))
|
||||
)
|
||||
)
|
||||
)
|
||||
: div({ class: "backers-leaderboard empty" }, p(i18n.projectNoBackersFound))
|
||||
}
|
||||
|
||||
const renderBudget = (project) => {
|
||||
const S = budgetSummary(project)
|
||||
const pct = S.goal > 0 ? clamp(Math.round((S.assigned / S.goal) * 100), 0, 100) : 0
|
||||
return div(
|
||||
{ class: `budget-summary${S.exceeded ? " over" : ""}` },
|
||||
S.goal > 0 ? renderProgressBlock(i18n.projectBudgetAssigned + ":", `${S.assigned}/${S.goal}`, pct, 100) : null,
|
||||
S.exceeded ? p({ class: "warning" }, i18n.projectBudgetOver) : null
|
||||
)
|
||||
}
|
||||
|
||||
const renderFollowers = (project) => {
|
||||
const followers = safeArr(project && 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(userLink(uid)))),
|
||||
followers.length > show.length ? p(`+${followers.length - show.length} ${i18n.projectMore}`) : null
|
||||
)
|
||||
}
|
||||
|
||||
const renderBackers = (project, filter) => {
|
||||
const backers = safeArr(project && project.backers)
|
||||
const total = sumAmounts(backers)
|
||||
const mine = sumAmounts(backers.filter((b) => b && b.userId === userId))
|
||||
const rt = `/projects/${encodeURIComponent(project.id)}?filter=${encodeURIComponent(filter || "ALL")}`
|
||||
|
||||
const pending = project && project.author === userId
|
||||
? backers.filter((b) => b && b.transferId && b.confirmed === false).slice(0, 10)
|
||||
: []
|
||||
|
||||
return div(
|
||||
{ class: "backers-block" },
|
||||
h2(i18n.projectBackersTitle),
|
||||
renderCardField(i18n.projectBackersTotal + ":", String(backers.length)),
|
||||
renderCardField(i18n.projectBackersTotalPledged + ":", `${total} ECO`),
|
||||
mine > 0 ? renderCardField(i18n.projectBackersYourPledge + ":", span({ class: "chip chip-you" }, `${mine} ECO`)) : null,
|
||||
backers.length
|
||||
? table(
|
||||
{ class: "backers-table" },
|
||||
thead(tr(th(i18n.projectBackerDate), th(i18n.projectBackerAuthor), th(i18n.projectBackerAmount))),
|
||||
tbody(
|
||||
...backers.slice(0, 8).map((b) =>
|
||||
tr(
|
||||
td(b.at ? moment(b.at).format("YYYY/MM/DD HH:mm") : ""),
|
||||
td(userLink(b.userId)),
|
||||
td(`${b.amount} ECO`)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: p(i18n.projectBackersNone),
|
||||
pending.length
|
||||
? div(
|
||||
{ class: "pending-transfers" },
|
||||
h2(i18n.projectPendingTransfersTitle),
|
||||
...pending.map((b) =>
|
||||
div(
|
||||
{ class: "card-field" },
|
||||
span({ class: "card-label" }, b.userId),
|
||||
span(
|
||||
{ class: "card-value" },
|
||||
form(
|
||||
{ method: "POST", action: `/projects/confirm-transfer/${encodeURIComponent(b.transferId)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: rt }),
|
||||
button({ type: "submit", class: "btn" }, i18n.projectConfirmTransferButton)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: null
|
||||
)
|
||||
}
|
||||
|
||||
const renderPledgeBox = (project, filter, isAuthor) => {
|
||||
const statusUpper = String((project && project.status) || "ACTIVE").toUpperCase()
|
||||
const isActive = statusUpper === "ACTIVE"
|
||||
if (!isActive || isAuthor) return null
|
||||
const rt = `/projects/${encodeURIComponent(project.id)}?filter=${encodeURIComponent(filter || "ALL")}`
|
||||
|
||||
return div(
|
||||
{ class: "pledge-box" },
|
||||
h2(i18n.projectPledgeTitle),
|
||||
form(
|
||||
{ method: "POST", action: `/projects/pledge/${encodeURIComponent(project.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: rt }),
|
||||
input({ type: "number", name: "amount", min: "0.01", step: "0.01", required: true, placeholder: i18n.projectPledgePlaceholder }),
|
||||
select(
|
||||
{ name: "milestoneOrBounty" },
|
||||
option({ value: "" }, i18n.projectSelectMilestoneOrBounty),
|
||||
...safeArr(project && project.milestones).map((m, idx) => option({ value: `milestone:${idx}` }, m.title)),
|
||||
...safeArr(project && project.bounties).map((b, idx) => option({ value: `bounty:${idx}` }, b.title))
|
||||
),
|
||||
button({ class: "btn", type: "submit" }, i18n.projectPledgeButton)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const bountyTotalsForMilestone = (project, mIndex) => {
|
||||
const bounties = safeArr(project && project.bounties)
|
||||
const list = bounties.filter((b) => {
|
||||
const mi = b && b.milestoneIndex
|
||||
return mi === mIndex
|
||||
})
|
||||
const total = sumAmounts(list)
|
||||
const done = list.filter((b) => !!(b && b.done)).length
|
||||
return { total, count: list.length, done }
|
||||
}
|
||||
|
||||
const renderMilestonesAndBounties = (project, filter, editable) => {
|
||||
const milestones = safeArr(project && project.milestones)
|
||||
const bounties = safeArr(project && project.bounties)
|
||||
const rt = `/projects/${encodeURIComponent(project.id)}?filter=${encodeURIComponent(filter || "ALL")}`
|
||||
const remain = budgetSummary(project).remaining
|
||||
|
||||
const blocks = milestones.map((m, idx) => {
|
||||
const totals = bountyTotalsForMilestone(project, idx)
|
||||
const items = bounties.filter((b) => b && b.milestoneIndex === idx)
|
||||
const maxCount = Math.max(1, totals.count)
|
||||
const pctDone = clamp(Math.round((totals.done / maxCount) * 100), 0, 100)
|
||||
|
||||
return div(
|
||||
{ class: "milestone-with-bounties" },
|
||||
div(
|
||||
{ class: "milestone-stats" },
|
||||
renderCardField(i18n.projectMilestoneStatus + ":", m.done ? i18n.projectMilestoneDone.toUpperCase() : i18n.projectMilestoneOpen.toUpperCase()),
|
||||
renderProgressBlock(i18n.projectBounties + ":", `${totals.done}/${totals.count} · ${totals.total} ECO`, pctDone, 100)
|
||||
),
|
||||
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,
|
||||
safeText(m.description) ? p(...renderUrl(m.description)) : null,
|
||||
editable && !m.done
|
||||
? form(
|
||||
{ method: "POST", action: `/projects/milestones/complete/${encodeURIComponent(project.id)}/${idx}` },
|
||||
input({ type: "hidden", name: "returnTo", value: rt }),
|
||||
button({ class: "btn", type: "submit" }, i18n.projectMilestoneMarkDone)
|
||||
)
|
||||
: null
|
||||
),
|
||||
items.length
|
||||
? ul(
|
||||
items.map((b) => {
|
||||
const globalIndex = bounties.indexOf(b)
|
||||
const statusText = b.done
|
||||
? i18n.projectBountyDone.toUpperCase()
|
||||
: (b.claimedBy ? i18n.projectBountyClaimed.toUpperCase() : i18n.projectBountyOpen.toUpperCase())
|
||||
|
||||
return li(
|
||||
{ class: "bounty-item" },
|
||||
div(
|
||||
{ class: "bounty-main" },
|
||||
span({ class: "bounty-title" }, b.title),
|
||||
span({ class: "bounty-amount" }, `${b.amount} ECO`)
|
||||
),
|
||||
safeText(b.description) ? p(...renderUrl(b.description)) : null,
|
||||
renderCardField(i18n.projectBountyStatus + ":", statusText),
|
||||
b.claimedBy ? renderCardField(i18n.projectBountyClaimedBy + ":", userLink(b.claimedBy)) : null,
|
||||
!editable && !b.done && !b.claimedBy && project.author !== userId
|
||||
? form(
|
||||
{ method: "POST", action: `/projects/bounties/claim/${encodeURIComponent(project.id)}/${globalIndex}` },
|
||||
input({ type: "hidden", name: "returnTo", value: rt }),
|
||||
button({ type: "submit", class: "btn" }, i18n.projectBountyClaimButton)
|
||||
)
|
||||
: null,
|
||||
editable && !b.done
|
||||
? form(
|
||||
{ method: "POST", action: `/projects/bounties/complete/${encodeURIComponent(project.id)}/${globalIndex}` },
|
||||
input({ type: "hidden", name: "returnTo", value: rt }),
|
||||
button({ type: "submit", class: "btn" }, i18n.projectBountyCompleteButton)
|
||||
)
|
||||
: null,
|
||||
editable
|
||||
? form(
|
||||
{ method: "POST", action: `/projects/bounties/update/${encodeURIComponent(project.id)}/${globalIndex}`, class: "bounty-update-form" },
|
||||
input({ type: "hidden", name: "returnTo", value: rt }),
|
||||
label(i18n.projectMilestoneSelect),
|
||||
br(),
|
||||
select(
|
||||
{ name: "milestoneIndex" },
|
||||
option({ value: "", selected: b.milestoneIndex == null }, "-"),
|
||||
...milestones.map((m2, idx2) => option({ value: String(idx2), selected: b.milestoneIndex === idx2 }, m2.title))
|
||||
),
|
||||
br(),
|
||||
br(),
|
||||
button({ class: "btn", type: "submit", disabled: remain <= 0 }, i18n.projectBountyCreateButton)
|
||||
)
|
||||
: null
|
||||
)
|
||||
})
|
||||
)
|
||||
: p(i18n.projectNoBounties)
|
||||
)
|
||||
})
|
||||
|
||||
const unassigned = bounties.filter((b) => b && (b.milestoneIndex === null || b.milestoneIndex === undefined))
|
||||
const unassignedBlock = unassigned.length
|
||||
? div(
|
||||
{ class: "bounty-milestone-block" },
|
||||
h2(i18n.projectBounties),
|
||||
ul(
|
||||
unassigned.map((b) => {
|
||||
const globalIndex = bounties.indexOf(b)
|
||||
const statusText = b.done
|
||||
? i18n.projectBountyDone.toUpperCase()
|
||||
: (b.claimedBy ? i18n.projectBountyClaimed.toUpperCase() : i18n.projectBountyOpen.toUpperCase())
|
||||
|
||||
return li(
|
||||
{ class: "bounty-item" },
|
||||
div(
|
||||
{ class: "bounty-main" },
|
||||
span({ class: "bounty-title" }, b.title),
|
||||
span({ class: "bounty-amount" }, `${b.amount} ECO`)
|
||||
),
|
||||
safeText(b.description) ? p(...renderUrl(b.description)) : null,
|
||||
renderCardField(i18n.projectBountyStatus + ":", statusText),
|
||||
b.claimedBy ? renderCardField(i18n.projectBountyClaimedBy + ":", userLink(b.claimedBy)) : null,
|
||||
!editable && !b.done && !b.claimedBy && project.author !== userId
|
||||
? form(
|
||||
{ method: "POST", action: `/projects/bounties/claim/${encodeURIComponent(project.id)}/${globalIndex}` },
|
||||
input({ type: "hidden", name: "returnTo", value: rt }),
|
||||
button({ type: "submit", class: "btn" }, i18n.projectBountyClaimButton)
|
||||
)
|
||||
: null,
|
||||
editable && !b.done
|
||||
? form(
|
||||
{ method: "POST", action: `/projects/bounties/complete/${encodeURIComponent(project.id)}/${globalIndex}` },
|
||||
input({ type: "hidden", name: "returnTo", value: rt }),
|
||||
button({ type: "submit", class: "btn" }, i18n.projectBountyCompleteButton)
|
||||
)
|
||||
: null
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
: null
|
||||
|
||||
if (blocks.length === 0 && !unassignedBlock) return null
|
||||
return div({ class: "milestones-bounties" }, ...blocks, unassignedBlock)
|
||||
}
|
||||
|
||||
const renderProjectStatusChip = (status) => {
|
||||
const s = String(status || "ACTIVE").toUpperCase()
|
||||
const variant =
|
||||
s === "ACTIVE" ? "mutuals" :
|
||||
s === "PAUSED" ? "whole" :
|
||||
s === "COMPLETED" ? "encrypted" :
|
||||
s === "CANCELLED" ? "closed" :
|
||||
"whole"
|
||||
const localized = i18n["projectStatus" + s] || s
|
||||
return renderStateChip(variant, "", localized)
|
||||
}
|
||||
|
||||
const renderProjectList = exports.renderProjectList = (projects, filter, spreadMap = new Map()) => {
|
||||
const list = safeArr(projects)
|
||||
|
||||
if (!list.length) return p(i18n.projectNoProjectsFound)
|
||||
|
||||
return div({ class: "jobs-grid" },
|
||||
list.map((pr) => {
|
||||
const goal = Math.max(0, toNum(pr && pr.goal) || 0)
|
||||
const pledged = Math.max(0, toNum(pr && pr.pledged) || 0)
|
||||
const fundingPct = goal > 0 ? clamp(Math.round((pledged / goal) * 100), 0, 100) : 0
|
||||
const heroNode = pr.image
|
||||
? div({ class: "tribe-card-image-wrapper" },
|
||||
a({ href: `/projects/${encodeURIComponent(pr.id)}` },
|
||||
renderMediaBlob(pr.image, { class: "tribe-card-hero-image" })
|
||||
)
|
||||
)
|
||||
: null
|
||||
const chips = [
|
||||
renderProjectStatusChip(pr.status),
|
||||
renderLifespanChip(pr.lifetime, i18n)
|
||||
].filter(Boolean)
|
||||
|
||||
const isOwn = pr.author && String(pr.author) === String(userId)
|
||||
return div({ class: "trending-card project-card" + (isOwn ? " own-content" : "") },
|
||||
div(
|
||||
{ class: "card-header activity-card-header" },
|
||||
span(),
|
||||
renderContentActions(pr.id || pr.key, `/projects/${encodeURIComponent(pr.id)}`)
|
||||
),
|
||||
div({ class: "card-section project-card-body" },
|
||||
heroNode,
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" },
|
||||
a({ href: `/projects/${encodeURIComponent(pr.id)}` }, pr.title || i18n.projectsTitle)
|
||||
)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
goal > 0
|
||||
? div({ class: "card-date-highlight" }, `${pr.goal} ECO`)
|
||||
: null,
|
||||
goal > 0
|
||||
? renderProgressBlock(i18n.projectFunding + ":", `${fundingPct}%`, fundingPct, 100)
|
||||
: null,
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.projectFollowers}: ${followersCount(pr)}`)
|
||||
),
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(pr.id || pr.key, spreadMap.get(pr.id || pr.key)))
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const renderProjectForm = (project, mode) => {
|
||||
const pr = project || {}
|
||||
const isEdit = mode === "edit"
|
||||
const nowLocal = moment().format("YYYY-MM-DDTHH:mm")
|
||||
const deadlineValue = pr.deadline ? moment(pr.deadline).format("YYYY-MM-DDTHH:mm") : ""
|
||||
const milestoneMax = deadlineValue || undefined
|
||||
const returnTo = "/projects?filter=MINE"
|
||||
|
||||
return div(
|
||||
{ class: "div-center project-form" },
|
||||
form(
|
||||
{
|
||||
action: isEdit ? `/projects/update/${encodeURIComponent(pr.id)}` : "/projects/create",
|
||||
method: "POST",
|
||||
enctype: "multipart/form-data"
|
||||
},
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
label(i18n.projectTitle),
|
||||
br(),
|
||||
input({ type: "text", name: "title", required: true, placeholder: i18n.projectTitlePlaceholder, value: pr.title || "" }),
|
||||
br(),
|
||||
label(i18n.projectDescription),
|
||||
br(),
|
||||
textarea({ name: "description", rows: "6", required: true, placeholder: i18n.projectDescriptionPlaceholder }, pr.description || ""),
|
||||
br(),
|
||||
label(i18n.projectImage),
|
||||
br(),
|
||||
input({ type: "file", name: "image" }),
|
||||
br(),
|
||||
pr.image ? renderMediaBlob(pr.image) : null,
|
||||
br(),
|
||||
label(i18n.projectGoal),
|
||||
br(),
|
||||
input({ type: "number", step: "0.01", min: "0.01", name: "goal", required: true, placeholder: i18n.projectGoalPlaceholder, value: pr.goal || "" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.mapLocationTitle || "Map Location"),
|
||||
br(),
|
||||
input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: pr.mapUrl || "" }),
|
||||
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(),
|
||||
label(i18n.projectMilestoneDescription),
|
||||
br(),
|
||||
textarea({ name: "milestoneDescription", rows: "3", placeholder: i18n.projectMilestoneDescriptionPlaceholder }),
|
||||
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, _unused, params = {}) => {
|
||||
const f = String(filter || "ALL").toUpperCase()
|
||||
const filterObj = FILTERS.find((x) => x.key === f) || FILTERS[0]
|
||||
const sectionTitle = i18n[filterObj.title] || i18n.projectAllTitle
|
||||
const { renderReachChip: renderReachChipProjects } = require('./clearnet_view');
|
||||
const viewerClearnetProjects = !!(params.viewerPrefs && params.viewerPrefs.clearnetProjects);
|
||||
|
||||
return template(
|
||||
i18n.projectsTitle,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(sectionTitle),
|
||||
p(i18n.projectsDescription)
|
||||
),
|
||||
div({ class: "shop-title-row" }, renderReachChipProjects(viewerClearnetProjects, i18n)),
|
||||
br(),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/projects", class: "ui-toolbar ui-toolbar--filters" },
|
||||
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: f === x.key ? "filter-btn active" : "filter-btn" }, i18n[x.i18n]))
|
||||
.concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.projectCreateProject))
|
||||
)
|
||||
),
|
||||
f === "CREATE" || f === "EDIT"
|
||||
? (() => {
|
||||
const prToEdit = f === "EDIT" ? (safeArr(projectsOrForm)[0] || {}) : {}
|
||||
return renderProjectForm(prToEdit, f === "EDIT" ? "edit" : "create")
|
||||
})()
|
||||
: (f === "BACKERS"
|
||||
? renderBackersLeaderboard(projectsOrForm)
|
||||
: div({ class: "projects-list" }, renderProjectList(projectsOrForm, f, params.spreadMap))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
exports.singleProjectView = async (project, filter, comments, params = {}) => {
|
||||
const pr = project || {}
|
||||
const f = String(filter || "ALL").toUpperCase()
|
||||
const isAuthor = pr.author === userId
|
||||
const isFollower = safeArr(pr.followers).includes(userId)
|
||||
|
||||
const statusUpper = String(pr.status || "ACTIVE").toUpperCase()
|
||||
const pctRaw = toNum(pr.progress)
|
||||
const pct = clamp(Math.round(Number.isFinite(pctRaw) ? pctRaw : 0), 0, 100)
|
||||
const goal = Math.max(0, toNum(pr.goal) || 0)
|
||||
const pledged = Math.max(0, toNum(pr.pledged) || 0)
|
||||
const fundingPct = goal > 0 ? clamp(Math.round((pledged / goal) * 100), 0, 100) : 0
|
||||
|
||||
const returnTo = `/projects/${encodeURIComponent(pr.id)}?filter=${encodeURIComponent(f)}`
|
||||
|
||||
const chips = [
|
||||
renderProjectStatusChip(pr.status),
|
||||
isFollower ? renderStateChip("whole", "★", i18n.projectFollowing || "FOLLOWING") : null,
|
||||
renderLifespanChip(pr.lifetime, i18n),
|
||||
renderEcoTax(pr.msgSize, pr.id || pr.key)
|
||||
].filter(Boolean)
|
||||
|
||||
const sideActions = []
|
||||
if (!isAuthor && pr.author) {
|
||||
sideActions.push(form({ method: "GET", action: "/pm" },
|
||||
input({ type: "hidden", name: "recipients", value: pr.author }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
|
||||
))
|
||||
}
|
||||
if (!isAuthor && statusUpper === "ACTIVE") {
|
||||
sideActions.push(isFollower
|
||||
? form({ method: "POST", action: `/projects/unfollow/${encodeURIComponent(pr.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", class: "unsubscribe-btn" }, i18n.projectUnfollowButton)
|
||||
)
|
||||
: form({ method: "POST", action: `/projects/follow/${encodeURIComponent(pr.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", class: "subscribe-btn" }, i18n.projectFollowButton)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (isAuthor) {
|
||||
sideActions.push(form({ method: "GET", action: `/projects/edit/${encodeURIComponent(pr.id)}` },
|
||||
button({ class: "update-btn", type: "submit" }, i18n.projectUpdateButton)
|
||||
))
|
||||
sideActions.push(form({ method: "POST", action: `/projects/delete/${encodeURIComponent(pr.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "delete-btn", type: "submit" }, i18n.projectDeleteButton)
|
||||
))
|
||||
sideActions.push(form(
|
||||
{ method: "POST", action: `/projects/status/${encodeURIComponent(pr.id)}`, class: "project-control-form project-control-form--status" },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
select(
|
||||
{ name: "status", class: "project-control-select" },
|
||||
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 project-control-btn", type: "submit" }, i18n.projectSetStatus)
|
||||
))
|
||||
sideActions.push(form(
|
||||
{ method: "POST", action: `/projects/progress/${encodeURIComponent(pr.id)}`, class: "project-control-form project-control-form--progress" },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
input({ type: "number", name: "progress", min: "0", max: "100", value: pct, class: "project-control-input project-progress-input" }),
|
||||
button({ class: "status-btn project-control-btn", type: "submit" }, i18n.projectSetProgress)
|
||||
))
|
||||
}
|
||||
|
||||
const projectSide = div({ class: "tribe-side" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, pr.title)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(pr.id || pr.key, params.spreads)),
|
||||
pr.image ? renderMediaBlob(pr.image, { class: "tribe-detail-image" }) : null,
|
||||
div({ class: "job-price-line card-salary" }, `${pr.goal || 0} ECO`),
|
||||
div({ class: "job-price-line card-salary" }, `${i18n.projectFollowers}: ${followersCount(pr)}`),
|
||||
renderProgressBlock(i18n.projectProgress + ":", `${pct}%`, pct, 100),
|
||||
goal > 0 ? renderProgressBlock(i18n.projectFunding + ":", `${fundingPct}%`, fundingPct, 100) : null,
|
||||
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null
|
||||
)
|
||||
|
||||
const projectMain = div({ class: "tribe-main" },
|
||||
!isAuthor && isFollower ? p({ class: "hint" }, i18n.projectYouFollowHint) : null,
|
||||
safeText(pr.description)
|
||||
? div({ class: "job-section" },
|
||||
h2({ class: "job-section-title" }, i18n.projectDescription),
|
||||
p({ class: "tribe-side-description" }, ...renderUrl(pr.description))
|
||||
)
|
||||
: null,
|
||||
pr.mapUrl ? div({ class: "job-section" }, renderMapEmbedWithZoom(params.mapData, pr.mapUrl, `/projects/${encodeURIComponent(pr.id || pr.key)}`, params.zoom)) : null,
|
||||
renderBudget(pr),
|
||||
renderBackers(pr, f),
|
||||
renderMilestonesAndBounties(pr, f, isAuthor),
|
||||
renderFollowers(pr),
|
||||
renderPledgeBox(pr, f, isAuthor),
|
||||
div({ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${moment(pr.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
|
||||
userLink(pr.author)
|
||||
),
|
||||
renderOpinionsVoting('/projects/opinions', pr.id || pr.key, pr.opinions, null, pr.opinions_inhabitants),
|
||||
div(
|
||||
{ id: "comments", class: "vote-comments-section" },
|
||||
div({ class: "comment-form-wrapper" },
|
||||
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
|
||||
form(
|
||||
{ method: "POST", action: `/projects/${encodeURIComponent(pr.id || pr.key)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
|
||||
textarea({ id: "comment-text", name: "text", rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }),
|
||||
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), br(), input({ type: "file", name: "blob" })),
|
||||
br(),
|
||||
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
|
||||
)
|
||||
),
|
||||
(() => {
|
||||
const visibleComments = (comments || []).filter(c => {
|
||||
const t = c && c.value && c.value.content && c.value.content.text
|
||||
return t && String(t).trim()
|
||||
})
|
||||
return visibleComments.length
|
||||
? div({ class: "comments-list" },
|
||||
visibleComments.map((c) => {
|
||||
const author = c?.value?.author || ""
|
||||
const ts = c?.value?.timestamp || c?.timestamp
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""
|
||||
const relDate = ts ? moment(ts).fromNow() : ""
|
||||
return div({ class: "comment-card" },
|
||||
div({ class: "comment-header" }, userLink(author)),
|
||||
div({ class: "comment-date" }, span({ title: absDate }, relDate)),
|
||||
div({ class: "comment-body" }, ...renderUrl(c?.value?.content?.text || ""))
|
||||
)
|
||||
})
|
||||
)
|
||||
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
|
||||
})()
|
||||
)
|
||||
)
|
||||
|
||||
return template(
|
||||
i18n.projectsTitle,
|
||||
section(
|
||||
div({ class: "tags-header" }, h2(i18n.projectsTitle), p(i18n.projectsDescription)),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/projects", class: "ui-toolbar ui-toolbar--filters" },
|
||||
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: f === x.key ? "filter-btn active" : "filter-btn" }, i18n[x.i18n]))
|
||||
.concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.projectCreateProject))
|
||||
)
|
||||
),
|
||||
div({ class: "tribe-details" }, projectSide, projectMain)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
exports.clearnetProjectView = async (project) => {
|
||||
const { escapeHtml: esc, blobUrl: cnBlob, renderClearnetPage } = require('./clearnet_view');
|
||||
const pr = project || {};
|
||||
const title = esc(pr.title || 'Project');
|
||||
const desc = esc(pr.description || '');
|
||||
const goal = Math.max(0, toNum(pr.goal) || 0);
|
||||
const pledged = Math.max(0, toNum(pr.pledged) || 0);
|
||||
const fundingPct = goal > 0 ? Math.min(100, Math.round((pledged / goal) * 100)) : 0;
|
||||
const status = String(pr.status || 'ACTIVE').toUpperCase();
|
||||
const projectImg = cnBlob(pr.image);
|
||||
const deadline = pr.deadline ? new Date(pr.deadline).toISOString().slice(0, 10) : '';
|
||||
const milestones = Array.isArray(pr.milestones) ? pr.milestones.slice(0, 10) : [];
|
||||
const milestonesBlock = milestones.length
|
||||
? `<div class="cn-prj-section"><h2>Milestones</h2><ol class="cn-prj-ms">${milestones.map(m => `<li>${esc(m.title || '')}${m.targetPercent ? ` <span class="cn-prj-pct">— ${m.targetPercent}%</span>` : ''}</li>`).join('')}</ol></div>`
|
||||
: '';
|
||||
const extraCss = `
|
||||
.cn-prj-title{color:var(--fg);margin:0 0 12px 0;font-size:32px;font-weight:700}
|
||||
.cn-prj-status{display:inline-block;background:var(--bg-sub);border:1px solid var(--fg);color:var(--fg);padding:6px 12px;border-radius:6px;font-weight:600;text-transform:uppercase;letter-spacing:1px;font-size:12px;margin-bottom:16px}
|
||||
.cn-prj-img{display:block;max-width:100%;border:1px solid var(--border);border-radius:8px;margin-bottom:20px}
|
||||
.cn-prj-funding{background:var(--bg-sub);border:1px solid var(--border);border-radius:8px;padding:14px;margin-bottom:20px}
|
||||
.cn-prj-funding-label{color:var(--fg-dim);font-size:12px;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px}
|
||||
.cn-prj-funding-amount{color:var(--fg);font-size:18px;font-weight:700;margin-bottom:8px}
|
||||
.cn-prj-bar{height:8px;background:#000;border-radius:4px;overflow:hidden}
|
||||
.cn-prj-bar-fill{height:100%;background:var(--fg);border-radius:4px}
|
||||
.cn-prj-section{margin-top:24px}
|
||||
.cn-prj-section h2{color:var(--fg);font-size:18px;text-transform:uppercase;letter-spacing:2px;margin:0 0 12px;padding-bottom:6px;border-bottom:1px solid var(--border)}
|
||||
.cn-prj-section p{color:var(--fg-soft);white-space:pre-wrap;line-height:1.6;font-size:15px;margin:0}
|
||||
.cn-prj-ms{padding-left:22px;margin:0;color:var(--fg-soft);line-height:1.7}
|
||||
.cn-prj-pct{color:var(--fg-dim);font-size:13px}
|
||||
.cn-prj-deadline{color:var(--fg-dim);font-size:13px;margin-top:8px}
|
||||
.cn-prj-meta{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:16px}
|
||||
.cn-prj-date,.cn-prj-idmeta{background:var(--bg-sub);border:1px solid var(--border);border-radius:6px;padding:6px 12px;font-size:13px;color:var(--fg-soft)}
|
||||
.cn-prj-idmeta{font-family:monospace;font-size:11px;word-break:break-all}
|
||||
`;
|
||||
const body = `
|
||||
<h1 class="cn-prj-title">${title}</h1>
|
||||
<div class="cn-prj-meta">
|
||||
<span class="cn-prj-status">${esc(status)}</span>
|
||||
${pr.createdAt ? `<span class="cn-prj-date">📅 ${esc(new Date(pr.createdAt).toISOString().slice(0,10))}</span>` : ''}
|
||||
</div>
|
||||
${projectImg ? `<img class="cn-prj-img" src="${projectImg}" alt="${title}"/>` : ''}
|
||||
${goal > 0 ? `
|
||||
<div class="cn-prj-funding">
|
||||
<div class="cn-prj-funding-label">Funding</div>
|
||||
<div class="cn-prj-funding-amount">${pledged.toFixed(2)} / ${goal.toFixed(2)} ECO · ${fundingPct}%</div>
|
||||
<div class="cn-prj-bar"><div class="cn-prj-bar-fill" style="width:${fundingPct}%"></div></div>
|
||||
${deadline ? `<div class="cn-prj-deadline">Deadline: ${deadline}</div>` : ''}
|
||||
</div>` : ''}
|
||||
${desc ? `<div class="cn-prj-section"><h2>Description</h2><p>${desc}</p></div>` : ''}
|
||||
${milestonesBlock}
|
||||
`;
|
||||
return renderClearnetPage({
|
||||
title: `${pr.title || 'Project'} — Oasis`,
|
||||
ogTitle: pr.title || 'Project',
|
||||
ogDescription: pr.description || '',
|
||||
ogImage: projectImg,
|
||||
extraCss,
|
||||
body,
|
||||
hubFeedId: pr.author || null
|
||||
});
|
||||
};
|
||||
|
||||
696
src/views/report_view.js
Normal file
696
src/views/report_view.js
Normal file
|
|
@ -0,0 +1,696 @@
|
|||
const { div, h2, p, section, button, form, a, textarea, br, input, img, span, label, select, option, video, audio, table, tr, td } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, renderOpinionsVoting, userLink, renderStateChip, renderLifespanChip, renderEcoTax, renderSpreadButton } = require("./main_views");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { renderUrl } = require("../backend/renderUrl");
|
||||
|
||||
const renderMediaBlob = (value, attrs = {}) => {
|
||||
if (!value) return null;
|
||||
const s = String(value).trim();
|
||||
if (!s) return null;
|
||||
if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}`, ...attrs });
|
||||
const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/);
|
||||
if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` });
|
||||
const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/);
|
||||
if (mAudio) return audio({ controls: true, class: attrs.class || 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` });
|
||||
const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/);
|
||||
if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, class: attrs.class || 'post-image' });
|
||||
return null;
|
||||
};
|
||||
|
||||
const userId = config.keys.id;
|
||||
|
||||
const normU = (v) => String(v || "").trim().toUpperCase();
|
||||
const normalizeStatus = (v) => normU(v).replace(/\s+/g, "_").replace(/-+/g, "_");
|
||||
|
||||
const CATEGORY_BY_FILTER = {
|
||||
features: "FEATURES",
|
||||
bugs: "BUGS",
|
||||
abuse: "ABUSE",
|
||||
content: "CONTENT"
|
||||
};
|
||||
|
||||
const STATUS_BY_FILTER = {
|
||||
open: "OPEN",
|
||||
under_review: "UNDER_REVIEW",
|
||||
resolved: "RESOLVED",
|
||||
invalid: "INVALID",
|
||||
closed: "CLOSED"
|
||||
};
|
||||
|
||||
const opt = (value, isSelected, text) =>
|
||||
option(Object.assign({ value }, isSelected ? { selected: "selected" } : {}), text);
|
||||
|
||||
const hasAnyTemplateValue = (t) => {
|
||||
if (!t || typeof t !== "object") return false;
|
||||
return Object.values(t).some((v) => String(v || "").trim());
|
||||
};
|
||||
|
||||
const renderCardField = (labelText, value = "") =>
|
||||
div(
|
||||
{ class: "card-field" },
|
||||
span({ class: "card-label" }, labelText),
|
||||
span({ class: "card-value" }, ...renderUrl(String(value ?? "")))
|
||||
);
|
||||
|
||||
const renderStackedTextField = (lbl, val) =>
|
||||
String(val || "").trim()
|
||||
? div(
|
||||
{ class: "card-field card-field-stacked" },
|
||||
span({ class: "card-label" }, lbl),
|
||||
span({ class: "card-value" }, ...renderUrl(String(val)))
|
||||
)
|
||||
: null;
|
||||
|
||||
const renderPmButton = (recipientId) =>
|
||||
recipientId && String(recipientId) !== String(userId)
|
||||
? form(
|
||||
{ method: "GET", action: "/pm" },
|
||||
input({ type: "hidden", name: "recipients", value: recipientId }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
|
||||
)
|
||||
: null;
|
||||
|
||||
const renderReportStatusSetter = (report) => {
|
||||
const st = normalizeStatus(report && report.status ? report.status : "OPEN");
|
||||
return form(
|
||||
{ method: "POST", action: `/reports/status/${encodeURIComponent(report.id)}`, class: "project-control-form project-control-form--status" },
|
||||
select(
|
||||
{ name: "status", class: "project-control-select" },
|
||||
opt("OPEN", st === "OPEN", i18n.reportsStatusOpen),
|
||||
opt("UNDER_REVIEW", st === "UNDER_REVIEW", i18n.reportsStatusUnderReview),
|
||||
opt("RESOLVED", st === "RESOLVED", i18n.reportsStatusResolved),
|
||||
opt("INVALID", st === "INVALID", i18n.reportsStatusInvalid),
|
||||
opt("CLOSED", st === "CLOSED", i18n.reportsStatusClosed || "CLOSED")
|
||||
),
|
||||
button({ class: "status-btn project-control-btn", type: "submit" }, i18n.reportsSetStatus || i18n.projectSetStatus || "Set status")
|
||||
);
|
||||
};
|
||||
|
||||
const renderTemplateDetails = (report) => {
|
||||
const category = normU(report.category);
|
||||
const t = report.template && typeof report.template === "object" ? report.template : {};
|
||||
if (!hasAnyTemplateValue(t)) return null;
|
||||
|
||||
const renderValueField = (lbl, val) =>
|
||||
String(val || "").trim()
|
||||
? renderCardField(lbl, String(val))
|
||||
: null;
|
||||
|
||||
if (category === "BUGS") {
|
||||
return div(
|
||||
{ class: "report-template" },
|
||||
h2({ class: "report-template-title" }, i18n.reportsBugTemplateTitle),
|
||||
renderStackedTextField(i18n.reportsStepsToReproduceLabel + ":", t.stepsToReproduce),
|
||||
renderStackedTextField(i18n.reportsExpectedBehaviorLabel + ":", t.expectedBehavior),
|
||||
renderStackedTextField(i18n.reportsActualBehaviorLabel + ":", t.actualBehavior),
|
||||
renderStackedTextField(i18n.reportsEnvironmentLabel + ":", t.environment),
|
||||
renderValueField(i18n.reportsReproduceRateLabel + ":", t.reproduceRate)
|
||||
);
|
||||
}
|
||||
|
||||
if (category === "FEATURES") {
|
||||
return div(
|
||||
{ class: "report-template" },
|
||||
h2({ class: "report-template-title" }, i18n.reportsFeatureTemplateTitle),
|
||||
renderStackedTextField(i18n.reportsProblemStatementLabel + ":", t.problemStatement),
|
||||
renderStackedTextField(i18n.reportsUserStoryLabel + ":", t.userStory),
|
||||
renderStackedTextField(i18n.reportsAcceptanceCriteriaLabel + ":", t.acceptanceCriteria)
|
||||
);
|
||||
}
|
||||
|
||||
if (category === "ABUSE") {
|
||||
return div(
|
||||
{ class: "report-template" },
|
||||
h2({ class: "report-template-title" }, i18n.reportsAbuseTemplateTitle),
|
||||
renderStackedTextField(i18n.reportsWhatHappenedLabel + ":", t.whatHappened),
|
||||
renderStackedTextField(i18n.reportsReportedUserLabel + ":", t.reportedUser),
|
||||
renderStackedTextField(i18n.reportsEvidenceLinksLabel + ":", t.evidenceLinks)
|
||||
);
|
||||
}
|
||||
|
||||
if (category === "CONTENT") {
|
||||
return div(
|
||||
{ class: "report-template" },
|
||||
h2({ class: "report-template-title" }, i18n.reportsContentTemplateTitle),
|
||||
renderStackedTextField(i18n.reportsContentLocationLabel + ":", t.contentLocation),
|
||||
renderStackedTextField(i18n.reportsWhyInappropriateLabel + ":", t.whyInappropriate),
|
||||
renderStackedTextField(i18n.reportsRequestedActionLabel + ":", t.requestedAction),
|
||||
renderStackedTextField(i18n.reportsEvidenceLinksLabel + ":", t.evidenceLinks)
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderReportCommentsSection = (reportId, comments = []) => {
|
||||
const commentsCount = Array.isArray(comments) ? comments.length : 0;
|
||||
|
||||
return div(
|
||||
{ class: "vote-comments-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
|
||||
span({ class: "card-value" }, String(commentsCount))
|
||||
),
|
||||
div(
|
||||
{ class: "comment-form-wrapper" },
|
||||
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
|
||||
form(
|
||||
{
|
||||
method: "POST",
|
||||
action: `/reports/${encodeURIComponent(reportId)}/comments`,
|
||||
class: "comment-form",
|
||||
enctype: "multipart/form-data"
|
||||
},
|
||||
textarea({
|
||||
id: "comment-text",
|
||||
name: "text",
|
||||
rows: 4,
|
||||
class: "comment-textarea",
|
||||
placeholder: i18n.voteNewCommentPlaceholder
|
||||
}),
|
||||
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
|
||||
br(),
|
||||
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
|
||||
)
|
||||
),
|
||||
(() => {
|
||||
const visibleComments = (comments || []).filter(c => {
|
||||
const t = c && c.value && c.value.content && c.value.content.text;
|
||||
return t && String(t).trim();
|
||||
});
|
||||
return visibleComments.length
|
||||
? div(
|
||||
{ class: "comments-list" },
|
||||
visibleComments.map((c) => {
|
||||
const author = c.value && c.value.author ? c.value.author : "";
|
||||
const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
|
||||
const relDate = ts ? moment(ts).fromNow() : "";
|
||||
|
||||
const content = c.value && c.value.content ? c.value.content : {};
|
||||
const root = content.fork || content.root || "";
|
||||
const text = content.text || "";
|
||||
|
||||
return div(
|
||||
{ class: "votations-comment-card" },
|
||||
span(
|
||||
{ class: "created-at" },
|
||||
span(i18n.createdBy),
|
||||
author ? userLink(author) : span("(unknown)"),
|
||||
absDate ? span(" | ") : "",
|
||||
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
|
||||
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
|
||||
relDate && root ? a({ href: `/thread/${encodeURIComponent(root)}#${encodeURIComponent(c.key)}` }, relDate) : ""
|
||||
),
|
||||
p({ class: "votations-comment-text" }, ...renderUrl(text))
|
||||
);
|
||||
})
|
||||
)
|
||||
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet);
|
||||
})()
|
||||
);
|
||||
};
|
||||
|
||||
const renderTemplateForCategory = (category, templateData = {}) => {
|
||||
const cat = normU(category || "FEATURES");
|
||||
const t = templateData && typeof templateData === "object" ? templateData : {};
|
||||
const tval = (k) => String(t[k] || "");
|
||||
const reproduceRateVal = normU(t.reproduceRate || "");
|
||||
|
||||
if (cat === "BUGS") {
|
||||
return div(
|
||||
{ class: "report-template-block" },
|
||||
h2({ class: "report-template-title" }, i18n.reportsBugTemplateTitle),
|
||||
label(i18n.reportsStepsToReproduceLabel),
|
||||
br(),
|
||||
textarea({ name: "stepsToReproduce", rows: "4", placeholder: i18n.reportsStepsToReproducePlaceholder }, tval("stepsToReproduce")),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.reportsExpectedBehaviorLabel),
|
||||
br(),
|
||||
textarea({ name: "expectedBehavior", rows: "3", placeholder: i18n.reportsExpectedBehaviorPlaceholder }, tval("expectedBehavior")),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.reportsActualBehaviorLabel),
|
||||
br(),
|
||||
textarea({ name: "actualBehavior", rows: "3", placeholder: i18n.reportsActualBehaviorPlaceholder }, tval("actualBehavior")),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.reportsEnvironmentLabel),
|
||||
br(),
|
||||
textarea({ name: "environment", rows: "3", placeholder: i18n.reportsEnvironmentPlaceholder }, tval("environment")),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.reportsReproduceRateLabel),
|
||||
br(),
|
||||
select(
|
||||
{ name: "reproduceRate" },
|
||||
opt("", !reproduceRateVal, i18n.reportsReproduceRateUnknown),
|
||||
opt("ALWAYS", reproduceRateVal === "ALWAYS", i18n.reportsReproduceRateAlways),
|
||||
opt("OFTEN", reproduceRateVal === "OFTEN", i18n.reportsReproduceRateOften),
|
||||
opt("SOMETIMES", reproduceRateVal === "SOMETIMES", i18n.reportsReproduceRateSometimes),
|
||||
opt("RARELY", reproduceRateVal === "RARELY", i18n.reportsReproduceRateRarely),
|
||||
opt("UNABLE", reproduceRateVal === "UNABLE", i18n.reportsReproduceRateUnable)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (cat === "ABUSE") {
|
||||
return div(
|
||||
{ class: "report-template-block" },
|
||||
h2({ class: "report-template-title" }, i18n.reportsAbuseTemplateTitle),
|
||||
label(i18n.reportsWhatHappenedLabel),
|
||||
br(),
|
||||
textarea({ name: "whatHappened", rows: "4", placeholder: i18n.reportsWhatHappenedPlaceholder }, tval("whatHappened")),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.reportsReportedUserLabel),
|
||||
br(),
|
||||
textarea({ name: "reportedUser", rows: "2", placeholder: i18n.reportsReportedUserPlaceholder }, tval("reportedUser")),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.reportsEvidenceLinksLabel),
|
||||
br(),
|
||||
textarea({ name: "evidenceLinks", rows: "3", placeholder: i18n.reportsEvidenceLinksPlaceholder }, tval("evidenceLinks"))
|
||||
);
|
||||
}
|
||||
|
||||
if (cat === "CONTENT") {
|
||||
return div(
|
||||
{ class: "report-template-block" },
|
||||
h2({ class: "report-template-title" }, i18n.reportsContentTemplateTitle),
|
||||
label(i18n.reportsContentLocationLabel),
|
||||
br(),
|
||||
textarea({ name: "contentLocation", rows: "3", placeholder: i18n.reportsContentLocationPlaceholder }, tval("contentLocation")),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.reportsWhyInappropriateLabel),
|
||||
br(),
|
||||
textarea({ name: "whyInappropriate", rows: "4", placeholder: i18n.reportsWhyInappropriatePlaceholder }, tval("whyInappropriate")),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.reportsRequestedActionLabel),
|
||||
br(),
|
||||
textarea({ name: "requestedAction", rows: "3", placeholder: i18n.reportsRequestedActionPlaceholder }, tval("requestedAction")),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.reportsEvidenceLinksLabel),
|
||||
br(),
|
||||
textarea({ name: "evidenceLinks", rows: "3", placeholder: i18n.reportsEvidenceLinksPlaceholder }, tval("evidenceLinks"))
|
||||
);
|
||||
}
|
||||
|
||||
return div(
|
||||
{ class: "report-template-block" },
|
||||
h2({ class: "report-template-title" }, i18n.reportsFeatureTemplateTitle),
|
||||
label(i18n.reportsProblemStatementLabel),
|
||||
br(),
|
||||
textarea({ name: "problemStatement", rows: "4", placeholder: i18n.reportsProblemStatementPlaceholder }, tval("problemStatement")),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.reportsUserStoryLabel),
|
||||
br(),
|
||||
textarea({ name: "userStory", rows: "3", placeholder: i18n.reportsUserStoryPlaceholder }, tval("userStory")),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.reportsAcceptanceCriteriaLabel),
|
||||
br(),
|
||||
textarea({ name: "acceptanceCriteria", rows: "4", placeholder: i18n.reportsAcceptanceCriteriaPlaceholder }, tval("acceptanceCriteria"))
|
||||
);
|
||||
};
|
||||
|
||||
const renderReportStatusChip = (status) => {
|
||||
const s = normalizeStatus(status);
|
||||
const variant =
|
||||
s === "OPEN" ? "whole" :
|
||||
s === "UNDER_REVIEW" ? "lifespan-orange" :
|
||||
s === "RESOLVED" ? "mutuals" :
|
||||
s === "INVALID" ? "closed" :
|
||||
s === "CLOSED" ? "closed" :
|
||||
"whole";
|
||||
const localized =
|
||||
s === "OPEN" ? i18n.reportsStatusOpen :
|
||||
s === "UNDER_REVIEW" ? i18n.reportsStatusUnderReview :
|
||||
s === "RESOLVED" ? i18n.reportsStatusResolved :
|
||||
s === "INVALID" ? i18n.reportsStatusInvalid :
|
||||
s === "CLOSED" ? (i18n.reportsStatusClosed || "CLOSED") :
|
||||
s;
|
||||
return renderStateChip(variant, "", localized);
|
||||
};
|
||||
|
||||
const renderReportSeverityChip = (severity) => {
|
||||
const s = String(severity || "low").toLowerCase();
|
||||
const variant =
|
||||
s === "critical" ? "closed" :
|
||||
s === "high" ? "lifespan-orange" :
|
||||
s === "medium" ? "whole" :
|
||||
"mutuals";
|
||||
const localized =
|
||||
s === "critical" ? i18n.reportsSeverityCritical :
|
||||
s === "high" ? i18n.reportsSeverityHigh :
|
||||
s === "medium" ? i18n.reportsSeverityMedium :
|
||||
i18n.reportsSeverityLow;
|
||||
return renderStateChip(variant, "⚑", localized);
|
||||
};
|
||||
|
||||
const renderReportCategoryChip = (category) => {
|
||||
const c = normU(category);
|
||||
const localized =
|
||||
c === "FEATURES" ? i18n.reportsCategoryFeatures :
|
||||
c === "BUGS" ? i18n.reportsCategoryBugs :
|
||||
c === "ABUSE" ? i18n.reportsCategoryAbuse :
|
||||
c === "CONTENT" ? i18n.reportsCategoryContent :
|
||||
c;
|
||||
return renderStateChip("encrypted", "", localized);
|
||||
};
|
||||
|
||||
const renderReportCard = (report, userId, currentFilter = "all", spreadInfo) => {
|
||||
const confirmations = Array.isArray(report.confirmations) ? report.confirmations : [];
|
||||
|
||||
const chips = [
|
||||
renderReportStatusChip(report.status),
|
||||
renderReportSeverityChip(report.severity),
|
||||
renderReportCategoryChip(report.category),
|
||||
renderLifespanChip(report.lifetime, i18n)
|
||||
].filter(Boolean);
|
||||
|
||||
return div({ class: "tribe-card report-card" },
|
||||
div({ class: "tribe-card-body" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" },
|
||||
a({ href: `/reports/${encodeURIComponent(report.id)}` }, report.title || i18n.reportsTitle)
|
||||
)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.reportsConfirmations}: ${confirmations.length}`)
|
||||
),
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(report.id, spreadInfo)),
|
||||
div({ class: "card-visit-btn-centered" },
|
||||
form({ method: "GET", action: `/reports/${encodeURIComponent(report.id)}` },
|
||||
input({ type: "hidden", name: "filter", value: currentFilter }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.viewReport || "View Report")
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.reportView = async (reports, filter, reportId, createCategory, params = {}) => {
|
||||
const title =
|
||||
filter === "create" ? i18n.reportsCreateButton :
|
||||
filter === "edit" ? i18n.reportsUpdateButton :
|
||||
filter === "mine" ? i18n.reportsMineSectionTitle :
|
||||
filter === "features" ? i18n.reportsFeaturesSectionTitle :
|
||||
filter === "bugs" ? i18n.reportsBugsSectionTitle :
|
||||
filter === "abuse" ? i18n.reportsAbuseSectionTitle :
|
||||
filter === "content" ? i18n.reportsContentSectionTitle :
|
||||
filter === "confirmed" ? i18n.reportsConfirmedSectionTitle :
|
||||
filter === "open" ? i18n.reportsOpenSectionTitle :
|
||||
filter === "under_review" ? i18n.reportsUnderReviewSectionTitle :
|
||||
filter === "resolved" ? i18n.reportsResolvedSectionTitle :
|
||||
filter === "invalid" ? i18n.reportsInvalidSectionTitle :
|
||||
i18n.reportsAllSectionTitle;
|
||||
|
||||
let filtered = Array.isArray(reports) ? reports : [];
|
||||
|
||||
if (filter === "mine") {
|
||||
filtered = filtered.filter((r) => r.author === userId);
|
||||
} else if (filter === "confirmed") {
|
||||
filtered = filtered.filter((r) => Array.isArray(r.confirmations) && r.confirmations.includes(userId));
|
||||
} else if (CATEGORY_BY_FILTER[filter]) {
|
||||
const wanted = CATEGORY_BY_FILTER[filter];
|
||||
filtered = filtered.filter((r) => normU(r.category) === wanted);
|
||||
} else if (STATUS_BY_FILTER[filter]) {
|
||||
const wanted = STATUS_BY_FILTER[filter];
|
||||
filtered = filtered.filter((r) => normalizeStatus(r.status) === wanted);
|
||||
}
|
||||
|
||||
filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
|
||||
const reportToEdit = filter === "edit"
|
||||
? (Array.isArray(reports) ? reports.find((r) => r.id === reportId) : null)
|
||||
: null;
|
||||
|
||||
const btnClass = (v) => (filter === v ? "filter-btn active" : "filter-btn");
|
||||
|
||||
const selectedCategory = normU(
|
||||
filter === "create"
|
||||
? (createCategory || "FEATURES")
|
||||
: (reportToEdit?.category || "FEATURES")
|
||||
);
|
||||
|
||||
const selectedTemplate = reportToEdit?.template && typeof reportToEdit.template === "object" ? reportToEdit.template : {};
|
||||
const applyLabel = i18n.apply || "Apply";
|
||||
const sev = String(reportToEdit?.severity || "low");
|
||||
const hiddenDescription = String(reportToEdit?.description || "");
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div(
|
||||
{ class: "tags-header" },
|
||||
h2(i18n.reportsTitle),
|
||||
p(i18n.reportsDescription)
|
||||
),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/reports" },
|
||||
button({ type: "submit", name: "filter", value: "all", class: btnClass("all") }, i18n.reportsFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: btnClass("mine") }, i18n.reportsFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "features", class: btnClass("features") }, i18n.reportsFilterFeatures),
|
||||
button({ type: "submit", name: "filter", value: "bugs", class: btnClass("bugs") }, i18n.reportsFilterBugs),
|
||||
button({ type: "submit", name: "filter", value: "abuse", class: btnClass("abuse") }, i18n.reportsFilterAbuse),
|
||||
button({ type: "submit", name: "filter", value: "content", class: btnClass("content") }, i18n.reportsFilterContent),
|
||||
button({ type: "submit", name: "filter", value: "confirmed", class: btnClass("confirmed") }, i18n.reportsFilterConfirmed),
|
||||
button({ type: "submit", name: "filter", value: "open", class: btnClass("open") }, i18n.reportsFilterOpen),
|
||||
button({ type: "submit", name: "filter", value: "under_review", class: btnClass("under_review") }, i18n.reportsFilterUnderReview),
|
||||
button({ type: "submit", name: "filter", value: "resolved", class: btnClass("resolved") }, i18n.reportsFilterResolved),
|
||||
button({ type: "submit", name: "filter", value: "invalid", class: btnClass("invalid") }, i18n.reportsFilterInvalid),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.reportsCreateButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
filter === "edit" || filter === "create"
|
||||
? div(
|
||||
{ class: "report-form" },
|
||||
filter === "create"
|
||||
? div(
|
||||
label(i18n.reportsTitleLabel),
|
||||
br(),
|
||||
input({ type: "text", name: "title", required: true, value: "", form: "report-create-form" }),
|
||||
br(),
|
||||
br(),
|
||||
form(
|
||||
{ id: "report-category-form", method: "GET", action: "/reports" },
|
||||
input({ type: "hidden", name: "filter", value: "create" }),
|
||||
label(i18n.reportsCategory),
|
||||
br(),
|
||||
select(
|
||||
{ name: "category" },
|
||||
opt("FEATURES", selectedCategory === "FEATURES", i18n.reportsCategoryFeatures),
|
||||
opt("BUGS", selectedCategory === "BUGS", i18n.reportsCategoryBugs),
|
||||
opt("ABUSE", selectedCategory === "ABUSE", i18n.reportsCategoryAbuse),
|
||||
opt("CONTENT", selectedCategory === "CONTENT", i18n.reportsCategoryContent)
|
||||
),
|
||||
br(),
|
||||
br(),
|
||||
button({ type: "submit", class: "create-button" }, applyLabel)
|
||||
),
|
||||
br(),
|
||||
h2({ class: "report-category-fixed" }, selectedCategory),
|
||||
br(),
|
||||
form(
|
||||
{ id: "report-create-form", action: "/reports/create", method: "POST", enctype: "multipart/form-data" },
|
||||
input({ type: "hidden", name: "category", value: selectedCategory }),
|
||||
input({ type: "hidden", name: "description", value: "" }),
|
||||
label(i18n.reportsSeverity),
|
||||
br(),
|
||||
select(
|
||||
{ name: "severity" },
|
||||
opt("critical", sev === "critical", i18n.reportsSeverityCritical),
|
||||
opt("high", sev === "high", i18n.reportsSeverityHigh),
|
||||
opt("medium", sev === "medium", i18n.reportsSeverityMedium),
|
||||
opt("low", sev === "low", i18n.reportsSeverityLow)
|
||||
),
|
||||
br(),
|
||||
br(),
|
||||
h2({ class: "report-template-main-title" }, i18n.reportsTemplateSectionTitle),
|
||||
renderTemplateForCategory(selectedCategory, {}),
|
||||
label(i18n.reportsUploadFile),
|
||||
br(),
|
||||
input({ type: "file", name: "image" }),
|
||||
br(),
|
||||
br(),
|
||||
label("Tags"),
|
||||
br(),
|
||||
input({ type: "text", name: "tags", value: "" }),
|
||||
br(),
|
||||
br(),
|
||||
button({ type: "submit", class: "create-button" }, i18n.reportsCreateButton)
|
||||
)
|
||||
)
|
||||
: div(
|
||||
form(
|
||||
{ id: "report-edit-form", action: `/reports/update/${encodeURIComponent(reportId)}`, method: "POST", enctype: "multipart/form-data" },
|
||||
label(i18n.reportsTitleLabel),
|
||||
br(),
|
||||
input({ type: "text", name: "title", required: true, value: reportToEdit?.title || "" }),
|
||||
br(),
|
||||
br(),
|
||||
input({ type: "hidden", name: "description", value: hiddenDescription }),
|
||||
label(i18n.reportsCategory),
|
||||
br(),
|
||||
select(
|
||||
{ name: "category", required: true },
|
||||
opt("FEATURES", selectedCategory === "FEATURES", i18n.reportsCategoryFeatures),
|
||||
opt("BUGS", selectedCategory === "BUGS", i18n.reportsCategoryBugs),
|
||||
opt("ABUSE", selectedCategory === "ABUSE", i18n.reportsCategoryAbuse),
|
||||
opt("CONTENT", selectedCategory === "CONTENT", i18n.reportsCategoryContent)
|
||||
),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.reportsSeverity),
|
||||
br(),
|
||||
select(
|
||||
{ name: "severity" },
|
||||
opt("critical", sev === "critical", i18n.reportsSeverityCritical),
|
||||
opt("high", sev === "high", i18n.reportsSeverityHigh),
|
||||
opt("medium", sev === "medium", i18n.reportsSeverityMedium),
|
||||
opt("low", sev === "low", i18n.reportsSeverityLow)
|
||||
),
|
||||
br(),
|
||||
br(),
|
||||
h2({ class: "report-template-main-title" }, i18n.reportsTemplateSectionTitle),
|
||||
renderTemplateForCategory(selectedCategory, selectedTemplate),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.reportsUploadFile),
|
||||
br(),
|
||||
input({ type: "file", name: "image" }),
|
||||
br(),
|
||||
br(),
|
||||
label("Tags"),
|
||||
br(),
|
||||
input({ type: "text", name: "tags", value: reportToEdit?.tags?.join(", ") || "" }),
|
||||
br(),
|
||||
br(),
|
||||
button({ type: "submit" }, i18n.reportsUpdateButton)
|
||||
)
|
||||
)
|
||||
)
|
||||
: filtered.length > 0
|
||||
? div({ class: "jobs-grid" }, filtered.map((r) => renderReportCard(r, userId, filter, params.spreadMap && params.spreadMap.get(r.id))))
|
||||
: p(i18n.reportsNoItems)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.singleReportView = async (report, filter, comments = [], params = {}) => {
|
||||
const btnClass = (v) => (filter === v ? "filter-btn active" : "filter-btn");
|
||||
const confirmations = Array.isArray(report.confirmations) ? report.confirmations : [];
|
||||
const isAuthor = String(report.author) === String(userId);
|
||||
const details = renderTemplateDetails(report);
|
||||
|
||||
const chips = [
|
||||
renderReportStatusChip(report.status),
|
||||
renderReportSeverityChip(report.severity),
|
||||
renderReportCategoryChip(report.category),
|
||||
renderLifespanChip(report.lifetime, i18n),
|
||||
renderEcoTax(report.msgSize, report.id)
|
||||
].filter(Boolean);
|
||||
|
||||
const sideActions = [];
|
||||
const pm = renderPmButton(report.author);
|
||||
if (pm) sideActions.push(pm);
|
||||
sideActions.push(form({ method: "POST", action: `/reports/confirm/${encodeURIComponent(report.id)}` },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.reportsConfirmButton)
|
||||
));
|
||||
sideActions.push(a({ href: "/tasks?filter=create", target: "_blank" },
|
||||
button({ type: "button", class: "filter-btn" }, i18n.reportsCreateTaskButton)
|
||||
));
|
||||
if (isAuthor) {
|
||||
sideActions.push(renderReportStatusSetter(report));
|
||||
sideActions.push(form({ method: "GET", action: `/reports/edit/${encodeURIComponent(report.id)}` },
|
||||
button({ type: "submit", class: "update-btn" }, i18n.reportsUpdateButton)
|
||||
));
|
||||
sideActions.push(form({ method: "POST", action: `/reports/delete/${encodeURIComponent(report.id)}` },
|
||||
button({ type: "submit", class: "delete-btn" }, i18n.reportsDeleteButton)
|
||||
));
|
||||
}
|
||||
|
||||
const tagsNode = report.tags && report.tags.length
|
||||
? div({ class: "card-tags" },
|
||||
report.tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
|
||||
)
|
||||
: null;
|
||||
|
||||
const infoRows = [];
|
||||
const pushRow = (labelText, valueNode) =>
|
||||
infoRows.push(tr(
|
||||
td({ class: "tribe-info-label" }, labelText),
|
||||
td({ class: "tribe-info-value" }, valueNode)
|
||||
));
|
||||
pushRow(i18n.reportsStatus, report.status);
|
||||
pushRow(i18n.reportsSeverity, normU(report.severity || "low"));
|
||||
pushRow(i18n.reportsCategory, report.category);
|
||||
|
||||
const reportSide = div({ class: "tribe-side" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, report.title)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(report.id, params.spreads)),
|
||||
table({ class: "tribe-info-table jobs-info-table" }, ...infoRows),
|
||||
tagsNode,
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.reportsConfirmations}: ${confirmations.length}`)
|
||||
),
|
||||
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null
|
||||
);
|
||||
|
||||
const opinionsBar = renderOpinionsVoting('/reports/opinions', report.id, report.opinions, null, report.opinions_inhabitants);
|
||||
|
||||
const reportMain = div({ class: "tribe-main" },
|
||||
(details || report.image) ? div({ class: "job-section" },
|
||||
details || null,
|
||||
report.image ? renderMediaBlob(report.image, { class: "report-detail-image" }) : null
|
||||
) : null,
|
||||
p({ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${moment(report.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
|
||||
userLink(report.author)
|
||||
),
|
||||
opinionsBar,
|
||||
renderReportCommentsSection(report.id, comments)
|
||||
);
|
||||
|
||||
return template(
|
||||
report.title,
|
||||
section(
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/reports" },
|
||||
button({ type: "submit", name: "filter", value: "all", class: btnClass("all") }, i18n.reportsFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: btnClass("mine") }, i18n.reportsFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "features", class: btnClass("features") }, i18n.reportsFilterFeatures),
|
||||
button({ type: "submit", name: "filter", value: "bugs", class: btnClass("bugs") }, i18n.reportsFilterBugs),
|
||||
button({ type: "submit", name: "filter", value: "abuse", class: btnClass("abuse") }, i18n.reportsFilterAbuse),
|
||||
button({ type: "submit", name: "filter", value: "content", class: btnClass("content") }, i18n.reportsFilterContent),
|
||||
button({ type: "submit", name: "filter", value: "confirmed", class: btnClass("confirmed") }, i18n.reportsFilterConfirmed),
|
||||
button({ type: "submit", name: "filter", value: "open", class: btnClass("open") }, i18n.reportsFilterOpen),
|
||||
button({ type: "submit", name: "filter", value: "under_review", class: btnClass("under_review") }, i18n.reportsFilterUnderReview),
|
||||
button({ type: "submit", name: "filter", value: "resolved", class: btnClass("resolved") }, i18n.reportsFilterResolved),
|
||||
button({ type: "submit", name: "filter", value: "invalid", class: btnClass("invalid") }, i18n.reportsFilterInvalid),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.reportsCreateButton)
|
||||
)
|
||||
),
|
||||
div({ class: "tribe-details" }, reportSide, reportMain)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
660
src/views/search_view.js
Normal file
660
src/views/search_view.js
Normal file
|
|
@ -0,0 +1,660 @@
|
|||
const { form, button, div, h2, p, section, input, label, select, option, img, audio: audioHyperaxe, video: videoHyperaxe, table, hr, hd, br, td, tr, th, a, span } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, userLink, renderContentActions } = require('./main_views');
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
|
||||
const { renderUrl } = require('../backend/renderUrl');
|
||||
const { sanitizeHtml } = require('../backend/sanitizeHtml');
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const userId = config.keys.id;
|
||||
|
||||
const decodeMaybe = (s) => {
|
||||
try { return decodeURIComponent(String(s || '')); } catch { return String(s || ''); }
|
||||
};
|
||||
|
||||
const rewriteHashtagLinks = (html) => {
|
||||
const s = String(html || '');
|
||||
return s.replace(/href=(["'])(?:https?:\/\/[^"']+)?\/hashtag\/([^"'?#]+)([^"']*)\1/g, (m, q, tag, rest) => {
|
||||
const t = decodeMaybe(tag).replace(/^#/, '').trim().toLowerCase();
|
||||
const href = `/search?query=%23${encodeURIComponent(t)}`;
|
||||
return `href=${q}${href}${q}`;
|
||||
});
|
||||
};
|
||||
|
||||
const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = [], hashtag = null, results = {}, resultCount = "10" }) => {
|
||||
const searchInput = input({
|
||||
id: "search_query",
|
||||
name: "query",
|
||||
required: false,
|
||||
type: "search",
|
||||
value: query,
|
||||
placeholder: i18n.searchPlaceholder
|
||||
});
|
||||
searchInput.setAttribute("minlength", 3);
|
||||
|
||||
const contentTypes = [
|
||||
"post", "about", "curriculum", "tribe", "market", "transfer", "feed", "votes",
|
||||
"report", "task", "event", "bookmark", "image", "audio", "video", "document", "torrent",
|
||||
"bankWallet", "bankClaim", "project", "job", "forum", "vote", "contact", "pub", "map", "shop", "shopProduct", "chat", "pad", "all"
|
||||
];
|
||||
|
||||
const filterSelect = select(
|
||||
{
|
||||
id: "search-type",
|
||||
name: "type",
|
||||
class: "input-select",
|
||||
style: "position:relative; z-index:10;"
|
||||
},
|
||||
contentTypes.map(type =>
|
||||
option({
|
||||
value: type === 'all' ? "" : type,
|
||||
selected: (types.length === 0 && type === 'all') || types.includes(type)
|
||||
}, i18n[type + "Label"] || type.toUpperCase())
|
||||
)
|
||||
);
|
||||
|
||||
const resultsPerPageSelect = select(
|
||||
{
|
||||
id: "results-per-page",
|
||||
name: "resultsPerPage",
|
||||
class: "input-select",
|
||||
style: "position:relative; z-index:10;margin-left:10px;"
|
||||
},
|
||||
option({ value: "100", selected: resultCount === "100" ? "selected" : undefined }, "100"),
|
||||
option({ value: "50", selected: resultCount === "50" ? "selected" : undefined }, "50"),
|
||||
option({ value: "10", selected: resultCount === "10" ? "selected" : undefined }, "10"),
|
||||
option({ value: "all", selected: resultCount === "all" ? "selected" : undefined }, i18n.allTypesLabel)
|
||||
);
|
||||
|
||||
const getViewDetailsActionForSearch = (type, contentId, content) => {
|
||||
switch (type) {
|
||||
case 'votes': return `/votes/${encodeURIComponent(contentId)}`;
|
||||
case 'transfer': return `/transfers/${encodeURIComponent(contentId)}`;
|
||||
case 'tribe': return `/tribe/${encodeURIComponent(contentId)}`;
|
||||
case 'about': return content && (content.about || content.author) ? `/inhabitant/${encodeURIComponent(content.about || content.author)}` : '#';
|
||||
case 'curriculum': return content && content.author ? `/inhabitant/${encodeURIComponent(content.author)}` : '#';
|
||||
case 'image': return `/images/${encodeURIComponent(contentId)}`;
|
||||
case 'audio': return `/audios/${encodeURIComponent(contentId)}`;
|
||||
case 'video': return `/videos/${encodeURIComponent(contentId)}`;
|
||||
case 'document': return `/documents/${encodeURIComponent(contentId)}`;
|
||||
case 'bookmark': return `/bookmarks/${encodeURIComponent(contentId)}`;
|
||||
case 'event': return `/events/${encodeURIComponent(contentId)}`;
|
||||
case 'task': return `/tasks/${encodeURIComponent(contentId)}`;
|
||||
case 'post': return `/thread/${encodeURIComponent(contentId)}#${encodeURIComponent(contentId)}`;
|
||||
case 'market': return `/market/${encodeURIComponent(contentId)}`;
|
||||
case 'report': return `/reports/${encodeURIComponent(contentId)}`;
|
||||
case 'project': return `/projects/${encodeURIComponent(contentId)}`;
|
||||
case 'job': return `/jobs/${encodeURIComponent(contentId)}`;
|
||||
case 'forum': return `/forum/${encodeURIComponent(contentId)}`;
|
||||
case 'vote': return content && content.vote && content.vote.link ? `/thread/${encodeURIComponent(content.vote.link)}#${encodeURIComponent(content.vote.link)}` : '#';
|
||||
case 'contact': return content && content.contact ? `/author/${encodeURIComponent(content.contact)}` : '#';
|
||||
case 'pub': return '#';
|
||||
case 'bankWallet': return `/banking`;
|
||||
case 'bankClaim': return `/banking`;
|
||||
case 'map': return `/maps/${encodeURIComponent(contentId)}`;
|
||||
case 'shop': return `/shops/${encodeURIComponent(contentId)}`;
|
||||
case 'shopProduct': return `/shops/product/${encodeURIComponent(contentId)}`;
|
||||
case 'chat': return `/chats/${encodeURIComponent(contentId)}`;
|
||||
case 'pad': return `/pads/${encodeURIComponent(contentId)}`;
|
||||
case 'torrent': return `/torrents/${encodeURIComponent(contentId)}`;
|
||||
case 'gameScore': return content && content.game ? `/games/${encodeURIComponent(content.game)}` : '/games';
|
||||
default: return '#';
|
||||
}
|
||||
};
|
||||
|
||||
let hasDocument = false;
|
||||
|
||||
const blobImg = (value) => {
|
||||
if (!value) return null;
|
||||
const s = String(value).trim().replace(/&/g, '&');
|
||||
const m = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/);
|
||||
const src = m ? m[1] : s;
|
||||
return src.startsWith('&') ? img({ src: `/blob/${encodeURIComponent(src)}`, class: 'search-result-image' }) : null;
|
||||
};
|
||||
|
||||
const renderContentHtml = (content) => {
|
||||
switch (content.type) {
|
||||
case 'post':
|
||||
return div({ class: 'search-post' },
|
||||
content.contentWarning ? h2({ class: 'card-field' }, span({ class: 'card-value' }, content.contentWarning)) : null,
|
||||
content.text ? div({ class: 'card-field' }, span({ class: 'card-value', innerHTML: sanitizeHtml(content.text) })) : null
|
||||
);
|
||||
case 'about':
|
||||
return div({ class: 'search-about' },
|
||||
content.name ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.name + ':'), span({ class: 'card-value' }, content.name)) : null,
|
||||
content.description ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.description + ':'), span({ class: 'card-value' }, content.description)) : null,
|
||||
content.image ? img({ src: `/image/64/${encodeURIComponent(content.image)}` }) : null
|
||||
);
|
||||
case 'feed': {
|
||||
const rawText = typeof content.text === 'string' ? content.text.trim() : '';
|
||||
const htmlText = rawText ? rewriteHashtagLinks(renderTextWithStyles(rawText)) : '';
|
||||
const refeedsNum = Number(content.refeeds || 0) || 0;
|
||||
return div({ class: 'search-feed' },
|
||||
rawText ? div({ class: 'card-field' }, span({ class: 'card-value', innerHTML: sanitizeHtml(htmlText) })) : null,
|
||||
refeedsNum > 0
|
||||
? h2({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ':'),
|
||||
span({ class: 'card-value' }, String(refeedsNum))
|
||||
)
|
||||
: null
|
||||
);
|
||||
}
|
||||
case 'event': {
|
||||
const rawEvDesc = content.description || '';
|
||||
const blobInEvDesc = rawEvDesc.match(/!\[[^\]]*\]\((&[^)\s]+\.sha256)\)/)?.[1] || null;
|
||||
const cleanEvDesc = rawEvDesc.replace(/!\[[^\]]*\]\(&[^)]+\.sha256\)/g, '').trim();
|
||||
return div({ class: 'search-event' },
|
||||
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.eventTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
|
||||
cleanEvDesc ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, cleanEvDesc)) : null,
|
||||
blobImg(content.image || blobInEvDesc),
|
||||
content.date ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.eventDate + ':'), span({ class: 'card-value' }, new Date(content.date).toLocaleString())) : null,
|
||||
content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.eventLocation + ':'), span({ class: 'card-value' }, content.location)) : null,
|
||||
content.isPublic ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.eventPrivacyLabel + ':'), span({ class: 'card-value' }, content.isPublic)) : null,
|
||||
content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.eventStatus + ':'), span({ class: 'card-value' }, content.status)) : null,
|
||||
content.eventUrl ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.eventUrlLabel + ':'), span({ class: 'card-value' }, a({ href: content.eventUrl, target: '_blank' }, content.eventUrl))) : null,
|
||||
content.price ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.eventPrice + ':'), span({ class: 'card-value' }, content.price)) : null,
|
||||
content.tags && content.tags.length
|
||||
? div({ class: 'card-tags' }, content.tags.map(tag =>
|
||||
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
|
||||
))
|
||||
: null
|
||||
);
|
||||
}
|
||||
case 'votes':
|
||||
const votesList = content.votes && typeof content.votes === 'object'
|
||||
? Object.entries(content.votes).map(([option, count]) => ({ option, count }))
|
||||
: [];
|
||||
return div({ class: 'search-vote' },
|
||||
br(),
|
||||
content.question ? div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.voteQuestionLabel + ':' ),
|
||||
span({ class: 'card-value' }, content.question)
|
||||
) : null,
|
||||
content.status ? div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.voteStatus + ':' ),
|
||||
span({ class: 'card-value' }, content.status)
|
||||
) : null,
|
||||
content.deadline ? div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.voteDeadline + ':' ),
|
||||
span({ class: 'card-value' }, content.deadline ? new Date(content.deadline).toLocaleString() : '')
|
||||
) : null,
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.voteTotalVotes + ':' ),
|
||||
span({ class: 'card-value' }, content.totalVotes !== undefined ? content.totalVotes : '0')
|
||||
),
|
||||
br(),
|
||||
votesList.length > 0 ? div({ class: 'card-votes' },
|
||||
table(
|
||||
tr(...votesList.map(({ option }) => th(i18n[option] || option))),
|
||||
tr(...votesList.map(({ count }) => td(count)))
|
||||
)
|
||||
) : null
|
||||
);
|
||||
case 'tribe':
|
||||
return div({ class: 'search-tribe' },
|
||||
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
|
||||
(() => { const s = String(content.image || '').trim().replace(/&/g, '&'); const m = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/); const src = m ? m[1] : s; return src.startsWith('&') ? img({ src: `/blob/${encodeURIComponent(src)}`, class: 'feed-image' }) : img({ src: '/assets/images/default-tribe.png', class: 'feed-image' }); })(),
|
||||
content.description ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, ...renderUrl(content.description))) : null,
|
||||
content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeLocationLabel + ':'), span({ class: 'card-value' }, ...renderUrl(content.location))) : null,
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeIsAnonymousLabel + ':'), span({ class: 'card-value' }, content.isAnonymous ? i18n.tribePrivate : i18n.tribePublic)),
|
||||
content.inviteMode ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeModeLabel + ':'), span({ class: 'card-value' }, String(content.inviteMode).toUpperCase())) : null,
|
||||
Array.isArray(content.members)
|
||||
? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeMembersCount + ':'), span({ class: 'card-value' }, String(content.members.length)))
|
||||
: null,
|
||||
content.tags && content.tags.length
|
||||
? div({ class: 'card-tags' }, content.tags.map(tag =>
|
||||
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
|
||||
))
|
||||
: null
|
||||
);
|
||||
case 'audio':
|
||||
return content.url ? div({ class: 'search-audio' },
|
||||
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.audioTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
|
||||
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.audioDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null,
|
||||
br(),
|
||||
audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(content.url)}`, type: content.mimeType, preload: 'metadata' }),
|
||||
br(),
|
||||
content.tags && content.tags.length
|
||||
? div({ class: 'card-tags' }, content.tags.map(tag =>
|
||||
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
|
||||
))
|
||||
: null
|
||||
) : null;
|
||||
case 'image':
|
||||
return content.url ? div({ class: 'search-image' },
|
||||
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
|
||||
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null,
|
||||
content.meme ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.trendingCategory + ':'), span({ class: 'card-value' }, i18n.meme)) : null,
|
||||
br(),
|
||||
img({ src: `/blob/${encodeURIComponent(content.url)}` }),
|
||||
br(),
|
||||
content.tags && content.tags.length
|
||||
? div({ class: 'card-tags' }, content.tags.map(tag =>
|
||||
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
|
||||
))
|
||||
: null
|
||||
) : null;
|
||||
case 'video':
|
||||
return content.url ? div({ class: 'search-video' },
|
||||
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.videoTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
|
||||
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.videoDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null,
|
||||
br(),
|
||||
videoHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(content.url)}`, type: content.mimeType || 'video/mp4', width: '640', height: '360' }),
|
||||
br(),
|
||||
content.tags && content.tags.length
|
||||
? div({ class: 'card-tags' }, content.tags.map(tag =>
|
||||
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
|
||||
))
|
||||
: null
|
||||
) : null;
|
||||
case 'document':
|
||||
return div({ class: 'search-document' },
|
||||
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.documentTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
|
||||
br(),
|
||||
content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null,
|
||||
br(),
|
||||
div({
|
||||
id: `pdf-container-${content.key || content.url}`,
|
||||
class: 'pdf-viewer-container',
|
||||
'data-pdf-url': `/blob/${encodeURIComponent(content.url)}`
|
||||
}),
|
||||
br(),
|
||||
content.tags && content.tags.length
|
||||
? div({ class: 'card-tags' }, content.tags.map(tag =>
|
||||
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
|
||||
))
|
||||
: null
|
||||
);
|
||||
case 'torrent':
|
||||
return div({ class: 'search-torrent' },
|
||||
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || i18n.title) + ':'), span({ class: 'card-value' }, content.title)) : null,
|
||||
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentDescriptionLabel || i18n.searchDescription) + ':'), span({ class: 'card-value' }, content.description)) : null,
|
||||
content.size ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentSizeLabel || 'Size') + ':'), span({ class: 'card-value' }, String(content.size))) : null,
|
||||
content.tags && content.tags.length
|
||||
? div({ class: 'card-tags' }, content.tags.map(tag =>
|
||||
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
|
||||
))
|
||||
: null
|
||||
);
|
||||
case 'market':
|
||||
return div({ class: 'search-market' },
|
||||
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
|
||||
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null,
|
||||
content.item_type ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemType + ':'), span({ class: 'card-value' }, content.item_type.toUpperCase())) : null,
|
||||
content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemCondition + ':'), span({ class: 'card-value' }, content.status)) : null,
|
||||
content.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemDeadline + ':'), span({ class: 'card-value' }, new Date(content.deadline).toLocaleString())) : null,
|
||||
br(),
|
||||
blobImg(content.image),
|
||||
br(),
|
||||
content.seller ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemSeller + ':'), span({ class: 'card-value' }, userLink(content.seller))) : null,
|
||||
content.stock ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, content.stock || 'N/A')) : null,
|
||||
content.price ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchPriceLabel + ':'), span({ class: 'card-value' }, `${content.price} ECO`)) : null,
|
||||
content.condition ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.condition)) : null,
|
||||
content.includesShipping ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemIncludesShipping + ':'), span({ class: 'card-value' }, `${content.includesShipping ? i18n.YESLabel : i18n.NOLabel}`)) : null,
|
||||
content.auctions_poll && content.auctions_poll.length > 0
|
||||
? div({ class: 'auction-info' },
|
||||
p(i18n.marketAuctionBids),
|
||||
table({ class: 'auction-bid-table' },
|
||||
tr(
|
||||
th(i18n.marketAuctionBidTime),
|
||||
th(i18n.marketAuctionUser),
|
||||
th(i18n.marketAuctionBidAmount)
|
||||
),
|
||||
content.auctions_poll.map(bid => {
|
||||
const [userId, bidAmount, bidTime] = bid.split(':');
|
||||
return tr(
|
||||
td(moment(bidTime).format('YYYY-MM-DD HH:mm:ss')),
|
||||
td(a({ href: `/author/${encodeURIComponent(userId)}` }, userId)),
|
||||
td(`${parseFloat(bidAmount).toFixed(6)} ECO`)
|
||||
);
|
||||
})
|
||||
)
|
||||
)
|
||||
: null,
|
||||
content.tags && content.tags.length
|
||||
? div({ class: 'card-tags' }, content.tags.map(tag =>
|
||||
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
|
||||
))
|
||||
: null
|
||||
);
|
||||
case 'bookmark':
|
||||
return div({ class: 'search-bookmark' },
|
||||
content.url ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkUrlLabel + ':'), span({ class: 'card-value' }, a({ href: content.url, target: '_blank' }, content.url))) : null,
|
||||
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null,
|
||||
content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkCategory + ':'), span({ class: 'card-value' }, content.category)) : null,
|
||||
content.lastVisit ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkLastVisit + ':'), span({ class: 'card-value' }, new Date(content.lastVisit).toLocaleString())) : null,
|
||||
content.tags && content.tags.length
|
||||
? div({ class: 'card-tags' }, content.tags.map(tag =>
|
||||
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
|
||||
))
|
||||
: null
|
||||
);
|
||||
case 'task':
|
||||
return div({ class: 'search-task' },
|
||||
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.taskTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
|
||||
content.description ? div({ class: 'card-field', style: 'display:flex;flex-direction:column;' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), p({ class: 'card-value', style: 'white-space:pre-wrap;margin-top:4px;' }, content.description)) : null,
|
||||
content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchLocationLabel + ':'), span({ class: 'card-value' }, content.location)) : null,
|
||||
content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchStatusLabel + ':'), span({ class: 'card-value' }, content.status)) : null,
|
||||
content.priority ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchPriorityLabel + ':'), span({ class: 'card-value' }, content.priority)) : null,
|
||||
typeof content.isPublic === 'boolean' ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchIsPublicLabel + ':'), span({ class: 'card-value' }, content.isPublic ? i18n.YESLabel : i18n.NOLabel)) : null,
|
||||
content.startTime ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.taskStartTimeLabel + ':'), span({ class: 'card-value' }, new Date(content.startTime).toLocaleString())) : null,
|
||||
content.endTime ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.taskEndTimeLabel + ':'), span({ class: 'card-value' }, new Date(content.endTime).toLocaleString())) : null,
|
||||
Array.isArray(content.assignees) ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.taskAssignees + ':'), span({ class: 'card-value' }, content.assignees.length)) : null,
|
||||
content.tags && content.tags.length
|
||||
? div({ class: 'card-tags' }, content.tags.map(tag =>
|
||||
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
|
||||
))
|
||||
: null
|
||||
);
|
||||
case 'report':
|
||||
return div({ class: 'search-report' },
|
||||
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.reportsTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
|
||||
content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchStatusLabel + ':'), span({ class: 'card-value' }, content.status)) : null,
|
||||
content.severity ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.reportsSeverity + ':'), span({ class: 'card-value' }, content.severity)) : null,
|
||||
content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchCategoryLabel + ':'), span({ class: 'card-value' }, content.category)) : null,
|
||||
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null,
|
||||
br(),
|
||||
blobImg(content.image),
|
||||
br(),
|
||||
typeof content.confirmations === 'number' ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.reportsConfirmations + ':'), span({ class: 'card-value' }, content.confirmations)) : null,
|
||||
content.tags && content.tags.length
|
||||
? div({ class: 'card-tags' }, content.tags.map(tag =>
|
||||
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
|
||||
))
|
||||
: null
|
||||
);
|
||||
case 'transfer':
|
||||
return div({ class: 'search-transfer' },
|
||||
content.concept ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersConcept + ':'), span({ class: 'card-value' }, content.concept)) : null,
|
||||
content.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersDeadline + ':'), span({ class: 'card-value' }, content.deadline)) : null,
|
||||
content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersStatus + ':'), span({ class: 'card-value' }, content.status)) : null,
|
||||
content.amount ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersAmount + ':'), span({ class: 'card-value' }, content.amount)) : null,
|
||||
br(),
|
||||
content.from ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersFrom + ':'), span({ class: 'card-value' }, userLink(content.from))) : null,
|
||||
content.to ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersTo + ':'), span({ class: 'card-value' }, userLink(content.to))) : null,
|
||||
br(),
|
||||
content.confirmedBy && content.confirmedBy.length
|
||||
? h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersConfirmations + ':'), span({ class: 'card-value' }, content.confirmedBy.length))
|
||||
: null,
|
||||
content.tags && content.tags.length
|
||||
? div({ class: 'card-tags' }, content.tags.map(tag =>
|
||||
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
|
||||
))
|
||||
: null
|
||||
);
|
||||
case 'curriculum':
|
||||
return div({ class: 'search-curriculum' },
|
||||
content.name ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvNameLabel + ':'), span({ class: 'card-value' }, content.name)) : null,
|
||||
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null,
|
||||
content.photo ? img({ src: `/blob/${encodeURIComponent(content.photo)}`, class: 'curriculum-photo' }) : null,
|
||||
content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvLocationLabel + ':'), span({ class: 'card-value' }, content.location)) : null,
|
||||
content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvStatusLabel + ':'), span({ class: 'card-value' }, content.status)) : null,
|
||||
content.preferences ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvPreferencesLabel + ':'), span({ class: 'card-value' }, content.preferences)) : null,
|
||||
Array.isArray(content.personalSkills) && content.personalSkills.length
|
||||
? div({ class: 'card-field' }, content.personalSkills.map(skill =>
|
||||
a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: 'tag-link' }, `#${skill}`)
|
||||
)) : null,
|
||||
Array.isArray(content.personalExperiences) && content.personalExperiences.length
|
||||
? div({ class: 'card-field' }, content.personalExperiences.map(exp => p(exp))) : null,
|
||||
Array.isArray(content.oasisExperiences) && content.oasisExperiences.length
|
||||
? div({ class: 'card-field' }, content.oasisExperiences.map(exp => p(exp))) : null,
|
||||
Array.isArray(content.oasisSkills) && content.oasisSkills.length
|
||||
? div({ class: 'card-field' }, content.oasisSkills.map(skill => p(skill))) : null,
|
||||
Array.isArray(content.educationExperiences) && content.educationExperiences.length
|
||||
? div({ class: 'card-field' }, content.educationExperiences.map(exp => p(exp))) : null,
|
||||
Array.isArray(content.educationalSkills) && content.educationalSkills.length
|
||||
? div({ class: 'card-field' }, content.educationalSkills.map(skill =>
|
||||
a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: 'tag-link' }, `#${skill}`)
|
||||
)) : null,
|
||||
Array.isArray(content.languages) && content.languages.length
|
||||
? div({ class: 'card-field' }, content.languages.map(lang => p(lang))) : null,
|
||||
Array.isArray(content.professionalExperiences) && content.professionalExperiences.length
|
||||
? div({ class: 'card-field' }, content.professionalExperiences.map(exp => p(exp))) : null,
|
||||
Array.isArray(content.professionalSkills) && content.professionalSkills.length
|
||||
? div({ class: 'card-field' }, content.professionalSkills.map(skill => p(skill))) : null
|
||||
);
|
||||
case 'bankWallet':
|
||||
return div({ class: 'search-bank-wallet' },
|
||||
content.address ? div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.bankWalletConnected + ':' ),
|
||||
span({ class: 'card-value' }, content.address)
|
||||
) : null
|
||||
);
|
||||
case 'bankClaim':
|
||||
return div({ class: 'search-bank-claim' },
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.bankUbiReceived + ':' ),
|
||||
span({ class: 'card-value' }, `${Number(content.amount || 0).toFixed(6)} ECO`)
|
||||
),
|
||||
content.epochId ? div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.bankEpochShort + ':' ),
|
||||
span({ class: 'card-value' }, content.epochId)
|
||||
) : null,
|
||||
content.allocationId ? div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.bankAllocId + ':' ),
|
||||
span({ class: 'card-value' }, content.allocationId)
|
||||
) : null,
|
||||
content.txid ? div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.bankTx + ':' ),
|
||||
a({ href: `https://ecoin.03c8.net/blockexplorer/search?q=${content.txid}`, target: '_blank' }, content.txid)
|
||||
) : null
|
||||
);
|
||||
case 'job':
|
||||
return div({ class: 'search-job' },
|
||||
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
|
||||
content.salary ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.jobSalary + ':'), span({ class: 'card-value' }, `${content.salary} ECO`)) : null,
|
||||
content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.jobStatus + ':'), span({ class: 'card-value' }, content.status.toUpperCase())) : null,
|
||||
content.job_type ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.jobType + ':'), span({ class: 'card-value' }, content.job_type.toUpperCase())) : null,
|
||||
content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.jobLocation + ':'), span({ class: 'card-value' }, String(content.location).toUpperCase())) : null,
|
||||
content.vacants ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.jobVacants + ':'), span({ class: 'card-value' }, content.vacants)) : null,
|
||||
Array.isArray(content.subscribers) ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.jobSubscribers + ':'), span({ class: 'card-value' }, `${content.subscribers.length}`)) : null
|
||||
);
|
||||
case 'forum':
|
||||
return div({ class: 'search-forum' },
|
||||
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
|
||||
content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchCategoryLabel + ':'), span({ class: 'card-value' }, content.category)) : null,
|
||||
content.text ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.text)) : null
|
||||
);
|
||||
case 'vote':
|
||||
return div({ class: 'search-vote-link' },
|
||||
content.vote && content.vote.link ? p(a({ href: `/thread/${encodeURIComponent(content.vote.link)}#${encodeURIComponent(content.vote.link)}`, class: 'activityVotePost' }, content.vote.link)) : null
|
||||
);
|
||||
case 'contact':
|
||||
return div({ class: 'search-contact' },
|
||||
content.contact ? p(a({ href: `/author/${encodeURIComponent(content.contact)}`, class: 'activitySpreadInhabitant2' }, content.contact)) : null
|
||||
);
|
||||
case 'pub':
|
||||
return div({ class: 'search-pub' },
|
||||
content.address && content.address.key ? p(a({ href: `/author/${encodeURIComponent(content.address.key)}`, class: 'activitySpreadInhabitant2' }, content.address.key)) : null
|
||||
);
|
||||
case 'shop':
|
||||
return div({ class: 'search-shop' },
|
||||
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
|
||||
blobImg(content.image),
|
||||
content.shortDescription ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.shortDescription)) : null,
|
||||
content.visibility ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.shopStatus || 'STATUS') + ':'), span({ class: 'card-value' }, content.visibility)) : null,
|
||||
content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.searchLocationLabel || 'LOCATION') + ':'), span({ class: 'card-value' }, content.location)) : null,
|
||||
content.tags && content.tags.length
|
||||
? div({ class: 'card-tags' }, content.tags.map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)))
|
||||
: null
|
||||
);
|
||||
case 'shopProduct':
|
||||
return div({ class: 'search-shop-product' },
|
||||
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
|
||||
blobImg(content.image),
|
||||
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null,
|
||||
content.price ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.searchPriceLabel || 'PRICE') + ':'), span({ class: 'card-value' }, `${content.price} ECO`)) : null,
|
||||
content.stock !== undefined ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, content.stock)) : null
|
||||
);
|
||||
case 'chat': {
|
||||
const chatInviteOnly = content.status === 'INVITE-ONLY' && content.author !== userId && !(Array.isArray(content.members) && content.members.includes(userId));
|
||||
if (chatInviteOnly) return div({ class: 'search-chat' },
|
||||
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatsTitle || 'Chat') + ':'), span({ class: 'card-value' }, content.title)) : null,
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatStatus || 'STATUS') + ':'), span({ class: 'card-value' }, i18n.chatStatusInviteOnly || 'INVITE-ONLY'))
|
||||
);
|
||||
return div({ class: 'search-chat' },
|
||||
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatsTitle || 'Chat') + ':'), span({ class: 'card-value' }, content.title)) : null,
|
||||
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatDescription || 'Description') + ':'), span({ class: 'card-value' }, content.description)) : null,
|
||||
content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatCategoryLabel || 'Category') + ':'), span({ class: 'card-value' }, content.category)) : null,
|
||||
content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatStatus || 'STATUS') + ':'), span({ class: 'card-value' }, content.status)) : null
|
||||
);
|
||||
}
|
||||
case 'pad': {
|
||||
const padInviteOnly = content.status === 'INVITE-ONLY' && content.author !== userId && !(Array.isArray(content.members) && content.members.includes(userId));
|
||||
if (padInviteOnly) return div({ class: 'search-pad' },
|
||||
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padTitle || 'Pad') + ':'), span({ class: 'card-value' }, content.title)) : null,
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padStatusLabel || 'Status') + ':'), span({ class: 'card-value' }, i18n.padStatusInviteOnly || 'INVITE-ONLY'))
|
||||
);
|
||||
return div({ class: 'search-pad' },
|
||||
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padTitle || 'Pad') + ':'), span({ class: 'card-value' }, content.title)) : null,
|
||||
content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padStatusLabel || 'Status') + ':'), span({ class: 'card-value' }, content.status)) : null,
|
||||
content.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padDeadlineLabel || 'Deadline') + ':'), span({ class: 'card-value' }, content.deadline)) : null
|
||||
);
|
||||
}
|
||||
case 'gameScore':
|
||||
return div({ class: 'search-game' },
|
||||
content.game ? div({ class: 'game-row' },
|
||||
img({ src: `/game-assets/${content.game}/thumbnail.svg`, alt: content.game, class: 'game-scoring-thumb', loading: 'lazy' }),
|
||||
div({ class: 'game-row-body' },
|
||||
h2({ class: 'game-card-title' }, content.game.charAt(0).toUpperCase() + content.game.slice(1)),
|
||||
content.score !== undefined ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.gamesHallScore || 'Score') + ':'), span({ class: 'card-value' }, String(content.score))) : null,
|
||||
a({ href: `/games/${encodeURIComponent(content.game)}`, class: 'filter-btn' }, i18n.gamesPlayButton || 'PLAY!')
|
||||
)
|
||||
) : null
|
||||
);
|
||||
case 'map':
|
||||
return div({ class: 'search-map' },
|
||||
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
|
||||
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.mapDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null,
|
||||
content.lat && content.lng ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.mapLocationTitle + ':'), span({ class: 'card-value' }, `${content.lat}, ${content.lng}`)) : null,
|
||||
content.mapType ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.mapTypeLabel + ':'), span({ class: 'card-value' }, content.mapType)) : null,
|
||||
content.tags && content.tags.length
|
||||
? div({ class: 'card-tags' }, content.tags.map(tag =>
|
||||
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
|
||||
))
|
||||
: null
|
||||
);
|
||||
default:
|
||||
return div({ class: 'styled-text', innerHTML: sanitizeHtml(renderTextWithStyles(content.text || content.description || content.title || '[no content]')) });
|
||||
}
|
||||
};
|
||||
|
||||
const resultSection = Object.entries(results).length > 0
|
||||
? Object.entries(results).map(([key, msgs]) =>
|
||||
div(
|
||||
{ class: "search-result-group" },
|
||||
...msgs.map((msg) => {
|
||||
const content = msg.value.content || {};
|
||||
const created = new Date(msg.timestamp).toLocaleString();
|
||||
if (content.type === 'document') hasDocument = true;
|
||||
const contentHtml = renderContentHtml(content);
|
||||
let author;
|
||||
let authorUrl = '#';
|
||||
if (content.type === 'market') {
|
||||
author = content.seller || i18n.anonymous || "Anonymous";
|
||||
authorUrl = `/author/${encodeURIComponent(content.seller)}`;
|
||||
} else if (content.type === 'event') {
|
||||
author = content.organizer || i18n.anonymous || "Anonymous";
|
||||
authorUrl = `/author/${encodeURIComponent(content.organizer)}`;
|
||||
} else if (content.type === 'transfer') {
|
||||
author = content.from || i18n.anonymous || "Anonymous";
|
||||
authorUrl = `/author/${encodeURIComponent(content.from)}`;
|
||||
} else if (content.type === 'post' || content.type === 'about') {
|
||||
author = msg.value.author || i18n.anonymous || "Anonymous";
|
||||
authorUrl = `/author/${encodeURIComponent(msg.value.author)}`;
|
||||
} else if (content.type === 'report') {
|
||||
author = content.author || i18n.anonymous || "Anonymous";
|
||||
authorUrl = `/author/${encodeURIComponent(content.author || 'Anonymous')}`;
|
||||
} else if (content.type === 'votes') {
|
||||
author = content.createdBy || i18n.anonymous || "Anonymous";
|
||||
authorUrl = `/author/${encodeURIComponent(content.createdBy || 'Anonymous')}`;
|
||||
} else if (content.type === 'shop' || content.type === 'shopProduct' || content.type === 'chat' || content.type === 'gameScore') {
|
||||
author = content.author || content.player || msg.value.author || i18n.anonymous || "Anonymous";
|
||||
authorUrl = `/author/${encodeURIComponent(content.author || content.player || msg.value.author || 'Anonymous')}`;
|
||||
} else {
|
||||
author = content.author;
|
||||
authorUrl = `/author/${encodeURIComponent(content.author || 'Anonymous')}`;
|
||||
}
|
||||
|
||||
const contentId = msg.key;
|
||||
const isOwn = msg.value.author && String(msg.value.author) === String(userId);
|
||||
|
||||
return div({ class: 'trending-card search-card' + (isOwn ? ' own-content' : '') }, [
|
||||
div({ class: 'card-header activity-card-header' },
|
||||
div({ class: 'card-chips-row' },
|
||||
span({ class: 'pm-exposition-chip pm-exposition-whole' },
|
||||
span({ class: 'pm-exposition-text' }, String(content.type || '').toUpperCase())
|
||||
)
|
||||
),
|
||||
renderContentActions(contentId, getViewDetailsActionForSearch(content.type, contentId, content))
|
||||
),
|
||||
div({ class: 'card-section search-card-body' },
|
||||
contentHtml,
|
||||
author
|
||||
? p({ class: 'card-footer' },
|
||||
span({ class: 'date-link' }, `${created} ${i18n.performed} `),
|
||||
(authorUrl && authorUrl !== '#' && authorUrl.startsWith('/author/'))
|
||||
? userLink(decodeURIComponent(authorUrl.replace(/^\/author\//, '')))
|
||||
: a({ href: authorUrl, class: 'user-link' }, `${author}`)
|
||||
): null
|
||||
)
|
||||
]);
|
||||
})
|
||||
)
|
||||
)
|
||||
: div({ class: 'no-results' }, p(i18n.noResultsFound));
|
||||
|
||||
let html = template(
|
||||
hashtag ? `#${hashtag}` : i18n.searchTitle,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(hashtag ? `#${hashtag}` : i18n.searchTitle),
|
||||
p(hashtag ? i18n.hashtagDescription : i18n.searchDescriptionLabel)
|
||||
),
|
||||
form(
|
||||
{ action: "/search", method: "POST", class: "search-form" },
|
||||
div({ class: "search-filters-row" },
|
||||
table({ class: "search-filters-table" },
|
||||
tr(
|
||||
td({ class: 'card-label' }, label({ for: "search_from" }, i18n.searchFromLabel || "From")),
|
||||
td(input({ id: "search_from", type: "datetime-local", name: "from" }))
|
||||
),
|
||||
tr(
|
||||
td({ class: 'card-label' }, label({ for: "search_to" }, i18n.searchToLabel || "To")),
|
||||
td(input({ id: "search_to", type: "datetime-local", name: "to" }))
|
||||
),
|
||||
tr(
|
||||
td({ class: 'card-label' }, label({ for: "search_inhabitant" }, "Oasis ID")),
|
||||
td(input({ id: "search_inhabitant", type: "text", name: "inhabitant", placeholder: "@...=.ed25519", pattern: "@[A-Za-z0-9+/_\\-]{43}=\\.ed25519", maxlength: 56, class: "search-oasis-id" }))
|
||||
)
|
||||
),
|
||||
table({ class: "search-filters-table" },
|
||||
tr(
|
||||
td({ class: 'card-label' }, label({ for: "search_query" }, i18n.searchQueryLabel || "Query")),
|
||||
td(searchInput)
|
||||
),
|
||||
tr(
|
||||
td({ class: 'card-label' }, label({ for: "results-per-page" }, i18n.searchPerPageLabel || "Results per page")),
|
||||
td(resultsPerPageSelect)
|
||||
)
|
||||
)
|
||||
),
|
||||
div({ class: "search-submit-row" },
|
||||
button({ type: "submit" }, i18n.searchSubmit)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(resultSection)
|
||||
);
|
||||
|
||||
if (hasDocument) {
|
||||
html += `
|
||||
<script type="module" src="/js/pdf.min.mjs"></script>
|
||||
<script src="/js/pdf-viewer.js"></script>
|
||||
`;
|
||||
}
|
||||
|
||||
return html;
|
||||
};
|
||||
|
||||
exports.searchView = searchView;
|
||||
|
||||
368
src/views/settings_view.js
Normal file
368
src/views/settings_view.js
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
const { form, button, div, h2, h3, p, section, select, option, input, br, a, label, span } = require("../server/node_modules/hyperaxe");
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
const { template, selectedLanguage, i18n, setLanguage } = require('./main_views');
|
||||
const i18nBase = require("../client/assets/translations/i18n");
|
||||
|
||||
const snhUrl = "https://wiki.solarnethub.com/socialnet/overview";
|
||||
|
||||
const themeFilePath = path.join(__dirname, '../configs/oasis-config.json');
|
||||
const getThemeConfig = () => {
|
||||
try {
|
||||
const configData = fs.readFileSync(themeFilePath);
|
||||
return JSON.parse(configData);
|
||||
} catch (error) {
|
||||
console.error('Error reading config file:', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const settingsView = ({ version, aiPrompt, fediverseAccount, fediverseError }) => {
|
||||
const currentThemeConfig = getThemeConfig();
|
||||
const theme = currentThemeConfig.themes?.current || "Dark-SNH";
|
||||
const currentConfig = getConfig();
|
||||
let serverConfig = {};
|
||||
try { serverConfig = JSON.parse(fs.readFileSync(path.join(__dirname, '../configs/server-config.json'), 'utf8')); } catch (_) {}
|
||||
const currentHops = (serverConfig.friends && Number.isFinite(serverConfig.friends.hops)) ? serverConfig.friends.hops : 2;
|
||||
const walletUrl = currentConfig.wallet.url;
|
||||
const walletUser = currentConfig.wallet.user;
|
||||
const walletFee = currentConfig.wallet.fee;
|
||||
const pubId = currentConfig.walletPub?.pubId || '';
|
||||
const currentWish = currentConfig.wish === 'mutuals' ? 'mutuals' : 'whole';
|
||||
const currentPmVisibility = currentConfig.pmVisibility === 'mutuals' ? 'mutuals' : 'whole';
|
||||
|
||||
const themeElements = [
|
||||
option({ value: "Dark-SNH", selected: theme === "Dark-SNH" ? true : undefined }, "Dark-SNH"),
|
||||
option({ value: "Clear-SNH", selected: theme === "Clear-SNH" ? true : undefined }, "Clear-SNH"),
|
||||
option({ value: "Purple-SNH", selected: theme === "Purple-SNH" ? true : undefined }, "Purple-SNH"),
|
||||
option({ value: "Matrix-SNH", selected: theme === "Matrix-SNH" ? true : undefined }, "Matrix-SNH"),
|
||||
option({ value: "OasisMobile", selected: theme === "OasisMobile" ? true : undefined }, "Oasis-Mobile")
|
||||
];
|
||||
|
||||
const languageOption = (longName, shortName) => {
|
||||
return shortName === selectedLanguage
|
||||
? option({ value: shortName, selected: true }, longName)
|
||||
: option({ value: shortName }, longName);
|
||||
};
|
||||
|
||||
const rebuildButton = form(
|
||||
{ action: "/settings/rebuild", method: "post" },
|
||||
button({ type: "submit" }, i18n.rebuildName)
|
||||
);
|
||||
|
||||
const updateFlagPath = path.join(__dirname, '../server/.update_required');
|
||||
let updateButton = null;
|
||||
if (fs.existsSync(updateFlagPath)) {
|
||||
updateButton = form(
|
||||
{ action: "/update", method: "post" },
|
||||
button({ type: "submit" }, i18n.updateit)
|
||||
);
|
||||
}
|
||||
|
||||
return template(
|
||||
i18n.settings,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.settings),
|
||||
p(a({ href: snhUrl, target: "_blank" }, i18n.settingsIntro({ version }))),
|
||||
updateButton
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.theme),
|
||||
p(i18n.themeIntro),
|
||||
form(
|
||||
{ action: "/settings/theme", method: "post" },
|
||||
select({ name: "theme" }, ...themeElements),
|
||||
br(),
|
||||
br(),
|
||||
button({ type: "submit" }, i18n.setTheme)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.language),
|
||||
p(i18n.languageDescription),
|
||||
form(
|
||||
{ action: "/language", method: "post" },
|
||||
select({ name: "language" }, [
|
||||
languageOption("English", "en"),
|
||||
languageOption("Español", "es"),
|
||||
languageOption("Français", "fr"),
|
||||
languageOption("Euskara", "eu"),
|
||||
languageOption("Deutsch", "de"),
|
||||
languageOption("Italiano", "it"),
|
||||
languageOption("Português", "pt"),
|
||||
languageOption("中文", "zh"),
|
||||
languageOption("العربية", "ar"),
|
||||
languageOption("हिन्दी", "hi"),
|
||||
languageOption("Русский", "ru")
|
||||
]),
|
||||
br(),
|
||||
br(),
|
||||
button({ type: "submit" }, i18n.setLanguage)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.uxModeTitle || "UX"),
|
||||
p(i18n.uxModeDescription || "Select which UX navigation mode you want for your GUI."),
|
||||
form(
|
||||
{ action: "/settings/ux", method: "POST" },
|
||||
(() => {
|
||||
const aiNavEnabled = currentConfig.modules && currentConfig.modules.aiNavMod === 'on';
|
||||
const opts = [
|
||||
option({ value: "blocks", selected: (currentConfig.ux?.current !== "ainav") ? true : undefined }, i18n.uxModeMenus || "Blocks")
|
||||
];
|
||||
if (aiNavEnabled) {
|
||||
opts.push(option({ value: "ainav", selected: currentConfig.ux?.current === "ainav" ? true : undefined }, i18n.uxModeAINav || "AI"));
|
||||
}
|
||||
return select({ name: "ux" }, ...opts);
|
||||
})(),
|
||||
br(), br(),
|
||||
button({ type: "submit" }, i18n.saveSettings)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.homePageTitle),
|
||||
p(i18n.homePageDescription),
|
||||
form(
|
||||
{ action: "/settings/home-page", method: "POST" },
|
||||
select({ name: "homePage" },
|
||||
option({ value: "activity", selected: currentConfig.homePage === "activity" ? true : undefined }, i18n.activityTitle),
|
||||
option({ value: "ai", selected: currentConfig.homePage === "ai" ? true : undefined }, i18n.aiTitle),
|
||||
option({ value: "trending", selected: currentConfig.homePage === "trending" ? true : undefined }, i18n.trendingTitle),
|
||||
option({ value: "opinions", selected: currentConfig.homePage === "opinions" ? true : undefined }, i18n.opinionsTitle),
|
||||
option({ value: "forum", selected: currentConfig.homePage === "forum" ? true : undefined }, i18n.forumTitle),
|
||||
option({ value: "feed", selected: currentConfig.homePage === "feed" ? true : undefined }, i18n.feedTitle),
|
||||
option({ value: "mentions", selected: currentConfig.homePage === "mentions" ? true : undefined }, i18n.mentions),
|
||||
option({ value: "inbox", selected: currentConfig.homePage === "inbox" ? true : undefined }, i18n.inbox),
|
||||
option({ value: "agenda", selected: currentConfig.homePage === "agenda" ? true : undefined }, i18n.agendaTitle),
|
||||
option({ value: "favorites", selected: currentConfig.homePage === "favorites" ? true : undefined }, i18n.favoritesTitle),
|
||||
option({ value: "stats", selected: currentConfig.homePage === "stats" ? true : undefined }, i18n.statsTitle),
|
||||
option({ value: "blockexplorer", selected: currentConfig.homePage === "blockexplorer" ? true : undefined }, i18n.blockchain)
|
||||
),
|
||||
br(), br(),
|
||||
button({ type: "submit" }, i18n.saveHomePage)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.ssbLogStream),
|
||||
p(i18n.ssbLogStreamDescription),
|
||||
form(
|
||||
{ action: "/settings/ssb-logstream", method: "POST" },
|
||||
input({
|
||||
type: "number",
|
||||
id: "ssb_log_limit",
|
||||
name: "ssb_log_limit",
|
||||
min: 1,
|
||||
max: 100000,
|
||||
value: currentConfig.ssbLogStream?.limit || 1000
|
||||
}), br(),br(),
|
||||
button({ type: "submit" }, i18n.saveSettings)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.settingsReplicationTitle || 'Replication'),
|
||||
p(i18n.settingsReplicationDesc || 'Configure the number of hops your peer follows out from your own feed when replicating content.'),
|
||||
form(
|
||||
{ action: "/settings/replication", method: "POST" },
|
||||
label({ for: "replication_hops" }, i18n.settingsReplicationHopsLabel || 'Hops'),
|
||||
br(),
|
||||
input({
|
||||
type: "number",
|
||||
id: "replication_hops",
|
||||
name: "hops",
|
||||
min: 0,
|
||||
max: 6,
|
||||
value: currentHops
|
||||
}),
|
||||
br(), br(),
|
||||
button({ type: "submit" }, i18n.saveSettings)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.settingsLanTitle || 'LAN Broadcasting'),
|
||||
p(i18n.settingsLanDesc || 'Periodically announce this peer to other Oasis instances on the same local network. Disable to stop UDP broadcasts.'),
|
||||
form(
|
||||
{ action: "/settings/lan-broadcasting", method: "POST" },
|
||||
label({ for: "lanBroadcasting", class: "lan-checkbox-label" },
|
||||
input({
|
||||
type: "checkbox",
|
||||
id: "lanBroadcasting",
|
||||
name: "lanBroadcasting",
|
||||
value: "on",
|
||||
class: "lan-checkbox-input",
|
||||
checked: currentConfig.lanBroadcasting !== false ? true : undefined
|
||||
}),
|
||||
span({ class: "lan-checkbox-text" }, i18n.settingsLanEnable || 'Enable LAN broadcasting')
|
||||
),
|
||||
br(),
|
||||
button({ type: "submit" }, i18n.saveSettings)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.settingsWishTitle),
|
||||
p(i18n.settingsWishDesc),
|
||||
form(
|
||||
{ action: "/settings/wish", method: "POST" },
|
||||
select({ name: "wish" },
|
||||
option({ value: "whole", selected: currentWish === "whole" ? true : undefined }, i18n.settingsWishWhole),
|
||||
option({ value: "mutuals", selected: currentWish === "mutuals" ? true : undefined }, i18n.settingsWishMutuals),
|
||||
option({ value: "only-lan", selected: currentWish === "only-lan" ? true : undefined }, i18n.settingsWishOnlyLan || "Only LAN")
|
||||
), br(), br(),
|
||||
button({ type: "submit" }, i18n.saveSettings)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.settingsPmVisibilityTitle),
|
||||
p(i18n.settingsPmVisibilityDesc),
|
||||
form(
|
||||
{ action: "/settings/pm-visibility", method: "POST" },
|
||||
select({ name: "pmVisibility" },
|
||||
option({ value: "whole", selected: currentPmVisibility === "whole" ? true : undefined }, i18n.settingsPmVisibilityWhole),
|
||||
option({ value: "mutuals", selected: currentPmVisibility === "mutuals" ? true : undefined }, i18n.settingsPmVisibilityMutuals)
|
||||
), br(), br(),
|
||||
button({ type: "submit" }, i18n.saveSettings)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
{ id: "wallet" },
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.wallet),
|
||||
p(
|
||||
i18n.walletSettingsDescription, " ",
|
||||
a({ href: "docs/ecoin.md", target: "_blank", rel: "noopener" }, `[${i18n.walletSettingsDocLink}]`)
|
||||
),
|
||||
form(
|
||||
{ action: "/settings/wallet", method: "POST" },
|
||||
label({ for: "wallet_url" }, i18n.walletAddress), br(),
|
||||
input({ type: "text", id: "wallet_url", name: "wallet_url", placeholder: walletUrl, value: walletUrl }), br(),
|
||||
label({ for: "wallet_user" }, i18n.walletUser), br(),
|
||||
input({ type: "text", id: "wallet_user", name: "wallet_user", placeholder: walletUser, value: walletUser }), br(),
|
||||
label({ for: "wallet_pass" }, i18n.walletPass), br(),
|
||||
input({ type: "password", id: "wallet_pass", name: "wallet_pass" }), br(),
|
||||
label({ for: "wallet_fee" }, i18n.walletFee), br(),
|
||||
input({ type: "text", id: "wallet_fee", name: "wallet_fee", placeholder: walletFee, value: walletFee }), br(),
|
||||
button({ type: "submit" }, i18n.walletConfiguration)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.pubIdTitle || "PUB Wallet"),
|
||||
p(i18n.pubIdDescription || "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI)."),
|
||||
form(
|
||||
{ action: "/settings/pub-id", method: "POST" },
|
||||
input({
|
||||
type: "text",
|
||||
id: "pub_id",
|
||||
name: "pub_id",
|
||||
value: pubId,
|
||||
placeholder: i18n.pubIdPlaceholder || "@example.ed25519"
|
||||
}), br(),
|
||||
button({ type: "submit" }, i18n.pubIdSave || "Save configuration")
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.aiTitle),
|
||||
p(i18n.aiSettingsDescription),
|
||||
form(
|
||||
{ action: "/settings/ai", method: "POST" },
|
||||
input({
|
||||
type: "text",
|
||||
id: "ai_prompt",
|
||||
name: "ai_prompt",
|
||||
placeholder: aiPrompt,
|
||||
value: aiPrompt,
|
||||
maxlength: "128",
|
||||
required: true
|
||||
}), br(),
|
||||
button({ type: "submit" }, i18n.aiConfiguration)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
{ id: "fediverse" },
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.fediverseSettingsTitle),
|
||||
div({ class: "fediverse-network" },
|
||||
h3("Mastodon"),
|
||||
p(i18n.fediverseTokenHelp),
|
||||
fediverseError ? p({ class: "fediverse-error" }, i18n[fediverseError] || i18n.fediverseError) : "",
|
||||
fediverseAccount
|
||||
? (() => {
|
||||
const host = String(fediverseAccount.instance || "").replace(/^https?:\/\//, "");
|
||||
const profileUrl = `${fediverseAccount.instance}/@${fediverseAccount.acct}`;
|
||||
const link = (txt) => a({ href: profileUrl, target: "_blank", rel: "noopener noreferrer" }, txt);
|
||||
return form(
|
||||
{ action: "/settings/fediverse/disconnect", method: "POST" },
|
||||
p(
|
||||
`${i18n.fediverseConnectedAs}: `,
|
||||
link(`${fediverseAccount.acct}@${host}`)
|
||||
),
|
||||
button({ type: "submit" }, i18n.fediverseDisconnect)
|
||||
);
|
||||
})()
|
||||
: form(
|
||||
{ action: "/settings/fediverse", method: "POST" },
|
||||
label({ for: "fediverse_instance" }, i18n.fediverseInstanceLabel), br(),
|
||||
input({ type: "text", id: "fediverse_instance", name: "instance", placeholder: "mastodon.social", required: true }), br(),
|
||||
label({ for: "fediverse_token" }, i18n.fediverseTokenLabel), br(),
|
||||
input({ type: "password", id: "fediverse_token", name: "token", autocomplete: "off", required: true }), br(),
|
||||
button({ type: "submit" }, i18n.fediverseConnect)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.indexes),
|
||||
p(i18n.indexesDescription),
|
||||
rebuildButton
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.exportDataTitle),
|
||||
p(i18n.exportDataDescription),
|
||||
form(
|
||||
{ action: "/export/create", method: "POST", id: "exportForm" },
|
||||
button({ type: "submit" }, i18n.exportDataButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.panicMode),
|
||||
p(i18n.removeDataDescription),
|
||||
form(
|
||||
{ action: "/panic/remove", method: "POST", id: "removeForm" },
|
||||
button({ type: "submit" }, i18n.removePanicButton)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.settingsView = settingsView;
|
||||
|
||||
711
src/views/shops_view.js
Normal file
711
src/views/shops_view.js
Normal file
|
|
@ -0,0 +1,711 @@
|
|||
const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, progress, video, table, tr, td } = require("../server/node_modules/hyperaxe")
|
||||
const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton, renderOpinionsVoting, renderInviteQrCard } = require("./main_views")
|
||||
const moment = require("../server/node_modules/moment")
|
||||
const { config } = require("../server/SSB_server.js")
|
||||
const { renderUrl } = require("../backend/renderUrl")
|
||||
const { renderMapLocationUrl, renderMapEmbed, renderMapLocationVisitLabel } = require("./maps_view")
|
||||
const opinionCategories = require("../backend/opinion_categories")
|
||||
const { renderReachChip, renderClearnetUrlBlock, renderClearnetPage, renderClearnetSearchForm, renderEncryptedChip, blobUrl: cnBlobUrl, escapeHtml: cnEscapeHtml } = require("./clearnet_view")
|
||||
|
||||
const userId = config.keys.id
|
||||
const safeArr = (v) => (Array.isArray(v) ? v : [])
|
||||
const safeText = (v) => String(v || "").trim()
|
||||
const voteSum = (opinions = {}) => Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)
|
||||
const sumCats = (opinions = {}, cats = []) => (cats || []).reduce((s, c) => s + (Number((opinions || {})[c]) || 0), 0)
|
||||
const renderStarRating = (opinions, voterCount) => {
|
||||
const pos = sumCats(opinions, opinionCategories.positive)
|
||||
const neg = sumCats(opinions, opinionCategories.constructive) + sumCats(opinions, opinionCategories.moderation)
|
||||
const totalVotes = pos + neg
|
||||
const full = totalVotes > 0 ? Math.round((pos / totalVotes) * 5) : 0
|
||||
const stars = "\u2605".repeat(full) + "\u2606".repeat(5 - full)
|
||||
return span({ class: "shop-product-stars" }, `${stars} (${voterCount})`)
|
||||
}
|
||||
|
||||
const renderMediaBlob = (value, fallbackSrc = null, attrs = {}) => {
|
||||
if (!value) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
|
||||
const s = String(value).trim()
|
||||
if (!s) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
|
||||
if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}`, ...attrs })
|
||||
const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
|
||||
if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` })
|
||||
const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
|
||||
if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, ...attrs })
|
||||
return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
|
||||
}
|
||||
|
||||
const buildReturnTo = (filter, params = {}) => {
|
||||
const f = safeText(filter || "all")
|
||||
const q = safeText(params.q || "")
|
||||
const sort = safeText(params.sort || "recent")
|
||||
const parts = [`filter=${encodeURIComponent(f)}`]
|
||||
if (q) parts.push(`q=${encodeURIComponent(q)}`)
|
||||
if (sort) parts.push(`sort=${encodeURIComponent(sort)}`)
|
||||
return `/shops?${parts.join("&")}`
|
||||
}
|
||||
|
||||
const renderModeButtons = (currentFilter) =>
|
||||
div({ class: "tribe-mode-buttons" },
|
||||
["all", "recent", "mine", "top", "products", "prices", "favorites"].map(f =>
|
||||
form({ method: "GET", action: "/shops" },
|
||||
input({ type: "hidden", name: "filter", value: f }),
|
||||
button({ type: "submit", class: currentFilter === f ? "filter-btn active" : "filter-btn" }, i18n[`shopFilter${f.charAt(0).toUpperCase() + f.slice(1)}`] || f.toUpperCase())
|
||||
)
|
||||
),
|
||||
form({ method: "GET", action: "/shops/purchases" },
|
||||
button({ type: "submit", class: currentFilter === "purchases" ? "filter-btn active" : "filter-btn" }, (i18n.shopPurchasesButton || "Purchases").toUpperCase())
|
||||
),
|
||||
form({ method: "GET", action: "/shops" },
|
||||
input({ type: "hidden", name: "filter", value: "create" }),
|
||||
button({ type: "submit", class: "create-button" }, i18n.shopUpload)
|
||||
)
|
||||
)
|
||||
|
||||
const renderFavoriteToggle = (shop, returnTo) =>
|
||||
form(
|
||||
{ method: "POST", action: shop.isFavorite ? `/shops/favorites/remove/${encodeURIComponent(shop.key)}` : `/shops/favorites/add/${encodeURIComponent(shop.key)}` },
|
||||
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
||||
button({ type: "submit", class: "filter-btn" }, shop.isFavorite ? i18n.shopRemoveFavorite : i18n.shopAddFavorite)
|
||||
)
|
||||
|
||||
const renderShopCard = exports.renderShopCard = (shop, filter, params = {}) => {
|
||||
const returnTo = buildReturnTo(filter, params)
|
||||
const isAuthor = String(shop.author) === String(userId)
|
||||
|
||||
return div({ class: "tribe-card" },
|
||||
div({ class: "tribe-card-image-wrapper" },
|
||||
a({ href: `/shops/${encodeURIComponent(shop.key)}` },
|
||||
renderMediaBlob(shop.image, '/assets/images/default-avatar.png', { class: 'tribe-card-hero-image' })
|
||||
)
|
||||
),
|
||||
div({ class: "tribe-card-body" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, a({ href: `/shops/${encodeURIComponent(shop.key)}` }, shop.title || i18n.shopUntitled))
|
||||
),
|
||||
div({ class: "card-chips-row" },
|
||||
shop.visibility === "CLOSED"
|
||||
? renderStateChip("closed", "✗", i18n.shopClosed)
|
||||
: renderStateChip("mutuals", "✓", i18n.shopOpen),
|
||||
renderLifespanChip(shop.lifetime, i18n)
|
||||
),
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.shopProducts}: ${shop.productCount || 0}`)
|
||||
),
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(shop.key, params.spreadMap && params.spreadMap.get(shop.key))),
|
||||
div({ class: "card-visit-btn-centered" },
|
||||
form({ method: 'GET', action: `/shops/${encodeURIComponent(shop.key)}` },
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.viewShop || i18n.shopVisitShop || 'View Shop')
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const renderProductCard = (product, shopId, returnTo) => {
|
||||
const isAuthor = String(product.author) === String(userId)
|
||||
const stock = Number(product.stock) || 0
|
||||
const voterCount = safeArr(product.opinions_inhabitants).length
|
||||
const productUrl = `/shops/product/${encodeURIComponent(product.key)}?shopId=${encodeURIComponent(shopId)}`
|
||||
|
||||
return div({ class: "shop-product-card" },
|
||||
product.image ? div({ class: "shop-product-media" }, a({ href: productUrl }, renderMediaBlob(product.image))) : null,
|
||||
div({ class: "shop-product-body" },
|
||||
product.shopTitle ? p(a({ href: `/shops/${encodeURIComponent(shopId)}`, class: "user-link" }, product.shopTitle)) : null,
|
||||
h2(a({ href: productUrl }, product.title || i18n.shopProductUntitled)),
|
||||
renderStarRating(product.opinions, voterCount),
|
||||
product.description ? p(...renderUrl(product.description)) : null,
|
||||
div({ class: "shop-product-price" }, `${Number(product.price || 0).toFixed(6)} ECO`),
|
||||
div({ class: "confirmations-block stock-block" },
|
||||
div({ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.shopProductStock}: `),
|
||||
span({ class: "card-value" }, stock > 0 ? String(stock) : i18n.shopOutOfStock)
|
||||
),
|
||||
progress({ class: "confirmations-progress stock-progress", value: Math.min(stock, 100), max: 100 })
|
||||
),
|
||||
(() => {
|
||||
const actions = [];
|
||||
if (!isAuthor && stock > 0) {
|
||||
actions.push(a({ href: productUrl, class: "buy-btn" }, i18n.marketActionsBuy || i18n.shopBuy));
|
||||
}
|
||||
actions.push(form({ method: "POST", action: product.isFavorite ? `/shops/favorites/remove/${encodeURIComponent(product.key)}` : `/shops/favorites/add/${encodeURIComponent(product.key)}` },
|
||||
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
||||
button({ type: "submit", class: "filter-btn" }, product.isFavorite ? i18n.shopRemoveFavorite : i18n.shopAddFavorite)));
|
||||
return actions.length ? div({ class: "shop-product-actions" }, ...actions) : null;
|
||||
})()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const renderCommentsSection = (parentId, returnTo, comments = []) => {
|
||||
const count = Array.isArray(comments) ? comments.length : 0
|
||||
return div({ class: "vote-comments-section market-comments" },
|
||||
div({ class: "comments-count" }, span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), span({ class: "card-value" }, String(count))),
|
||||
div({ class: "comment-form-wrapper" },
|
||||
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
|
||||
form({ method: "POST", action: `/shops/${encodeURIComponent(parentId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
textarea({ name: "text", rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }),
|
||||
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
|
||||
br(),
|
||||
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
|
||||
)
|
||||
),
|
||||
count
|
||||
? div({ class: "comments-list" },
|
||||
comments.map(c => {
|
||||
const author = c.value?.author || ""
|
||||
const ts = c.value?.timestamp || c.timestamp
|
||||
const text = c.value?.content?.text || ""
|
||||
const rootId = c.value?.content?.fork || c.value?.content?.root || null
|
||||
return div({ class: "votations-comment-card" },
|
||||
span({ class: "created-at" },
|
||||
span(i18n.createdBy),
|
||||
author ? userLink(author) : span("(unknown)"),
|
||||
ts ? span(" | ", span({ class: "votations-comment-date" }, moment(ts).format("YYYY/MM/DD HH:mm:ss"))) : "",
|
||||
ts && rootId ? span(" | ", a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, moment(ts).fromNow())) : ""
|
||||
),
|
||||
p({ class: "votations-comment-text" }, ...renderUrl(text))
|
||||
)
|
||||
})
|
||||
)
|
||||
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
|
||||
)
|
||||
}
|
||||
|
||||
const renderShopForm = (filter, shop = {}, params = {}) => {
|
||||
const isEdit = filter === "edit"
|
||||
const returnTo = safeText(params.returnTo) || buildReturnTo("all")
|
||||
return div({ class: "create-tribe-form" },
|
||||
h2(isEdit ? i18n.shopUpdateSectionTitle : i18n.shopCreateSectionTitle),
|
||||
form({ action: isEdit ? `/shops/update/${encodeURIComponent(shop.key || "")}` : "/shops/create", method: "POST", enctype: "multipart/form-data" },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
label(i18n.title || "Title"), br,
|
||||
input({ type: "text", name: "title", required: true, placeholder: i18n.shopTitlePlaceholder || "Name of your shop", value: shop.title || "" }), br(),
|
||||
label(i18n.shopShortDescription), br,
|
||||
input({ type: "text", name: "shortDescription", required: true, maxlength: 160, placeholder: i18n.shopShortDescriptionPlaceholder || "Brief description of your shop", value: shop.shortDescription || "" }), br(),
|
||||
label(i18n.description || "Description"), br,
|
||||
textarea({ name: "description", rows: 4, placeholder: i18n.shopDescriptionPlaceholder || "Detailed description of your shop" }, shop.description || ""), br,
|
||||
label(i18n.blogImage || "Upload media (max-size: 50MB)"), br,
|
||||
input({ type: "file", name: "image", accept: "image/*,video/*" }), br(), br(),
|
||||
label(i18n.shopUrl), br,
|
||||
input({ type: "text", name: "url", placeholder: "https://", value: shop.url || "" }), br,
|
||||
label(i18n.shopLocation), br,
|
||||
input({ type: "text", name: "location", placeholder: i18n.shopLocationPlaceholder || "City, Country", value: shop.location || "" }), br,
|
||||
label(i18n.mapLocationTitle || "Map Location"), br,
|
||||
input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: shop.mapUrl || "" }), br,
|
||||
label(i18n.shopTags), br,
|
||||
input({ type: "text", name: "tags", placeholder: i18n.shopTagsPlaceholder || "tag1, tag2, tag3", value: safeArr(shop.tags).join(", ") }), br,
|
||||
isEdit ? null : label(i18n.shopVisibility),
|
||||
isEdit ? null : br,
|
||||
isEdit ? null : select({ name: "visibility" },
|
||||
option({ value: "OPEN", selected: (shop.visibility || "OPEN") === "OPEN" }, i18n.shopOpen),
|
||||
option({ value: "CLOSED", selected: shop.visibility === "CLOSED" }, i18n.shopClosed)
|
||||
),
|
||||
isEdit ? null : br(),
|
||||
br(),
|
||||
button({ type: "submit" }, isEdit ? i18n.shopUpdate : i18n.shopCreate)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const renderProductForm = (shopId, product = {}, isEdit = false, returnTo = "") => {
|
||||
return div({ class: "create-tribe-form" },
|
||||
h2(isEdit ? i18n.shopProductUpdate : i18n.shopProductAdd),
|
||||
form({ action: isEdit ? `/shops/product/update/${encodeURIComponent(product.key || "")}` : "/shops/product/create", method: "POST", enctype: "multipart/form-data" },
|
||||
input({ type: "hidden", name: "shopId", value: shopId }),
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
label(i18n.title || "Title"), br,
|
||||
input({ type: "text", name: "title", required: true, value: product.title || "" }), br(),
|
||||
label(i18n.description || "Description"), br,
|
||||
textarea({ name: "description", rows: 4 }, product.description || ""), br,
|
||||
label(i18n.shopProductPrice), br,
|
||||
input({ type: "number", name: "price", step: "0.000001", min: "0.000001", required: true, value: product.price || "" }), br(), br(),
|
||||
label(i18n.shopProductStock), br,
|
||||
input({ type: "number", name: "stock", min: "0", value: product.stock !== undefined ? product.stock : 1 }), br(), br(),
|
||||
label(i18n.blogImage || "Upload media (max-size: 50MB)"), br,
|
||||
input({ type: "file", name: "image", accept: "image/*,video/*" }), br(), br(),
|
||||
input({ type: "hidden", name: "featured", value: "0" }),
|
||||
label(i18n.shopProductFeatured),
|
||||
input({ id: "featured", type: "checkbox", name: "featured", value: "1", class: "meme-checkbox", ...(product.featured ? { checked: true } : {}) }),
|
||||
br(),
|
||||
br(),
|
||||
...(isEdit ? [] : [
|
||||
input({ type: "hidden", name: "sendToMarket", value: "0" }),
|
||||
label(i18n.shopSendToMarket),
|
||||
input({ id: "sendToMarket", type: "checkbox", name: "sendToMarket", value: "1", class: "meme-checkbox" }),
|
||||
br(),
|
||||
br(),
|
||||
]),
|
||||
button({ type: "submit" }, isEdit ? i18n.shopUpdate : i18n.shopProductAdd)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
exports.shopsView = async (shops, filter, shopToEdit = null, params = {}) => {
|
||||
const q = safeText(params.q || "")
|
||||
const sort = safeText(params.sort || "recent")
|
||||
const list = safeArr(shops)
|
||||
|
||||
const title =
|
||||
filter === "mine" ? i18n.shopMineSectionTitle :
|
||||
filter === "recent" ? i18n.shopRecentSectionTitle :
|
||||
filter === "top" ? i18n.shopTopSectionTitle :
|
||||
filter === "products" ? i18n.shopProductsSectionTitle :
|
||||
filter === "prices" ? (i18n.shopPricesSectionTitle || "Products by Price") :
|
||||
filter === "favorites" ? i18n.shopFavoritesSectionTitle :
|
||||
filter === "create" ? i18n.shopCreateSectionTitle :
|
||||
filter === "edit" ? i18n.shopUpdateSectionTitle :
|
||||
i18n.shopAllSectionTitle
|
||||
|
||||
const isForm = filter === "create" || filter === "edit"
|
||||
|
||||
const viewerClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetShops)
|
||||
const header = [
|
||||
div({ class: "tags-header" },
|
||||
h2(title),
|
||||
p(i18n.shopDescription)
|
||||
),
|
||||
div({ class: "shop-title-row" }, renderReachChip(viewerClearnet, i18n)),
|
||||
br()
|
||||
]
|
||||
|
||||
const searchBar = div({ class: "filters" },
|
||||
form({ method: "GET", action: "/shops" },
|
||||
input({ type: "hidden", name: "filter", value: filter }),
|
||||
input({ type: "text", name: "q", placeholder: i18n.shopSearchPlaceholder, value: q }),
|
||||
br(),
|
||||
button({ type: "submit" }, i18n.search),
|
||||
br()
|
||||
)
|
||||
)
|
||||
|
||||
const sortedProducts = filter === "products"
|
||||
? [...list].sort((a, b) => safeArr(b.opinions_inhabitants).length - safeArr(a.opinions_inhabitants).length)
|
||||
: filter === "prices"
|
||||
? [...list].sort((a, b) => Number(b.price || 0) - Number(a.price || 0))
|
||||
: list
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(...header),
|
||||
section(renderModeButtons(filter)),
|
||||
!isForm ? section(searchBar) : null,
|
||||
section(
|
||||
isForm
|
||||
? renderShopForm(filter, filter === "edit" ? (shopToEdit || {}) : {}, params)
|
||||
: filter === "products" || filter === "prices"
|
||||
? div({ class: "shop-products-grid" },
|
||||
sortedProducts.length
|
||||
? sortedProducts.map(prod => renderProductCard(prod, prod.shopId, buildReturnTo(filter, params)))
|
||||
: p(i18n.shopNoItems)
|
||||
)
|
||||
: div({ class: "tribe-grid" },
|
||||
list.length
|
||||
? list.map(shop => renderShopCard(shop, filter, { q, sort, spreadMap: params.spreadMap }))
|
||||
: p(i18n.shopNoItems)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
exports.singleShopView = async (shop, filter, products = [], comments = [], params = {}) => {
|
||||
const q = safeText(params.q || "")
|
||||
const sort = safeText(params.sort || "recent")
|
||||
const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort })
|
||||
const isAuthor = String(shop.author) === String(userId)
|
||||
const fullShareUrl = `/shops/${encodeURIComponent(shop.key)}`
|
||||
|
||||
const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetShops && shop.visibility !== 'CLOSED');
|
||||
const shopSide = div({ class: "tribe-side" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, shop.title || i18n.shopUntitled)
|
||||
),
|
||||
div({ class: "card-chips-row" },
|
||||
shop.visibility === "CLOSED"
|
||||
? renderStateChip("closed", "✗", i18n.shopClosed)
|
||||
: renderStateChip("mutuals", "✓", i18n.shopOpen),
|
||||
shop.encrypted ? renderStateChip("encrypted", "🔒", i18n.encryptedChipLabel || "E2E") : null,
|
||||
renderLifespanChip(shop.lifetime, i18n),
|
||||
renderReachChip(isClearnet, i18n)
|
||||
),
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(shop.key, params.spreads)),
|
||||
renderMediaBlob(shop.image, '/assets/images/default-avatar.png', { class: 'tribe-detail-image' }),
|
||||
shop.description ? p({ class: "tribe-side-description" }, ...renderUrl(shop.description)) : null,
|
||||
div({ class: "shop-share" },
|
||||
span({ class: "tribe-info-label" }, `${i18n.shopShareUrl}: `),
|
||||
input({ type: "text", value: fullShareUrl, readonly: true, class: "shop-share-input" })
|
||||
),
|
||||
table({ class: "tribe-info-table" },
|
||||
tr(
|
||||
td({ class: "tribe-info-label" }, i18n.shopCreatedAt || "CREATED"),
|
||||
td({ class: "tribe-info-value", colspan: "3" }, new Date(shop.createdAt).toLocaleString())
|
||||
),
|
||||
tr(
|
||||
td({ class: "tribe-info-value", colspan: "4" }, userLink(shop.author))
|
||||
),
|
||||
shop.location ? tr(
|
||||
td({ class: "tribe-info-label" }, i18n.shopLocation),
|
||||
td({ class: "tribe-info-value", colspan: "3" }, ...renderUrl(shop.location))
|
||||
) : null,
|
||||
tr(
|
||||
td({ class: "tribe-info-label" }, i18n.shopStatus || "STATUS"),
|
||||
td({ class: "tribe-info-value", colspan: "3" }, shop.visibility === "CLOSED" ? i18n.shopClosed : i18n.shopOpen)
|
||||
),
|
||||
shop.url ? tr(
|
||||
td({ class: "tribe-info-label" }, i18n.shopUrl),
|
||||
td({ class: "tribe-info-value", colspan: "3" }, ...renderUrl(shop.url))
|
||||
) : null
|
||||
),
|
||||
renderMapEmbed(params.mapData, shop.mapUrl),
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.shopProducts}: ${shop.productCount || 0}`)
|
||||
),
|
||||
div({ class: "tribe-side-actions" },
|
||||
renderFavoriteToggle(shop, returnTo),
|
||||
shop.author && String(shop.author) !== String(userId)
|
||||
? form({ method: "GET", action: "/pm" }, input({ type: "hidden", name: "recipients", value: shop.author }), button({ type: "submit", class: "tribe-action-btn" }, i18n.privateMessage))
|
||||
: null,
|
||||
isAuthor
|
||||
? form({ method: "GET", action: `/shops/edit/${encodeURIComponent(shop.key)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.shopUpdate)
|
||||
)
|
||||
: null,
|
||||
isAuthor
|
||||
? form({ method: "GET", action: `/shops/${encodeURIComponent(shop.key)}/orders` },
|
||||
button({ type: "submit", class: "tribe-action-btn" },
|
||||
i18n.shopOrdersTitle || "Orders",
|
||||
Number(shop.pendingOrders || 0) > 0 ? span({ class: "shop-orders-badge", title: i18n.shopOrdersPending || "Pending orders" }, ` ● ${shop.pendingOrders}`) : null
|
||||
)
|
||||
)
|
||||
: null,
|
||||
isAuthor
|
||||
? form({ method: "POST", action: `/shops/delete/${encodeURIComponent(shop.key)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.shopDelete)
|
||||
)
|
||||
: null
|
||||
),
|
||||
isAuthor
|
||||
? div({ class: "tribe-side-actions shop-visibility-row" },
|
||||
span({ class: "card-label" }, `${i18n.visibilityLabel || "Visibility"}: `),
|
||||
shop.encrypted
|
||||
? renderStateChip("encrypted", "🔒", i18n.encryptedChipLabel || "E2E")
|
||||
: renderStateChip("mutuals", "👁", i18n.shopOpen),
|
||||
shop.encrypted
|
||||
? form({ method: "POST", action: `/shops/generate-invite/${encodeURIComponent(shop.key)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeGenerateInvite))
|
||||
: null,
|
||||
(shop.encrypted && shop.author === userId && !shop.openInviteCode)
|
||||
? form({ method: "POST", action: `/shops/open-invite/create/${encodeURIComponent(shop.key)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeOpenInvitation))
|
||||
: null,
|
||||
(shop.encrypted && shop.author === userId && shop.openInviteCode)
|
||||
? span({ class: "tribe-open-invite" },
|
||||
span({ class: "card-label" }, i18n.tribeInviteCodeText),
|
||||
span({ class: "tribe-open-invite-code" }, shop.openInviteCode))
|
||||
: null,
|
||||
(shop.encrypted && shop.author === userId && shop.openInviteCode)
|
||||
? renderInviteQrCard({ qrDataUrl: shop.openInviteQr, name: shop.title })
|
||||
: null,
|
||||
(shop.encrypted && shop.author === userId && shop.openInviteCode)
|
||||
? form({ method: "POST", action: `/shops/open-invite/remove/${encodeURIComponent(shop.key)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeRemoveInvitation))
|
||||
: null,
|
||||
form({ method: "POST", action: `/shops/visibility/${encodeURIComponent(shop.key)}`, class: "inline-form" },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
input({ type: "hidden", name: "visibility", value: shop.encrypted ? "OPEN" : "CLOSED" }),
|
||||
button({ type: "submit", class: "tribe-action-btn" },
|
||||
shop.encrypted ? i18n.shopMakePublic : i18n.shopMakePrivate))
|
||||
)
|
||||
: null,
|
||||
safeArr(shop.tags).length
|
||||
? div({ class: "tribe-side-tags" }, safeArr(shop.tags).map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)))
|
||||
: null
|
||||
)
|
||||
|
||||
const shopMain = div({ class: "tribe-main" },
|
||||
isAuthor
|
||||
? div({ class: "shop-add-product" },
|
||||
renderProductForm(shop.rootId || shop.key, {}, false, returnTo)
|
||||
)
|
||||
: null,
|
||||
div({ class: "shop-products-grid" },
|
||||
products.length
|
||||
? products.map(prod => renderProductCard(prod, shop.rootId || shop.key, returnTo))
|
||||
: p(i18n.shopNoProducts)
|
||||
)
|
||||
)
|
||||
|
||||
return template(
|
||||
shop.title || i18n.shopTitle,
|
||||
section(renderModeButtons(filter)),
|
||||
section(
|
||||
div({ class: "tribe-details" },
|
||||
shopSide,
|
||||
shopMain
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
exports.singleProductView = async (product, shop, comments = [], params = {}) => {
|
||||
const returnTo = safeText(params.returnTo) || `/shops/${encodeURIComponent(params.shopId || product.shopId)}`
|
||||
const isAuthor = String(product.author) === String(userId)
|
||||
const stock = Number(product.stock) || 0
|
||||
|
||||
return template(
|
||||
product.title || i18n.shopProductTitle,
|
||||
section(renderModeButtons("products")),
|
||||
section(
|
||||
div({ class: "shop-detail" },
|
||||
div({ class: "bookmark-topbar transfer-topbar-single" },
|
||||
div({ class: "bookmark-topbar-left" },
|
||||
product.author && String(product.author) !== String(userId)
|
||||
? form({ method: "GET", action: "/pm" }, input({ type: "hidden", name: "recipients", value: product.author }), button({ type: "submit", class: "filter-btn" }, i18n.privateMessage))
|
||||
: null,
|
||||
form({ method: "GET", action: `/shops/${encodeURIComponent(product.shopId)}` },
|
||||
button({ type: "submit", class: "filter-btn" }, `← ${i18n.shopBackToShop}`))
|
||||
),
|
||||
isAuthor
|
||||
? div({ class: "bookmark-actions transfer-actions" },
|
||||
form({ method: "GET", action: `/shops/product/edit/${encodeURIComponent(product.key)}` },
|
||||
input({ type: "hidden", name: "shopId", value: product.shopId }),
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "update-btn", type: "submit" }, i18n.shopUpdate)
|
||||
),
|
||||
form({ method: "POST", action: `/shops/product/delete/${encodeURIComponent(product.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "delete-btn", type: "submit" }, i18n.shopDelete)
|
||||
)
|
||||
)
|
||||
: null
|
||||
),
|
||||
product.image ? div({ class: "shop-detail-media" }, renderMediaBlob(product.image)) : null,
|
||||
h2(product.title),
|
||||
renderStarRating(product.opinions, safeArr(product.opinions_inhabitants).length),
|
||||
product.description ? div({ class: "shop-detail-desc" }, ...renderUrl(product.description)) : null,
|
||||
div({ class: "shop-product-price" }, `${Number(product.price || 0).toFixed(6)} ECO`),
|
||||
div({ class: "confirmations-block stock-block" },
|
||||
div({ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.shopProductStock}: `),
|
||||
span({ class: "card-value" }, stock > 0 ? String(stock) : i18n.shopOutOfStock)
|
||||
),
|
||||
progress({ class: "confirmations-progress stock-progress", value: Math.min(stock, 100), max: 100 })
|
||||
),
|
||||
!isAuthor && stock > 0
|
||||
? form({ method: "POST", action: `/shops/product/buy/${encodeURIComponent(product.key)}`, class: "shop-buy-form" },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
p({ class: "shop-buy-form-note" }, i18n.shopBuyEncryptedNote || "Your delivery details are sent encrypted only to the shop owner."),
|
||||
label(i18n.shopBuyDeliveryAddress || "Delivery address"),
|
||||
br(),
|
||||
textarea({ name: "deliveryAddress", required: true, rows: 3, placeholder: i18n.shopBuyDeliveryAddressPlaceholder || "" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.shopBuyContact || "Contact"),
|
||||
br(),
|
||||
input({ type: "text", name: "contact", placeholder: i18n.shopBuyContactPlaceholder || "email, phone, etc." }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.shopBuyNotes || "Notes"),
|
||||
br(),
|
||||
textarea({ name: "notes", rows: 2, placeholder: i18n.shopBuyNotesPlaceholder || "" }),
|
||||
br(),
|
||||
br(),
|
||||
button({ type: "submit", class: "buy-btn" }, i18n.marketActionsBuy || i18n.shopBuy)
|
||||
)
|
||||
: null,
|
||||
br(),
|
||||
p({ class: "card-footer" },
|
||||
span({ class: "date-link" }, moment(product.createdAt).format("YYYY-MM-DD HH:mm")),
|
||||
" ",
|
||||
userLink(product.author)
|
||||
),
|
||||
!isAuthor && params.canRate
|
||||
? renderOpinionsVoting('/shops/product/opinions', product.key, product.opinions, returnTo, product.opinions_inhabitants)
|
||||
: null
|
||||
),
|
||||
renderCommentsSection(product.key, returnTo, comments)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
exports.editProductView = async (product, shopId, params = {}) => {
|
||||
const returnTo = safeText(params.returnTo) || `/shops/${encodeURIComponent(shopId)}`
|
||||
return template(
|
||||
i18n.shopProductUpdate,
|
||||
section(
|
||||
div({ class: "tags-header" }, h2(i18n.shopProductUpdate)),
|
||||
renderProductForm(shopId, product, true, returnTo)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const ORDER_STATUS_VARIANT = { PENDING: "whole", ACCEPTED: "mutuals", REJECTED: "closed", PAID: "mutuals", SHIPPED: "hidden", RECEIVED: "mutuals" }
|
||||
const orderStatusChip = (st) => {
|
||||
const s = String(st || "PENDING").toUpperCase()
|
||||
const labels = {
|
||||
PENDING: i18n.shopOrderStatusPending || "Pending",
|
||||
ACCEPTED: i18n.shopOrderStatusAccepted || "Accepted",
|
||||
REJECTED: i18n.shopOrderStatusRejected || "Rejected",
|
||||
PAID: i18n.shopOrderStatusPaid || "Payment received",
|
||||
SHIPPED: i18n.shopOrderStatusShipped || "Shipped",
|
||||
RECEIVED: i18n.shopOrderStatusReceived || "Received"
|
||||
}
|
||||
return renderStateChip(ORDER_STATUS_VARIANT[s] || "whole", "", labels[s] || s)
|
||||
}
|
||||
|
||||
exports.shopOrdersView = async (shop, orders) => {
|
||||
const sid = shop.rootId || shop.key || shop.id || ""
|
||||
const title = `${i18n.shopOrdersTitle || "Orders"}: ${shop.title || ""}`
|
||||
const statusForm = (orderId, status, label, cls) =>
|
||||
form({ method: "POST", action: `/shops/orders/${encodeURIComponent(orderId)}/status`, class: "inline-form" },
|
||||
input({ type: "hidden", name: "shopId", value: sid }),
|
||||
input({ type: "hidden", name: "status", value: status }),
|
||||
button({ type: "submit", class: cls || "tribe-action-btn" }, label)
|
||||
)
|
||||
const orderActions = (o) => {
|
||||
const s = String(o.status || "PENDING").toUpperCase()
|
||||
const acts = []
|
||||
if (s === "PENDING") {
|
||||
acts.push(statusForm(o.id, "ACCEPTED", i18n.shopOrderAccept || "Accept"))
|
||||
acts.push(statusForm(o.id, "REJECTED", i18n.shopOrderReject || "Reject", "tribe-action-btn danger-btn"))
|
||||
} else if (s === "ACCEPTED") {
|
||||
const concept = `${i18n.shopOrderInvoiceConcept || "Invoice"} — ${i18n.shopOrderContractConcept || "Shop order"}: ${o.title || ""}`
|
||||
const cHref = `/transfers?filter=create&to=${encodeURIComponent(o.buyer)}&amount=${encodeURIComponent(Number(o.price || 0).toFixed(6))}&concept=${encodeURIComponent(concept)}&category=ECONOMIC`
|
||||
acts.push(a({ href: cHref, class: "tribe-action-btn" }, i18n.shopOrderInvoice || "Invoice"))
|
||||
acts.push(statusForm(o.id, "PAID", i18n.shopOrderMarkPaid || "Payment received"))
|
||||
} else if (s === "PAID") {
|
||||
acts.push(statusForm(o.id, "SHIPPED", i18n.shopOrderMarkShipped || "Mark shipped"))
|
||||
}
|
||||
acts.push(a({ href: `/pm?recipients=${encodeURIComponent(o.buyer)}`, class: "tribe-action-btn" }, i18n.shopOrderPmBuyer || i18n.privateMessage || "PM"))
|
||||
return div({ class: "tribe-side-actions" }, ...acts)
|
||||
}
|
||||
const rows = (orders || []).map(o => div({ class: "shop-order-card card-section" },
|
||||
div({ class: "card-chips-row" }, renderEncryptedChip(i18n), orderStatusChip(o.status)),
|
||||
div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopOrderProduct || "Product"}:`), span({ class: "card-value" }, String(o.title || o.productId || ""))),
|
||||
div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopOrderPrice || "Price"}:`), span({ class: "card-value" }, `${Number(o.price || 0).toFixed(6)} ECO`)),
|
||||
div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopOrderBuyer || "Buyer"}:`), userLink(o.buyer)),
|
||||
div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyDeliveryAddress || "Delivery address"}:`), span({ class: "card-value" }, String(o.deliveryAddress || ""))),
|
||||
o.contact ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyContact || "Contact"}:`), span({ class: "card-value" }, String(o.contact))) : null,
|
||||
o.notes ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyNotes || "Notes"}:`), span({ class: "card-value" }, String(o.notes))) : null,
|
||||
p({ class: "card-footer" }, span({ class: "date-link" }, moment(o.createdAt || o.ts).format("YYYY-MM-DD HH:mm"))),
|
||||
orderActions(o)
|
||||
))
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: "tags-header" }, h2(title), p(i18n.shopOrdersDescription || "Encrypted purchase orders received by this shop.")),
|
||||
a({ href: `/shops/${encodeURIComponent(shop.key || shop.id || "")}`, class: "filter-btn" }, i18n.goBack || "Go back")
|
||||
),
|
||||
section(
|
||||
rows.length ? div({ class: "shop-orders-list" }, ...rows) : p(i18n.shopOrdersEmpty || "No orders yet.")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
exports.myPurchasesView = async (purchases) => {
|
||||
const title = i18n.shopMyOrdersTitle || "My orders"
|
||||
const buyerActions = (o) => {
|
||||
const acts = []
|
||||
if (o.seller) acts.push(a({ href: `/pm?recipients=${encodeURIComponent(o.seller)}`, class: "tribe-action-btn" }, i18n.shopOrderPmSeller || i18n.privateMessage || "PM"))
|
||||
if (String(o.status || "PENDING").toUpperCase() === "SHIPPED") {
|
||||
acts.push(form({ method: "POST", action: `/shops/orders/${encodeURIComponent(o.id)}/status`, class: "inline-form" },
|
||||
input({ type: "hidden", name: "status", value: "RECEIVED" }),
|
||||
input({ type: "hidden", name: "returnTo", value: "/shops/purchases" }),
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.shopOrderConfirmReceived || "Confirm received")
|
||||
))
|
||||
}
|
||||
return acts.length ? div({ class: "tribe-side-actions" }, ...acts) : null
|
||||
}
|
||||
const rows = (purchases || []).map(o => div({ class: "shop-order-card card-section" },
|
||||
div({ class: "card-chips-row" }, renderEncryptedChip(i18n), orderStatusChip(o.status)),
|
||||
div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopOrderProduct || "Product"}:`), span({ class: "card-value" }, String(o.title || o.productId || ""))),
|
||||
div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopOrderPrice || "Price"}:`), span({ class: "card-value" }, `${Number(o.price || 0).toFixed(6)} ECO`)),
|
||||
o.seller ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopOrderSeller || "Seller"}:`), userLink(o.seller)) : null,
|
||||
div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyDeliveryAddress || "Delivery address"}:`), span({ class: "card-value" }, String(o.deliveryAddress || ""))),
|
||||
o.contact ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyContact || "Contact"}:`), span({ class: "card-value" }, String(o.contact))) : null,
|
||||
o.notes ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyNotes || "Notes"}:`), span({ class: "card-value" }, String(o.notes))) : null,
|
||||
p({ class: "card-footer" }, span({ class: "date-link" }, moment(o.createdAt || o.ts).format("YYYY-MM-DD HH:mm"))),
|
||||
buyerActions(o)
|
||||
))
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: "tags-header" }, h2(title), p(i18n.shopMyOrdersDescription || "Your encrypted purchase orders."))
|
||||
),
|
||||
section(renderModeButtons("purchases")),
|
||||
section(
|
||||
rows.length ? div({ class: "shop-orders-list" }, ...rows) : p(i18n.shopMyOrdersEmpty || "You have no purchases yet.")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
exports.clearnetShopView = async (shop, products = []) => {
|
||||
const fmtPrice = (v) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n.toFixed(6) : '0.000000';
|
||||
};
|
||||
const productCards = (products || []).filter(p => Number(p.stock) > 0 || p.featured).map(prod => {
|
||||
const pImg = cnBlobUrl(prod.image);
|
||||
return `<article class="cn-product">
|
||||
${pImg ? `<img class="cn-product-img" src="${pImg}" alt="" loading="lazy"/>` : ''}
|
||||
<h3 class="cn-product-title">${cnEscapeHtml(prod.title || '')}</h3>
|
||||
${prod.description ? `<p class="cn-product-desc">${cnEscapeHtml(prod.description)}</p>` : ''}
|
||||
<p class="cn-product-price">${fmtPrice(prod.price)} ECO</p>
|
||||
${Number(prod.stock) > 0 ? `<p class="cn-product-stock">Stock: ${prod.stock}</p>` : ''}
|
||||
</article>`;
|
||||
}).join('\n');
|
||||
const shopBlobUrl = cnBlobUrl(shop.image);
|
||||
const shopImg = shopBlobUrl ? `<img class="cn-shop-img" src="${shopBlobUrl}" alt="${cnEscapeHtml(shop.title || '')}"/>` : '';
|
||||
const desc = cnEscapeHtml(shop.shortDescription || shop.description || '');
|
||||
const extraCss = `
|
||||
.cn-hero{display:flex;gap:24px;margin-bottom:24px;flex-wrap:wrap;align-items:flex-start}
|
||||
.cn-shop-img{display:block;max-width:280px;width:100%;border:3px solid var(--fg);border-radius:8px;background:#000}
|
||||
.cn-hero-body{flex:1 1 320px;min-width:0}
|
||||
.cn-shop-title{color:var(--fg);margin:0 0 12px 0;font-size:32px;font-weight:700;letter-spacing:0.3px}
|
||||
.cn-shop-desc{color:var(--fg-soft);margin:0 0 16px 0;font-size:15px;white-space:pre-wrap}
|
||||
.cn-shop-meta{display:flex;gap:12px;flex-wrap:wrap;background:var(--bg-sub);border:1px solid var(--border);border-radius:8px;padding:10px 14px;font-size:13px;color:var(--fg-soft)}
|
||||
.cn-shop-meta-item{display:inline-flex;align-items:center;gap:6px}
|
||||
.cn-products{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:16px}
|
||||
.cn-product{background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:16px;transition:border-color .15s ease}
|
||||
.cn-product:hover{border-color:var(--fg)}
|
||||
.cn-product-img{width:100%;height:180px;object-fit:cover;border-radius:6px;margin-bottom:10px;background:#000;border:1px solid var(--border)}
|
||||
.cn-product-title{color:var(--fg);font-size:16px;margin:0 0 8px 0;font-weight:600}
|
||||
.cn-product-desc{color:var(--fg-soft);font-size:13px;margin:0 0 10px 0;line-height:1.4}
|
||||
.cn-product-price{color:var(--fg);background:var(--bg-sub);border:1px solid var(--fg);display:inline-block;padding:4px 10px;border-radius:4px;font-weight:bold;margin:0;font-size:14px}
|
||||
.cn-product-stock{color:var(--fg-dim);font-size:12px;margin:8px 0 0 0;text-transform:uppercase;letter-spacing:1px}
|
||||
.cn-empty{background:var(--bg-elev);border:1px dashed var(--border);border-radius:8px;padding:32px;text-align:center;color:var(--fg-dim)}
|
||||
`;
|
||||
const body = `
|
||||
<div class="cn-hero">
|
||||
${shopImg}
|
||||
<div class="cn-hero-body">
|
||||
<h1 class="cn-shop-title">${cnEscapeHtml(shop.title || '')}</h1>
|
||||
${desc ? `<p class="cn-shop-desc">${desc}</p>` : ''}
|
||||
<div class="cn-shop-meta">
|
||||
${shop.createdAt ? `<span class="cn-shop-meta-item">📅 ${new Date(shop.createdAt).toISOString().slice(0,10)}</span>` : ''}
|
||||
${shop.location ? `<span class="cn-shop-meta-item">📍 ${cnEscapeHtml(shop.location)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="cn-section">Products</h2>
|
||||
${productCards ? `<div class="cn-products">${productCards}</div>` : '<div class="cn-empty">No products available.</div>'}
|
||||
`;
|
||||
return renderClearnetPage({
|
||||
title: `${shop.title || 'Shop'} — Oasis`,
|
||||
ogTitle: shop.title || 'Oasis',
|
||||
ogDescription: shop.shortDescription || shop.description || '',
|
||||
ogImage: shopBlobUrl,
|
||||
extraCss,
|
||||
body,
|
||||
hubFeedId: shop.author || null
|
||||
});
|
||||
};
|
||||
|
||||
exports.renderShopInvitePage = (code) => {
|
||||
const pageContent = div({ class: "invite-page" },
|
||||
h2(i18n.tribeInviteCodeText, code),
|
||||
form({ method: "GET", action: "/shops" },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.walletBack)
|
||||
)
|
||||
);
|
||||
return template(i18n.invitesShopsTitle || "Shops", section(pageContent));
|
||||
};
|
||||
505
src/views/stats_view.js
Normal file
505
src/views/stats_view.js
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
const { div, h2, p, section, button, form, input, ul, li, a, h3, span, strong, table, thead, tbody, tr, td, th } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, userLink } = require('./main_views');
|
||||
|
||||
Object.assign(i18n, {
|
||||
statsChat: "Chats",
|
||||
statsChatMessage: "Chat messages",
|
||||
statsPad: "Pads",
|
||||
statsPadEntry: "Pad entries",
|
||||
statsGameScore: "Game scores",
|
||||
statsParliamentCandidature: "Parliament candidatures",
|
||||
statsParliamentTerm: "Parliament terms",
|
||||
statsParliamentProposal: "Parliament proposals",
|
||||
statsParliamentRevocation: "Parliament revocations",
|
||||
statsParliamentLaw: "Parliament laws",
|
||||
statsCourtsCase: "Court cases",
|
||||
statsCourtsEvidence: "Court evidence",
|
||||
statsCourtsAnswer: "Court answers",
|
||||
statsCourtsVerdict: "Court verdicts",
|
||||
statsCourtsSettlement: "Court settlements",
|
||||
statsCourtsSettlementProposal: "Settlement proposals",
|
||||
statsCourtsSettlementAccepted: "Settlements accepted",
|
||||
statsCourtsNomination: "Judge nominations",
|
||||
statsCourtsNominationVote: "Nomination votes"
|
||||
});
|
||||
|
||||
const C = (stats, t) => Number((stats && stats.content && stats.content[t]) || 0);
|
||||
const O = (stats, t) => Number((stats && stats.opinions && stats.opinions[t]) || 0);
|
||||
|
||||
const wClass = (pct) => {
|
||||
const n = Math.max(0, Math.min(100, Math.round((pct || 0) / 5) * 5));
|
||||
return `stats-w-${n}`;
|
||||
};
|
||||
|
||||
exports.statsView = (stats, filter) => {
|
||||
const title = i18n.statsTitle;
|
||||
const description = i18n.statsDescription;
|
||||
const modes = ['ALL', 'MINE', 'TOMBSTONE'];
|
||||
const types = [
|
||||
'bookmark', 'event', 'task', 'votes', 'report', 'feed', 'project',
|
||||
'image', 'torrent', 'audio', 'video', 'document', 'transfer', 'post', 'tribe',
|
||||
'market', 'forum', 'job', 'aiExchange', 'map', 'shop', 'shopProduct',
|
||||
'chat', 'chatMessage', 'pad', 'padEntry', 'gameScore', 'calendar', 'calendarDate', 'calendarNote',
|
||||
'parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw',
|
||||
'courtsCase','courtsEvidence','courtsAnswer','courtsVerdict','courtsSettlement','courtsSettlementProposal','courtsSettlementAccepted','courtsNomination','courtsNominationVote'
|
||||
];
|
||||
const labels = {
|
||||
bookmark: i18n.statsBookmark,
|
||||
event: i18n.statsEvent,
|
||||
task: i18n.statsTask,
|
||||
votes: i18n.statsVotes,
|
||||
report: i18n.statsReport,
|
||||
feed: i18n.statsFeed,
|
||||
project: i18n.statsProject,
|
||||
image: i18n.statsImage,
|
||||
torrent: i18n.statsTorrent,
|
||||
audio: i18n.statsAudio,
|
||||
video: i18n.statsVideo,
|
||||
document: i18n.statsDocument,
|
||||
transfer: i18n.statsTransfer,
|
||||
post: i18n.statsPost,
|
||||
tribe: i18n.statsTribe,
|
||||
market: i18n.statsMarket,
|
||||
forum: i18n.statsForum,
|
||||
job: i18n.statsJob,
|
||||
aiExchange: i18n.statsAiExchange,
|
||||
map: i18n.statsMap,
|
||||
shop: i18n.statsShop,
|
||||
shopProduct: i18n.statsShopProduct,
|
||||
chat: i18n.statsChat,
|
||||
chatMessage: i18n.statsChatMessage,
|
||||
pad: i18n.statsPad,
|
||||
padEntry: i18n.statsPadEntry,
|
||||
gameScore: i18n.statsGameScore,
|
||||
calendar: i18n.statsCalendar,
|
||||
calendarDate: i18n.statsCalendarDate,
|
||||
calendarNote: i18n.statsCalendarNote,
|
||||
parliamentCandidature: i18n.statsParliamentCandidature,
|
||||
parliamentTerm: i18n.statsParliamentTerm,
|
||||
parliamentProposal: i18n.statsParliamentProposal,
|
||||
parliamentRevocation: i18n.statsParliamentRevocation,
|
||||
parliamentLaw: i18n.statsParliamentLaw,
|
||||
courtsCase: i18n.statsCourtsCase,
|
||||
courtsEvidence: i18n.statsCourtsEvidence,
|
||||
courtsAnswer: i18n.statsCourtsAnswer,
|
||||
courtsVerdict: i18n.statsCourtsVerdict,
|
||||
courtsSettlement: i18n.statsCourtsSettlement,
|
||||
courtsSettlementProposal: i18n.statsCourtsSettlementProposal,
|
||||
courtsSettlementAccepted: i18n.statsCourtsSettlementAccepted,
|
||||
courtsNomination: i18n.statsCourtsNomination,
|
||||
courtsNominationVote: i18n.statsCourtsNominationVote
|
||||
};
|
||||
const totalContent = types.filter(t => t !== 'karmaScore').reduce((sum, t) => sum + C(stats, t), 0);
|
||||
const totalOpinions = types.reduce((sum, t) => sum + O(stats, t), 0);
|
||||
|
||||
const fmtNum = (n) => {
|
||||
if (typeof n !== 'number' || !isFinite(n)) return '0';
|
||||
if (Math.abs(n) >= 100) return n.toFixed(0);
|
||||
if (Math.abs(n) >= 10) return n.toFixed(1);
|
||||
return n.toFixed(2);
|
||||
};
|
||||
|
||||
const isZero = (value) => {
|
||||
if (value === 0 || value === '0') return true;
|
||||
const s = String(value == null ? '' : value).trim();
|
||||
if (!s) return true;
|
||||
const n = parseFloat(s);
|
||||
if (!Number.isFinite(n)) return false;
|
||||
return n === 0;
|
||||
};
|
||||
|
||||
const kpi = (label, value) => {
|
||||
if (isZero(value)) return null;
|
||||
return div({ class: 'stats-kpi' },
|
||||
div({ class: 'stats-kpi-label' }, label),
|
||||
div({ class: 'stats-kpi-value' }, String(value))
|
||||
);
|
||||
};
|
||||
|
||||
const kpiBar = (label, value, pct) => {
|
||||
const n = Math.max(0, Math.min(100, Number(pct) || 0));
|
||||
return div({ class: 'stats-kpi' },
|
||||
div({ class: 'stats-kpi-label' }, label),
|
||||
div({ class: 'stats-kpi-value' }, String(value)),
|
||||
n > 0
|
||||
? div({ class: 'stats-bar-track stats-kpi-bar' },
|
||||
div({ class: `stats-bar-fill ${wClass(n)}` })
|
||||
)
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const kpiGrid = (...tiles) => div({ class: 'stats-grid' }, tiles.filter(Boolean));
|
||||
|
||||
const renderTopList = (items, getName, getCount, max) => {
|
||||
if (!items || !items.length) return p({ class: 'no-content' }, i18n.no_results || 'No data');
|
||||
const m = Math.max(1, max || items[0] && getCount(items[0]) || 1);
|
||||
return ul({ class: 'stats-toplist' },
|
||||
...items.map(it => {
|
||||
const cnt = getCount(it);
|
||||
const pct = (cnt / m) * 100;
|
||||
return li(
|
||||
span({ class: 'stats-toplist-name' }, getName(it)),
|
||||
div({ class: 'stats-bar-track' },
|
||||
div({ class: `stats-bar-fill ${wClass(pct)}` })
|
||||
),
|
||||
span({ class: 'stats-toplist-num' }, String(cnt))
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const carbonChart = (() => {
|
||||
const parseSize = (s) => {
|
||||
if (!s) return 0;
|
||||
const m = String(s).match(/([\d.]+)\s*(GB|MB|KB|B)/i);
|
||||
if (!m) return 0;
|
||||
const v = parseFloat(m[1]);
|
||||
const u = m[2].toUpperCase();
|
||||
if (u === 'GB') return v * 1024;
|
||||
if (u === 'MB') return v;
|
||||
if (u === 'KB') return v / 1024;
|
||||
return v / (1024 * 1024);
|
||||
};
|
||||
const blobsMB = parseSize(stats.statsBlobsSize);
|
||||
const chainMB = parseSize(stats.statsBlockchainSize);
|
||||
const totalMB = blobsMB + chainMB;
|
||||
const kWhPerMB = 0.0002;
|
||||
const gCO2PerKWh = 475;
|
||||
const networkCO2 = parseFloat((totalMB * kWhPerMB * gCO2PerKWh).toFixed(2));
|
||||
const inhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 1;
|
||||
const userCO2 = parseFloat((networkCO2 / Math.max(1, inhabitants)).toFixed(2));
|
||||
const maxAnnualCO2 = 500;
|
||||
|
||||
if (filter === 'MINE') {
|
||||
const pct = networkCO2 > 0 ? Math.min(100, (userCO2 / networkCO2) * 100) : 0;
|
||||
return div({ class: 'carbon-chart' },
|
||||
div({ class: 'carbon-bar-label' },
|
||||
span(i18n.statsCarbonUser || 'Your footprint'),
|
||||
span(`${userCO2} g CO₂`)
|
||||
),
|
||||
div({ class: 'carbon-bar-track' },
|
||||
div({ class: `carbon-bar-fill carbon-bar-mine ${wClass(pct)}` })
|
||||
),
|
||||
div({ class: 'carbon-bar-label' },
|
||||
span(i18n.statsCarbonNetwork || 'Network total'),
|
||||
span(`${networkCO2} g CO₂`)
|
||||
),
|
||||
div({ class: 'carbon-bar-track' },
|
||||
div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' })
|
||||
),
|
||||
p({ class: 'carbon-bar-note' }, strong(`${pct.toFixed(1)}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'}`),
|
||||
p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
|
||||
);
|
||||
}
|
||||
if (filter === 'TOMBSTONE') {
|
||||
const tombCount = stats.tombstoneKPIs?.networkTombstoneCount || 0;
|
||||
const avgTombBytes = 500;
|
||||
const tombMB = (tombCount * avgTombBytes) / (1024 * 1024);
|
||||
const tombCO2 = parseFloat((tombMB * kWhPerMB * gCO2PerKWh).toFixed(4));
|
||||
const tombPct = networkCO2 > 0 ? Math.min(100, (tombCO2 / networkCO2) * 100) : 0;
|
||||
return div({ class: 'carbon-chart' },
|
||||
div({ class: 'carbon-bar-label' },
|
||||
span(i18n.statsCarbonTombstone || 'Tombstoning footprint'),
|
||||
span(`${tombCO2} g CO₂`)
|
||||
),
|
||||
div({ class: 'carbon-bar-track' },
|
||||
div({ class: `carbon-bar-fill carbon-bar-mine ${wClass(tombPct)}` })
|
||||
),
|
||||
div({ class: 'carbon-bar-label' },
|
||||
span(i18n.statsCarbonNetwork || 'Network total'),
|
||||
span(`${networkCO2} g CO₂`)
|
||||
),
|
||||
div({ class: 'carbon-bar-track' },
|
||||
div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' })
|
||||
),
|
||||
p({ class: 'carbon-bar-note' }, strong(`${tombPct.toFixed(1)}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'} (${tombCount} tombstones × ~${avgTombBytes} bytes)`),
|
||||
p({ class: 'carbon-bar-formula' }, 'Based on estimated tombstone message size ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
|
||||
);
|
||||
}
|
||||
const pct = Math.min(100, (networkCO2 / maxAnnualCO2) * 100);
|
||||
return div({ class: 'carbon-chart' },
|
||||
div({ class: 'carbon-bar-label' },
|
||||
span(i18n.statsCarbonNetwork || 'Network footprint'),
|
||||
span(`${networkCO2} g CO₂`)
|
||||
),
|
||||
div({ class: 'carbon-bar-track' },
|
||||
div({ class: `carbon-bar-fill carbon-bar-network ${wClass(pct)}` })
|
||||
),
|
||||
div({ class: 'carbon-bar-label' },
|
||||
span(i18n.statsCarbonMaxAnnual || 'Annual max estimate'),
|
||||
span(`${maxAnnualCO2} g CO₂`)
|
||||
),
|
||||
div({ class: 'carbon-bar-track' },
|
||||
div({ class: 'carbon-bar-fill carbon-bar-max stats-w-100' })
|
||||
),
|
||||
p({ class: 'carbon-bar-note' }, strong(`${pct.toFixed(1)}%`), ` ${i18n.statsCarbonOfEstMax || 'of estimated max capacity'}`),
|
||||
p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
|
||||
);
|
||||
})();
|
||||
|
||||
const headerCard = div({ class: 'stats-card' },
|
||||
table({ class: 'block-info-table' },
|
||||
tr(td({ class: 'card-label' }, i18n.statsCreatedAt), td({ class: 'card-value' }, stats.createdAt)),
|
||||
tr(td({ class: 'card-label' }, 'ID'), td({ class: 'card-value' }, userLink(stats.id))),
|
||||
tr(td({ class: 'card-label' }, i18n.statsBlobsSize), td({ class: 'card-value' }, stats.statsBlobsSize)),
|
||||
tr(td({ class: 'card-label' }, i18n.statsBlockchainSize), td({ class: 'card-value' }, stats.statsBlockchainSize)),
|
||||
tr(td({ class: 'card-label' }, i18n.statsSize), td({ class: 'card-value' }, stats.folderSize))
|
||||
)
|
||||
);
|
||||
|
||||
const totalInhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 0;
|
||||
const networkKPIs = stats.networkKPIs || {};
|
||||
|
||||
const topStrip = div({ class: 'stats-block' },
|
||||
kpiGrid(
|
||||
kpi(i18n.bankingUserEngagementScore, C(stats, 'karmaScore')),
|
||||
kpi(i18n.statsUsersTitle, totalInhabitants),
|
||||
kpi(i18n.statsTotalMsgs || 'Total messages', networkKPIs.totalMsgs || 0),
|
||||
kpi(i18n.statsLogsTitle || 'Logs', stats?.logsCount || 0),
|
||||
kpi(i18n.statsAITraining, C(stats, 'aiExchange') || 0),
|
||||
kpi(i18n.statsPUBs, stats.pubsCount || 0)
|
||||
)
|
||||
);
|
||||
|
||||
const carbonCard = div({ class: 'stats-card' },
|
||||
h3({ class: 'stats-section-h' }, i18n.statsCarbonFootprintTitle || 'Carbon Footprint'),
|
||||
carbonChart
|
||||
);
|
||||
|
||||
const bankingCard = div({ class: 'stats-card' },
|
||||
table({ class: 'block-info-table' },
|
||||
tr(td({ class: 'card-label' }, i18n.statsEcoWalletLabel), td({ class: 'card-value' }, a({ href: '/wallet', class: 'stats-link-break' }, stats?.banking?.myAddress || i18n.statsEcoWalletNotConfigured))),
|
||||
tr(td({ class: 'card-label' }, i18n.profileGpgChip || 'GPG'), td({ class: 'card-value' },
|
||||
stats?.gpgFingerprint
|
||||
? a({ href: `/profile/${encodeURIComponent(stats.id)}/gpg.asc`, class: 'stats-link-break' }, String(stats.gpgFingerprint).slice(-8).toUpperCase())
|
||||
: a({ href: '/profile/edit', class: 'stats-link-break' }, i18n.statsEcoWalletNotConfigured)
|
||||
))
|
||||
)
|
||||
);
|
||||
|
||||
const networkBlock = div({ class: 'stats-block' },
|
||||
kpiGrid(
|
||||
filter === 'MINE'
|
||||
? kpi(i18n.statsMyShare || 'Your share of the network', `${fmtNum(networkKPIs.myShare || 0)}%`)
|
||||
: null,
|
||||
kpi(i18n.statsAvgPerInhabitant || 'Avg per inhabitant', fmtNum(networkKPIs.avgMsgsPerInhabitant || 0)),
|
||||
kpi(i18n.statsMsgsPerDay || 'Messages/day (lifetime)', fmtNum(networkKPIs.networkMsgsPerDay || 0)),
|
||||
kpi(i18n.statsNetworkSpan || 'Network span', `${fmtNum(networkKPIs.networkSpanDays || 0)} d`),
|
||||
kpi(i18n.statsTombstoneRatioLabel || 'Tombstone ratio', `${fmtNum(stats.tombstoneKPIs?.ratio || 0)}%`)
|
||||
)
|
||||
);
|
||||
|
||||
const activityBlock = (() => {
|
||||
const rows = Array.isArray(stats.activity?.daily7) ? stats.activity.daily7 : [];
|
||||
const max = Math.max(1, ...rows.map(r => Number(r.count) || 0));
|
||||
return div({ class: 'stats-block' },
|
||||
h2(i18n.statsActivity7d),
|
||||
rows.length
|
||||
? ul({ class: 'stats-toplist' },
|
||||
...rows.map(row => {
|
||||
const cnt = Number(row.count) || 0;
|
||||
const pct = (cnt / max) * 100;
|
||||
return li(
|
||||
span({ class: 'stats-toplist-name' }, row.day),
|
||||
div({ class: 'stats-bar-track' },
|
||||
div({ class: `stats-bar-fill ${wClass(pct)}` })
|
||||
),
|
||||
span({ class: 'stats-toplist-num' }, String(cnt))
|
||||
);
|
||||
})
|
||||
)
|
||||
: p({ class: 'no-content' }, i18n.no_results || 'No data'),
|
||||
div({ class: 'stats-activity-totals' },
|
||||
span(`${i18n.statsActivity7dTotal}: `, strong(String(stats.activity?.daily7Total || 0))),
|
||||
span(`${i18n.statsActivity30dTotal}: `, strong(String(stats.activity?.daily30Total || 0)))
|
||||
)
|
||||
);
|
||||
})();
|
||||
|
||||
const topTypes = Array.isArray(stats.topTypes) ? stats.topTypes : [];
|
||||
const topTypesBlock = topTypes.length ? div({ class: 'stats-block' },
|
||||
h2(i18n.statsTopTypesTitle || 'Top Content Types'),
|
||||
renderTopList(
|
||||
topTypes,
|
||||
it => labels[it.type] || it.type,
|
||||
it => it.count,
|
||||
topTypes[0] ? topTypes[0].count : 1
|
||||
)
|
||||
) : null;
|
||||
|
||||
const topTags = Array.isArray(stats.topTags) ? stats.topTags : [];
|
||||
const topTagsBlock = topTags.length ? div({ class: 'stats-block' },
|
||||
h2(i18n.statsTopTagsTitle || 'Top Tags'),
|
||||
div({ class: 'stats-mb-16' },
|
||||
topTags.map(t => a({ class: 'stats-pill', href: `/search?query=%23${encodeURIComponent(t.tag)}` }, `#${t.tag} (${t.count})`))
|
||||
)
|
||||
) : null;
|
||||
|
||||
const marketTiles = [
|
||||
kpi(i18n.statsMarketTotal, stats.marketKPIs?.total || 0),
|
||||
kpi(i18n.statsMarketForSale, stats.marketKPIs?.forSale || 0),
|
||||
kpi(i18n.statsMarketReserved, stats.marketKPIs?.reserved || 0),
|
||||
kpi(i18n.statsMarketClosed, stats.marketKPIs?.closed || 0),
|
||||
kpi(i18n.statsMarketSold, stats.marketKPIs?.sold || 0)
|
||||
].filter(Boolean);
|
||||
const marketBlock = marketTiles.length
|
||||
? div({ class: 'stats-block' }, h2(i18n.statsMarketTitle), kpiGrid(...marketTiles))
|
||||
: null;
|
||||
|
||||
const projectsTiles = [
|
||||
kpi(i18n.statsProjectsTotal, stats.projectsKPIs?.total || 0),
|
||||
kpi(i18n.statsProjectsActive, stats.projectsKPIs?.active || 0),
|
||||
kpi(i18n.statsProjectsCompleted, stats.projectsKPIs?.completed || 0),
|
||||
kpi(i18n.statsProjectsPaused, stats.projectsKPIs?.paused || 0),
|
||||
kpi(i18n.statsProjectsCancelled, stats.projectsKPIs?.cancelled || 0),
|
||||
kpi(i18n.statsProjectsGoalTotal, `${stats.projectsKPIs?.ecoGoalTotal || 0} ECO`),
|
||||
kpi(i18n.statsProjectsPledgedTotal, `${stats.projectsKPIs?.ecoPledgedTotal || 0} ECO`)
|
||||
].filter(Boolean);
|
||||
const projectsBlock = projectsTiles.length
|
||||
? div({ class: 'stats-block' }, h2(i18n.statsProjectsTitle), kpiGrid(...projectsTiles))
|
||||
: null;
|
||||
|
||||
const allTribesPublic = Array.isArray(stats.allTribesPublic) ? stats.allTribesPublic : [];
|
||||
const memberTribesDetailed = Array.isArray(stats.memberTribesDetailed) ? stats.memberTribesDetailed : [];
|
||||
const myPrivateTribesDetailed = Array.isArray(stats.myPrivateTribesDetailed) ? stats.myPrivateTribesDetailed : [];
|
||||
|
||||
const buildContentRows = () => {
|
||||
const excluded = new Set([
|
||||
'karmaScore', 'shopProduct', 'padEntry', 'chatMessage', 'calendarDate', 'calendarNote',
|
||||
'parliamentTerm'
|
||||
]);
|
||||
const rows = [];
|
||||
types.filter(t => !excluded.has(t)).forEach(t => {
|
||||
const cnt = C(stats, t);
|
||||
if (cnt <= 0) return;
|
||||
rows.push([labels[t], cnt]);
|
||||
});
|
||||
return rows;
|
||||
};
|
||||
const buildContentTable = () => {
|
||||
const rows = buildContentRows().slice().sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0));
|
||||
if (!rows.length) return p({ class: 'no-content' }, i18n.no_results || 'No data');
|
||||
return table({ class: 'tag-table' },
|
||||
thead(tr(
|
||||
th(i18n.statsContentTypeColumn || 'Type'),
|
||||
th(i18n.statsContentCountColumn || 'Count')
|
||||
)),
|
||||
tbody(...rows.map(([label, count]) => tr(td(label), td(String(count)))))
|
||||
);
|
||||
};
|
||||
|
||||
const buildOpinionTiles = () =>
|
||||
types.map(t => O(stats, t) > 0 ? kpi(labels[t], O(stats, t)) : null).filter(Boolean);
|
||||
|
||||
const tribeListBlock = (label, list) => div({ class: 'stats-block' },
|
||||
h2(`${label}: ${list.length}`),
|
||||
list.length
|
||||
? table({ class: 'stats-table-mt8' },
|
||||
...list.map(t => tr(td(a({ href: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-link' }, t.name))))
|
||||
)
|
||||
: p({ class: 'no-content' }, i18n.no_results || 'No data')
|
||||
);
|
||||
|
||||
const allMode = filter === 'ALL'
|
||||
? div({ class: 'stats-container' }, [
|
||||
networkBlock,
|
||||
activityBlock,
|
||||
totalOpinions > 0
|
||||
? div({ class: 'stats-block' },
|
||||
h2(`${i18n.statsNetworkOpinions}: ${totalOpinions}`),
|
||||
kpiGrid(...buildOpinionTiles())
|
||||
)
|
||||
: null,
|
||||
totalContent > 0
|
||||
? div({ class: 'stats-block' },
|
||||
h2(`${i18n.statsNetworkContent}: ${totalContent}`),
|
||||
buildContentTable()
|
||||
)
|
||||
: null
|
||||
])
|
||||
: null;
|
||||
|
||||
const mineMode = filter === 'MINE'
|
||||
? div({ class: 'stats-container' }, [
|
||||
networkBlock,
|
||||
activityBlock,
|
||||
totalOpinions > 0
|
||||
? div({ class: 'stats-block' },
|
||||
h2(`${i18n.statsYourOpinions}: ${totalOpinions}`),
|
||||
kpiGrid(...buildOpinionTiles())
|
||||
)
|
||||
: null,
|
||||
totalContent > 0
|
||||
? div({ class: 'stats-block' },
|
||||
h2(`${i18n.statsYourContent}: ${totalContent}`),
|
||||
buildContentTable()
|
||||
)
|
||||
: null
|
||||
])
|
||||
: null;
|
||||
|
||||
const tombMode = filter === 'TOMBSTONE'
|
||||
? (() => {
|
||||
const userTomb = Number(stats.userTombstoneCount || 0);
|
||||
const netTomb = Number(stats.tombstoneKPIs?.networkTombstoneCount || 0);
|
||||
if (userTomb === 0 && netTomb === 0) {
|
||||
return div({ class: 'stats-container' },
|
||||
div({ class: 'stats-block' },
|
||||
p({ class: 'no-content' }, i18n.statsTombstoneEmpty || 'No tombstones in the network yet.')
|
||||
)
|
||||
);
|
||||
}
|
||||
return div({ class: 'stats-container' }, [
|
||||
div({ class: 'stats-block' },
|
||||
kpiGrid(
|
||||
kpi(i18n.TOMBSTONEButton, userTomb),
|
||||
kpi(i18n.statsTombstoneRatio, `${(stats.tombstoneKPIs?.ratio || 0).toFixed(2)}%`)
|
||||
)
|
||||
)
|
||||
]);
|
||||
})()
|
||||
: null;
|
||||
|
||||
const ets = stats.ecoTaxStats || null;
|
||||
const userTax = Number(stats.userEcoinTax || 0);
|
||||
const ecoTaxBlock = ets ? div({ class: 'stats-card' },
|
||||
table({ class: 'block-info-table' },
|
||||
filter === 'MINE'
|
||||
? tr(td({ class: 'card-label' }, i18n.bankTaxesUserAmount || 'Your ECO tax'), td({ class: 'card-value' }, `${userTax.toFixed(6)} ECO`))
|
||||
: null,
|
||||
tr(td({ class: 'card-label' }, i18n.bankTaxesTotalEcoin || 'Total ECO tax (lifetime)'), td({ class: 'card-value' }, `${Number(ets.totalEcoinTax || 0).toFixed(6)} ECO`)),
|
||||
tr(td({ class: 'card-label' }, i18n.bankTaxesAnnualEcoin || 'ECO tax (annual)'), td({ class: 'card-value' }, `${Number(ets.annualEcoinTax || 0).toFixed(6)} ECO`)),
|
||||
tr(td({ class: 'card-label' }, i18n.bankTaxesMonthlyEcoin || 'ECO tax (monthly)'), td({ class: 'card-value' }, `${Number(ets.monthlyEcoinTax || 0).toFixed(6)} ECO`)),
|
||||
tr(td({ class: 'card-label' }, i18n.bankTaxesTotalCarbon || 'Total carbon'), td({ class: 'card-value' }, `${Number(ets.totalGramsCO2 || 0).toFixed(3)} g CO₂`)),
|
||||
tr(td({ class: 'card-label' }, i18n.bankTaxesRate || 'Rate'), td({ class: 'card-value' }, `${Number(ets.ecoinPerGramCO2 || 0).toFixed(4)} ECO / g CO₂`))
|
||||
)
|
||||
) : null;
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: 'tags-header' },
|
||||
h2(title),
|
||||
p(description)
|
||||
),
|
||||
div({ class: 'mode-buttons stats-mode-row' },
|
||||
modes.map(m =>
|
||||
form({ method: 'GET', action: '/stats' },
|
||||
input({ type: 'hidden', name: 'filter', value: m }),
|
||||
button({ type: 'submit', class: filter === m ? 'filter-btn active' : 'filter-btn' }, i18n[m + 'Button'])
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
topStrip,
|
||||
headerCard,
|
||||
filter !== 'TOMBSTONE' ? ecoTaxBlock : null,
|
||||
bankingCard,
|
||||
allMode,
|
||||
mineMode,
|
||||
tombMode
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
91
src/views/tags_view.js
Normal file
91
src/views/tags_view.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
const { form, button, div, h2, p, section, table, thead, tr, th, td, a, tbody } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n } = require('./main_views');
|
||||
|
||||
const getFilteredTags = (filter, tags) => {
|
||||
let filteredTags = Array.isArray(tags) ? tags : [];
|
||||
if (filter === 'top') {
|
||||
filteredTags = filteredTags.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
||||
} else {
|
||||
filteredTags = filteredTags.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
return filteredTags;
|
||||
};
|
||||
|
||||
const renderTagsTable = (filteredTags) => {
|
||||
return table({ class: 'tag-table' },
|
||||
thead(
|
||||
tr(
|
||||
th(i18n.tagsTableHeaderTag),
|
||||
th(i18n.tagsTableHeaderCount)
|
||||
)
|
||||
),
|
||||
tbody(
|
||||
filteredTags.map(tag =>
|
||||
tr(
|
||||
td(a({ href: `/search?query=%23${encodeURIComponent(tag.name)}` }, tag.name)),
|
||||
td(`${tag.count}`)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderTagsCloud = (mergedTags) => {
|
||||
const maxCount = Math.max(...mergedTags.map(t => t.count), 1);
|
||||
return div({ class: 'tag-cloud-wrap' },
|
||||
mergedTags.map(tag => {
|
||||
const angle = Math.random() * 2 * Math.PI;
|
||||
const radius = 10 + Math.random() * 40;
|
||||
const x = 50 + Math.cos(angle) * radius;
|
||||
const y = 50 + Math.sin(angle) * radius;
|
||||
const weight = tag.count / maxCount;
|
||||
const fontSize = 12 + Math.round(weight * 32);
|
||||
const hue = 200 + Math.round(weight * 120);
|
||||
|
||||
return a({
|
||||
href: `/search?query=%23${encodeURIComponent(tag.name)}`,
|
||||
class: 'tag-cloud-item',
|
||||
style: `
|
||||
left: ${x}%;
|
||||
top: ${y}%;
|
||||
font-size: ${fontSize}px;
|
||||
color: hsl(${hue},70%,60%);
|
||||
`
|
||||
}, tag.name);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
exports.tagsView = async (tags, filter) => {
|
||||
const filteredTags = getFilteredTags(filter, tags);
|
||||
|
||||
const title =
|
||||
filter === 'top' ? i18n.tagsTopSectionTitle :
|
||||
filter === 'cloud' ? i18n.tagsCloudSectionTitle :
|
||||
i18n.tagsAllSectionTitle;
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: 'tags-header' },
|
||||
h2(i18n.tagsTitle),
|
||||
p(i18n.tagsDescription)
|
||||
),
|
||||
div({ class: 'filters' },
|
||||
form({ method: 'GET', action: '/tags' },
|
||||
button({ type: 'submit', name: 'filter', value: 'all', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.tagsFilterAll),
|
||||
button({ type: 'submit', name: 'filter', value: 'top', class: filter === 'top' ? 'filter-btn active' : 'filter-btn' }, i18n.tagsFilterTop),
|
||||
button({ type: 'submit', name: 'filter', value: 'cloud', class: filter === 'cloud' ? 'filter-btn active' : 'filter-btn' }, i18n.tagsFilterCloud)
|
||||
)
|
||||
),
|
||||
div({ class: 'tags-list' },
|
||||
filteredTags.length === 0
|
||||
? p(i18n.tagsNoItems)
|
||||
: filter !== 'cloud'
|
||||
? renderTagsTable(filteredTags)
|
||||
: renderTagsCloud(filteredTags)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
488
src/views/task_view.js
Normal file
488
src/views/task_view.js
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
const { div, h2, p, section, button, form, input, select, option, a, br, textarea, label, span, table, tr, td, img, video } = require("../server/node_modules/hyperaxe");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { template, i18n, renderOpinionsVoting, userLink, renderStateChip, renderPrivacyChip, renderLifespanChip, renderEcoTax, renderSpreadButton } = require("./main_views");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const { renderUrl } = require("../backend/renderUrl");
|
||||
|
||||
const renderTaskMediaBlob = (value, attrs = {}) => {
|
||||
if (!value) return null;
|
||||
const s = String(value).trim();
|
||||
if (!s) return null;
|
||||
if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}`, ...attrs });
|
||||
const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/);
|
||||
if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, class: attrs.class || 'post-image' });
|
||||
const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/);
|
||||
if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` });
|
||||
return null;
|
||||
};
|
||||
|
||||
const userId = config.keys.id;
|
||||
|
||||
const opt = (value, isSelected, text) =>
|
||||
option(Object.assign({ value }, isSelected ? { selected: "selected" } : {}), text);
|
||||
|
||||
const safeArray = (v) => Array.isArray(v) ? v : [];
|
||||
|
||||
const normalizeStatus = (v) => {
|
||||
const up = String(v || "").toUpperCase();
|
||||
if (up === "OPEN" || up === "IN-PROGRESS" || up === "CLOSED") return up;
|
||||
return "OPEN";
|
||||
};
|
||||
|
||||
const statusLabel = (s) => {
|
||||
const up = normalizeStatus(s);
|
||||
if (up === "OPEN") return i18n.taskStatusOpen;
|
||||
if (up === "IN-PROGRESS") return i18n.taskStatusInProgress;
|
||||
return i18n.taskStatusClosed;
|
||||
};
|
||||
|
||||
const visibilityLabel = (v) => {
|
||||
const vv = String(v || "").toUpperCase();
|
||||
if (vv === "PRIVATE") return i18n.taskPrivate;
|
||||
return i18n.taskPublic;
|
||||
};
|
||||
|
||||
const renderTaskOwnerActions = (task, returnTo) => {
|
||||
const st = normalizeStatus(task.status || "OPEN");
|
||||
const setStatusLabel = i18n.taskSetStatus;
|
||||
|
||||
return [
|
||||
form(
|
||||
{ method: "GET", action: `/tasks/edit/${encodeURIComponent(task.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", class: "update-btn" }, i18n.taskUpdateButton)
|
||||
),
|
||||
form(
|
||||
{ method: "POST", action: `/tasks/delete/${encodeURIComponent(task.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", class: "delete-btn" }, i18n.taskDeleteButton)
|
||||
),
|
||||
form(
|
||||
{ method: "POST", action: `/tasks/status/${encodeURIComponent(task.id)}`, class: "project-control-form project-control-form--status" },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
select(
|
||||
{ name: "status", class: "project-control-select" },
|
||||
option({ value: "OPEN", selected: st === "OPEN" }, i18n.taskStatusOpen),
|
||||
option({ value: "IN-PROGRESS", selected: st === "IN-PROGRESS" }, i18n.taskStatusInProgress),
|
||||
option({ value: "CLOSED", selected: st === "CLOSED" }, i18n.taskStatusClosed)
|
||||
),
|
||||
button({ class: "status-btn project-control-btn", type: "submit" }, setStatusLabel)
|
||||
)
|
||||
];
|
||||
};
|
||||
|
||||
const renderTaskAssignAction = (task, isAssignedToMe, returnTo) => {
|
||||
const st = normalizeStatus(task.status || "OPEN");
|
||||
if (st === "CLOSED") return null;
|
||||
return form(
|
||||
{ method: "POST", action: `/tasks/assign/${encodeURIComponent(task.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", class: "filter-btn" }, isAssignedToMe ? i18n.taskUnassignButton : i18n.taskAssignButton)
|
||||
);
|
||||
};
|
||||
|
||||
const renderTaskCommentsSection = (taskId, comments = [], currentFilter = "all") => {
|
||||
const commentsCount = Array.isArray(comments) ? comments.length : 0;
|
||||
const returnTo = `/tasks/${encodeURIComponent(taskId)}?filter=${encodeURIComponent(currentFilter || "all")}`;
|
||||
|
||||
return div(
|
||||
{ class: "vote-comments-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
|
||||
span({ class: "card-value" }, String(commentsCount))
|
||||
),
|
||||
div(
|
||||
{ class: "comment-form-wrapper" },
|
||||
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
|
||||
form(
|
||||
{ method: "POST", action: `/tasks/${encodeURIComponent(taskId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
textarea({
|
||||
id: "comment-text",
|
||||
name: "text",
|
||||
rows: 4,
|
||||
class: "comment-textarea",
|
||||
placeholder: i18n.voteNewCommentPlaceholder
|
||||
}),
|
||||
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
|
||||
br(),
|
||||
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
|
||||
)
|
||||
),
|
||||
(() => {
|
||||
const visibleComments = (comments || []).filter(c => {
|
||||
const t = c && c.value && c.value.content && c.value.content.text;
|
||||
return t && String(t).trim();
|
||||
});
|
||||
return visibleComments.length
|
||||
? div(
|
||||
{ class: "comments-list" },
|
||||
visibleComments.map((c) => {
|
||||
const author = c.value && c.value.author ? c.value.author : "";
|
||||
const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
|
||||
const relDate = ts ? moment(ts).fromNow() : "";
|
||||
|
||||
const content = c.value && c.value.content ? c.value.content : {};
|
||||
const root = content.fork || content.root || "";
|
||||
const text = content.text || "";
|
||||
|
||||
return div(
|
||||
{ class: "votations-comment-card" },
|
||||
span(
|
||||
{ class: "created-at" },
|
||||
span(i18n.createdBy),
|
||||
author ? userLink(author) : span("(unknown)"),
|
||||
absDate ? span(" | ") : "",
|
||||
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
|
||||
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
|
||||
relDate && root ? a({ href: `/thread/${encodeURIComponent(root)}#${encodeURIComponent(c.key)}` }, relDate) : ""
|
||||
),
|
||||
p({ class: "votations-comment-text" }, ...renderUrl(String(text)))
|
||||
);
|
||||
})
|
||||
)
|
||||
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet);
|
||||
})()
|
||||
);
|
||||
};
|
||||
|
||||
const renderTaskStatusChip = (status) => {
|
||||
const s = normalizeStatus(status);
|
||||
const variant = s === "OPEN" ? "mutuals" : s === "IN-PROGRESS" ? "whole" : "closed";
|
||||
const icon = s === "OPEN" ? "✓" : s === "IN-PROGRESS" ? "↻" : "✗";
|
||||
return renderStateChip(variant, icon, statusLabel(s));
|
||||
};
|
||||
|
||||
const renderTaskPriorityChip = (priority) => {
|
||||
const p = String(priority || "").toUpperCase();
|
||||
if (!p) return null;
|
||||
const variant =
|
||||
p === "URGENT" ? "closed" :
|
||||
p === "HIGH" ? "lifespan-orange" :
|
||||
p === "MEDIUM" ? "whole" :
|
||||
"mutuals";
|
||||
const localized =
|
||||
p === "URGENT" ? i18n.taskPriorityUrgent :
|
||||
p === "HIGH" ? i18n.taskPriorityHigh :
|
||||
p === "MEDIUM" ? i18n.taskPriorityMedium :
|
||||
i18n.taskPriorityLow;
|
||||
return renderStateChip(variant, "⚑", localized || p);
|
||||
};
|
||||
|
||||
const renderTaskItem = (task, filter, spreadInfo) => {
|
||||
const currentFilter = filter || "all";
|
||||
const assignees = safeArray(task.assignees);
|
||||
const isPrivate = String(task.isPublic || "").toUpperCase() === "PRIVATE";
|
||||
|
||||
const heroNode = task.image
|
||||
? div({ class: "tribe-card-image-wrapper" },
|
||||
a({ href: `/tasks/${encodeURIComponent(task.id)}` },
|
||||
renderTaskMediaBlob(task.image, { class: "tribe-card-hero-image" })
|
||||
),
|
||||
div({ class: "tribe-visit-btn-wrapper" },
|
||||
form({ method: "GET", action: `/tasks/${encodeURIComponent(task.id)}` },
|
||||
input({ type: "hidden", name: "filter", value: currentFilter }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.viewTask || "View Task")
|
||||
)
|
||||
)
|
||||
)
|
||||
: null;
|
||||
|
||||
const chips = [
|
||||
renderTaskStatusChip(task.status),
|
||||
isPrivate ? renderPrivacyChip(true, i18n) : null,
|
||||
renderTaskPriorityChip(task.priority),
|
||||
renderLifespanChip(task.lifetime, i18n)
|
||||
].filter(Boolean);
|
||||
|
||||
const start = task.startTime ? moment(task.startTime).format("YYYY/MM/DD HH:mm") : "";
|
||||
const end = task.endTime ? moment(task.endTime).format("YYYY/MM/DD HH:mm") : "";
|
||||
|
||||
return div({ class: "tribe-card task-card" },
|
||||
heroNode,
|
||||
div({ class: "tribe-card-body" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" },
|
||||
a({ href: `/tasks/${encodeURIComponent(task.id)}` }, task.title || i18n.tasksTitle)
|
||||
)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
(start || end) ? p({ class: "card-date-highlight" }, start && end ? `${start} → ${end}` : (start || end)) : null,
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.taskAssignedTo}: ${assignees.length}`)
|
||||
),
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(task.id, spreadInfo)),
|
||||
!heroNode
|
||||
? div({ class: "card-visit-btn-centered" },
|
||||
form({ method: "GET", action: `/tasks/${encodeURIComponent(task.id)}` },
|
||||
input({ type: "hidden", name: "filter", value: currentFilter }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.viewTask || "View Task")
|
||||
)
|
||||
)
|
||||
: null
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.taskView = async (tasks, filter, taskId, returnTo, params = {}) => {
|
||||
const list = Array.isArray(tasks) ? tasks : [tasks];
|
||||
const currentFilter = filter || "all";
|
||||
|
||||
const title =
|
||||
currentFilter === "mine" ? i18n.taskMineSectionTitle :
|
||||
currentFilter === "create" ? i18n.taskCreateSectionTitle :
|
||||
currentFilter === "edit" ? i18n.taskUpdateSectionTitle :
|
||||
currentFilter === "open" ? i18n.taskOpenTitle :
|
||||
currentFilter === "in-progress" ? i18n.taskInProgressTitle :
|
||||
currentFilter === "closed" ? i18n.taskClosedTitle :
|
||||
currentFilter === "assigned" ? i18n.taskAssignedTitle :
|
||||
currentFilter === "priority-urgent" ? i18n.taskFilterUrgent :
|
||||
currentFilter === "priority-high" ? i18n.taskFilterHigh :
|
||||
currentFilter === "priority-medium" ? i18n.taskFilterMedium :
|
||||
currentFilter === "priority-low" ? i18n.taskFilterLow :
|
||||
i18n.taskAllSectionTitle;
|
||||
|
||||
const canSee = (t) => {
|
||||
const vis = String(t.isPublic || "").toUpperCase();
|
||||
if (vis === "PUBLIC") return true;
|
||||
if (t.author === userId) return true;
|
||||
return safeArray(t.assignees).includes(userId);
|
||||
};
|
||||
|
||||
const visible = list.filter(canSee);
|
||||
|
||||
let filtered;
|
||||
if (currentFilter === "mine") filtered = visible.filter((t) => t.author === userId);
|
||||
else if (currentFilter === "assigned") filtered = visible.filter((t) => safeArray(t.assignees).includes(userId));
|
||||
else if (currentFilter === "open") filtered = visible.filter((t) => normalizeStatus(t.status) === "OPEN");
|
||||
else if (currentFilter === "in-progress") filtered = visible.filter((t) => normalizeStatus(t.status) === "IN-PROGRESS");
|
||||
else if (currentFilter === "closed") filtered = visible.filter((t) => normalizeStatus(t.status) === "CLOSED");
|
||||
else if (currentFilter === "priority-urgent") filtered = visible.filter((t) => String(t.priority).toUpperCase() === "URGENT");
|
||||
else if (currentFilter === "priority-high") filtered = visible.filter((t) => String(t.priority).toUpperCase() === "HIGH");
|
||||
else if (currentFilter === "priority-medium") filtered = visible.filter((t) => String(t.priority).toUpperCase() === "MEDIUM");
|
||||
else if (currentFilter === "priority-low") filtered = visible.filter((t) => String(t.priority).toUpperCase() === "LOW");
|
||||
else filtered = visible;
|
||||
|
||||
filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
|
||||
const editTask = list.find((t) => t.id === taskId) || {};
|
||||
const editTags = Array.isArray(editTask.tags) ? editTask.tags : [];
|
||||
const minCreate = moment().add(1, "minute").format("YYYY-MM-DDTHH:mm");
|
||||
|
||||
const ret = typeof returnTo === "string" && returnTo.startsWith("/tasks")
|
||||
? returnTo
|
||||
: "/tasks?filter=mine";
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div(
|
||||
{ class: "tags-header" },
|
||||
h2(i18n.tasksTitle),
|
||||
p(i18n.tasksDescription)
|
||||
),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/tasks" },
|
||||
button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "assigned", class: currentFilter === "assigned" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAssigned),
|
||||
button({ type: "submit", name: "filter", value: "open", class: currentFilter === "open" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterOpen),
|
||||
button({ type: "submit", name: "filter", value: "in-progress", class: currentFilter === "in-progress" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterInProgress),
|
||||
button({ type: "submit", name: "filter", value: "closed", class: currentFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterClosed),
|
||||
button({ type: "submit", name: "filter", value: "priority-low", class: currentFilter === "priority-low" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterLow),
|
||||
button({ type: "submit", name: "filter", value: "priority-medium", class: currentFilter === "priority-medium" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMedium),
|
||||
button({ type: "submit", name: "filter", value: "priority-high", class: currentFilter === "priority-high" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterHigh),
|
||||
button({ type: "submit", name: "filter", value: "priority-urgent", class: currentFilter === "priority-urgent" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterUrgent),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.taskCreateButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
currentFilter === "edit" || currentFilter === "create"
|
||||
? div(
|
||||
{ class: "task-form" },
|
||||
form(
|
||||
{ action: currentFilter === "edit" ? `/tasks/update/${encodeURIComponent(taskId)}` : "/tasks/create", method: "POST", enctype: "multipart/form-data" },
|
||||
input({ type: "hidden", name: "returnTo", value: ret }),
|
||||
label(i18n.taskTitleLabel), br(),
|
||||
input({ type: "text", name: "title", required: true, value: currentFilter === "edit" ? (editTask.title || "") : "" }), br(),
|
||||
label(i18n.taskDescriptionLabel), br(),
|
||||
textarea({ name: "description", required: true, placeholder: i18n.taskDescriptionPlaceholder, rows: "4" }, currentFilter === "edit" ? (editTask.description || "") : ""), br(),
|
||||
label(i18n.uploadMedia), br(),
|
||||
input({ type: "file", name: "image", accept: "image/*" }), br(),br(),
|
||||
label(i18n.taskStartTimeLabel), br(),
|
||||
input({
|
||||
type: "datetime-local",
|
||||
name: "startTime",
|
||||
required: true,
|
||||
min: currentFilter === "create" ? minCreate : undefined,
|
||||
value: currentFilter === "edit" && editTask.startTime ? moment(editTask.startTime).format("YYYY-MM-DDTHH:mm") : ""
|
||||
}), br(), br(),
|
||||
label(i18n.taskEndTimeLabel), br(),
|
||||
input({
|
||||
type: "datetime-local",
|
||||
name: "endTime",
|
||||
required: true,
|
||||
min: currentFilter === "create" ? minCreate : undefined,
|
||||
value: currentFilter === "edit" && editTask.endTime ? moment(editTask.endTime).format("YYYY-MM-DDTHH:mm") : ""
|
||||
}), br(), br(),
|
||||
label(i18n.taskPriorityLabel), br(),
|
||||
select(
|
||||
{ name: "priority", required: true },
|
||||
opt("URGENT", String(editTask.priority || "").toUpperCase() === "URGENT", i18n.taskPriorityUrgent),
|
||||
opt("HIGH", String(editTask.priority || "").toUpperCase() === "HIGH", i18n.taskPriorityHigh),
|
||||
opt("MEDIUM", String(editTask.priority || "").toUpperCase() === "MEDIUM", i18n.taskPriorityMedium),
|
||||
opt("LOW", !editTask.priority || String(editTask.priority || "").toUpperCase() === "LOW", i18n.taskPriorityLow)
|
||||
), br(), br(),
|
||||
label(i18n.taskLocationLabel), br(),
|
||||
input({ type: "text", name: "location", value: editTask.location || "" }), br(),
|
||||
label(i18n.taskTagsLabel), br(),
|
||||
input({ type: "text", name: "tags", value: editTags.join(", ") }), br(),
|
||||
label(i18n.taskVisibilityLabel), br(),
|
||||
select(
|
||||
{ name: "isPublic", id: "isPublic" },
|
||||
opt("PUBLIC", String(editTask.isPublic || "PUBLIC").toUpperCase() === "PUBLIC", i18n.taskPublic),
|
||||
opt("PRIVATE", String(editTask.isPublic || "").toUpperCase() === "PRIVATE", i18n.taskPrivate)
|
||||
), br(), br(),
|
||||
button({ type: "submit" }, currentFilter === "edit" ? i18n.taskUpdateButton : i18n.taskCreateButton)
|
||||
)
|
||||
)
|
||||
: filtered.length > 0
|
||||
? div({ class: "jobs-grid" }, filtered.map((t) => renderTaskItem(t, currentFilter, params.spreadMap && params.spreadMap.get(t.id))))
|
||||
: p(i18n.notasks)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.singleTaskView = async (task, filter, comments = [], params = {}) => {
|
||||
const currentFilter = filter || "all";
|
||||
const assignees = safeArray(task.assignees);
|
||||
const commentCount = typeof task.commentCount === "number" ? task.commentCount : 0;
|
||||
|
||||
const isPrivateNoAccess = String(task.isPublic || "").toUpperCase() === "PRIVATE" &&
|
||||
String(task.author) !== String(userId) &&
|
||||
!assignees.includes(userId);
|
||||
|
||||
const filterBar = div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/tasks" },
|
||||
button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "assigned", class: currentFilter === "assigned" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAssigned),
|
||||
button({ type: "submit", name: "filter", value: "open", class: currentFilter === "open" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterOpen),
|
||||
button({ type: "submit", name: "filter", value: "in-progress", class: currentFilter === "in-progress" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterInProgress),
|
||||
button({ type: "submit", name: "filter", value: "closed", class: currentFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterClosed),
|
||||
button({ type: "submit", name: "filter", value: "priority-low", class: currentFilter === "priority-low" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterLow),
|
||||
button({ type: "submit", name: "filter", value: "priority-medium", class: currentFilter === "priority-medium" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMedium),
|
||||
button({ type: "submit", name: "filter", value: "priority-high", class: currentFilter === "priority-high" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterHigh),
|
||||
button({ type: "submit", name: "filter", value: "priority-urgent", class: currentFilter === "priority-urgent" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterUrgent),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.taskCreateButton)
|
||||
)
|
||||
);
|
||||
|
||||
if (isPrivateNoAccess) {
|
||||
return template(
|
||||
task.title,
|
||||
section(filterBar, p({ class: "access-denied-msg" }, i18n.contentAccessDenied))
|
||||
);
|
||||
}
|
||||
|
||||
const isAuthor = String(task.author) === String(userId);
|
||||
const isAssignedToMe = assignees.includes(userId);
|
||||
const isPrivate = String(task.isPublic || "").toUpperCase() === "PRIVATE";
|
||||
const returnToSelf = `/tasks/${encodeURIComponent(task.id)}?filter=${encodeURIComponent(currentFilter)}`;
|
||||
|
||||
const lifespanChipNode = renderLifespanChip(task.lifetime, i18n);
|
||||
const ecoTaxChipNode = renderEcoTax(task.msgSize, task.id);
|
||||
|
||||
const chips = [
|
||||
renderTaskStatusChip(task.status),
|
||||
renderPrivacyChip(isPrivate, i18n),
|
||||
renderTaskPriorityChip(task.priority),
|
||||
lifespanChipNode,
|
||||
ecoTaxChipNode
|
||||
].filter(Boolean);
|
||||
|
||||
const tagsNode = Array.isArray(task.tags) && task.tags.length
|
||||
? div({ class: "card-tags" },
|
||||
task.tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
|
||||
)
|
||||
: null;
|
||||
|
||||
const assigneesListNode = assignees.length
|
||||
? div({ class: "card-assigned-list" },
|
||||
...assignees.filter(Boolean).map((id) => userLink(id))
|
||||
)
|
||||
: null;
|
||||
|
||||
const sideActions = [];
|
||||
if (task.author && task.author !== userId) {
|
||||
sideActions.push(form({ method: "GET", action: "/pm" },
|
||||
input({ type: "hidden", name: "recipients", value: task.author }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
|
||||
));
|
||||
}
|
||||
const assignNode = renderTaskAssignAction(task, isAssignedToMe, returnToSelf);
|
||||
if (assignNode) sideActions.push(assignNode);
|
||||
if (isAuthor) sideActions.push(...renderTaskOwnerActions(task, returnToSelf));
|
||||
|
||||
const infoRows = [];
|
||||
const pushRow = (labelText, valueNode) =>
|
||||
infoRows.push(tr(
|
||||
td({ class: "tribe-info-label" }, labelText),
|
||||
td({ class: "tribe-info-value" }, valueNode)
|
||||
));
|
||||
if (task.startTime) pushRow(i18n.taskStartTimeLabel, moment(task.startTime).format("YYYY/MM/DD HH:mm"));
|
||||
if (task.endTime) pushRow(i18n.taskEndTimeLabel, moment(task.endTime).format("YYYY/MM/DD HH:mm"));
|
||||
pushRow(i18n.taskPriorityLabel, String(task.priority || "").toUpperCase() || "—");
|
||||
pushRow(i18n.taskStatus, statusLabel(task.status));
|
||||
if (task.location && String(task.location).trim()) pushRow(i18n.taskLocationLabel, task.location);
|
||||
|
||||
const taskSide = div({ class: "tribe-side" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, task.title)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(task.id, params.spreads)),
|
||||
task.image ? renderTaskMediaBlob(task.image, { class: "tribe-detail-image" }) : null,
|
||||
table({ class: "tribe-info-table jobs-info-table" }, ...infoRows),
|
||||
tagsNode,
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.taskAssignedTo}: ${assignees.length}`)
|
||||
),
|
||||
assigneesListNode,
|
||||
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null
|
||||
);
|
||||
|
||||
const returnToOpinions = `/tasks/${encodeURIComponent(task.id)}?filter=${encodeURIComponent(currentFilter)}`;
|
||||
const opinionsBar = renderOpinionsVoting('/tasks/opinions', task.id, task.opinions, returnToOpinions, task.opinions_inhabitants);
|
||||
|
||||
const taskMain = div({ class: "tribe-main" },
|
||||
task.description
|
||||
? div({ class: "job-section" },
|
||||
h2({ class: "job-section-title" }, i18n.taskDescriptionLabel),
|
||||
p({ class: "tribe-side-description" }, ...renderUrl(task.description))
|
||||
)
|
||||
: null,
|
||||
p({ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${moment(task.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
|
||||
userLink(task.author)
|
||||
),
|
||||
opinionsBar,
|
||||
renderTaskCommentsSection(task.id, comments, currentFilter)
|
||||
);
|
||||
|
||||
return template(
|
||||
task.title,
|
||||
section(
|
||||
filterBar,
|
||||
div({ class: "tribe-details" }, taskSide, taskMain)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
428
src/views/torrents_view.js
Normal file
428
src/views/torrents_view.js
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
const {
|
||||
form,
|
||||
button,
|
||||
div,
|
||||
h2,
|
||||
p,
|
||||
section,
|
||||
input,
|
||||
br,
|
||||
a,
|
||||
span,
|
||||
textarea,
|
||||
select,
|
||||
label,
|
||||
option,
|
||||
table,
|
||||
tr,
|
||||
th,
|
||||
td
|
||||
} = require("../server/node_modules/hyperaxe");
|
||||
|
||||
const { template, i18n, renderOpinionsVoting, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip } = 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 safeArr = (v) => (Array.isArray(v) ? v : []);
|
||||
const safeText = (v) => String(v || "").trim();
|
||||
|
||||
const buildReturnTo = (filter, params = {}) => {
|
||||
const f = safeText(filter || "all");
|
||||
const q = safeText(params.q || "");
|
||||
const sort = safeText(params.sort || "recent");
|
||||
const parts = [`filter=${encodeURIComponent(f)}`];
|
||||
if (q) parts.push(`q=${encodeURIComponent(q)}`);
|
||||
if (sort) parts.push(`sort=${encodeURIComponent(sort)}`);
|
||||
return `/torrents?${parts.join("&")}`;
|
||||
};
|
||||
|
||||
const renderTags = (tags) => {
|
||||
const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
|
||||
return list.length
|
||||
? div(
|
||||
{ class: "card-tags" },
|
||||
list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
|
||||
)
|
||||
: null;
|
||||
};
|
||||
|
||||
const renderTorrentFavoriteToggle = (torrentObj, returnTo = "") =>
|
||||
form(
|
||||
{
|
||||
method: "POST",
|
||||
action: torrentObj.isFavorite
|
||||
? `/torrents/favorites/remove/${encodeURIComponent(torrentObj.key)}`
|
||||
: `/torrents/favorites/add/${encodeURIComponent(torrentObj.key)}`
|
||||
},
|
||||
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
||||
button(
|
||||
{ type: "submit", class: "filter-btn" },
|
||||
torrentObj.isFavorite ? i18n.torrentRemoveFavoriteButton : i18n.torrentAddFavoriteButton
|
||||
)
|
||||
);
|
||||
|
||||
const renderTorrentOwnerActions = (filter, torrentObj, params = {}) => {
|
||||
const returnTo = buildReturnTo(filter, params);
|
||||
const isAuthor = String(torrentObj.author) === String(userId);
|
||||
const hasOpinions = Object.keys(torrentObj.opinions || {}).length > 0;
|
||||
|
||||
if (!isAuthor) return [];
|
||||
|
||||
const items = [];
|
||||
if (!hasOpinions) {
|
||||
items.push(
|
||||
form(
|
||||
{ method: "GET", action: `/torrents/edit/${encodeURIComponent(torrentObj.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "update-btn", type: "submit" }, i18n.torrentUpdateButton)
|
||||
)
|
||||
);
|
||||
}
|
||||
items.push(
|
||||
form(
|
||||
{ method: "POST", action: `/torrents/delete/${encodeURIComponent(torrentObj.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "delete-btn", type: "submit" }, i18n.torrentDeleteButton)
|
||||
)
|
||||
);
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const renderTorrentCommentsSection = (torrentId, comments = [], returnTo = null) => {
|
||||
const list = safeArr(comments);
|
||||
const commentsCount = list.length;
|
||||
|
||||
return div(
|
||||
{ class: "vote-comments-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
|
||||
span({ class: "card-value" }, String(commentsCount))
|
||||
),
|
||||
div(
|
||||
{ class: "comment-form-wrapper" },
|
||||
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
|
||||
form(
|
||||
{ method: "POST", action: `/torrents/${encodeURIComponent(torrentId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
|
||||
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
||||
textarea({
|
||||
id: "comment-text",
|
||||
name: "text",
|
||||
rows: 4,
|
||||
class: "comment-textarea",
|
||||
placeholder: i18n.voteNewCommentPlaceholder
|
||||
}),
|
||||
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
|
||||
br(),
|
||||
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
|
||||
)
|
||||
),
|
||||
list.length
|
||||
? div(
|
||||
{ class: "comments-list" },
|
||||
list.map((c) => {
|
||||
const author = c?.value?.author || "";
|
||||
const ts = c?.value?.timestamp || c?.timestamp;
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
|
||||
const relDate = ts ? moment(ts).fromNow() : "";
|
||||
const content = c?.value?.content || {};
|
||||
const rootId = content.fork || content.root || null;
|
||||
const text = content.text || "";
|
||||
|
||||
return div(
|
||||
{ class: "votations-comment-card" },
|
||||
span(
|
||||
{ class: "created-at" },
|
||||
span(i18n.createdBy),
|
||||
author ? userLink(author) : span("(unknown)"),
|
||||
absDate ? span(" | ") : "",
|
||||
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
|
||||
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
|
||||
relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : ""
|
||||
),
|
||||
p({ class: "votations-comment-text" }, ...renderUrl(text))
|
||||
);
|
||||
})
|
||||
)
|
||||
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
|
||||
);
|
||||
};
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
const n = Number(bytes) || 0;
|
||||
if (n === 0) return "—";
|
||||
if (n < 1024) return n + " B";
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
||||
return (n / (1024 * 1024)).toFixed(1) + " MB";
|
||||
};
|
||||
|
||||
const renderTorrentTable = exports.renderTorrentTable = (torrents, filter, params = {}) => {
|
||||
const returnTo = buildReturnTo(filter, params);
|
||||
|
||||
if (!torrents.length) return p(params.q ? i18n.torrentNoMatch : i18n.noTorrents);
|
||||
|
||||
return table(
|
||||
{ border: "1", class: "torrent-table" },
|
||||
tr(
|
||||
th(i18n.createdAt || "DATE"),
|
||||
th(i18n.authorLabel || "AUTHOR"),
|
||||
th(i18n.torrentTitleLabel || "TITLE"),
|
||||
th(i18n.torrentSizeLabel || "SIZE"),
|
||||
th(""),
|
||||
th("")
|
||||
),
|
||||
torrents.map((t) =>
|
||||
tr(
|
||||
td(moment(t.createdAt).format("YYYY/MM/DD HH:mm")),
|
||||
td(userLink(t.author)),
|
||||
td(t.title || ""),
|
||||
td(formatSize(t.size)),
|
||||
td(
|
||||
form(
|
||||
{ method: "GET", action: `/torrents/${encodeURIComponent(t.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
input({ type: "hidden", name: "filter", value: filter || "all" }),
|
||||
params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
|
||||
params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
|
||||
)
|
||||
),
|
||||
td(
|
||||
t.url && t.url.startsWith("&")
|
||||
? a({ href: `/blob/${encodeURIComponent(t.url)}`, class: "filter-btn" }, i18n.torrentDownloadButton || "DOWNLOAD IT!")
|
||||
: ""
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderTorrentForm = (filter, torrentId, torrentToEdit, params = {}) => {
|
||||
const returnTo = safeText(params.returnTo) || buildReturnTo("all", params);
|
||||
const tribeId = safeText(params.tribeId || "");
|
||||
return div(
|
||||
{ class: "div-center audio-form" },
|
||||
form(
|
||||
{
|
||||
action: filter === "edit" ? `/torrents/update/${encodeURIComponent(torrentId)}` : "/torrents/create",
|
||||
method: "POST",
|
||||
enctype: "multipart/form-data"
|
||||
},
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null,
|
||||
span(i18n.torrentFileLabel),
|
||||
br(),
|
||||
input({ type: "file", name: "torrent", accept: ".torrent", required: filter !== "edit" }),
|
||||
br(),
|
||||
br(),
|
||||
span(i18n.torrentTitleLabel),
|
||||
br(),
|
||||
input({ type: "text", name: "title", placeholder: i18n.torrentTitlePlaceholder, value: torrentToEdit?.title || "", required: true }),
|
||||
br(),
|
||||
span(i18n.torrentDescriptionLabel),
|
||||
br(),
|
||||
textarea({ name: "description", placeholder: i18n.torrentDescriptionPlaceholder, rows: "4" }, torrentToEdit?.description || ""),
|
||||
br(),
|
||||
span(i18n.torrentTagsLabel),
|
||||
br(),
|
||||
input({
|
||||
type: "text",
|
||||
name: "tags",
|
||||
placeholder: i18n.torrentTagsPlaceholder,
|
||||
value: safeArr(torrentToEdit?.tags).join(", ")
|
||||
}),
|
||||
br(),
|
||||
br(),
|
||||
button({ type: "submit" }, filter === "edit" ? i18n.torrentUpdateButton : i18n.torrentCreateButton)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.torrentsView = async (torrents, filter = "all", torrentId = null, params = {}) => {
|
||||
const title =
|
||||
filter === "mine"
|
||||
? i18n.torrentMineSectionTitle
|
||||
: filter === "create"
|
||||
? i18n.torrentCreateSectionTitle
|
||||
: filter === "edit"
|
||||
? i18n.torrentUpdateSectionTitle
|
||||
: filter === "recent"
|
||||
? i18n.torrentRecentSectionTitle
|
||||
: filter === "top"
|
||||
? i18n.torrentTopSectionTitle
|
||||
: filter === "favorites"
|
||||
? i18n.torrentFavoritesSectionTitle
|
||||
: i18n.torrentAllSectionTitle;
|
||||
|
||||
const q = safeText(params.q || "");
|
||||
const sort = safeText(params.sort || "recent");
|
||||
|
||||
const list = safeArr(torrents);
|
||||
const torrentToEdit = torrentId ? list.find((t) => t.key === torrentId) : null;
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(title),
|
||||
p(i18n.torrentsDescription)
|
||||
),
|
||||
(() => {
|
||||
const { renderReachChip } = require('./clearnet_view');
|
||||
const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetTorrents);
|
||||
return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n));
|
||||
})(),
|
||||
br(),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/torrents", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterRecent),
|
||||
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterTop),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
|
||||
i18n.torrentFilterFavorites
|
||||
),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.torrentCreateButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
filter === "create" || filter === "edit"
|
||||
? renderTorrentForm(filter, torrentId, torrentToEdit, { ...params, filter })
|
||||
: section(
|
||||
div(
|
||||
{ class: "audios-search" },
|
||||
form(
|
||||
{ method: "GET", action: "/torrents", class: "filter-box" },
|
||||
input({ type: "hidden", name: "filter", value: filter }),
|
||||
input({
|
||||
type: "text",
|
||||
name: "q",
|
||||
value: q,
|
||||
placeholder: i18n.torrentSearchPlaceholder,
|
||||
class: "filter-box__input"
|
||||
}),
|
||||
div(
|
||||
{ class: "filter-box__controls" },
|
||||
select(
|
||||
{ name: "sort", class: "filter-box__select" },
|
||||
option({ value: "recent", selected: sort === "recent" }, i18n.torrentSortRecent),
|
||||
option({ value: "oldest", selected: sort === "oldest" }, i18n.torrentSortOldest),
|
||||
option({ value: "top", selected: sort === "top" }, i18n.torrentSortTop)
|
||||
),
|
||||
button({ type: "submit", class: "filter-box__button" }, i18n.torrentSearchButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
div({ class: "audios-list" }, renderTorrentTable(list, filter, { q, sort }))
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], params = {}) => {
|
||||
const q = safeText(params.q || "");
|
||||
const sort = safeText(params.sort || "recent");
|
||||
const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort });
|
||||
|
||||
const title = safeText(torrentObj.title);
|
||||
const { renderReachChip } = require('./clearnet_view');
|
||||
const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetTorrents);
|
||||
|
||||
const chips = [
|
||||
renderLifespanChip(torrentObj.lifetime, i18n),
|
||||
torrentObj.sizeBytes ? renderEcoTax(torrentObj.sizeBytes, torrentObj.key) : null
|
||||
].filter(Boolean);
|
||||
|
||||
const ownerActions = renderTorrentOwnerActions(filter, torrentObj, { q, sort });
|
||||
const sideActions = [];
|
||||
sideActions.push(renderTorrentFavoriteToggle(torrentObj, returnTo));
|
||||
if (torrentObj.author && String(torrentObj.author) !== String(userId)) {
|
||||
sideActions.push(form(
|
||||
{ method: "GET", action: "/pm" },
|
||||
input({ type: "hidden", name: "recipients", value: torrentObj.author }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
|
||||
));
|
||||
}
|
||||
for (const a of ownerActions) sideActions.push(a);
|
||||
|
||||
const tagsNode = renderTags(torrentObj.tags);
|
||||
|
||||
const torrentSide = div({ class: "tribe-side" },
|
||||
div({ class: "shop-title-row" },
|
||||
title ? h2({ class: "tribe-card-title" }, title) : null,
|
||||
renderReachChip(isClearnet, i18n)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
safeText(torrentObj.description)
|
||||
? p({ class: "tribe-side-description" }, ...renderUrl(torrentObj.description))
|
||||
: null,
|
||||
tagsNode,
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(torrentObj.key, params.spreads)),
|
||||
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null
|
||||
);
|
||||
|
||||
const torrentMain = div({ class: "tribe-main" },
|
||||
torrentObj.url && torrentObj.url.startsWith("&")
|
||||
? div({ class: "torrent-download" },
|
||||
a({ href: `/blob/${encodeURIComponent(torrentObj.url)}?name=${encodeURIComponent((torrentObj.title || 'download').replace(/\.torrent$/i, '') + '.torrent')}` , class: "filter-btn" }, i18n.torrentDownloadButton || "DOWNLOAD IT!")
|
||||
)
|
||||
: p(i18n.torrentNoFile),
|
||||
(() => {
|
||||
const createdTs = torrentObj.createdAt ? new Date(torrentObj.createdAt).getTime() : NaN;
|
||||
const updatedTs = torrentObj.updatedAt ? new Date(torrentObj.updatedAt).getTime() : NaN;
|
||||
const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
|
||||
|
||||
return p(
|
||||
{ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${moment(torrentObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
|
||||
userLink(torrentObj.author),
|
||||
showUpdated
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
` | ${i18n.torrentUpdatedAt}: ${moment(torrentObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
|
||||
)
|
||||
: null
|
||||
);
|
||||
})(),
|
||||
renderOpinionsVoting('/torrents/opinions', torrentObj.key, torrentObj.opinions, returnTo, torrentObj.opinions_inhabitants),
|
||||
renderTorrentCommentsSection(torrentObj.key, comments, returnTo)
|
||||
);
|
||||
|
||||
return template(
|
||||
i18n.torrentsTitle,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.torrentAllSectionTitle || i18n.torrentsTitle),
|
||||
p(i18n.torrentDescription)
|
||||
),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/torrents", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterRecent),
|
||||
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterTop),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
|
||||
i18n.torrentFilterFavorites
|
||||
),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.torrentCreateButton)
|
||||
)
|
||||
),
|
||||
div({ class: "tribe-details" }, torrentSide, torrentMain)
|
||||
)
|
||||
);
|
||||
};
|
||||
571
src/views/transfer_view.js
Normal file
571
src/views/transfer_view.js
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
const { div, h2, p, section, button, form, a, input, br, span, label, select, option, progress, table, tr, td } = require("../server/node_modules/hyperaxe")
|
||||
const { template, i18n, renderOpinionsVoting, userLink, renderStateChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions } = require("./main_views")
|
||||
const moment = require("../server/node_modules/moment")
|
||||
const { config } = require("../server/SSB_server.js")
|
||||
|
||||
const userId = config.keys.id
|
||||
|
||||
const safeArr = (v) => (Array.isArray(v) ? v : [])
|
||||
const safeText = (v) => String(v || "").trim()
|
||||
|
||||
const parseNum = (v) => {
|
||||
const n = parseFloat(String(v ?? "").replace(",", "."))
|
||||
return Number.isFinite(n) ? n : NaN
|
||||
}
|
||||
|
||||
const fmtAmount = (v) => {
|
||||
const n = parseNum(v)
|
||||
return Number.isFinite(n) ? n.toFixed(6) : String(v ?? "")
|
||||
}
|
||||
|
||||
const categoryOf = (t) => {
|
||||
const c = String(t?.category || "ECONOMIC").toUpperCase()
|
||||
return ["ECONOMIC", "TIME", "TRUST"].includes(c) ? c : "ECONOMIC"
|
||||
}
|
||||
|
||||
const fmtAmountWithUnit = (transfer) => {
|
||||
const amt = fmtAmount(transfer.amount)
|
||||
const cat = categoryOf(transfer)
|
||||
const unit = cat === "TIME" ? (i18n.transfersUnitHours || "h")
|
||||
: cat === "TRUST" ? (i18n.transfersUnitTrust || "trust")
|
||||
: (i18n.transfersUnitEco || "ECO")
|
||||
return `${amt} ${unit}`
|
||||
}
|
||||
|
||||
const categoryLabel = (cat) => (
|
||||
cat === "TIME" ? (i18n.transfersCategoryTime || "Time") :
|
||||
cat === "TRUST" ? (i18n.transfersCategoryTrust || "Trust") :
|
||||
(i18n.transfersCategoryEconomic || "Economic")
|
||||
)
|
||||
|
||||
const buildReturnTo = (filter, params = {}) => {
|
||||
const f = safeText(filter || "all")
|
||||
const q = safeText(params.q || "")
|
||||
const minAmount = params.minAmount ?? ""
|
||||
const maxAmount = params.maxAmount ?? ""
|
||||
const sort = safeText(params.sort || "")
|
||||
const parts = [`filter=${encodeURIComponent(f)}`]
|
||||
if (q) parts.push(`q=${encodeURIComponent(q)}`)
|
||||
if (String(minAmount) !== "") parts.push(`minAmount=${encodeURIComponent(String(minAmount))}`)
|
||||
if (String(maxAmount) !== "") parts.push(`maxAmount=${encodeURIComponent(String(maxAmount))}`)
|
||||
if (sort) parts.push(`sort=${encodeURIComponent(sort)}`)
|
||||
return `/transfers?${parts.join("&")}`
|
||||
}
|
||||
|
||||
const statusKey = (s) => {
|
||||
const up = String(s || "").toUpperCase()
|
||||
const pretty = up.charAt(0) + up.slice(1).toLowerCase()
|
||||
return `transfersStatus${pretty}`
|
||||
}
|
||||
|
||||
const renderTags = (tags = []) => {
|
||||
const arr = safeArr(tags).map(t => String(t || "").trim()).filter(Boolean)
|
||||
return arr.length
|
||||
? div(
|
||||
{ class: "card-tags" },
|
||||
arr.map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
|
||||
)
|
||||
: null
|
||||
}
|
||||
|
||||
const renderCardField = (labelText, valueNode) =>
|
||||
div(
|
||||
{ class: "card-field" },
|
||||
span({ class: "card-label" }, labelText),
|
||||
span({ class: "card-value" }, valueNode)
|
||||
)
|
||||
|
||||
const formatBlockSize = (bytes) => {
|
||||
const n = Number(bytes || 0)
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(2)} KB`
|
||||
return `${(n / (1024 * 1024)).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
const renderBlockInfoCard = (block) => {
|
||||
if (!block || !block.id) return null
|
||||
return div(
|
||||
{ class: "transfer-block-card" },
|
||||
div({ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.blockchainBlockID || "Block ID"}: `),
|
||||
span({ class: "card-value" },
|
||||
a({ href: `/blockexplorer/block/${encodeURIComponent(block.id)}` }, block.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const renderConfirmationsBar = (confirmedCount, required) => {
|
||||
const req = Math.max(1, Number(required || 2))
|
||||
const cc = Math.max(0, Number(confirmedCount || 0))
|
||||
return div(
|
||||
{ class: "confirmations-block" },
|
||||
div({ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.transfersConfirmations}: `),
|
||||
span({ class: "card-value" }, `${cc}/${req}`)
|
||||
),
|
||||
progress({ class: "confirmations-progress", value: cc, max: req })
|
||||
)
|
||||
}
|
||||
|
||||
const renderOwnerActions = (transfer, returnTo) => {
|
||||
const canEdit = transfer.from === userId && String(transfer.status || "").toUpperCase() === "UNCONFIRMED"
|
||||
if (!canEdit) return []
|
||||
return [
|
||||
form(
|
||||
{ method: "GET", action: `/transfers/edit/${encodeURIComponent(transfer.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", class: "update-btn" }, i18n.transfersUpdateButton)
|
||||
),
|
||||
form(
|
||||
{ method: "POST", action: `/transfers/delete/${encodeURIComponent(transfer.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", class: "delete-btn" }, i18n.transfersDeleteButton)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
const renderUpdatedLabel = (createdAt, updatedAt) => {
|
||||
const createdTs = createdAt ? new Date(createdAt).getTime() : NaN
|
||||
const updatedTs = updatedAt ? new Date(updatedAt).getTime() : NaN
|
||||
const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs)
|
||||
return showUpdated
|
||||
? span({ class: "votations-comment-date" }, ` | ${i18n.transfersUpdatedAt}: ${moment(updatedAt).format("YYYY-MM-DD HH:mm")}`)
|
||||
: null
|
||||
}
|
||||
|
||||
|
||||
const renderTransferStatusChip = (status) => {
|
||||
const up = String(status || "").toUpperCase()
|
||||
const variant =
|
||||
up === "CLOSED" ? "mutuals" :
|
||||
up === "DISCARDED" ? "closed" :
|
||||
"whole"
|
||||
const icon = up === "CLOSED" ? "✓" : up === "DISCARDED" ? "✗" : "↻"
|
||||
return renderStateChip(variant, icon, i18n[statusKey(up)] || up)
|
||||
}
|
||||
|
||||
const renderTransferCategoryChip = (cat) =>
|
||||
renderStateChip("encrypted", "", categoryLabel(cat))
|
||||
|
||||
const generateTransferCard = (transfer, filter, params = {}) => {
|
||||
const confirmedBy = safeArr(transfer.confirmedBy)
|
||||
const required = transfer.from === transfer.to ? 1 : 2
|
||||
const confirmedCount = confirmedBy.length
|
||||
const dl = transfer.deadline ? moment(transfer.deadline) : null
|
||||
const isExpired = dl && dl.isValid() ? dl.isBefore(moment()) : false
|
||||
const tags = Array.isArray(transfer.tags) ? transfer.tags.map(t => String(t).toUpperCase()) : []
|
||||
const isUbi = tags.includes("UBI")
|
||||
const cat = categoryOf(transfer)
|
||||
|
||||
const chips = [
|
||||
renderTransferStatusChip(transfer.status),
|
||||
isUbi ? renderStateChip("mutuals", "🎁", "UBI") : null,
|
||||
isExpired ? renderStateChip("closed", "⏰", i18n.transfersExpiredBadge) : null,
|
||||
renderLifespanChip(transfer.lifetime, i18n)
|
||||
].filter(Boolean)
|
||||
|
||||
const otherParty = transfer.from === userId ? transfer.to : transfer.from
|
||||
const partyDir = transfer.from === userId ? "→" : "←"
|
||||
const returnTo = buildReturnTo(filter, params)
|
||||
|
||||
const isOwn = transfer.from && String(transfer.from) === String(userId)
|
||||
return div({ class: "trending-card transfer-card" + (isOwn ? " own-content" : "") },
|
||||
div(
|
||||
{ class: "card-header activity-card-header" },
|
||||
span(),
|
||||
renderContentActions(transfer.id, `/transfers/${encodeURIComponent(transfer.id)}`)
|
||||
),
|
||||
div({ class: "card-section transfer-card-body" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" },
|
||||
a({ href: `/transfers/${encodeURIComponent(transfer.id)}` }, transfer.concept || i18n.transfersTitle)
|
||||
)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
!isUbi && dl && dl.isValid()
|
||||
? p({ class: "card-date-highlight" }, dl.format("YYYY-MM-DD HH:mm"))
|
||||
: null,
|
||||
cat !== "TRUST"
|
||||
? div({ class: "job-price-line card-salary" }, fmtAmountWithUnit(transfer))
|
||||
: null,
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.transfersConfirmations}: ${confirmedCount}/${required}`)
|
||||
),
|
||||
div(
|
||||
{ class: "card-comments-summary feed-opinions-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "),
|
||||
span({ class: "card-value" }, String(Object.values(transfer.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)))
|
||||
),
|
||||
renderOpinionsVoting('/transfers/opinions', transfer.id, transfer.opinions, returnTo, transfer.opinions_inhabitants)
|
||||
),
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(transfer.id, params.spreadMap && params.spreadMap.get(transfer.id)))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
exports.transferView = async (transfers, filter, transferId, params = {}) => {
|
||||
const normalizedFilter = filter === "favs" ? "all" : (filter || "all")
|
||||
|
||||
const title =
|
||||
normalizedFilter === "mine" ? i18n.transfersMineSectionTitle :
|
||||
normalizedFilter === "ubi" ? i18n.transfersUBISectionTitle :
|
||||
normalizedFilter === "pending" ? i18n.transfersPendingSectionTitle :
|
||||
normalizedFilter === "top" ? i18n.transfersTopSectionTitle :
|
||||
normalizedFilter === "unconfirmed" ? i18n.transfersUnconfirmedSectionTitle :
|
||||
normalizedFilter === "closed" ? i18n.transfersClosedSectionTitle :
|
||||
normalizedFilter === "discarded" ? i18n.transfersDiscardedSectionTitle :
|
||||
normalizedFilter === "create" ? i18n.transfersCreateSectionTitle :
|
||||
normalizedFilter === "edit" ? i18n.transfersUpdateSectionTitle :
|
||||
normalizedFilter === "economic" ? (i18n.transfersEconomicSectionTitle || (i18n.transfersCategoryEconomic + " " + i18n.transfersTitle)) :
|
||||
normalizedFilter === "time" ? (i18n.transfersTimeSectionTitle || (i18n.transfersCategoryTime + " " + i18n.transfersTitle)) :
|
||||
normalizedFilter === "trust" ? (i18n.transfersTrustSectionTitle || (i18n.transfersCategoryTrust + " " + i18n.transfersTitle)) :
|
||||
i18n.transfersAllSectionTitle
|
||||
|
||||
const q = safeText(params.q || "")
|
||||
const minAmountRaw = params.minAmount ?? ""
|
||||
const maxAmountRaw = params.maxAmount ?? ""
|
||||
const minAmount = parseNum(minAmountRaw)
|
||||
const maxAmount = parseNum(maxAmountRaw)
|
||||
const sort = safeText(params.sort || "recent")
|
||||
|
||||
const list = safeArr(transfers)
|
||||
|
||||
let filtered =
|
||||
normalizedFilter === "mine" ? list.filter(t => t.from === userId || t.to === userId) :
|
||||
normalizedFilter === "ubi" ? list.filter(t => safeArr(t.tags).some(tag => String(tag).toUpperCase() === "UBI")) :
|
||||
normalizedFilter === "pending" ? list.filter(t => String(t.status || "").toUpperCase() === "UNCONFIRMED" && t.to === userId && !safeArr(t.confirmedBy).includes(userId)) :
|
||||
normalizedFilter === "top" ? list.filter(t => String(t.status || "").toUpperCase() === "CLOSED") :
|
||||
normalizedFilter === "unconfirmed" ? list.filter(t => String(t.status || "").toUpperCase() === "UNCONFIRMED") :
|
||||
normalizedFilter === "closed" ? list.filter(t => String(t.status || "").toUpperCase() === "CLOSED") :
|
||||
normalizedFilter === "discarded" ? list.filter(t => String(t.status || "").toUpperCase() === "DISCARDED") :
|
||||
normalizedFilter === "economic" ? list.filter(t => categoryOf(t) === "ECONOMIC") :
|
||||
normalizedFilter === "time" ? list.filter(t => categoryOf(t) === "TIME") :
|
||||
normalizedFilter === "trust" ? list.filter(t => categoryOf(t) === "TRUST") :
|
||||
normalizedFilter === "market" ? list :
|
||||
list
|
||||
|
||||
if (q) {
|
||||
const qq = q.toLowerCase()
|
||||
filtered = filtered.filter(t => {
|
||||
const concept = String(t.concept || "").toLowerCase()
|
||||
const tags = safeArr(t.tags).join(" ").toLowerCase()
|
||||
const from = String(t.from || "").toLowerCase()
|
||||
const to = String(t.to || "").toLowerCase()
|
||||
return concept.includes(qq) || tags.includes(qq) || from.includes(qq) || to.includes(qq)
|
||||
})
|
||||
}
|
||||
|
||||
if (Number.isFinite(minAmount)) filtered = filtered.filter(t => parseNum(t.amount) >= minAmount)
|
||||
if (Number.isFinite(maxAmount)) filtered = filtered.filter(t => parseNum(t.amount) <= maxAmount)
|
||||
|
||||
if (normalizedFilter === "top" || sort === "amount") {
|
||||
filtered = filtered.sort((a, b) => parseNum(b.amount) - parseNum(a.amount))
|
||||
} else if (sort === "deadline") {
|
||||
filtered = filtered.sort((a, b) => new Date(a.deadline || 0) - new Date(b.deadline || 0))
|
||||
} else {
|
||||
filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
}
|
||||
|
||||
const isForm = normalizedFilter === "create" || normalizedFilter === "edit"
|
||||
const transferToEdit = normalizedFilter === "edit" ? (list.find(t => t.id === transferId) || {}) : (params.prefill || {})
|
||||
const returnToForForm = buildReturnTo("all", {})
|
||||
const validCategories = ["ECONOMIC", "TIME", "TRUST"]
|
||||
const paramCategoryRaw = safeText(params.category || "").toUpperCase()
|
||||
const selectedCategory =
|
||||
transferToEdit.category && validCategories.includes(transferToEdit.category)
|
||||
? transferToEdit.category
|
||||
: (validCategories.includes(paramCategoryRaw) ? paramCategoryRaw : "ECONOMIC")
|
||||
const amountUnitLabel =
|
||||
selectedCategory === "TIME" ? (i18n.transfersUnitHours || "h") :
|
||||
selectedCategory === "TRUST" ? (i18n.transfersUnitTrust || "trust") :
|
||||
(i18n.transfersUnitEco || "ECO")
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: "tags-header" }, h2(title), p(i18n.transfersDescription)),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/transfers", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "minAmount", value: String(minAmountRaw ?? "") }),
|
||||
input({ type: "hidden", name: "maxAmount", value: String(maxAmountRaw ?? "") }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: normalizedFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: normalizedFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "ubi", class: normalizedFilter === "ubi" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterUBI),
|
||||
button({ type: "submit", name: "filter", value: "economic", class: normalizedFilter === "economic" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterEconomic || (i18n.transfersCategoryEconomic || "ECONOMIC")),
|
||||
button({ type: "submit", name: "filter", value: "time", class: normalizedFilter === "time" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTime || (i18n.transfersCategoryTime || "TIME")),
|
||||
button({ type: "submit", name: "filter", value: "trust", class: normalizedFilter === "trust" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTrust || (i18n.transfersCategoryTrust || "TRUST")),
|
||||
button({ type: "submit", name: "filter", value: "market", class: normalizedFilter === "market" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterMarket),
|
||||
button({ type: "submit", name: "filter", value: "pending", class: normalizedFilter === "pending" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterPending),
|
||||
button({ type: "submit", name: "filter", value: "unconfirmed", class: normalizedFilter === "unconfirmed" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterUnconfirmed),
|
||||
button({ type: "submit", name: "filter", value: "closed", class: normalizedFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterClosed),
|
||||
button({ type: "submit", name: "filter", value: "discarded", class: normalizedFilter === "discarded" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterDiscarded),
|
||||
button({ type: "submit", name: "filter", value: "top", class: normalizedFilter === "top" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTop),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.transfersCreateButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
isForm
|
||||
? div(
|
||||
{ class: "transfer-form" },
|
||||
normalizedFilter === "create"
|
||||
? form(
|
||||
{ method: "GET", action: "/transfers", class: "transfer-category-picker" },
|
||||
input({ type: "hidden", name: "filter", value: "create" }),
|
||||
label(i18n.transfersCategory || "Category"),
|
||||
br(),
|
||||
select(
|
||||
{ name: "category", class: "transfer-category-select" },
|
||||
option({ value: "ECONOMIC", selected: selectedCategory === "ECONOMIC" ? "selected" : undefined }, i18n.transfersCategoryEconomic || "Economic"),
|
||||
option({ value: "TIME", selected: selectedCategory === "TIME" ? "selected" : undefined }, i18n.transfersCategoryTime || "Time"),
|
||||
option({ value: "TRUST", selected: selectedCategory === "TRUST" ? "selected" : undefined }, i18n.transfersCategoryTrust || "Trust")
|
||||
),
|
||||
" ",
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.transfersCategoryApply || "Apply"),
|
||||
br(), br()
|
||||
)
|
||||
: null,
|
||||
form(
|
||||
{ action: normalizedFilter === "edit" ? `/transfers/update/${encodeURIComponent(transferId)}` : "/transfers/create", method: "POST" },
|
||||
input({ type: "hidden", name: "returnTo", value: returnToForForm }),
|
||||
normalizedFilter === "create" ? input({ type: "hidden", name: "category", value: selectedCategory }) : null,
|
||||
label(i18n.transfersToUser),
|
||||
br(),
|
||||
input({ type: "text", name: "to", required: true, pattern: "^@[A-Za-z0-9+/]+={0,2}\\.ed25519$", title: i18n.transfersToUserValidation, value: transferToEdit.to || "" }),
|
||||
br(),
|
||||
label(i18n.transfersConcept),
|
||||
br(),
|
||||
input({ type: "text", name: "concept", required: true, value: transferToEdit.concept || "" }),
|
||||
br(),
|
||||
normalizedFilter === "edit"
|
||||
? [
|
||||
label(i18n.transfersCategory || "Category"),
|
||||
br(),
|
||||
select(
|
||||
{ name: "category", required: true },
|
||||
option({ value: "ECONOMIC", selected: selectedCategory === "ECONOMIC" ? "selected" : undefined }, i18n.transfersCategoryEconomic || "Economic"),
|
||||
option({ value: "TIME", selected: selectedCategory === "TIME" ? "selected" : undefined }, i18n.transfersCategoryTime || "Time"),
|
||||
option({ value: "TRUST", selected: selectedCategory === "TRUST" ? "selected" : undefined }, i18n.transfersCategoryTrust || "Trust")
|
||||
),
|
||||
br(), br()
|
||||
]
|
||||
: null,
|
||||
selectedCategory === "TRUST"
|
||||
? input({ type: "hidden", name: "amount", value: "1" })
|
||||
: [
|
||||
label(`${i18n.transfersAmount} (${amountUnitLabel})`),
|
||||
br(),
|
||||
input({ type: "number", name: "amount", step: "0.000001", required: true, min: "0.000001", value: transferToEdit.amount || "" }),
|
||||
br(), br()
|
||||
],
|
||||
label(i18n.transfersDeadline),
|
||||
br(),
|
||||
input({ type: "datetime-local", name: "deadline", required: true, min: moment().format("YYYY-MM-DDTHH:mm"), value: transferToEdit.deadline ? moment(transferToEdit.deadline).format("YYYY-MM-DDTHH:mm") : "" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.transfersTags),
|
||||
br(),
|
||||
input({ type: "text", name: "tags", value: safeArr(transferToEdit.tags).join(", ") }),
|
||||
br(),
|
||||
br(),
|
||||
button({ type: "submit" }, normalizedFilter === "edit" ? i18n.transfersUpdateButton : i18n.transfersCreateButton)
|
||||
)
|
||||
)
|
||||
: section(
|
||||
div(
|
||||
{ class: "transfers-search" },
|
||||
form(
|
||||
{ method: "GET", action: "/transfers", class: "filter-box" },
|
||||
input({ type: "hidden", name: "filter", value: normalizedFilter || "all" }),
|
||||
input({ type: "text", name: "q", value: q, placeholder: i18n.transfersSearchPlaceholder, class: "filter-box__input" }),
|
||||
div(
|
||||
{ class: "filter-box__controls" },
|
||||
div(
|
||||
{ class: "transfer-range" },
|
||||
input({ type: "number", name: "minAmount", step: "0.000001", min: "0", value: String(minAmountRaw ?? ""), placeholder: i18n.transfersMinAmountLabel, class: "filter-box__number transfer-amount-input" }),
|
||||
input({ type: "number", name: "maxAmount", step: "0.000001", min: "0", value: String(maxAmountRaw ?? ""), placeholder: i18n.transfersMaxAmountLabel, class: "filter-box__number transfer-amount-input" })
|
||||
),
|
||||
select(
|
||||
{ name: "sort", class: "filter-box__select" },
|
||||
option({ value: "recent", selected: sort === "recent" ? "selected" : undefined }, i18n.transfersSortRecent),
|
||||
option({ value: "amount", selected: sort === "amount" ? "selected" : undefined }, i18n.transfersSortAmount),
|
||||
option({ value: "deadline", selected: sort === "deadline" ? "selected" : undefined }, i18n.transfersSortDeadline)
|
||||
),
|
||||
button({ type: "submit", class: "filter-box__button" }, i18n.transfersSearchButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
br(),
|
||||
filtered.length
|
||||
? div({ class: "jobs-grid" },
|
||||
filtered.map(t => generateTransferCard(t, normalizedFilter, { q, minAmount: minAmountRaw, maxAmount: maxAmountRaw, sort, spreadMap: params.spreadMap }))
|
||||
)
|
||||
: p(q || String(minAmountRaw) || String(maxAmountRaw) ? i18n.transfersNoMatch : i18n.transfersNoItems)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
exports.singleTransferView = async (transfer, filter, params = {}) => {
|
||||
const normalizedFilter = filter === "favs" ? "all" : (filter || "all")
|
||||
const q = safeText(params.q || "")
|
||||
const sort = safeText(params.sort || "recent")
|
||||
const returnTo = safeText(params.returnTo) || buildReturnTo(normalizedFilter, { ...params, q, sort })
|
||||
|
||||
const confirmedBy = safeArr(transfer.confirmedBy)
|
||||
const required = transfer.from === transfer.to ? 1 : 2
|
||||
const confirmedCount = confirmedBy.length
|
||||
const isUnconfirmed = String(transfer.status || "").toUpperCase() === "UNCONFIRMED"
|
||||
const dl = transfer.deadline ? moment(transfer.deadline) : null
|
||||
const isExpired = dl && dl.isValid() ? dl.isBefore(moment()) : false
|
||||
const tags = Array.isArray(transfer.tags) ? transfer.tags.map(t => String(t).toUpperCase()) : []
|
||||
const isUbi = tags.includes("UBI")
|
||||
const showConfirm = isUnconfirmed && transfer.to === userId && !confirmedBy.includes(userId) && !isExpired && !tags.includes("PENDING")
|
||||
|
||||
const tagsNode = renderTags(transfer.tags)
|
||||
const cat = categoryOf(transfer)
|
||||
const otherParty = transfer.from === userId ? transfer.to : transfer.from
|
||||
const chips = [
|
||||
renderTransferStatusChip(transfer.status),
|
||||
isUbi ? renderStateChip("mutuals", "🎁", "UBI") : null,
|
||||
isExpired ? renderStateChip("closed", "⏰", i18n.transfersExpiredBadge) : null,
|
||||
renderLifespanChip(transfer.lifetime, i18n)
|
||||
].filter(Boolean)
|
||||
|
||||
const sideActions = []
|
||||
if (otherParty && String(otherParty) !== String(userId)) {
|
||||
sideActions.push(form({ method: "GET", action: "/pm" },
|
||||
input({ type: "hidden", name: "recipients", value: otherParty }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
|
||||
))
|
||||
}
|
||||
if (showConfirm) {
|
||||
sideActions.push(form({ method: "POST", action: `/transfers/confirm/${encodeURIComponent(transfer.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.transfersConfirmButton)
|
||||
))
|
||||
}
|
||||
const canEdit = transfer.from === userId && String(transfer.status || "").toUpperCase() === "UNCONFIRMED"
|
||||
if (canEdit) {
|
||||
sideActions.push(form({ method: "GET", action: `/transfers/edit/${encodeURIComponent(transfer.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", class: "update-btn" }, i18n.transfersUpdateButton)
|
||||
))
|
||||
sideActions.push(form({ method: "POST", action: `/transfers/delete/${encodeURIComponent(transfer.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", class: "delete-btn" }, i18n.transfersDeleteButton)
|
||||
))
|
||||
}
|
||||
sideActions.push(form({ method: "GET", action: `/transfers/contract/${encodeURIComponent(transfer.id)}`, class: "transfer-contract-form" },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.transfersExportContract || 'Create Contract')
|
||||
))
|
||||
|
||||
const infoRows = []
|
||||
const pushRow = (labelText, valueNode) =>
|
||||
infoRows.push(tr(
|
||||
td({ class: "tribe-info-label" }, labelText),
|
||||
td({ class: "tribe-info-value" }, valueNode)
|
||||
))
|
||||
pushRow(i18n.transfersFrom, userLink(transfer.from))
|
||||
pushRow(i18n.transfersTo, userLink(transfer.to))
|
||||
if (cat !== "TRUST") pushRow(i18n.transfersAmount, fmtAmountWithUnit(transfer))
|
||||
pushRow(i18n.transfersCategory || "Category", categoryLabel(cat))
|
||||
if (!isUbi) pushRow(i18n.transfersDeadline, dl && dl.isValid() ? dl.format("YYYY-MM-DD HH:mm") : "")
|
||||
pushRow(i18n.transfersStatus, i18n[statusKey(transfer.status)] || String(transfer.status || ""))
|
||||
|
||||
const transferSide = div({ class: "tribe-side" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, transfer.concept || i18n.transfersTitle)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(transfer.id, params.spreads)),
|
||||
tagsNode,
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.transfersConfirmations}: ${confirmedCount}/${required}`)
|
||||
),
|
||||
params.block && params.block.id
|
||||
? div({ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.blockchainBlockID || "Block ID"}: `),
|
||||
span({ class: "card-value" }, a({ class: "user-link", href: `/blockexplorer/block/${encodeURIComponent(params.block.id)}` }, params.block.id))
|
||||
)
|
||||
: null,
|
||||
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null
|
||||
)
|
||||
|
||||
const transferMain = div({ class: "tribe-main" },
|
||||
div({ class: "job-section" },
|
||||
div({ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.transfersFrom}: `),
|
||||
span({ class: "card-value" }, userLink(transfer.from))
|
||||
),
|
||||
div({ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.transfersTo}: `),
|
||||
span({ class: "card-value" }, userLink(transfer.to))
|
||||
),
|
||||
cat !== "TRUST" ? div({ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.transfersAmount}: `),
|
||||
span({ class: "card-value card-salary" }, fmtAmountWithUnit(transfer))
|
||||
) : null,
|
||||
div({ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.transfersCategory || "Category"}: `),
|
||||
span({ class: "card-value" }, categoryLabel(cat))
|
||||
),
|
||||
div({ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.transfersConcept}: `),
|
||||
span({ class: "card-value" }, transfer.concept || "")
|
||||
),
|
||||
!isUbi && dl && dl.isValid() ? div({ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.transfersDeadline}: `),
|
||||
span({ class: "card-value" }, dl.format("YYYY-MM-DD HH:mm"))
|
||||
) : null,
|
||||
div({ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.transfersStatus}: `),
|
||||
span({ class: "card-value" }, i18n[statusKey(transfer.status)] || String(transfer.status || ""))
|
||||
)
|
||||
),
|
||||
p({ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${moment(transfer.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `),
|
||||
userLink(transfer.from),
|
||||
renderUpdatedLabel(transfer.createdAt, transfer.updatedAt)
|
||||
),
|
||||
renderOpinionsVoting('/transfers/opinions', transfer.id, transfer.opinions, returnTo, transfer.opinions_inhabitants)
|
||||
)
|
||||
|
||||
return template(
|
||||
transfer.concept,
|
||||
section(
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/transfers", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "minAmount", value: String(params.minAmount ?? "") }),
|
||||
input({ type: "hidden", name: "maxAmount", value: String(params.maxAmount ?? "") }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: normalizedFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: normalizedFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "ubi", class: normalizedFilter === "ubi" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterUBI),
|
||||
button({ type: "submit", name: "filter", value: "economic", class: normalizedFilter === "economic" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterEconomic || (i18n.transfersCategoryEconomic || "ECONOMIC")),
|
||||
button({ type: "submit", name: "filter", value: "time", class: normalizedFilter === "time" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTime || (i18n.transfersCategoryTime || "TIME")),
|
||||
button({ type: "submit", name: "filter", value: "trust", class: normalizedFilter === "trust" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTrust || (i18n.transfersCategoryTrust || "TRUST")),
|
||||
button({ type: "submit", name: "filter", value: "market", class: normalizedFilter === "market" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterMarket),
|
||||
button({ type: "submit", name: "filter", value: "pending", class: normalizedFilter === "pending" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterPending),
|
||||
button({ type: "submit", name: "filter", value: "unconfirmed", class: normalizedFilter === "unconfirmed" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterUnconfirmed),
|
||||
button({ type: "submit", name: "filter", value: "closed", class: normalizedFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterClosed),
|
||||
button({ type: "submit", name: "filter", value: "discarded", class: normalizedFilter === "discarded" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterDiscarded),
|
||||
button({ type: "submit", name: "filter", value: "top", class: normalizedFilter === "top" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTop),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.transfersCreateButton)
|
||||
)
|
||||
),
|
||||
div({ class: "tribe-details" }, transferSide, transferMain)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
300
src/views/trending_view.js
Normal file
300
src/views/trending_view.js
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
const { div, h2, p, section, button, form, a, textarea, br, input, table, tr, th, td, img, video: videoHyperaxe, audio: audioHyperaxe, span, details, summary} = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, userLink, renderSpreadButton, renderContentActions} = require('./main_views');
|
||||
const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
|
||||
const { config } = require('../server/SSB_server.js');
|
||||
const { renderUrl } = require('../backend/renderUrl');
|
||||
const opinionCategories = require('../backend/opinion_categories');
|
||||
const { sanitizeHtml } = require('../backend/sanitizeHtml');
|
||||
|
||||
const userId = config.keys.id;
|
||||
|
||||
const filterButton = (mode, currentFilter) =>
|
||||
form({ method: 'GET', action: '/trending' },
|
||||
input({ type: 'hidden', name: 'filter', value: mode }),
|
||||
button(
|
||||
{ type: 'submit', class: currentFilter === mode ? 'filter-btn active' : 'filter-btn' },
|
||||
i18n[mode + 'Button'] || mode
|
||||
)
|
||||
);
|
||||
|
||||
const voteLabelFor = (cat) =>
|
||||
i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat;
|
||||
|
||||
const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new Map()) => {
|
||||
const c = item.value.content;
|
||||
const created = new Date(item.value.timestamp).toLocaleString();
|
||||
|
||||
let contentHtml;
|
||||
|
||||
if (c.type === 'bookmark') {
|
||||
const { url, description, lastVisit } = c;
|
||||
contentHtml = div({ class: 'trending-bookmark' },
|
||||
div({ class: 'card-section bookmark' },
|
||||
url ? h2(p(a({ href: url, target: '_blank', class: "bookmark-url" }, url))) : "",
|
||||
lastVisit
|
||||
? div(
|
||||
{ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.bookmarkLastVisitLabel + ':'),
|
||||
span({ class: 'card-value' }, new Date(lastVisit).toLocaleString())
|
||||
)
|
||||
: "",
|
||||
description ? [span({ class: 'card-label' }, i18n.bookmarkDescriptionLabel + ":"), p(...renderUrl(description))] : null
|
||||
)
|
||||
);
|
||||
} else if (c.type === 'image') {
|
||||
const { url, title, description, meme } = c;
|
||||
contentHtml = div({ class: 'trending-image' },
|
||||
div({ class: 'card-section image' },
|
||||
title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageTitleLabel + ':'), span({ class: 'card-value' }, title)) : "",
|
||||
description ? [span({ class: 'card-label' }, i18n.imageDescriptionLabel + ":"), p(...renderUrl(description))] : null,
|
||||
meme ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.trendingCategory + ':'), span({ class: 'card-value' }, i18n.meme)) : "",
|
||||
div({ class: 'card-field' }, img({ src: `/blob/${encodeURIComponent(url)}`, class: 'feed-image' }))
|
||||
)
|
||||
);
|
||||
} else if (c.type === 'audio') {
|
||||
const { url, mimeType, title, description } = c;
|
||||
contentHtml = div({ class: 'trending-audio' },
|
||||
div({ class: 'card-section audio' },
|
||||
title?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.audioTitleLabel + ':'), span({ class: 'card-value' }, title)) : "",
|
||||
description ? [span({ class: 'card-label' }, i18n.audioDescriptionLabel + ":"), p(...renderUrl(description))] : null,
|
||||
url
|
||||
? div({ class: 'card-field audio-container' }, audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(url)}`, type: mimeType }))
|
||||
: div({ class: 'card-field' }, p(i18n.audioNoFile))
|
||||
)
|
||||
);
|
||||
} else if (c.type === 'video') {
|
||||
const { url, mimeType, title, description } = c;
|
||||
contentHtml = div({ class: 'trending-video' },
|
||||
div({ class: 'card-section video' },
|
||||
title?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.videoTitleLabel + ':'), span({ class: 'card-value' }, title)) : "",
|
||||
description ? [span({ class: 'card-label' }, i18n.videoDescriptionLabel + ":"), p(...renderUrl(description))] : null,
|
||||
br(),
|
||||
url
|
||||
? div({ class: 'card-field video-container' }, videoHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(url)}`, type: mimeType, preload: 'metadata', width: '640', height: '360' }))
|
||||
: div({ class: 'card-field' }, p(i18n.videoNoFile))
|
||||
)
|
||||
);
|
||||
} else if (c.type === 'torrent') {
|
||||
const { url, title, description } = c;
|
||||
contentHtml = div({ class: 'trending-torrent' },
|
||||
div({ class: 'card-section torrent' },
|
||||
title?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || 'Title') + ':'), span({ class: 'card-value' }, title)) : "",
|
||||
description ? [span({ class: 'card-label' }, (i18n.torrentDescriptionLabel || 'Description') + ":"), p(...renderUrl(description))] : null,
|
||||
url && url.startsWith("&")
|
||||
? div({ class: 'card-field' }, a({ href: `/blob/${encodeURIComponent(url)}`, class: 'filter-btn' }, i18n.torrentDownload || 'Download'))
|
||||
: div({ class: 'card-field' }, p(i18n.torrentNoFile || 'No file'))
|
||||
)
|
||||
);
|
||||
} else if (c.type === 'document') {
|
||||
const { url, title, description } = c;
|
||||
const t = title?.trim();
|
||||
if (t && seenTitles.has(t)) return null;
|
||||
if (t) seenTitles.add(t);
|
||||
|
||||
contentHtml = div({ class: 'trending-document' },
|
||||
div({ class: 'card-section document' },
|
||||
t ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.documentTitleLabel + ':'), span({ class: 'card-value' }, t)) : "",
|
||||
description ? [span({ class: 'card-label' }, i18n.documentDescriptionLabel + ":"), p(...renderUrl(description))] : null,
|
||||
div({ id: `pdf-container-${item.key}`, class: 'pdf-viewer-container', 'data-pdf-url': `/blob/${encodeURIComponent(url)}` })
|
||||
)
|
||||
);
|
||||
} else if (c.type === 'feed') {
|
||||
const { text, refeeds } = c;
|
||||
contentHtml = div({ class: 'trending-feed' },
|
||||
div({ class: 'card-section feed' },
|
||||
div({ class: 'feed-text', innerHTML: sanitizeHtml(renderTextWithStyles(text)) }),
|
||||
refeeds
|
||||
? h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ': '), span({ class: 'card-value' }, refeeds))
|
||||
: ""
|
||||
)
|
||||
);
|
||||
} else if (c.type === 'votes') {
|
||||
const { question, deadline, votes: vmap, totalVotes } = c;
|
||||
const votesList = vmap && typeof vmap === 'object'
|
||||
? Object.entries(vmap).map(([o, cnt]) => ({ option: o, count: cnt }))
|
||||
: [];
|
||||
contentHtml = div({ class: 'trending-votes' },
|
||||
div({ class: 'card-section votes' },
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.voteQuestionLabel + ':'), span({ class: 'card-value' }, question)),
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.voteDeadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : '')),
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.voteTotalVotes + ':'), span({ class: 'card-value' }, totalVotes)),
|
||||
table(
|
||||
tr(...votesList.map(v => th(i18n[v.option] || v.option))),
|
||||
tr(...votesList.map(v => td(v.count)))
|
||||
)
|
||||
)
|
||||
);
|
||||
} else if (c.type === 'transfer') {
|
||||
const { from, to, concept, amount, deadline, status, confirmedBy = [] } = c;
|
||||
contentHtml = div({ class: 'trending-transfer' },
|
||||
div({ class: 'card-section transfer' },
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.concept + ':'), span({ class: 'card-value' }, concept)),
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.deadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : '')),
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.status + ':'), span({ class: 'card-value' }, status)),
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.amount + ':'), span({ class: 'card-value' }, amount)),
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.from + ':'), span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(from)}`, target: '_blank' }, from))),
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.to + ':'), span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(to)}`, target: '_blank' }, to))),
|
||||
h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersConfirmations + ': '), span({ class: 'card-value' }, `${confirmedBy.length}/2`))
|
||||
)
|
||||
);
|
||||
} else {
|
||||
contentHtml = div({ class: 'styled-text' },
|
||||
div({ class: 'card-section styled-text-content' },
|
||||
div(
|
||||
{ class: 'card-field' },
|
||||
span({ class: 'card-value', innerHTML: sanitizeHtml(renderTextWithStyles(c.text || c.description || c.title || '[no content]')) })
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const detailPaths = {
|
||||
bookmark: 'bookmarks',
|
||||
image: 'images',
|
||||
audio: 'audios',
|
||||
video: 'videos',
|
||||
torrent: 'torrents',
|
||||
document: 'documents',
|
||||
feed: 'feed',
|
||||
votes: 'votes',
|
||||
transfer: 'transfers'
|
||||
};
|
||||
const detailHref = detailPaths[c.type]
|
||||
? `/${detailPaths[c.type]}/${encodeURIComponent(item.key)}`
|
||||
: `/thread/${encodeURIComponent(item.key)}`;
|
||||
const isOwn = item.value.author && String(item.value.author) === String(userId);
|
||||
|
||||
return div(
|
||||
{ class: 'trending-card' + (isOwn ? ' own-content' : '') },
|
||||
div(
|
||||
{ class: 'card-header activity-card-header' },
|
||||
span({ class: 'pm-exposition-chip pm-exposition-whole' },
|
||||
span({ class: 'pm-exposition-text' }, String(c.type || '').toUpperCase())
|
||||
),
|
||||
renderContentActions(item.key, detailHref)
|
||||
),
|
||||
div(
|
||||
{ class: 'card-section trending-card-body' },
|
||||
contentHtml,
|
||||
div({ class: 'card-spread-left' }, renderSpreadButton(item.key, spreadMap.get(item.key))),
|
||||
p(
|
||||
{ class: 'card-footer' },
|
||||
span({ class: 'date-link' }, `${created} ${i18n.performed} `),
|
||||
userLink(item.value.author)
|
||||
),
|
||||
(() => {
|
||||
const ops = c.opinions || {};
|
||||
const entries = Object.entries(ops).filter(([, v]) => v > 0);
|
||||
const dominantPart = (() => {
|
||||
if (!entries.length) return null;
|
||||
const maxVal = Math.max(...entries.map(([, v]) => v));
|
||||
const dominant = entries.filter(([, v]) => v === maxVal).map(([k]) => voteLabelFor(k));
|
||||
return [
|
||||
span({ class: 'trending-dominant-sep' }, '|'),
|
||||
span({ class: 'trending-dominant-text' }, `${i18n.moreVoted || 'More Voted'}: ${dominant.join(' + ')}`)
|
||||
];
|
||||
})();
|
||||
return h2(
|
||||
`${i18n.trendingTotalOpinions || i18n.trendingTotalCount}: `,
|
||||
span({ class: 'trending-total-count' }, String(votes)),
|
||||
...(dominantPart || [])
|
||||
);
|
||||
})(),
|
||||
details({ class: 'opinions-voting-collapse' },
|
||||
summary({ class: 'opinions-summary' }, `${i18n.opinionsTitle || 'Opinions'} (${Object.values(c.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)})`),
|
||||
div(
|
||||
{ class: 'voting-buttons' },
|
||||
categories.map(cat =>
|
||||
form({ method: 'POST', action: `/trending/${encodeURIComponent(item.key)}/${cat}` },
|
||||
button(
|
||||
{ class: 'vote-btn' },
|
||||
`${voteLabelFor(cat)} [${c.opinions?.[cat] || 0}]`
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.trendingView = (items, filter, categories = opinionCategories, spreadMap = new Map()) => {
|
||||
const seenDocumentTitles = new Set();
|
||||
const title = i18n.trendingTitle;
|
||||
|
||||
const baseFilters = ['TOP', 'ALL', 'MINE', 'RECENT'];
|
||||
const contentFilters = [
|
||||
['votes', 'feed', 'transfer'],
|
||||
['bookmark', 'image', 'video', 'audio', 'document', 'torrent']
|
||||
];
|
||||
|
||||
let filteredItems = items.filter(item => {
|
||||
const c = item.value?.content || item.content;
|
||||
return c && typeof c === 'object' && c.type !== 'tombstone';
|
||||
});
|
||||
|
||||
if (filter === 'MINE') {
|
||||
filteredItems = filteredItems.filter(item => item.value.author === userId);
|
||||
} else if (filter === 'RECENT') {
|
||||
const now = Date.now();
|
||||
filteredItems = filteredItems.filter(item => now - item.value.timestamp < 24 * 60 * 60 * 1000);
|
||||
} else if (filter === 'TOP') {
|
||||
filteredItems.sort((a, b) => {
|
||||
const aVotes = (a.value.content.opinions_inhabitants || []).length;
|
||||
const bVotes = (b.value.content.opinions_inhabitants || []).length;
|
||||
return bVotes !== aVotes ? bVotes - aVotes : b.value.timestamp - a.value.timestamp;
|
||||
});
|
||||
} else if (contentFilters.flat().includes(filter)) {
|
||||
filteredItems = filteredItems.filter(item => item.value.content.type === filter);
|
||||
} else if (filter !== 'ALL') {
|
||||
filteredItems = filteredItems.filter(item => (item.value.content.opinions_inhabitants || []).length > 0);
|
||||
}
|
||||
|
||||
if (filter !== 'TOP') {
|
||||
filteredItems.sort((a, b) => b.value.timestamp - a.value.timestamp);
|
||||
}
|
||||
|
||||
const header = div({ class: 'tags-header' }, h2(title), p(i18n.exploreTrending));
|
||||
const cards = filteredItems
|
||||
.map(item =>
|
||||
renderTrendingCard(
|
||||
item,
|
||||
Object.values(item.value.content.opinions || {}).reduce((s, n) => s + (n || 0), 0),
|
||||
categories,
|
||||
seenDocumentTitles,
|
||||
spreadMap
|
||||
)
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
const hasDocument = filteredItems.some(item => item.value.content.type === 'document');
|
||||
|
||||
let html = template(
|
||||
title,
|
||||
section(
|
||||
header,
|
||||
div(
|
||||
{ class: 'mode-buttons' },
|
||||
div({ class: 'column' }, baseFilters.map(mode => filterButton(mode, filter))),
|
||||
...contentFilters.map(row =>
|
||||
div({ class: 'column' }, row.map(mode => filterButton(mode, filter)))
|
||||
)
|
||||
),
|
||||
section(
|
||||
cards.length
|
||||
? div({ class: 'trending-container', style: 'display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:20px;' }, ...cards)
|
||||
: div({ class: 'no-results' }, p(i18n.trendingNoContentMessage))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
if (hasDocument) {
|
||||
html += `
|
||||
<script type="module" src="/js/pdf.min.mjs"></script>
|
||||
<script src="/js/pdf-viewer.js"></script>
|
||||
`;
|
||||
}
|
||||
|
||||
return html;
|
||||
};
|
||||
|
||||
1900
src/views/tribes_view.js
Normal file
1900
src/views/tribes_view.js
Normal file
File diff suppressed because it is too large
Load diff
484
src/views/video_view.js
Normal file
484
src/views/video_view.js
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
const {
|
||||
form,
|
||||
button,
|
||||
div,
|
||||
h2,
|
||||
p,
|
||||
section,
|
||||
input,
|
||||
br,
|
||||
a,
|
||||
video: videoHyperaxe,
|
||||
span,
|
||||
textarea,
|
||||
label,
|
||||
select,
|
||||
option
|
||||
} = require("../server/node_modules/hyperaxe");
|
||||
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { template, i18n, renderOpinionsVoting, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions } = require("./main_views");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const { renderUrl } = require("../backend/renderUrl")
|
||||
const { renderMapLocationVisitLabel } = require("./maps_view");
|
||||
|
||||
const userId = config.keys.id;
|
||||
|
||||
const safeArr = (v) => (Array.isArray(v) ? v : []);
|
||||
const safeText = (v) => String(v || "").trim();
|
||||
|
||||
const buildReturnTo = (filter, params = {}) => {
|
||||
const f = safeText(filter || "all");
|
||||
const q = safeText(params.q || "");
|
||||
const sort = safeText(params.sort || "recent");
|
||||
const parts = [`filter=${encodeURIComponent(f)}`];
|
||||
if (q) parts.push(`q=${encodeURIComponent(q)}`);
|
||||
if (sort) parts.push(`sort=${encodeURIComponent(sort)}`);
|
||||
return `/videos?${parts.join("&")}`;
|
||||
};
|
||||
|
||||
const renderPMButton = (recipient, className = "filter-btn") => {
|
||||
const r = safeText(recipient);
|
||||
if (!r) return null;
|
||||
if (String(r) === String(userId)) return null;
|
||||
|
||||
return form(
|
||||
{ method: "GET", action: "/pm" },
|
||||
input({ type: "hidden", name: "recipients", value: r }),
|
||||
button({ type: "submit", class: className }, i18n.privateMessage)
|
||||
);
|
||||
};
|
||||
|
||||
const renderTags = (tags) => {
|
||||
const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
|
||||
return list.length
|
||||
? div(
|
||||
{ class: "card-tags" },
|
||||
list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
|
||||
)
|
||||
: null;
|
||||
};
|
||||
|
||||
const renderVideoFavoriteToggle = (videoObj, returnTo = "") =>
|
||||
form(
|
||||
{
|
||||
method: "POST",
|
||||
action: videoObj.isFavorite
|
||||
? `/videos/favorites/remove/${encodeURIComponent(videoObj.key)}`
|
||||
: `/videos/favorites/add/${encodeURIComponent(videoObj.key)}`
|
||||
},
|
||||
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
||||
button(
|
||||
{ type: "submit", class: "filter-btn" },
|
||||
videoObj.isFavorite ? i18n.videoRemoveFavoriteButton : i18n.videoAddFavoriteButton
|
||||
)
|
||||
);
|
||||
|
||||
const renderVideoPlayer = (videoObj) =>
|
||||
videoObj?.url
|
||||
? div(
|
||||
{ class: "video-container", style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
|
||||
videoHyperaxe({
|
||||
controls: true,
|
||||
src: `/blob/${encodeURIComponent(videoObj.url)}`,
|
||||
preload: "metadata"
|
||||
})
|
||||
)
|
||||
: p(i18n.videoNoFile);
|
||||
|
||||
const renderVideoOwnerActions = (filter, videoObj, params = {}) => {
|
||||
const returnTo = buildReturnTo(filter, params);
|
||||
const isAuthor = String(videoObj.author) === String(userId);
|
||||
const hasOpinions = Object.keys(videoObj.opinions || {}).length > 0;
|
||||
|
||||
if (!isAuthor) return [];
|
||||
|
||||
const items = [];
|
||||
if (!hasOpinions) {
|
||||
items.push(
|
||||
form(
|
||||
{ method: "GET", action: `/videos/edit/${encodeURIComponent(videoObj.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "update-btn", type: "submit" }, i18n.videoUpdateButton)
|
||||
)
|
||||
);
|
||||
}
|
||||
items.push(
|
||||
form(
|
||||
{ method: "POST", action: `/videos/delete/${encodeURIComponent(videoObj.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "delete-btn", type: "submit" }, i18n.videoDeleteButton)
|
||||
)
|
||||
);
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const renderVideoCommentsSection = (videoId, comments = [], returnTo = null) => {
|
||||
const list = safeArr(comments).filter(c => {
|
||||
const t = c && c.value && c.value.content && c.value.content.text;
|
||||
return t && String(t).trim();
|
||||
});
|
||||
const commentsCount = list.length;
|
||||
|
||||
return div(
|
||||
{ class: "vote-comments-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
|
||||
span({ class: "card-value" }, String(commentsCount))
|
||||
),
|
||||
div(
|
||||
{ class: "comment-form-wrapper" },
|
||||
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
|
||||
form(
|
||||
{ method: "POST", action: `/videos/${encodeURIComponent(videoId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
|
||||
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
||||
textarea({
|
||||
id: "comment-text",
|
||||
name: "text",
|
||||
rows: 4,
|
||||
class: "comment-textarea",
|
||||
placeholder: i18n.voteNewCommentPlaceholder
|
||||
}),
|
||||
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
|
||||
br(),
|
||||
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
|
||||
)
|
||||
),
|
||||
list.length
|
||||
? div(
|
||||
{ class: "comments-list" },
|
||||
list.map((c) => {
|
||||
const author = c?.value?.author || "";
|
||||
const ts = c?.value?.timestamp || c?.timestamp;
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
|
||||
const relDate = ts ? moment(ts).fromNow() : "";
|
||||
const content = c?.value?.content || {};
|
||||
const rootId = content.fork || content.root || null;
|
||||
const text = content.text || "";
|
||||
|
||||
return div(
|
||||
{ class: "votations-comment-card" },
|
||||
span(
|
||||
{ class: "created-at" },
|
||||
span(i18n.createdBy),
|
||||
author ? userLink(author) : span("(unknown)"),
|
||||
absDate ? span(" | ") : "",
|
||||
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
|
||||
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
|
||||
relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : ""
|
||||
),
|
||||
p({ class: "votations-comment-text" }, ...renderUrl(text))
|
||||
);
|
||||
})
|
||||
)
|
||||
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
|
||||
);
|
||||
};
|
||||
|
||||
const renderVideoList = exports.renderVideoList = (videos, filter, params = {}) => {
|
||||
const returnTo = buildReturnTo(filter, params);
|
||||
|
||||
return videos.length
|
||||
? videos.map((videoObj) => {
|
||||
const commentCount = typeof videoObj.commentCount === "number" ? videoObj.commentCount : 0;
|
||||
const title = safeText(videoObj.title);
|
||||
const ownerActions = renderVideoOwnerActions(filter, videoObj, params);
|
||||
|
||||
const isOwn = videoObj.author && String(videoObj.author) === String(userId);
|
||||
return div(
|
||||
{ class: "trending-card video-card" + (isOwn ? " own-content" : "") },
|
||||
div(
|
||||
{ class: "card-header activity-card-header" },
|
||||
span(),
|
||||
renderContentActions(videoObj.key, `/videos/${encodeURIComponent(videoObj.key)}`)
|
||||
),
|
||||
div(
|
||||
{ class: "card-section video-card-body" },
|
||||
div(
|
||||
{ class: "bookmark-topbar" },
|
||||
div(
|
||||
{ class: "bookmark-topbar-left" },
|
||||
renderVideoFavoriteToggle(videoObj, returnTo),
|
||||
renderPMButton(videoObj.author)
|
||||
),
|
||||
ownerActions.length
|
||||
? div({ class: "bookmark-topbar-right" }, div({ class: "bookmark-actions" }, ...ownerActions))
|
||||
: null
|
||||
),
|
||||
title ? h2(title) : null,
|
||||
videoObj.lifetime ? div({ class: "card-chips-row" }, renderLifespanChip(videoObj.lifetime, i18n)) : null,
|
||||
renderVideoPlayer(videoObj),
|
||||
div(
|
||||
{ class: "card-comments-summary" },
|
||||
span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
|
||||
span({ class: "card-value" }, String(commentCount)),
|
||||
br(),
|
||||
br(),
|
||||
form(
|
||||
{ method: "GET", action: `/videos/${encodeURIComponent(videoObj.key)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
input({ type: "hidden", name: "filter", value: filter || "all" }),
|
||||
params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
|
||||
params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
|
||||
)
|
||||
),
|
||||
div(
|
||||
{ class: "card-comments-summary feed-opinions-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "),
|
||||
span({ class: "card-value" }, String(Object.values(videoObj.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)))
|
||||
),
|
||||
renderOpinionsVoting('/videos/opinions', videoObj.key, videoObj.opinions, returnTo, videoObj.opinions_inhabitants)
|
||||
),
|
||||
div({ class: "card-spread-left" }, renderSpreadButton(videoObj.key, (params.spreadMap && params.spreadMap.get(videoObj.key)) || params.spreads)),
|
||||
renderMapLocationVisitLabel(videoObj.mapUrl),
|
||||
br(),
|
||||
(() => {
|
||||
const createdTs = videoObj.createdAt ? new Date(videoObj.createdAt).getTime() : NaN;
|
||||
const updatedTs = videoObj.updatedAt ? new Date(videoObj.updatedAt).getTime() : NaN;
|
||||
const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
|
||||
|
||||
return p(
|
||||
{ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
|
||||
userLink(videoObj.author),
|
||||
showUpdated
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
` | ${i18n.videoUpdatedAt}: ${moment(videoObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
|
||||
)
|
||||
: null
|
||||
);
|
||||
})()
|
||||
)
|
||||
);
|
||||
})
|
||||
: p(params.q ? i18n.videoNoMatch : i18n.noVideos);
|
||||
};
|
||||
|
||||
const renderVideoForm = (filter, videoId, videoToEdit, params = {}) => {
|
||||
const returnTo = safeText(params.returnTo) || buildReturnTo("all", params);
|
||||
|
||||
return div(
|
||||
{ class: "div-center video-form" },
|
||||
form(
|
||||
{
|
||||
action: filter === "edit" ? `/videos/update/${encodeURIComponent(videoId)}` : "/videos/create",
|
||||
method: "POST",
|
||||
enctype: "multipart/form-data"
|
||||
},
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
span(i18n.videoFileLabel),
|
||||
br(),
|
||||
input({ type: "file", name: "video", required: filter !== "edit" }),
|
||||
br(),
|
||||
br(),
|
||||
span(i18n.videoTitleLabel),
|
||||
br(),
|
||||
input({ type: "text", name: "title", placeholder: i18n.videoTitlePlaceholder, value: videoToEdit?.title || "" }),
|
||||
br(),
|
||||
span(i18n.videoDescriptionLabel),
|
||||
br(),
|
||||
textarea({ name: "description", placeholder: i18n.videoDescriptionPlaceholder, rows: "4" }, videoToEdit?.description || ""),
|
||||
br(),
|
||||
span(i18n.mapLocationTitle || "Map Location"),
|
||||
br(),
|
||||
input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: videoToEdit?.mapUrl || "" }),
|
||||
br(),
|
||||
span(i18n.videoTagsLabel),
|
||||
br(),
|
||||
input({
|
||||
type: "text",
|
||||
name: "tags",
|
||||
placeholder: i18n.videoTagsPlaceholder,
|
||||
value: safeArr(videoToEdit?.tags).join(", ")
|
||||
}),
|
||||
br(),
|
||||
br(),
|
||||
button({ type: "submit" }, filter === "edit" ? i18n.videoUpdateButton : i18n.videoCreateButton)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.videoView = async (videos, filter = "all", videoId = null, params = {}) => {
|
||||
const title =
|
||||
filter === "mine"
|
||||
? i18n.videoMineSectionTitle
|
||||
: filter === "create"
|
||||
? i18n.videoCreateSectionTitle
|
||||
: filter === "edit"
|
||||
? i18n.videoUpdateSectionTitle
|
||||
: filter === "recent"
|
||||
? i18n.videoRecentSectionTitle
|
||||
: filter === "top"
|
||||
? i18n.videoTopSectionTitle
|
||||
: filter === "favorites"
|
||||
? i18n.videoFavoritesSectionTitle
|
||||
: i18n.videoAllSectionTitle;
|
||||
|
||||
const q = safeText(params.q || "");
|
||||
const sort = safeText(params.sort || "recent");
|
||||
|
||||
const list = safeArr(videos);
|
||||
const videoToEdit = videoId ? list.find((v) => v.key === videoId) : null;
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(title),
|
||||
p(i18n.videoDescription)
|
||||
),
|
||||
(() => {
|
||||
const { renderReachChip } = require('./clearnet_view');
|
||||
const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetVideos);
|
||||
return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n));
|
||||
})(),
|
||||
br(),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/videos", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterRecent),
|
||||
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterTop),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
|
||||
i18n.videoFilterFavorites
|
||||
),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.videoCreateButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
filter === "create" || filter === "edit"
|
||||
? renderVideoForm(filter, videoId, videoToEdit, { ...params, filter })
|
||||
: section(
|
||||
div(
|
||||
{ class: "videos-search" },
|
||||
form(
|
||||
{ method: "GET", action: "/videos", class: "filter-box" },
|
||||
input({ type: "hidden", name: "filter", value: filter }),
|
||||
input({
|
||||
type: "text",
|
||||
name: "q",
|
||||
value: q,
|
||||
placeholder: i18n.videoSearchPlaceholder,
|
||||
class: "filter-box__input"
|
||||
}),
|
||||
div(
|
||||
{ class: "filter-box__controls" },
|
||||
select(
|
||||
{ name: "sort", class: "filter-box__select" },
|
||||
option({ value: "recent", selected: sort === "recent" }, i18n.videoSortRecent),
|
||||
option({ value: "oldest", selected: sort === "oldest" }, i18n.videoSortOldest),
|
||||
option({ value: "top", selected: sort === "top" }, i18n.videoSortTop)
|
||||
),
|
||||
button({ type: "submit", class: "filter-box__button" }, i18n.videoSearchButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
div({ class: "videos-list" }, renderVideoList(list, filter, { q, sort }))
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.singleVideoView = async (videoObj, filter = "all", comments = [], params = {}) => {
|
||||
const q = safeText(params.q || "");
|
||||
const sort = safeText(params.sort || "recent");
|
||||
const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort });
|
||||
|
||||
const title = safeText(videoObj.title);
|
||||
const { renderReachChip } = require('./clearnet_view');
|
||||
const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetVideos);
|
||||
|
||||
const chips = [
|
||||
renderLifespanChip(videoObj.lifetime, i18n),
|
||||
videoObj.sizeBytes ? renderEcoTax(videoObj.sizeBytes, videoObj.key) : null
|
||||
].filter(Boolean);
|
||||
|
||||
const ownerActions = renderVideoOwnerActions(filter, videoObj, { q, sort });
|
||||
const sideActions = [];
|
||||
sideActions.push(renderVideoFavoriteToggle(videoObj, returnTo));
|
||||
if (videoObj.author && String(videoObj.author) !== String(userId)) {
|
||||
sideActions.push(renderPMButton(videoObj.author));
|
||||
}
|
||||
for (const a of ownerActions) sideActions.push(a);
|
||||
|
||||
const tagsNode = renderTags(videoObj.tags);
|
||||
|
||||
const videoSide = div({ class: "tribe-side" },
|
||||
div({ class: "shop-title-row" },
|
||||
title ? h2({ class: "tribe-card-title" }, title) : null,
|
||||
renderReachChip(isClearnet, i18n)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
safeText(videoObj.description)
|
||||
? p({ class: "tribe-side-description" }, ...renderUrl(videoObj.description))
|
||||
: null,
|
||||
tagsNode,
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(videoObj.key, params.spreads)),
|
||||
renderMapLocationVisitLabel(videoObj.mapUrl),
|
||||
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null
|
||||
);
|
||||
|
||||
const videoMain = div({ class: "tribe-main" },
|
||||
renderVideoPlayer(videoObj),
|
||||
(() => {
|
||||
const createdTs = videoObj.createdAt ? new Date(videoObj.createdAt).getTime() : NaN;
|
||||
const updatedTs = videoObj.updatedAt ? new Date(videoObj.updatedAt).getTime() : NaN;
|
||||
const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
|
||||
|
||||
return p(
|
||||
{ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
|
||||
userLink(videoObj.author),
|
||||
showUpdated
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
` | ${i18n.videoUpdatedAt}: ${moment(videoObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
|
||||
)
|
||||
: null
|
||||
);
|
||||
})(),
|
||||
renderOpinionsVoting('/videos/opinions', videoObj.key, videoObj.opinions, returnTo, videoObj.opinions_inhabitants),
|
||||
renderVideoCommentsSection(videoObj.key, comments, returnTo)
|
||||
);
|
||||
|
||||
return template(
|
||||
i18n.videoTitle,
|
||||
section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.videoAllSectionTitle || i18n.videoTitle),
|
||||
p(i18n.videoDescription)
|
||||
),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/videos", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterRecent),
|
||||
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterTop),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
|
||||
i18n.videoFilterFavorites
|
||||
),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.videoCreateButton)
|
||||
)
|
||||
),
|
||||
div({ class: "tribe-details" }, videoSide, videoMain)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
371
src/views/vote_view.js
Normal file
371
src/views/vote_view.js
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
const { div, h2, p, section, button, form, a, textarea, br, input, table, tr, th, td, label, span } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, renderOpinionsVoting, userLink, renderOpenClosedChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions } = 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 VOTE_QUORUM = 2;
|
||||
|
||||
const safeArray = (v) => Array.isArray(v) ? v : [];
|
||||
|
||||
const voteLabel = (opt) =>
|
||||
i18n["vote" + opt.split("_").map(w => w.charAt(0) + w.slice(1).toLowerCase()).join("")] || opt;
|
||||
|
||||
const computeVoteOutcome = (baseCounts, voteOptions, totalVotesNum) => {
|
||||
const noResult = { text: i18n.voteNoQuorum || "NO QUORUM", color: "#ffcc00" };
|
||||
if (totalVotesNum < VOTE_QUORUM) return noResult;
|
||||
const opts = voteOptions.filter((o) => o !== "FOLLOW_MAJORITY");
|
||||
const maxCount = opts.reduce((m, o) => Math.max(m, baseCounts[o] || 0), 0);
|
||||
const topOpts = opts.filter((o) => (baseCounts[o] || 0) === maxCount);
|
||||
if (maxCount === 0 || topOpts.length > 1) return noResult;
|
||||
const winner = topOpts[0];
|
||||
const color = winner === "YES" ? "#4caf50" : winner === "NO" ? "#e53935" : "#ffcc00";
|
||||
return { text: voteLabel(winner), color };
|
||||
};
|
||||
|
||||
const normalizeStatus = (v) => {
|
||||
const up = String(v || "").toUpperCase();
|
||||
if (up === "OPEN" || up === "CLOSED") return up;
|
||||
return up || "OPEN";
|
||||
};
|
||||
|
||||
const statusLabel = (s) => {
|
||||
const up = normalizeStatus(s);
|
||||
if (up === "OPEN") return i18n.voteStatusOpen || i18n.voteFilterOpen || "OPEN";
|
||||
if (up === "CLOSED") return i18n.voteStatusClosed || i18n.voteFilterClosed || "CLOSED";
|
||||
return up;
|
||||
};
|
||||
|
||||
const renderVoteOwnerActions = (v, returnTo, mode) => {
|
||||
const showUpdateButton = mode === "mine" && !Object.keys(v.opinions || {}).length;
|
||||
const showDeleteButton = mode === "mine";
|
||||
|
||||
const actions = [];
|
||||
if (showUpdateButton) {
|
||||
actions.push(
|
||||
form(
|
||||
{ method: "GET", action: `/votes/edit/${encodeURIComponent(v.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "update-btn", type: "submit" }, i18n.voteUpdateButton)
|
||||
)
|
||||
);
|
||||
}
|
||||
if (showDeleteButton) {
|
||||
actions.push(
|
||||
form(
|
||||
{ method: "POST", action: `/votes/delete/${encodeURIComponent(v.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "delete-btn", type: "submit" }, i18n.voteDeleteButton)
|
||||
)
|
||||
);
|
||||
}
|
||||
return actions;
|
||||
};
|
||||
|
||||
const renderVoteButtons = (v, voteOptions, firstRow, secondRow, returnTo) => {
|
||||
if (normalizeStatus(v.status) !== "OPEN") return null;
|
||||
if (v.createdBy && String(v.createdBy) === String(userId)) return null;
|
||||
|
||||
const allOptions = Array.isArray(v.options) && v.options.length ? v.options : [...firstRow, ...secondRow];
|
||||
return div(
|
||||
{ class: "vote-buttons-block" },
|
||||
div(
|
||||
{ class: "vote-buttons-row-single" },
|
||||
...allOptions.map((opt) =>
|
||||
form(
|
||||
{ method: "POST", action: `/votes/vote/${encodeURIComponent(v.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", name: "choice", value: opt }, voteLabel(opt))
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderOpinionsBar = (v, returnTo) =>
|
||||
renderOpinionsVoting('/votes/opinions', v.id, v.opinions, returnTo, v.opinions_inhabitants);
|
||||
|
||||
const renderVoteStatusChip = (status) => {
|
||||
const localized = statusLabel(status);
|
||||
return renderOpenClosedChip(status, { statusChipOPEN: localized, statusChipCLOSED: localized });
|
||||
};
|
||||
|
||||
const renderVoteListItem = (v, voteOptionsDefault, activeFilter, spreadInfo) => {
|
||||
const voteOptions = Array.isArray(v.options) && v.options.length ? v.options : voteOptionsDefault;
|
||||
const baseCounts = voteOptions.reduce((acc, opt) => {
|
||||
acc[opt] = (v.votes && v.votes[opt]) ? v.votes[opt] : 0;
|
||||
return acc;
|
||||
}, {});
|
||||
const totalVotesNum = typeof v.totalVotes === "number" ? v.totalVotes : parseInt(String(v.totalVotes || "0"), 10) || 0;
|
||||
const outcome = computeVoteOutcome(baseCounts, voteOptions, totalVotesNum);
|
||||
const chips = [
|
||||
renderVoteStatusChip(v.status),
|
||||
renderLifespanChip(v.lifetime, i18n)
|
||||
].filter(Boolean);
|
||||
const returnTo = `/votes?filter=${encodeURIComponent(activeFilter || "all")}`;
|
||||
const totalOpinions = Object.values(v.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
|
||||
const isOwn = v.createdBy && String(v.createdBy) === String(userId);
|
||||
|
||||
return div({ class: "trending-card vote-card" + (isOwn ? " own-content" : "") },
|
||||
div({ class: "card-header activity-card-header" },
|
||||
span(),
|
||||
renderContentActions(v.id, `/votes/${encodeURIComponent(v.id)}`)
|
||||
),
|
||||
div({ class: "card-section vote-card-body" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" },
|
||||
a({ href: `/votes/${encodeURIComponent(v.id)}` }, v.question || i18n.votationsTitle)
|
||||
)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
v.deadline ? p({ class: "card-date-highlight" }, moment(v.deadline).format("YYYY/MM/DD HH:mm")) : null,
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.eventAttendees}: ${totalVotesNum}`)
|
||||
),
|
||||
div({ class: "job-meta-line", style: `color:${outcome.color};font-weight:bold;` }, `${i18n.voteResults || "Results"}: ${outcome.text}`),
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(v.id, spreadInfo))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderVoteDetail = (v, voteOptionsDefault, firstRow, secondRow, mode, activeFilter, params = {}) => {
|
||||
const voteOptions = Array.isArray(v.options) && v.options.length ? v.options : voteOptionsDefault;
|
||||
const baseCounts = voteOptions.reduce((acc, opt) => {
|
||||
acc[opt] = (v.votes && v.votes[opt]) ? v.votes[opt] : 0;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const totalVotesNum = typeof v.totalVotes === "number" ? v.totalVotes : parseInt(String(v.totalVotes || "0"), 10) || 0;
|
||||
const outcome = computeVoteOutcome(baseCounts, voteOptions, totalVotesNum);
|
||||
|
||||
const returnTo = `/votes/${encodeURIComponent(v.id)}?filter=${encodeURIComponent(activeFilter || "all")}`;
|
||||
const chips = [
|
||||
renderVoteStatusChip(v.status),
|
||||
renderLifespanChip(v.lifetime, i18n),
|
||||
renderEcoTax(v.msgSize, v.id)
|
||||
].filter(Boolean);
|
||||
|
||||
const sideActions = [];
|
||||
if (v.createdBy && v.createdBy !== userId) {
|
||||
sideActions.push(form({ method: "GET", action: "/pm" },
|
||||
input({ type: "hidden", name: "recipients", value: v.createdBy }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
|
||||
));
|
||||
}
|
||||
for (const a of renderVoteOwnerActions(v, returnTo, mode || "")) sideActions.push(a);
|
||||
|
||||
const cleanTags = (Array.isArray(v.tags) ? v.tags : []).filter((t) => t && !String(t).includes(":"));
|
||||
const tagsNode = cleanTags.length
|
||||
? div({ class: "card-tags" },
|
||||
cleanTags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
|
||||
)
|
||||
: null;
|
||||
|
||||
const infoRows = [];
|
||||
const pushRow = (labelText, valueNode) =>
|
||||
infoRows.push(tr(
|
||||
td({ class: "tribe-info-label" }, labelText),
|
||||
td({ class: "tribe-info-value" }, valueNode)
|
||||
));
|
||||
if (v.deadline) pushRow(i18n.voteDeadline, moment(v.deadline).format("YYYY/MM/DD HH:mm"));
|
||||
pushRow(i18n.voteStatus, statusLabel(v.status));
|
||||
|
||||
const voteSide = div({ class: "tribe-side" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, v.question)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(v.id, params.spreads)),
|
||||
table({ class: "tribe-info-table jobs-info-table" }, ...infoRows),
|
||||
tagsNode,
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.eventAttendees}: ${totalVotesNum}`)
|
||||
),
|
||||
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null
|
||||
);
|
||||
|
||||
const voteButtonsNode = renderVoteButtons(v, voteOptions, firstRow, secondRow, returnTo);
|
||||
|
||||
const voteMain = div({ class: "tribe-main" },
|
||||
voteButtonsNode
|
||||
? div({ class: "job-section" },
|
||||
h2({ class: "job-section-title" }, i18n.voteCastTitle || "Cast Vote"),
|
||||
voteButtonsNode
|
||||
)
|
||||
: null,
|
||||
div({ class: "job-section" },
|
||||
h2({ class: "job-section-title" },
|
||||
`${i18n.voteResults || "Results"}: `,
|
||||
span({ style: `color:${outcome.color} !important;font-weight:bold;` }, outcome.text)
|
||||
),
|
||||
div({ class: "vote-table" },
|
||||
table(
|
||||
tr(...voteOptions.map((opt) => th(voteLabel(opt)))),
|
||||
tr(...voteOptions.map((opt) => td(baseCounts[opt])))
|
||||
)
|
||||
)
|
||||
),
|
||||
p({ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${moment(v.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
|
||||
userLink(v.createdBy)
|
||||
),
|
||||
renderOpinionsBar(v, returnTo)
|
||||
);
|
||||
|
||||
return div({ class: "tribe-details" }, voteSide, voteMain);
|
||||
};
|
||||
|
||||
const renderCommentsSection = (voteId, comments, activeFilter) => {
|
||||
const commentsCount = Array.isArray(comments) ? comments.length : 0;
|
||||
const returnTo = `/votes/${encodeURIComponent(voteId)}?filter=${encodeURIComponent(activeFilter || "all")}`;
|
||||
|
||||
return div(
|
||||
{ class: "vote-comments-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
|
||||
span({ class: "card-value" }, String(commentsCount))
|
||||
),
|
||||
div(
|
||||
{ class: "comment-form-wrapper" },
|
||||
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
|
||||
form(
|
||||
{ method: "POST", action: `/votes/${encodeURIComponent(voteId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
textarea({
|
||||
id: "comment-text",
|
||||
name: "text",
|
||||
rows: 4,
|
||||
class: "comment-textarea",
|
||||
placeholder: i18n.voteNewCommentPlaceholder
|
||||
}),
|
||||
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
|
||||
br(),
|
||||
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
|
||||
)
|
||||
),
|
||||
comments && comments.length
|
||||
? div(
|
||||
{ class: "comments-list" },
|
||||
comments.map((c) => {
|
||||
const author = c.value && c.value.author ? c.value.author : "";
|
||||
const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
|
||||
const relDate = ts ? moment(ts).fromNow() : "";
|
||||
|
||||
const content = c.value && c.value.content ? c.value.content : {};
|
||||
const root = content.fork || content.root || "";
|
||||
const text = content.text || "";
|
||||
|
||||
return div(
|
||||
{ class: "votations-comment-card" },
|
||||
span(
|
||||
{ class: "created-at" },
|
||||
span(i18n.createdBy),
|
||||
author ? userLink(author) : span("(unknown)"),
|
||||
absDate ? span(" | ") : "",
|
||||
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
|
||||
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
|
||||
relDate && root ? a({ href: `/thread/${encodeURIComponent(root)}#${encodeURIComponent(c.key)}` }, relDate) : ""
|
||||
),
|
||||
p({ class: "votations-comment-text" }, ...renderUrl(String(text)))
|
||||
);
|
||||
})
|
||||
)
|
||||
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
|
||||
);
|
||||
};
|
||||
|
||||
exports.voteView = async (votes, mode, voteId, comments = [], activeFilterParam, params = {}) => {
|
||||
const list = Array.isArray(votes) ? votes : [votes];
|
||||
|
||||
const standardFilters = ["all", "mine", "open", "closed"];
|
||||
const activeFilter = standardFilters.includes(activeFilterParam)
|
||||
? activeFilterParam
|
||||
: (standardFilters.includes(mode) ? mode : "all");
|
||||
|
||||
const title =
|
||||
mode === "mine" ? i18n.voteMineSectionTitle :
|
||||
mode === "create" ? i18n.voteCreateSectionTitle :
|
||||
mode === "edit" ? i18n.voteUpdateSectionTitle :
|
||||
mode === "open" ? i18n.voteOpenTitle :
|
||||
mode === "closed" ? i18n.voteClosedTitle :
|
||||
mode === "detail" ? (i18n.voteDetailSectionTitle || i18n.voteAllSectionTitle) :
|
||||
i18n.voteAllSectionTitle;
|
||||
|
||||
const voteToEdit = list.find((v) => v.id === voteId) || {};
|
||||
const editTags = Array.isArray(voteToEdit.tags) ? voteToEdit.tags.filter(Boolean) : [];
|
||||
|
||||
let filtered =
|
||||
mode === "mine" ? list.filter((v) => v.createdBy === userId) :
|
||||
mode === "open" ? list.filter((v) => normalizeStatus(v.status) === "OPEN") :
|
||||
mode === "closed" ? list.filter((v) => normalizeStatus(v.status) === "CLOSED") :
|
||||
list;
|
||||
|
||||
filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
|
||||
const voteOptions = ["ABSTENTION", "YES", "NO", "CONFUSED", "FOLLOW_MAJORITY", "NOT_INTERESTED"];
|
||||
const firstRow = ["ABSTENTION", "YES", "NO"];
|
||||
const secondRow = ["CONFUSED", "FOLLOW_MAJORITY", "NOT_INTERESTED"];
|
||||
|
||||
const header = div(
|
||||
{ class: "tags-header" },
|
||||
h2(i18n.votationsTitle),
|
||||
p(i18n.votationsDescription)
|
||||
);
|
||||
|
||||
const listReturnTo = standardFilters.includes(activeFilter) ? `/votes?filter=${encodeURIComponent(activeFilter)}` : "/votes";
|
||||
|
||||
const deadlineMin = moment().add(1, "minute").format("YYYY-MM-DDTHH:mm");
|
||||
const deadlineValue = voteToEdit.deadline ? moment(voteToEdit.deadline).format("YYYY-MM-DDTHH:mm") : "";
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
header,
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/votes" },
|
||||
button({ type: "submit", name: "filter", value: "all", class: mode === "all" ? "filter-btn active" : "filter-btn" }, i18n.voteFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: mode === "mine" ? "filter-btn active" : "filter-btn" }, i18n.voteFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "open", class: mode === "open" ? "filter-btn active" : "filter-btn" }, i18n.voteFilterOpen),
|
||||
button({ type: "submit", name: "filter", value: "closed", class: mode === "closed" ? "filter-btn active" : "filter-btn" }, i18n.voteFilterClosed),
|
||||
button({ type: "submit", name: "filter", value: "create", class: mode === "create" ? "create-button active" : "create-button" }, i18n.voteCreateButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
(mode === "edit" || mode === "create")
|
||||
? div(
|
||||
{ class: "vote-form" },
|
||||
form(
|
||||
{ action: mode === "edit" ? `/votes/update/${encodeURIComponent(voteId)}` : "/votes/create", method: "POST" },
|
||||
input({ type: "hidden", name: "returnTo", value: listReturnTo }),
|
||||
h2(i18n.voteQuestionLabel),
|
||||
input({ type: "text", name: "question", id: "question", required: true, value: voteToEdit.question || "" }), br(), br(),
|
||||
label(i18n.voteDeadlineLabel), br(),
|
||||
input({
|
||||
type: "datetime-local",
|
||||
name: "deadline",
|
||||
id: "deadline",
|
||||
required: true,
|
||||
min: mode === "create" ? deadlineMin : undefined,
|
||||
value: deadlineValue
|
||||
}), br(), br(),
|
||||
label(i18n.voteTagsLabel), br(),
|
||||
input({ type: "text", name: "tags", id: "tags", value: editTags.join(", ") }), br(), br(),
|
||||
button({ type: "submit" }, mode === "edit" ? i18n.voteUpdateButton : i18n.voteCreateButton)
|
||||
)
|
||||
)
|
||||
: mode === "detail" && voteId
|
||||
? renderVoteDetail(filtered[0] || list.find(v => v.id === voteId) || {}, voteOptions, firstRow, secondRow, mode, activeFilter, params)
|
||||
: filtered.length > 0
|
||||
? div({ class: "jobs-grid" }, filtered.map((v) => renderVoteListItem(v, voteOptions, activeFilter, params.spreadMap && params.spreadMap.get(v.id))))
|
||||
: p(i18n.novotes),
|
||||
(mode === "detail" && voteId) ? renderCommentsSection(voteId, comments, activeFilter) : null
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
168
src/views/wallet_view.js
Normal file
168
src/views/wallet_view.js
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
const { form, button, div, h2, p, section, input, span, table, thead, tbody, tr, td, th, ul, li, a, br, label, img } = require("../server/node_modules/hyperaxe");
|
||||
const QRCode = require('../server/node_modules/qrcode');
|
||||
const { template, i18n } = require('./main_views');
|
||||
|
||||
const walletViewRender = (balance, address, ...elements) => {
|
||||
const header = div({ class: 'tags-header' }, h2(i18n.walletTitle), p(i18n.walletDescription));
|
||||
return template(
|
||||
i18n.walletTitle,
|
||||
section(
|
||||
header,
|
||||
div({ class: "wallet-section" },
|
||||
h2(i18n.walletAddress),
|
||||
div({ class: "wallet-address" }, h2({ class: "element" }, address || "-")),
|
||||
h2(i18n.walletBalanceTitle),
|
||||
div({ class: "div-center" }, h2(i18n.walletBalanceLine({ balance: Number(balance || 0).toFixed(6) }))),
|
||||
div({ class: "div-center" },
|
||||
span({ class: "wallet-form-button-group-center" },
|
||||
form({ action: "/wallet/send", method: "get" }, button({ type: 'submit' }, i18n.walletSend)),
|
||||
form({ action: "/wallet/receive", method: "get" }, button({ type: 'submit' }, i18n.walletReceive)),
|
||||
form({ action: "/wallet/history", method: "get" }, button({ type: 'submit' }, i18n.walletHistory))
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
elements.length > 0 ? section(...elements) : null
|
||||
);
|
||||
};
|
||||
|
||||
exports.walletView = async (balance, address) => {
|
||||
return walletViewRender(balance, address);
|
||||
};
|
||||
|
||||
exports.walletHistoryView = async (balance, transactions, address) => {
|
||||
const rows = Array.isArray(transactions) ? transactions : [];
|
||||
return walletViewRender(
|
||||
balance,
|
||||
address,
|
||||
h2(i18n.walletHistoryTitle),
|
||||
table(
|
||||
{ class: "wallet-history" },
|
||||
thead(
|
||||
tr(
|
||||
{ class: "full-center" },
|
||||
th({ class: "col-10" }, i18n.walletCnfrs),
|
||||
th(i18n.walletDate),
|
||||
th(i18n.walletType),
|
||||
th(i18n.walletAmount),
|
||||
th({ class: "col-30" }, i18n.walletTxId)
|
||||
)
|
||||
),
|
||||
tbody(
|
||||
...rows.map(tx => {
|
||||
const date = new Date((tx.time || tx.timereceived || 0) * 1000);
|
||||
const amount = Number(tx.amount) || 0;
|
||||
const fee = Number(tx.fee) || 0;
|
||||
const totalAmount = amount + fee;
|
||||
return tr(
|
||||
td({ class: "full-center" }, String(tx.confirmations || 0)),
|
||||
td(date.toLocaleDateString(), br(), date.toLocaleTimeString()),
|
||||
td(tx.category || "-"),
|
||||
td(totalAmount.toFixed(6)),
|
||||
td({ width: "30%", class: "tcell-ellipsis" },
|
||||
a({ href: `https://ecoin.03c8.net/blockexplorer/search?q=${encodeURIComponent(tx.txid || '')}`, target: "_blank" }, tx.txid || "-")
|
||||
)
|
||||
);
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.walletReceiveView = async (balance, address) => {
|
||||
const qrDataUrl = await QRCode.toDataURL(address || '', { type: 'image/png', width: 320, margin: 1 });
|
||||
return walletViewRender(
|
||||
balance,
|
||||
address,
|
||||
h2(i18n.walletReceiveTitle),
|
||||
div({ class: 'div-center qr-code' }, img({ src: qrDataUrl, alt: 'QR', class: 'wallet-qr-img' }))
|
||||
);
|
||||
};
|
||||
|
||||
exports.walletSendFormView = async (balance, destination, amount, fee, statusMessages, address) => {
|
||||
const type = statusMessages?.type || 'info';
|
||||
const titleKey = statusMessages?.title || '';
|
||||
const messages = statusMessages?.messages || [];
|
||||
const statusBlock = messages.length > 0
|
||||
? div(
|
||||
{ class: `wallet-status-${type}` },
|
||||
span(i18n.walletStatusMessages[titleKey] || titleKey || ''),
|
||||
ul(...messages.map(error => li(i18n.walletStatusMessages[error] || error)))
|
||||
)
|
||||
: null;
|
||||
|
||||
return walletViewRender(
|
||||
balance,
|
||||
address,
|
||||
h2(i18n.walletWalletSendTitle),
|
||||
div(
|
||||
{ class: "div-center" },
|
||||
statusBlock,
|
||||
form(
|
||||
{ action: '/wallet/send', method: 'POST' },
|
||||
label({ for: 'destination' }, i18n.walletAddress), br(),
|
||||
input({ type: 'text', id: 'destination', name: 'destination', placeholder: 'ETQ17sBv8QFoiCPGKDQzNcDJeXmB2317HX', value: destination || '' }), br(),
|
||||
label({ for: 'amount' }, i18n.walletAmount), br(),
|
||||
input({ type: 'text', id: 'amount', name: 'amount', placeholder: '0.25', value: amount || '' }), br(),
|
||||
label({ for: 'fee' }, i18n.walletFee), br(),
|
||||
input({ type: 'text', id: 'fee', name: 'fee', placeholder: '0.01', value: fee || '' }), br(),
|
||||
input({ type: 'hidden', name: 'action', value: 'confirm' }),
|
||||
div({ class: 'wallet-form-button-group-center' },
|
||||
button({ type: 'submit' }, i18n.walletSend),
|
||||
button({ type: 'reset' }, i18n.walletReset)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.walletSendConfirmView = async (balance, destination, amount, fee) => {
|
||||
const a = Number(amount || 0);
|
||||
const f = Number(fee || 0);
|
||||
const totalCost = a + f;
|
||||
return walletViewRender(
|
||||
balance,
|
||||
destination,
|
||||
p(
|
||||
i18n.walletAddressLine({ address: destination || '-' }), br(),
|
||||
i18n.walletAmountLine({ amount: a.toFixed(6) }), br(),
|
||||
i18n.walletFeeLine({ fee: f.toFixed(6) }), br(),
|
||||
i18n.walletTotalCostLine({ totalCost: totalCost.toFixed(6) })
|
||||
),
|
||||
form(
|
||||
{ action: '/wallet/send', method: 'POST' },
|
||||
input({ type: 'hidden', name: 'action', value: 'send' }),
|
||||
input({ type: 'hidden', name: 'destination', value: destination || '' }),
|
||||
input({ type: 'hidden', name: 'amount', value: String(a) }),
|
||||
input({ type: 'hidden', name: 'fee', value: String(f) }),
|
||||
div({ class: 'form-button-group-center' },
|
||||
button({ type: 'submit' }, i18n.walletConfirm),
|
||||
a({ href: `/wallet/send`, class: "button-like-link" }, i18n.walletBack)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.walletSendResultView = async (balance, destination, amount, txId) => {
|
||||
return walletViewRender(
|
||||
balance,
|
||||
destination,
|
||||
p(
|
||||
i18n.walletSentToLine({ destination: destination || '-', amount: Number(amount || 0).toFixed(6) }), br(),
|
||||
`${i18n.walletTransactionId}: `,
|
||||
a({ href: `https://ecoin.03c8.net/blockexplorer/search?q=${encodeURIComponent(txId || '')}`, target: "_blank" }, txId || '-')
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.walletErrorView = async (error) => {
|
||||
const header = div({ class: 'tags-header' }, h2(i18n.walletTitle), p(i18n.walletDescription));
|
||||
return template(
|
||||
i18n.walletTitle,
|
||||
section(
|
||||
header,
|
||||
div({ class: "wallet-error" }, h2(i18n.walletStatus), p(i18n.walletDisconnected))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue