Oasis 0.8.7 mobile (nodejs-project baseline)

This commit is contained in:
s1to 2026-07-09 09:56:30 +02:00
commit e563b86ede
226 changed files with 141968 additions and 0 deletions

147
src/views/AI_view.js Normal file
View 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

File diff suppressed because it is too large Load diff

260
src/views/agenda_view.js Normal file
View 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
View 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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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 };

View 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
View 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
View 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
View 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
View 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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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

File diff suppressed because it is too large Load diff

257
src/views/cv_view.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const escText = (s) => String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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
View 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)
)
);
};

View 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>');
};

View 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 = /&lt;img[^&]*src=&quot;([^&]*)&quot;[^&]*&gt;/gi;
clean = clean.replace(encImgRe, (_, src) => { imgs.push(src.replace(/&amp;/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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

541
src/views/maps_view.js Normal file
View 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}&amp;${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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const withLinks = escaped.replace(/https?:\/\/[^\s&"<>]+/g, (url) => {
const clean = url.replace(/&amp;/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">&#x2715;</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
View 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
View 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
View 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
View 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
View 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
View 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))
)
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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(/&amp;/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(/&amp;/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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

484
src/views/video_view.js Normal file
View 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
View 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
View 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))
)
);
};