Upgrade mobile UX to Oasis 0.9.0

Rebase onto upstream 0.9.0. Mobile topbar (logo->home, avatar->profile),
hexagon category nav (hive) and bottom bar; remove the six top-bar
buttons (inbox/pm/publish/search/graphos/peers) that cluttered the header.
Theme OasisMobileUX carries topbar/hexagon/bottombar styles.
This commit is contained in:
s1to 2026-08-01 13:41:09 +02:00
parent de36b38851
commit 8d21216362
69 changed files with 8974 additions and 820 deletions

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, a, input, img, textarea, br, span, video: videoHyperaxe, audio: audioHyperaxe, table, tr, td, th, details, summary, ul, li, label } = require("../server/node_modules/hyperaxe");
const { template, i18n, userLink, userLinkLabel, renderSpreadButton, renderContentActions, emptyState, renderHiveNav } = require('./main_views');
const { div, h2, p, section, button, form, a, input, img, textarea, br, span, video: videoHyperaxe, audio: audioHyperaxe, table, tr, td, th, details, summary } = require("../server/node_modules/hyperaxe");
const { template, i18n, userLink, userLinkLabel, renderSpreadButton, renderContentActions, renderHiveNav } = require('./main_views');
const opinionCategories = require('../backend/opinion_categories');
const OPINION_TYPES = new Set(['bookmark','votes','feed','image','audio','video','document','torrent','transfer']);
@ -285,7 +285,7 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map()) {
: validActions;
if (!deduped.length) {
return emptyState({ icon: "📭", title: i18n.noActions, text: i18n.emptyConnectHint, ctaHref: "/invites", ctaLabel: i18n.emptyConnectCta });
return div({ class: "no-actions" }, p(i18n.noActions));
}
const seenDocumentTitles = new Set();
@ -923,6 +923,52 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map()) {
);
}
if (type === 'chatThread') {
const c = action.content || {};
const chatRoot = c.chatRoot;
const href = `/chats/${encodeURIComponent(chatRoot)}`;
const chatTitle = c.title || chatRoot;
const chatDesc = c.description || '';
const replies = Array.isArray(c.replies) ? c.replies : [];
const repliesAsc = replies.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0));
const limit = 6;
const show = repliesAsc.slice(Math.max(0, repliesAsc.length - limit));
return div({ class: 'trending-card post-thread chat-thread' + (String(action.author) === String(userId) ? ' 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(i18n.typeChat || 'CHAT').toUpperCase()} · THREAD`)
)
),
renderContentActions(chatRoot, href)
),
div({ class: 'card-body' },
div({ class: 'card-section chat' },
div({ class: 'card-field' }, span({ class: 'card-value' }, a({ href, class: 'user-link' }, chatTitle))),
chatDesc ? div({ class: 'card-field' }, span({ class: 'card-value' }, chatDesc)) : ''
),
div({ class: 'card-section' },
show.map(r => {
const rDate = r.ts ? new Date(r.ts).toLocaleString() : '';
return div({ class: 'thread-reply-item' },
div({ class: 'thread-reply' },
r.text ? p({ class: 'post-text', style: 'white-space:pre-wrap;' }, ...renderUrlPreserveNewlines(r.text)) : ''
),
div({ class: 'card-footer thread-reply-footer' },
span({ class: 'date-link' }, rDate),
userLink(r.author, action.authorNames && action.authorNames[r.author])
)
);
})
)
),
p({ class: 'card-footer' },
span({ class: 'date-link' }, `${action.ts ? new Date(action.ts).toLocaleString() : ''} ${i18n.performed} `),
userLink(action.author, action.authorNames && action.authorNames[action.author])
)
);
}
if (type === 'forum') {
const { root, category, title, text, key, rootTitle, rootKey } = content;
if (!root) {
@ -1143,6 +1189,100 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map()) {
);
}
if (type === 'industry') {
const { name, sector, description, image, tags, membershipPolicy, status } = content;
const facKey = action.id || action.key || '';
const validTags = Array.isArray(tags) ? tags : [];
const statusValue = (() => {
const u = String(status || 'ACTIVE').toUpperCase();
if (u === 'PAUSED') return i18n.industryStatusPaused || 'PAUSED';
if (u === 'DISSOLVED') return i18n.industryStatusDissolved || 'DISSOLVED';
return i18n.industryStatusActive || 'WORKING';
})();
const policyValue = String(i18n[`industryPolicy_${membershipPolicy}`] || membershipPolicy || '').toUpperCase();
cardBody.push(
div({ class: 'card-section tribe industry' },
h2({ class: 'tribe-title' },
a({ href: `/industry/${encodeURIComponent(facKey)}`, class: "user-link" }, name || (i18n.industryTitle || 'Industry'))
),
div({ style: 'display:flex; gap:.6em; flex-wrap:wrap;' },
sector ? div({ class: 'card-field' }, span({ class: 'card-label' }, String(i18n.industrySector || 'Sector').toUpperCase() + ':'), span({ class: 'card-value' }, String(sector).toUpperCase())) : "",
status ? div({ class: 'card-field' }, span({ class: 'card-label' }, String(i18n.industryStatusLabel || 'Status').toUpperCase() + ':'), span({ class: 'card-value' }, statusValue)) : "",
membershipPolicy ? div({ class: 'card-field' }, span({ class: 'card-label' }, String(i18n.industryMembershipPolicy || 'Membership policy').toUpperCase() + ':'), span({ class: 'card-value' }, policyValue)) : ""
),
image
? (/^(\/|https?:)/.test(String(image))
? img({ src: image, class: 'feed-image tribe-image' })
: renderMediaBlob(image, null))
: null,
p({ class: 'tribe-description' }, ...renderUrl(description || '')),
validTags.length
? div({ class: 'card-tags' }, validTags.map(tag =>
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)))
: ""
)
);
}
if (type === 'industryBuild') {
const { title, notes, image, startDate, endDate, buildStatus } = content;
const buildKey = action.id || action.key || '';
const daysLeft = endDate ? Math.max(0, Math.ceil((new Date(endDate) - Date.now()) / 86400000)) : null;
cardBody.push(
div({ class: 'card-section tribe industry' },
h2({ class: 'tribe-title' },
a({ href: `/industry/build/${encodeURIComponent(buildKey)}`, class: "user-link" }, title || (i18n.industryBuild || 'Build'))
),
(buildStatus || startDate || endDate) ? div({ style: 'display:flex; gap:.6em; flex-wrap:wrap;' },
buildStatus ? div({ class: 'card-field' }, span({ class: 'card-label' }, String(i18n.industryStatusLabel || 'Status').toUpperCase() + ':'), span({ class: 'card-value' }, String(i18n['industryBuildStatus_' + buildStatus] || buildStatus).toUpperCase())) : "",
startDate ? div({ class: 'card-field' }, span({ class: 'card-label' }, String(i18n.industryBuildStart || 'Start date').toUpperCase() + ':'), span({ class: 'card-value' }, moment(startDate).format('YYYY-MM-DD'))) : "",
endDate ? div({ class: 'card-field' }, span({ class: 'card-label' }, String(i18n.industryBuildEnd || 'End date').toUpperCase() + ':'), span({ class: 'card-value' }, moment(endDate).format('YYYY-MM-DD'))) : "",
daysLeft != null ? div({ class: 'card-field' }, span({ class: 'card-label' }, String(i18n.industryTimeLeft || 'Time left').toUpperCase() + ':'), span({ class: 'card-value' }, `${daysLeft}d`)) : ""
) : null,
image
? (/^(\/|https?:)/.test(String(image))
? img({ src: image, class: 'feed-image tribe-image' })
: renderMediaBlob(image, null))
: null,
notes ? p({ class: 'tribe-description' }, ...renderUrl(notes)) : ""
)
);
}
if (type === 'industryBlueprint') {
const { name, facility, description, outKind, laborHours, image, estTotal } = content;
cardBody.push(
div({ class: 'card-section tribe industry' },
h2({ class: 'tribe-title' },
facility
? a({ href: `/industry/${encodeURIComponent(facility)}`, class: "user-link" }, name || (i18n.industryBlueprint || 'Blueprint'))
: (name || (i18n.industryBlueprint || 'Blueprint'))
),
div({ style: 'display:flex; gap:.6em; flex-wrap:wrap;' },
outKind ? div({ class: 'card-field' }, span({ class: 'card-label' }, String(i18n.industryOutputKind || 'Product type').toUpperCase() + ':'), span({ class: 'card-value' }, String(i18n['industryKind_' + outKind] || outKind).toUpperCase())) : "",
laborHours ? div({ class: 'card-field' }, span({ class: 'card-label' }, String(i18n.industryLaborHours || 'Labor hours').toUpperCase() + ':'), span({ class: 'card-value' }, String(laborHours))) : "",
estTotal != null ? div({ class: 'card-field' }, span({ class: 'card-label' }, String(i18n.industryEstTotal || 'Estimated price').toUpperCase() + ':'), span({ class: 'card-value' }, `${Number(estTotal).toFixed(2)} ECO`)) : ""
),
image
? (/^(\/|https?:)/.test(String(image))
? img({ src: image, class: 'feed-image tribe-image' })
: renderMediaBlob(image, null))
: null,
description ? p({ class: 'tribe-description' }, ...renderUrl(description)) : ""
)
);
}
if (type === 'industryAllocation') {
const { target, pot } = content;
cardBody.push(
div({ class: 'card-section industry' },
div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.industryDistribution || 'Distribution') + ':'), span({ class: 'card-value' }, target ? a({ href: `/industry/build/${encodeURIComponent(target)}`, class: 'user-link' }, i18n.industryBuild || 'Build') : '')),
div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.industryPot || 'Pot') + ':'), span({ class: 'card-value' }, `${Number(pot || 0).toFixed(6)} ECO`))
)
);
}
if (type === 'chat') {
const { title, description, image, category, status } = content;
const chatKey = action.id || action.key || '';
@ -1577,7 +1717,7 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map()) {
const SPREADABLE = new Set([
'post','audio','video','image','document','torrent','bookmark',
'event','calendar','task','votes','vote','market','shop','shopProduct',
'project','transfer','job','report',
'project','transfer','job','report','industry','industryBuild','industryBlueprint',
'chat','chatMessage','pad','padEntry','forum','map'
]);
if (!SPREADABLE.has(type)) return null;
@ -1596,7 +1736,7 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map()) {
const filteredCards = cards.filter(Boolean);
if (!filteredCards.length) {
return emptyState({ icon: "📭", title: i18n.noActions, text: i18n.emptyConnectHint, ctaHref: "/invites", ctaLabel: i18n.emptyConnectCta });
return div({ class: "no-actions" }, p(i18n.noActions));
}
return filteredCards;
}
@ -1653,6 +1793,10 @@ function getViewDetailsAction(type, action) {
case 'calendar': return `/calendars/${id}`;
case 'job': return `/jobs/${id}`;
case 'project': return `/projects/${id}`;
case 'industry': return `/industry/${id}`;
case 'industryBuild': return `/industry/build/${id}`;
case 'industryBlueprint': return `/industry/blueprint/${id}`;
case 'industryAllocation': return action.content?.target ? `/industry/build/${encodeURIComponent(action.content.target)}` : '/industry?filter=BUILDS';
case 'report': return `/reports/${id}`;
case 'bankWallet': return `/wallet`;
case 'bankClaim': return `/banking${action.content?.epochId ? `/epoch/${encodeURIComponent(action.content.epochId)}` : ''}`;
@ -1689,6 +1833,7 @@ exports.activityView = (actions, filter, userId, q = '', extras = {}) => {
{ type: 'banking', label: i18n.typeBanking },
{ type: 'market', label: i18n.typeMarket },
{ type: 'project', label: i18n.typeProject },
{ type: 'industry', label: i18n.typeIndustry },
{ type: 'job', label: i18n.typeJob },
{ type: 'shop', label: i18n.typeShop },
{ type: 'transfer', label: i18n.typeTransfer },
@ -1709,7 +1854,9 @@ exports.activityView = (actions, filter, userId, q = '', extras = {}) => {
task: ['task', 'taskAssignment'],
shop: ['shop', 'shopProduct'],
larp: ['larpHousePost'],
inhabitants:['about']
inhabitants:['about'],
chat: ['chat', 'chatThread'],
industry: ['industry', 'industryBuild', 'industryBlueprint', 'industryAllocation']
};
const ALLOWED_TYPES = new Set();
for (const { type } of activityTypes) {
@ -1744,6 +1891,10 @@ exports.activityView = (actions, filter, userId, q = '', extras = {}) => {
filteredActions = actions.filter(action => action.type !== 'tombstone' && (action.type === 'task' || action.type === 'taskAssignment'));
} else if (filter === 'torrent') {
filteredActions = actions.filter(action => action.type === 'torrent');
} else if (filter === 'chat') {
filteredActions = actions.filter(action => (action.type === 'chat' || action.type === 'chatThread') && action.type !== 'tombstone');
} else if (filter === 'industry') {
filteredActions = actions.filter(action => ['industry', 'industryBuild', 'industryBlueprint', 'industryAllocation'].includes(action.type) && action.type !== 'tombstone');
} else {
filteredActions = actions.filter(action => (action.type === filter || filter === 'all' || (filter === 'shop' && action.type === 'shopProduct')) && action.type !== 'tombstone');
}
@ -1825,6 +1976,7 @@ exports.activityView = (actions, filter, userId, q = '', extras = {}) => {
shop: { url: '/shops', filters: ['all', 'mine', 'recent'] },
job: { url: '/jobs', filters: ['ALL', 'MINE', 'REMOTE', 'PRESENCIAL', 'OPEN', 'CLOSED'] },
project: { url: '/projects', filters: ['all', 'mine', 'active', 'completed'] },
industry:{ url: '/industry', filters: ['ALL', 'MINE', 'ACTIVE', 'PAUSED', 'DISSOLVED', 'BLUEPRINTS', 'BUILDS', 'MEMBER', 'RULES'] },
chat: { url: '/chats', filters: ['all', 'mine'] },
pad: { url: '/pads', filters: ['all', 'mine'] },
calendar:{ url: '/calendars', filters: ['all', 'mine'] },
@ -1842,28 +1994,25 @@ exports.activityView = (actions, filter, userId, q = '', extras = {}) => {
p(desc)
),
renderHiveNav(),
(() => {
const labelOf = {};
activityTypes.forEach(t => { labelOf[t.type] = t.label; });
const mkBtn = (type) => form({ method: 'GET', action: '/activity' },
input({ type: 'hidden', name: 'filter', value: type }),
button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, labelOf[type] || type)
);
const QUICK = ['recent', 'all', 'mine'];
const isTypeFilter = filter && !QUICK.includes(filter) && labelOf[filter];
if (isTypeFilter) {
return div({ class: 'activity-filter-collapsed' },
a({ class: 'activity-back filter-btn', href: '/activity' },
span({ class: 'activity-back-arrow' }, '‹'),
span(i18n.activityChangeFilter || 'Change filter')
),
span({ class: 'activity-active-filter' }, labelOf[filter] || filter)
);
}
return div({ class: 'activity-filter-acc' },
div({ class: 'activity-quick-modes' }, QUICK.map(mkBtn))
);
})(),
div({ class: 'activity-filter-grid' },
...[
activityTypes.slice(0, 3),
activityTypes.slice(3, 8),
activityTypes.slice(8, 13),
activityTypes.slice(13, 19),
activityTypes.slice(19, 26),
activityTypes.slice(26)
].map(col =>
div({ class: 'activity-filter-col' },
col.map(({ type, label }) =>
form({ method: 'GET', action: '/activity' },
input({ type: 'hidden', name: 'filter', value: type }),
button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
)
)
)
)
),
sub
? div({ class: 'activity-sub-filter' },
sub.filters.map(f => a({ href: `${sub.url}?filter=${encodeURIComponent(f)}`, class: 'filter-btn' }, String(f).toUpperCase()))

View file

@ -24,6 +24,7 @@ function getViewDetailsAction(item) {
case 'report': return `/reports/${encodeURIComponent(item.id)}`;
case 'job': return `/jobs/${encodeURIComponent(item.id)}`;
case 'project': return `/projects/${encodeURIComponent(item.id)}`;
case 'industry': return `/industry/build/${encodeURIComponent(item.id)}`;
case 'calendar': return `/calendars/${encodeURIComponent(item.id)}`;
default: return `/messages/${encodeURIComponent(item.id)}`;
}
@ -185,6 +186,14 @@ const renderAgendaItem = (item, userId, filter) => {
}
}
if (item.type === 'industry') {
const st = String(item.status || 'PROPOSED').toUpperCase();
details = [
renderCardField((i18n.industryFacility || 'Facility') + ":", item.facilityName || ''),
renderCardField((i18n.industryStatusLabel || 'Status') + ":", i18n['industryBuildStatus_' + st] || st)
];
}
const isOwn = author && String(author) === String(userId);
return div({ class: 'trending-card agenda-card' + (isOwn ? ' own-content' : '') },
div({ class: 'card-header activity-card-header' },
@ -241,6 +250,8 @@ exports.agendaView = async (data, filter) => {
`${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: 'industry', class: filter === 'industry' ? 'filter-btn active' : 'filter-btn' },
`${i18n.agendaFilterIndustry || 'INDUSTRY'} (${counts.industry || 0})`),
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' },

View file

@ -314,14 +314,17 @@ const renderOverviewSummaryTable = (s, rules, userEcoinTax) => {
table({ class: "bank-info-table" },
tbody(
kvRow(i18n.bankUserBalance, `${Number(s.userBalance || 0).toFixed(6)} ECO`),
kvRow(i18n.bankIndustryBalance || 'Industry Production (estimated)', a({ href: '/industry' }, `${Number(s.industryNetworkTotal || 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.bankYourUbiMonth || 'Your UBI (this month)', `${future.toFixed(6)} ECO`),
kvRow(i18n.bankYourIndustryBalance || 'Your Industry Share', a({ href: '/industry?filter=MEMBER' }, `${Number(s.industryBalance || 0).toFixed(6)} ECO`)),
kvRow(i18n.bankOverviewYourTaxes || 'Your taxes', a({ href: '/banking?filter=taxes' }, `${tax.toFixed(6)} ECO`)),
kvRow(i18n.bankUbiThisMonth, `${future.toFixed(6)} ECO`)
kvRow(i18n.bankYourFundsMonth || 'Your Funds (this month)', `${(future + Number(s.industryBalance || 0) - tax).toFixed(6)} ECO`)
)
)
);

View file

@ -10,7 +10,7 @@ const FILTER_LABELS = {
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,
project: i18n.typeProject, industry: i18n.typeIndustry, industryBlueprint: i18n.industryBlueprints, 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',
@ -20,7 +20,7 @@ const FILTER_LABELS = {
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_BLOCK3 = ['banking', 'job', 'market', 'project', 'industry', 'industryBlueprint', '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'];
@ -180,6 +180,8 @@ const getViewDetailsAction = (type, block) => {
case 'market': return `/market/${encodeURIComponent(block.id)}`;
case 'job': return `/jobs/${encodeURIComponent(block.id)}`;
case 'project': return `/projects/${encodeURIComponent(block.id)}`;
case 'industry': return `/industry/${encodeURIComponent(block.id)}`;
case 'industryBlueprint': return `/industry/blueprint/${encodeURIComponent(block.id)}`;
case 'report': return `/reports/${encodeURIComponent(block.id)}`;
case 'calendar': return `/calendars/${encodeURIComponent(block.id)}`;
case 'bankWallet': return `/wallet`;

View file

@ -67,7 +67,7 @@ const Tabs = (active) =>
)
);
const CaseForm = () =>
const CaseForm = (prefill = {}) =>
div(
{ class: 'div-center' },
h2(i18n.courtsCaseFormTitle),
@ -82,14 +82,15 @@ const CaseForm = () =>
type: 'text',
name: 'titleSuffix',
required: true,
placeholder: 'Subject or short description'
placeholder: 'Subject or short description',
value: prefill.titleSuffix || ''
}),
br(),
label('Case type'),
br(),
select(
{ name: 'titlePreset', required: true },
CASE_TITLE_PRESETS.map((t) => option({ value: t }, t))
CASE_TITLE_PRESETS.map((t) => option({ value: t, ...(prefill.titlePreset === t ? { selected: true } : {}) }, t))
),
br(),
br(),
@ -101,7 +102,8 @@ const CaseForm = () =>
placeholder: i18n.courtsCaseRespondentPh,
required: true,
pattern: '^@[A-Za-z0-9+/]+=*\\.ed25519$',
title: i18n.courtsRespondentInvalid || 'Must be a valid SSB ID (@...ed25519)'
title: i18n.courtsRespondentInvalid || 'Must be a valid SSB ID (@...ed25519)',
value: prefill.respondentId || ''
}),
br(),
label(i18n.courtsCaseMethod),
@ -109,7 +111,7 @@ const CaseForm = () =>
select(
{ name: 'method', required: true },
['JUDGE', 'DICTATOR', 'POPULAR', 'MEDIATION', 'KARMATOCRACY'].map((m) =>
option({ value: m }, i18n[`courtsMethod${m}`])
option({ value: m, ...(prefill.method === m ? { selected: true } : {}) }, i18n[`courtsMethod${m}`])
)
),
br(),
@ -1382,7 +1384,7 @@ const courtsView = async (state) => {
filter === 'judges' ? JudgesSection(nominations, userId) : null,
filter === 'history' ? HistoryList(history) : null,
filter === 'rules' ? RulesContent() : null,
filter === 'open' ? CaseForm() : null
filter === 'open' ? CaseForm(state.prefill || {}) : null
)
);
};

275
src/views/dev_view.js Normal file
View file

@ -0,0 +1,275 @@
const { form, button, div, h2, p, section, input, a, span, ul, li, table, thead, tbody, tr, td, th, pre, code, select, option } = require("../server/node_modules/hyperaxe")
const { template, i18n } = require("./main_views")
const FILTERS = [
{ key: "tree", href: "/dev", i18n: "devFilterFiles", fallback: "FILES" },
{ key: "map", href: "/dev/map", i18n: "devFilterModules", fallback: "MODULES" },
{ key: "translations", href: "/dev?group=translations", i18n: "devFilterTranslations", fallback: "TRANSLATIONS" },
{ key: "styles", href: "/dev?group=styles", i18n: "devFilterStyles", fallback: "STYLES" },
{ key: "tests", href: "/dev?group=tests", i18n: "devFilterTests", fallback: "TESTS" }
]
const ICONS = {
folder: "📁",
parent: "↰",
".js": "📜",
".mjs": "📜",
".json": "🧾",
".css": "🎨",
".md": "📖",
".sh": "⌨",
".html": "🌐",
".txt": "📄",
".yml": "🧾",
".yaml": "🧾"
}
const safeText = (v) => String(v == null ? "" : v)
const iconFor = (file) => (file.viewable ? (ICONS[file.ext] || "📄") : "📦")
const fmtSize = (bytes) => {
const n = Number(bytes) || 0
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / (1024 * 1024)).toFixed(1)} MB`
}
const fmtCount = (n) => String(Number(n) || 0)
const fileHref = (path, from) => `/dev/file?path=${encodeURIComponent(path)}${from ? `&from=${from}` : ""}`
const dirHref = (path) => (path ? `/dev?path=${encodeURIComponent(path)}` : "/dev")
const renderHeader = (active) => [
div({ class: "tags-header" },
h2(i18n.devTitle || "Developer"),
p(i18n.devDescription || "Explore the source code running on this node.")
),
div({ class: "filters" },
div({ class: "ui-toolbar ui-toolbar--filters dev-toolbar" },
FILTERS.map(f => a({
class: active === f.key ? "filter-btn active" : "filter-btn",
href: f.href
}, i18n[f.i18n] || f.fallback))
)
)
]
const groupLabel = (key) => {
const f = FILTERS.find(x => x.key === key)
return f ? (i18n[f.i18n] || f.fallback) : String(key || "")
}
const renderPathRow = (parents, currentIsFile, group) => (group
? div({ class: "dev-breadcrumb" },
a({ href: "/dev" }, i18n.devRoot || "root"),
span({ class: "dev-breadcrumb-sep" }, " / "),
span({ class: "dev-breadcrumb-current" }, groupLabel(group)))
: renderBreadcrumb(parents, currentIsFile))
const renderStats = (stats) => {
const bit = (labelKey, fallback, value) => [
span({ class: "dev-label" }, `${i18n[labelKey] || fallback}: `),
span(String(value))
]
return div({ class: "dev-stats" },
p(
...bit("devVersion", "Version", stats.version || "-"), span({ class: "dev-sep" }, " · "),
...bit("devNodeVersion", "Node", stats.node || "-"), span({ class: "dev-sep" }, " · "),
...bit("devStatsFiles", "Files", fmtCount(stats.files)), span({ class: "dev-sep" }, " · "),
...bit("devStatsLines", "Lines", fmtCount(stats.lines)), span({ class: "dev-sep" }, " · "),
...bit("devStatsModules", "Modules", fmtCount(stats.modules)), span({ class: "dev-sep" }, " · "),
...bit("devStatsLanguages", "Languages", fmtCount(stats.languages)), span({ class: "dev-sep" }, " · "),
...bit("devStatsTests", "Test suites", fmtCount(stats.testMods))
)
)
}
const renderBreadcrumb = (parents, currentIsFile) => div({ class: "dev-breadcrumb" },
a({ href: "/dev" }, i18n.devRoot || "root"),
(parents || []).map((seg, idx) => [
span({ class: "dev-breadcrumb-sep" }, " / "),
(currentIsFile && idx === parents.length - 1)
? span({ class: "dev-breadcrumb-current" }, safeText(seg.name))
: a({ href: dirHref(seg.path) }, safeText(seg.name))
])
)
const renderSearchForm = (query, ext) => div({ class: "dev-search" },
form({ method: "GET", action: "/dev/search", class: "filter-box" },
input({ type: "text", name: "q", value: safeText(query), placeholder: i18n.devSearchPlaceholder || "Text to find in the code", minlength: "2", required: true, class: "filter-box__input" }),
div({ class: "filter-box__controls" },
select({ name: "ext", class: "filter-box__select" },
option({ value: "", ...(ext ? {} : { selected: true }) }, i18n.devAnyExtension || "Any file"),
[".js", ".json", ".css", ".md", ".sh"].map(x => option({ value: x, ...(ext === x ? { selected: true } : {}) }, x))
),
button({ type: "submit", class: "filter-btn" }, i18n.devFilterSearch || "SEARCH")
)
)
)
const renderDesktop = (tree) => {
const dirs = (tree && tree.dirs) || []
const files = (tree && tree.files) || []
const parents = (tree && tree.parents) || []
const items = []
if (tree.path) {
const up = parents.length > 1 ? parents[parents.length - 2].path : ""
items.push(a({ class: "dev-icon dev-icon-up", href: dirHref(up) },
span({ class: "dev-icon-glyph" }, ICONS.parent),
span({ class: "dev-icon-name" }, ".."),
span({ class: "dev-icon-meta" }, i18n.devParentFolder || "Parent folder")
))
}
for (const d of dirs) {
items.push(a({ class: "dev-icon", href: dirHref(d.path) },
span({ class: "dev-icon-glyph" }, ICONS.folder),
span({ class: "dev-icon-name" }, safeText(d.name)),
span({ class: "dev-icon-meta" }, i18n.devOpenFolder || "open")
))
}
for (const f of files) {
const glyph = span({ class: "dev-icon-glyph" }, iconFor(f))
const name = span({ class: "dev-icon-name" }, safeText(f.name))
const meta = span({ class: "dev-icon-meta" }, f.viewable ? fmtSize(f.size) : (i18n.devNotViewable || "not viewable"))
items.push(f.viewable
? a({ class: "dev-icon", href: fileHref(f.path) }, glyph, name, meta)
: div({ class: "dev-icon dev-icon-off" }, glyph, name, meta))
}
if (!items.length) return p({ class: "dev-hint" }, i18n.devEmptyFolder || "This folder is empty.")
return div({ class: "dev-desktop" }, ...items)
}
const renderPager = (file) => div({ class: "dev-pager" },
file.hasPrev
? a({ class: "filter-btn", href: fileHref(file.path, Math.max(1, file.from - file.pageLines)) }, i18n.devPrevPage || "Previous lines")
: null,
file.hasNext
? a({ class: "filter-btn", href: fileHref(file.path, file.to + 1) }, i18n.devNextPage || "Next lines")
: null
)
exports.devTreeView = async (tree, stats, active) => {
return template(
i18n.devTitle || "Developer",
section(
...renderHeader(active || (tree.group ? tree.group : "tree")),
renderStats(stats),
renderSearchForm("", ""),
renderPathRow(tree.parents, false, tree.group),
renderDesktop(tree)
)
)
}
exports.devFileView = async (file) => {
const reportTitle = `${file.path}:${file.from}`
const reportDescription = `${i18n.devReportContext || "Found while reading the Oasis source code"}: /${file.path} (${i18n.devLine || "line"} ${file.from})`
const taskTitle = `${i18n.devTaskPrefix || "Review"} ${file.path}`
return template(
i18n.devTitle || "Developer",
section(
...renderHeader("tree"),
renderPathRow(file.parents, true, null),
div({ class: "dev-file-head" },
span({ class: "dev-icon-glyph dev-file-glyph" }, iconFor({ ext: `.${String(file.name).split('.').pop()}`, viewable: true })),
h2({ class: "dev-file-name" }, safeText(file.name))
),
div({ class: "dev-file-meta" },
p(
span({ class: "dev-label" }, `${i18n.devStatsLines || "Lines"}: `), fmtCount(file.totalLines),
span({ class: "dev-sep" }, " · "),
span({ class: "dev-label" }, `${i18n.devSize || "Size"}: `), fmtSize(file.size),
span({ class: "dev-sep" }, " · "),
span({ class: "dev-label" }, `${i18n.devShowing || "Showing"}: `), `${fmtCount(file.from)}-${fmtCount(file.to)}`
)
),
div({ class: "dev-actions" },
form({ method: "GET", action: "/reports" },
input({ type: "hidden", name: "filter", value: "create" }),
input({ type: "hidden", name: "category", value: "BUGS" }),
input({ type: "hidden", name: "title", value: reportTitle }),
input({ type: "hidden", name: "description", value: reportDescription }),
button({ type: "submit", class: "filter-btn" }, i18n.devReportBug || "Report Bug")
),
form({ method: "GET", action: "/tasks" },
input({ type: "hidden", name: "filter", value: "create" }),
input({ type: "hidden", name: "title", value: taskTitle }),
input({ type: "hidden", name: "description", value: reportDescription }),
button({ type: "submit", class: "filter-btn" }, i18n.devCreateTask || "Create Task")
),
form({ method: "GET", action: "/dev/raw" },
input({ type: "hidden", name: "path", value: file.path }),
button({ type: "submit", class: "filter-btn" }, i18n.devRaw || "RAW")
)
),
div({ class: "dev-code" },
table({ class: "dev-code-table" },
tbody(
file.lines.map(l => tr({ id: `L${l.n}`, class: "dev-code-row" },
td({ class: "dev-code-num" }, a({ href: `${fileHref(file.path, file.from)}#L${l.n}` }, String(l.n))),
td({ class: "dev-code-line" }, pre(code(safeText(l.text))))
))
)
)
),
renderPager(file)
)
)
}
exports.devSearchView = async (result, ext) => {
const results = (result && result.results) || []
return template(
i18n.devTitle || "Developer",
section(
...renderHeader("search"),
renderSearchForm(result.query, ext),
result.query
? p({ class: "dev-hint" }, `${fmtCount(results.length)}${result.truncated ? "+" : ""} ${i18n.devMatches || "matches"} · ${fmtCount(result.scanned)} ${i18n.devFilesScanned || "files scanned"}`)
: null,
results.length
? ul({ class: "dev-list dev-results" },
results.map(r => li({ class: "dev-entry" },
div({ class: "dev-result-head" },
span({ class: "dev-icon-glyph dev-result-glyph" }, ICONS[`.${r.path.split('.').pop()}`] || "📄"),
a({ href: `${fileHref(r.path, Math.max(1, r.line - 20))}#L${r.line}` }, `${safeText(r.path)}:${r.line}`)
),
div({ class: "dev-result-text" }, pre(code(safeText(r.text))))
))
)
: (result.query ? p({ class: "dev-hint" }, i18n.devNoResults || "No matches, yet.") : null)
)
)
}
exports.devMapView = async (modules) => {
return template(
i18n.devTitle || "Developer",
section(
...renderHeader("map"),
div({ class: "dev-listing" },
table({ class: "dev-table" },
thead(tr(
th(i18n.devColModule || "Module"),
th(i18n.devColState || "State"),
th(i18n.devColModel || "Model"),
th(i18n.devColView || "View"),
th(i18n.devColRoutes || "Routes"),
th(i18n.devColTests || "Tests")
)),
tbody(
(modules || []).map(m => tr(
td(safeText(m.name)),
td(m.enabled ? (i18n.devOn || "on") : (i18n.devOff || "off")),
td(m.model ? a({ href: fileHref(m.model) }, safeText(m.model)) : span({ class: "dev-hint" }, "-")),
td(m.view ? a({ href: fileHref(m.view) }, safeText(m.view)) : span({ class: "dev-hint" }, "-")),
td(String(m.routes || 0)),
td(m.tests ? a({ href: dirHref(m.tests) }, safeText(m.tests)) : span({ class: "dev-hint" }, "-"))
))
)
)
)
)
)
}

991
src/views/industry_view.js Normal file
View file

@ -0,0 +1,991 @@
const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, ul, li, img, video, audio, table, thead, tbody, tr, td, th } = 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 { renderMapEmbedWithZoom } = require("./maps_view")
const { renderUrl } = require("../backend/renderUrl")
const userId = config.keys.id
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 SECTORS = ["software", "hardware", "agriculture", "textile", "energy", "food", "construction", "media", "services", "other"]
const POLICIES = ["open", "vote", "invite"]
const FILTERS = [
{ key: "ALL", i18n: "industryFilterAll", title: "industryAllTitle" },
{ key: "MINE", i18n: "industryFilterMine", title: "industryMineTitle" },
{ key: "ACTIVE", i18n: "industryFilterActive", title: "industryActiveTitle" },
{ key: "PAUSED", i18n: "industryFilterPaused", title: "industryPausedTitle" },
{ key: "DISSOLVED", i18n: "industryFilterDissolved", title: "industryDissolvedTitle" },
{ key: "BLUEPRINTS", i18n: "industryFilterBlueprints", title: "industryBlueprints" },
{ key: "BUILDS", i18n: "industryFilterBuilds", title: "industryBuilds" },
{ key: "MEMBER", i18n: "industryFilterMember", title: "industryMemberTitle" },
{ key: "RULES", i18n: "industryFilterRules", title: "industryRulesTitle" }
]
const renderIndustryRules = () => {
const points = [
i18n.industryRulesIntro,
i18n.industryRulesSteward,
i18n.industryRulesMembership,
i18n.industryRulesQuorum,
i18n.industryRulesMajority,
i18n.industryRulesDecisions,
i18n.industryRulesBlueprints,
i18n.industryRulesBuilds,
i18n.industryRulesContributions,
i18n.industryRulesLaborRate,
i18n.industryRulesShares,
i18n.industryRulesDistribution,
i18n.industryRulesTreasury
]
return div({ class: "card" },
h2(i18n.industryRulesTitle || "Rules"),
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 safeArr = (v) => (Array.isArray(v) ? v : [])
const safeText = (v) => String(v || "").trim()
const sectorLabel = (key) => i18n[`industrySector_${key}`] || key
const policyLabel = (key) => i18n[`industryPolicy_${key}`] || key
const renderStatusChip = (status) => {
const s = String(status || "ACTIVE").toUpperCase()
const map = {
ACTIVE: ["whole", "🟢", i18n.industryStatusActive || "ACTIVE"],
PAUSED: ["half", "⏸", i18n.industryStatusPaused || "PAUSED"],
DISSOLVED: ["tombstoned", "⚰", i18n.industryStatusDissolved || "DISSOLVED"]
}
const [variant, icon, text] = map[s] || map.ACTIVE
return renderStateChip(variant, icon, text)
}
const renderStatusBlock = (fc, returnTo) => {
const isMember = safeArr(fc.members).includes(userId)
const status = String(fc.status || "ACTIVE").toUpperCase()
const myPause = safeArr(fc.pauseVoters).includes(userId)
return div({ class: "industry-section industry-status-block" },
div({ class: "card-chips-row" }, renderStatusChip(status)),
(isMember && status !== "DISSOLVED")
? [
p({ class: "industry-hint" }, `${i18n.industryPauseVotes || "Pause votes"}: ${fc.pauseYes || 0}`),
form({ method: "POST", action: `/industry/govern/${encodeURIComponent(fc.id)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
input({ type: "hidden", name: "subject", value: "pause" }),
input({ type: "hidden", name: "ref", value: "" }),
button({ type: "submit", name: "choice", value: myPause ? "no" : "yes", class: "filter-btn" }, myPause ? (i18n.industryResumeButton || "Vote to resume") : (i18n.industryPauseButton || "Vote to pause"))
)
]
: null
)
}
const renderFacilityList = exports.renderFacilityList = (facilities, filter, spreadMap = new Map()) => {
const list = safeArr(facilities)
if (!list.length) return p(i18n.industryNoFacilitiesFound || "No facilities found.")
return div({ class: "tribe-grid" },
list.map((fc) => {
const isOwn = fc.steward && String(fc.steward) === String(userId)
const isMember = safeArr(fc.members).includes(userId)
const href = `/industry/${encodeURIComponent(fc.id)}`
const chips = [
renderStatusChip(fc.status),
renderStateChip("whole", "🏭", sectorLabel(fc.sector)),
renderStateChip("half", "⚖", policyLabel(fc.membershipPolicy)),
isMember ? renderStateChip("whole", "★", i18n.industryMemberBadge || "MEMBER") : null,
renderLifespanChip(fc.lifetime, i18n)
].filter(Boolean)
return div({ class: "trending-card tribes-card industry-card" + (isOwn ? " own-content" : "") },
div({ class: "card-header activity-card-header" },
span(),
renderContentActions(fc.id || fc.key, href)
),
div({ class: "card-section tribes-card-body" },
div({ class: "tribe-card-image-wrapper" },
a({ href }, renderMediaBlob(fc.image, { class: "tribe-card-hero-image" }) || div({ class: "industry-card-noimage" }, span("🏭"))),
isMember
? form({ method: "GET", action: href, class: "tribe-visit-btn-wrapper" },
button({ type: "submit", class: "filter-btn" }, String(i18n.industryVisitButton || "VISIT").toUpperCase())
)
: null
),
div({ class: "tribe-card-body" },
div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" }, a({ href }, safeText(fc.name) || (i18n.industryTitle || "Industry")))
),
div({ class: "card-chips-row" }, ...chips),
fc.description ? p({ class: "tribe-card-description" }, safeText(fc.description).slice(0, 220)) : null,
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.industryMembers || "Members"}: ${fc.memberCount != null ? fc.memberCount : safeArr(fc.members).length}`)
),
div({ class: "card-spread-centered" }, renderSpreadButton(fc.id || fc.key, spreadMap.get(fc.id || fc.key)))
)
)
)
})
)
}
const renderFacilityForm = (facility, mode) => {
const fc = facility || {}
const isEdit = mode === "edit"
const returnTo = "/industry?filter=MINE"
const curPolicy = fc.membershipPolicy || "vote"
const curSector = fc.sector || "other"
return div({ class: "div-center industry-form" },
form({
action: isEdit ? `/industry/update/${encodeURIComponent(fc.id)}` : "/industry/create",
method: "POST",
enctype: "multipart/form-data"
},
input({ type: "hidden", name: "returnTo", value: returnTo }),
label(i18n.industryName || "Name"),
br(),
input({ type: "text", name: "name", required: true, maxlength: "80", placeholder: i18n.industryNamePlaceholder || "Facility name", value: fc.name || "" }),
br(),
label(i18n.industryDescriptionLabel || "Description"),
br(),
textarea({ name: "description", rows: "5", maxlength: "2000", placeholder: i18n.industryDescriptionPlaceholder || "What does this facility produce?" }, fc.description || ""),
br(),
label(i18n.uploadMedia || "Upload media (max-size: 50MB)"),
br(),
input({ type: "file", name: "image" }),
fc.image ? div({ class: "industry-form-media" }, renderMediaBlob(fc.image, { class: "industry-hero-image" })) : null,
br(),
label(i18n.industrySector || "Sector"),
br(),
select({ name: "sector" },
SECTORS.map((s) => option({ value: s, ...(s === curSector ? { selected: true } : {}) }, sectorLabel(s)))
),
br(),
label(i18n.mapLocationTitle || "Map Location"),
br(),
input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: fc.mapUrl || "" }),
br(),
label(i18n.shopTags || "Tags"),
br(),
input({ type: "text", name: "tags", placeholder: i18n.shopTagsPlaceholder || "tag1, tag2, tag3", value: safeArr(fc.tags).join(", ") }),
br(),
label(i18n.industryMembershipPolicy || "Membership policy"),
br(),
select({ name: "membershipPolicy" },
POLICIES.map((pol) => option({ value: pol, ...(pol === curPolicy ? { selected: true } : {}) }, policyLabel(pol)))
),
br(),
label(i18n.industryQuorum || "Quorum"),
br(),
input({ type: "number", name: "quorum", min: "1", step: "1", value: fc.quorum != null ? fc.quorum : 1 }),
br(),
label(i18n.industryMajority || "Majority"),
br(),
input({ type: "number", name: "majority", min: "0.5", max: "1", step: "0.01", value: fc.majority != null ? fc.majority : 0.5 }),
br(),
label(i18n.industryLaborRate || "Labor rate"),
br(),
input({ type: "number", name: "laborRate", min: "0", step: "0.01", value: fc.laborRate != null ? fc.laborRate : 0 }),
br(),
button({ type: "submit" }, isEdit ? (i18n.industryUpdateButton || "Update facility") : (i18n.industryCreateButton || "Create facility"))
)
)
}
const renderGlobalBlueprints = (blueprints, spreadMap = new Map()) => {
const list = safeArr(blueprints)
if (!list.length) return p(i18n.industryNoBlueprints || "No blueprints yet.")
return div({ class: "industry-blueprints" },
list.map((bp) => div({ class: "industry-card-wrap" },
div({ class: "card-header activity-card-header" }, span(), renderContentActions(bp.id, "/industry/blueprint/" + encodeURIComponent(bp.id))),
div({ class: "industry-blueprint-card" + (bp.author === userId ? " own-content" : "") },
div({ class: "card-chips-row" },
renderSpreadButton(bp.id, spreadMap.get(bp.id)),
renderStateChip("half", bp.outKind === "digital" ? "💾" : "📦", i18n["industryKind_" + bp.outKind] || bp.outKind),
renderStateChip("half", "⚖", String(bp.license || "copyleft").toUpperCase())
),
p({ class: "industry-facility-line" }, span({ class: "industry-meta-label" }, `${i18n.industryFacility || "Facility"}: `), a({ href: `/industry/${encodeURIComponent(bp.facilityId)}` }, safeText(bp.facilityName))),
div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, a({ href: `/industry/blueprint/${encodeURIComponent(bp.id)}` }, safeText(bp.name)))),
bp.image ? renderMediaBlob(bp.image, { class: "post-image" }) : null,
bp.description ? p({ class: "tribe-card-description" }, ...renderUrl(safeText(bp.description))) : null,
renderBlueprintLaborHours(bp),
safeArr(bp.materials).length ? div({ class: "industry-materials" }, ul({ class: "industry-materials-list" }, bp.materials.map(m => li(`${m.qty || 0} × ${safeText(m.item)}${m.price != null ? ` = ${m.price} ECO` : ""}`)))) : null,
renderBlueprintEstimate(bp, i18n.industryBuildingPrice || "Building price"),
safeArr(bp.skills).length ? p(span({ class: "industry-meta-label" }, `${i18n.industrySkills || "Skills"}: `), bp.skills.join(", ")) : null,
(() => {
const acts = renderBlueprintGovernActions(bp.facilityId, bp, safeArr(bp.members).includes(userId) || bp.author === userId, "/industry?filter=BLUEPRINTS")
return acts.length ? div({ class: "industry-actions" }, ...acts) : null
})()
)
))
)
}
const renderGovernVoteButton = (facilityId, subject, ref, voters, yes, need, labelKey, fallback, returnTo) => {
const mine = safeArr(voters).includes(userId)
return form({ method: "POST", action: `/industry/govern/${encodeURIComponent(facilityId)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
input({ type: "hidden", name: "subject", value: subject }),
input({ type: "hidden", name: "ref", value: ref }),
button({ type: "submit", name: "choice", value: mine ? "no" : "yes", class: "filter-btn" },
`${mine ? (i18n.industryRevokeVote || "Revoke") : (i18n[labelKey] || fallback)} (${yes || 0}/${need || 1})`)
)
}
const renderBuildGovernActions = (b, isMember, returnTo) => {
const isProposer = b.proposer === userId
const multi = (b.facilityMembers || 1) > 1
const facilityId = b.facilityId || ""
return [
(isMember && isProposer && b.updateApproved !== false)
? form({ method: "GET", action: `/industry/builds/${encodeURIComponent(b.id)}/edit` }, button({ type: "submit", class: "update-btn" }, i18n.industryUpdateButton || "Update"))
: null,
(isMember && isProposer && b.deleteApproved !== false)
? form({ method: "POST", action: `/industry/builds/${encodeURIComponent(b.id)}/delete` }, button({ type: "submit", class: "delete-btn danger-btn" }, i18n.industryDeleteButton || "Delete"))
: null,
(isMember && multi) ? renderGovernVoteButton(facilityId, "buildUpdate", b.id, b.updateVoters, b.updateYes, b.voteNeed, "industryVoteUpdateButton", "Vote update", returnTo) : null,
(isMember && multi) ? renderGovernVoteButton(facilityId, "buildDelete", b.id, b.deleteVoters, b.deleteYes, b.voteNeed, "industryVoteDeleteButton", "Vote delete", returnTo) : null
].filter(Boolean)
}
const renderBuildCard = (b, opts = {}) => {
const href = `/industry/build/${encodeURIComponent(b.id)}`
const card = div({ class: "industry-blueprint-card" + (b.proposer === userId ? " own-content" : "") },
div({ class: "card-chips-row" },
renderSpreadButton(b.id, opts.spread),
renderBuildStatusChip(b.status),
...((b.blueprintKind && opts.withBlueprintChips !== false) ? [
renderStateChip("half", b.blueprintKind === "digital" ? "💾" : "📦", i18n["industryKind_" + b.blueprintKind] || b.blueprintKind),
renderStateChip("half", "⚖", String(b.blueprintLicense || "copyleft").toUpperCase())
] : [])
),
opts.withFacility ? p({ class: "industry-facility-line" }, span({ class: "industry-meta-label" }, `${i18n.industryFacility || "Facility"}: `), a({ href: `/industry/${encodeURIComponent(b.facilityId)}` }, safeText(b.facilityName))) : null,
opts.withProposer !== false ? p(span({ class: "industry-meta-label" }, `${i18n.industryProposer || "Proposer"}: `), userLink(b.proposer)) : null,
div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, a({ href }, safeText(b.title)))),
b.blueprintImage ? renderMediaBlob(b.blueprintImage, { class: "post-image" }) : null,
b.notes ? p({ class: "tribe-card-description" }, ...renderUrl(safeText(b.notes))) : null,
div({ class: "industry-blueprint-meta industry-dates-row" },
b.startDate ? p(span({ class: "industry-meta-label" }, `${i18n.industryBuildStart || "Start date"}: `), moment(b.startDate).format("YYYY-MM-DD")) : null,
b.endDate ? p(span({ class: "industry-meta-label" }, `${i18n.industryBuildEnd || "End date"}: `), moment(b.endDate).format("YYYY-MM-DD")) : null,
),
renderBlueprintEstimate(b, i18n.industryFinalPrice || "Final price"),
p(span({ class: "industry-meta-label" }, `${i18n.industryContributions || "Contributions"}: `), String(safeArr(b.contributions).length)),
(opts.actions && opts.actions.length) ? div({ class: "industry-actions" }, ...opts.actions) : null
)
if (!opts.withActions) return card
return div({ class: "industry-card-wrap" },
div({ class: "card-header activity-card-header" }, span(), renderContentActions(b.id, href)),
card
)
}
const renderGlobalBuilds = (builds, spreadMap = new Map()) => {
const list = safeArr(builds)
if (!list.length) return p(i18n.industryNoBuilds || "No builds yet.")
return div({ class: "industry-blueprints" },
list.map((b) => renderBuildCard(b, { withFacility: true, withActions: true, spread: spreadMap.get(b.id), actions: renderBuildGovernActions(b, safeArr(b.members).includes(userId) || b.proposer === userId, "/industry?filter=BUILDS") }))
)
}
exports.industryView = async (facilitiesOrForm, filter, params = {}) => {
const f = String(filter || "ALL").toUpperCase()
const search = safeText(params.search)
const sectorSel = safeText(params.sector)
const isForm = f === "CREATE" || f === "EDIT"
const isRules = f === "RULES"
return template(
i18n.industryTitle || "Industry",
section(
div({ class: "tags-header" },
h2(i18n.industryTitle || "Industry"),
p(i18n.industryDescription || "Network-owned production facilities.")
),
br(),
div({ class: "filters" },
form({ method: "GET", action: "/industry", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "search", value: search }),
input({ type: "hidden", name: "sector", value: sectorSel }),
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: f === x.key ? "filter-btn active" : "filter-btn" }, i18n[x.i18n] || x.key))
.concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.industryCreateFacility || "Create facility"))
)
),
isRules
? renderIndustryRules()
: isForm
? renderFacilityForm(f === "EDIT" ? (safeArr(facilitiesOrForm)[0] || {}) : {}, f === "EDIT" ? "edit" : "create")
: f === "BLUEPRINTS"
? div({ class: "industry-list" }, renderGlobalBlueprints(facilitiesOrForm, params.spreadMap || new Map()))
: f === "BUILDS"
? div({ class: "industry-list" }, renderGlobalBuilds(facilitiesOrForm, params.spreadMap || new Map()))
: section(
div({ class: "industry-search" },
form({ method: "GET", action: "/industry", class: "filter-box" },
input({ type: "hidden", name: "filter", value: f || "ALL" }),
input({ type: "text", name: "search", value: search, placeholder: i18n.industrySearchPlaceholder || "Search facilities…", class: "filter-box__input" }),
div({ class: "filter-box__controls" },
select({ name: "sector", class: "filter-box__number" },
[option({ value: "", ...(sectorSel ? {} : { selected: true }) }, i18n.industryAllSectors || "All sectors")]
.concat(SECTORS.map((s) => option({ value: s, ...(s === sectorSel ? { selected: true } : {}) }, sectorLabel(s))))
),
button({ type: "submit", class: "filter-btn" }, i18n.applyFilters || "Apply")
)
)
),
br(),
div({ class: "industry-list" }, renderFacilityList(facilitiesOrForm, f, params.spreadMap))
)
)
)
}
const membershipActions = (fc, returnTo) => {
const isSteward = fc.steward === userId
const isMember = safeArr(fc.members).includes(userId)
const status = String(fc.status || "ACTIVE").toUpperCase()
const policy = fc.membershipPolicy
const invited = safeArr(fc.invites).includes(userId)
const rows = []
if (!isMember && status !== "DISSOLVED") {
if (policy === "vote") {
rows.push(form({ method: "POST", action: `/industry/apply/${encodeURIComponent(fc.id)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ type: "submit", class: "subscribe-btn" }, i18n.industryApplyButton || "Request to join")
))
} else if (policy === "invite") {
rows.push(invited
? form({ method: "POST", action: `/industry/join/${encodeURIComponent(fc.id)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ type: "submit", class: "subscribe-btn" }, i18n.industryJoinButton || "Join")
)
: p({ class: "industry-hint" }, i18n.industryInviteNeeded || "You need an invitation to join.")
)
} else {
rows.push(form({ method: "POST", action: `/industry/join/${encodeURIComponent(fc.id)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ type: "submit", class: "subscribe-btn" }, i18n.industryJoinButton || "Join")
))
}
}
if (isMember && !isSteward) {
rows.push(form({ method: "POST", action: `/industry/leave/${encodeURIComponent(fc.id)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ type: "submit", class: "unsubscribe-btn" }, i18n.industryLeaveButton || "Leave")
))
}
return rows
}
const renderInviteSection = (fc, returnTo) => {
if (!safeArr(fc.members).includes(userId) || String(fc.status || "ACTIVE").toUpperCase() === "DISSOLVED") return null
if (fc.membershipPolicy !== "invite") return null
return div({ class: "industry-section" },
h2(i18n.industryInviteButton || "Invite"),
form({ method: "POST", action: `/industry/invite/${encodeURIComponent(fc.id)}`, class: "industry-invite-form" },
input({ type: "hidden", name: "returnTo", value: returnTo }),
input({ type: "text", name: "invitee", required: true, placeholder: i18n.industryInvitePlaceholder || "Oasis ID (@...ed25519)" }),
button({ type: "submit", class: "create-button" }, i18n.industryCreateInvite || "Generate invite")
)
)
}
const renderPendingApplicants = (fc, returnTo) => {
const isMember = safeArr(fc.members).includes(userId)
const pending = safeArr(fc.pendingApplicants)
if (!isMember || !pending.length) return null
return div({ class: "industry-section" },
h2(i18n.industryPendingApplicants || "Pending applicants"),
ul({ class: "industry-applicants" },
pending.map((ap) => li(
userLink(ap.id),
span({ class: "industry-vote-count" }, ` (${i18n.industryVotesYes || "yes"}: ${ap.yes} / ${i18n.industryVotesNo || "no"}: ${ap.no})`),
ap.voters.includes(userId)
? span({ class: "industry-hint" }, ` — ${i18n.industryAlreadyVoted || "voted"}`)
: form({ method: "POST", action: `/industry/govern/${encodeURIComponent(fc.id)}`, class: "industry-vote-form" },
input({ type: "hidden", name: "returnTo", value: returnTo }),
input({ type: "hidden", name: "subject", value: "admit" }),
input({ type: "hidden", name: "ref", value: ap.id }),
button({ type: "submit", name: "choice", value: "yes", class: "subscribe-btn" }, i18n.industryAdmit || "Admit"),
button({ type: "submit", name: "choice", value: "no", class: "unsubscribe-btn" }, i18n.industryReject || "Reject")
)
))
)
)
}
const renderDissolveBlock = (fc, returnTo) => {
const isMember = safeArr(fc.members).includes(userId)
const status = String(fc.status || "ACTIVE").toUpperCase()
const memberCount = fc.memberCount != null ? fc.memberCount : safeArr(fc.members).length
if (!isMember || status === "DISSOLVED" || memberCount <= 1) return null
return div({ class: "industry-section" },
h2(i18n.industryDissolveTitle || "Dissolve facility"),
form({ method: "POST", action: `/industry/govern/${encodeURIComponent(fc.id)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
input({ type: "hidden", name: "subject", value: "dissolve" }),
input({ type: "hidden", name: "ref", value: "" }),
button({ type: "submit", name: "choice", value: "yes", class: "unsubscribe-btn danger-btn" }, i18n.industryDissolveVote || "Vote to dissolve")
)
)
}
const BUILD_STATUSES = ["PROPOSED", "APPROVED", "STOCKING", "IN_PRODUCTION", "COMPLETED", "FAILED"]
const BUILD_TRANSITIONS = { APPROVED: ["STOCKING", "FAILED"], STOCKING: ["IN_PRODUCTION", "FAILED"], IN_PRODUCTION: ["COMPLETED", "FAILED"], FAILED: ["APPROVED", "STOCKING", "IN_PRODUCTION"], COMPLETED: ["IN_PRODUCTION"] }
const CONTRIBUTABLE_VIEW = new Set(["APPROVED", "STOCKING", "IN_PRODUCTION"])
const buildStatusLabel = (s) => i18n["industryBuildStatus_" + String(s || "").toUpperCase()] || s
const renderBuildStatusChip = (status) => {
const s = String(status || "PROPOSED").toUpperCase()
const map = { PROPOSED: ["half", "📋"], REJECTED: ["tombstoned", "🚫"], APPROVED: ["whole", "✅"], STOCKING: ["half", "📦"], IN_PRODUCTION: ["half", "⚙"], COMPLETED: ["whole", "🏁"], FAILED: ["tombstoned", "✗"] }
const [v, ic] = map[s] || map.PROPOSED
return renderStateChip(v, ic, buildStatusLabel(s))
}
const fmtEco = (n) => `${Number(n || 0).toFixed(2)} ECO`
const renderBlueprintLaborHours = (bp) => {
if (bp.laborHours == null) return null
return p({ class: "industry-labor-hours" }, span({ class: "industry-meta-label" }, `${i18n.industryLaborHours || "Labor (hours)"}: `), `${bp.laborHours || 0}h`)
}
const renderBlueprintEstimate = (bp, totalLabel) => {
if (bp.estTotal == null) return null
return div({ class: "industry-estimate" },
p(span({ class: "industry-meta-label" }, `${i18n.industryEstMaterials || "Total Materials"}: `), fmtEco(bp.estMaterialsCost)),
p(span({ class: "industry-meta-label" }, `${i18n.industryEstLabor || "Total Labor"}: `), fmtEco(bp.estLaborCost)),
br(),
p({ class: "industry-estimate-total" }, span({ class: "industry-meta-label" }, `${totalLabel || i18n.industryEstTotal || "Estimated price"}: `), fmtEco(bp.estTotal))
)
}
const renderBlueprintGovernActions = (facilityId, bp, isMember, returnTo) => {
const isAuthor = bp.author === userId
const multi = (bp.facilityMembers || 1) > 1
const ref = bp.root || bp.id
const voteBtn = (subject, voters, yes, labelKey, fallback) => {
const mine = safeArr(voters).includes(userId)
return form({ method: "POST", action: `/industry/govern/${encodeURIComponent(facilityId)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
input({ type: "hidden", name: "subject", value: subject }),
input({ type: "hidden", name: "ref", value: ref }),
button({ type: "submit", name: "choice", value: mine ? "no" : "yes", class: "filter-btn" },
`${mine ? (i18n.industryRevokeVote || "Revoke") : (i18n[labelKey] || fallback)} (${yes || 0}/${bp.voteNeed || 1})`)
)
}
return [
(isMember && isAuthor && bp.updateApproved !== false)
? form({ method: "GET", action: `/industry/blueprints/${encodeURIComponent(bp.id)}/edit` }, button({ type: "submit", class: "update-btn" }, i18n.industryUpdateButton || "Update"))
: null,
(isMember && isAuthor && bp.deleteApproved !== false)
? form({ method: "POST", action: `/industry/blueprints/${encodeURIComponent(bp.id)}/delete` }, button({ type: "submit", class: "delete-btn danger-btn" }, i18n.industryDeleteButton || "Delete"))
: null,
(isMember && multi) ? voteBtn("bpUpdate", bp.updateVoters, bp.updateYes, "industryVoteUpdateButton", "Vote update") : null,
(isMember && multi) ? voteBtn("bpDelete", bp.deleteVoters, bp.deleteYes, "industryVoteDeleteButton", "Vote delete") : null
].filter(Boolean)
}
const renderBlueprintsSection = (fc, blueprints, isMember, spreadMap = new Map()) => {
const list = safeArr(blueprints)
const cards = list.map((bp) => {
const actions = renderBlueprintGovernActions(fc.id, bp, isMember, `/industry/${encodeURIComponent(fc.id)}`)
return div({ class: "industry-card-wrap" },
div({ class: "card-header activity-card-header" }, span(), renderContentActions(bp.id, `/industry/blueprint/${encodeURIComponent(bp.id)}`)),
div({ class: "industry-blueprint-card" },
div({ class: "card-chips-row" },
renderSpreadButton(bp.id, spreadMap.get(bp.id)),
renderStateChip("half", bp.outKind === "digital" ? "💾" : "📦", i18n["industryKind_" + bp.outKind] || bp.outKind),
renderStateChip("half", "⚖", String(bp.license || "copyleft").toUpperCase())
),
div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, safeText(bp.name))),
bp.image ? renderMediaBlob(bp.image, { class: "post-image" }) : null,
bp.description ? p({ class: "tribe-card-description" }, ...renderUrl(safeText(bp.description))) : (bp.outItem ? p(span({ class: "industry-meta-label" }, `${i18n.industryOutput || "Output"}: `), `${bp.outQty || 0} × ${safeText(bp.outItem)}`) : null),
renderBlueprintLaborHours(bp),
safeArr(bp.materials).length ? div({ class: "industry-materials" }, ul({ class: "industry-materials-list" }, bp.materials.map(m => li(`${m.qty || 0} × ${safeText(m.item)}${m.price != null ? ` = ${m.price} ECO` : ""}`)))) : null,
renderBlueprintEstimate(bp, i18n.industryBuildingPrice || "Building price"),
safeArr(bp.skills).length ? p(span({ class: "industry-meta-label" }, `${i18n.industrySkills || "Skills"}: `), bp.skills.join(", ")) : null,
actions.length ? div({ class: "industry-actions" }, ...actions) : null
)
)
})
return div({ class: "industry-section" },
h2(i18n.industryBlueprints || "Blueprints"),
list.length ? div({ class: "industry-blueprints" }, ...cards) : p({ class: "industry-hint" }, i18n.industryNoBlueprints || "No blueprints yet."),
isMember ? renderBlueprintForm(fc, {}, "create") : null
)
}
const renderBlueprintForm = (fc, bp, mode) => {
const isEdit = mode === "edit"
const curKind = bp.outKind || "physical"
const materialsText = safeArr(bp.materials).map(m => `${m.item}:${m.qty}:${m.price != null ? m.price : 0}`).join("\n")
return div({ class: "industry-form industry-blueprint-form" },
h2(isEdit ? (i18n.industryEditBlueprint || "Edit blueprint") : (i18n.industryNewBlueprint || "New blueprint")),
form({ method: "POST", action: isEdit ? `/industry/blueprints/${encodeURIComponent(bp.id)}/update` : `/industry/${encodeURIComponent(fc.id)}/blueprints`, enctype: "multipart/form-data" },
label(i18n.industryName || "Name"),
br(), input({ type: "text", name: "name", required: true, maxlength: "80", value: bp.name || "" }), br(),
label(i18n.industryDescriptionLabel || "Description"),
br(), textarea({ name: "description", rows: "4", maxlength: "2000", placeholder: i18n.industryBlueprintDescPlaceholder || "Specs, dimensions, weight, docs…" }, bp.description || ""), br(),
label(i18n.uploadMedia || "Upload media (max-size: 50MB)"),
br(), input({ type: "file", name: "image" }),
bp.image ? div({ class: "industry-form-media" }, renderMediaBlob(bp.image, { class: "post-image" })) : null,
br(),
label(i18n.industryOutputKind || "Product type"),
br(), select({ name: "outKind" }, ["physical", "digital"].map(k => option({ value: k, ...(k === curKind ? { selected: true } : {}) }, i18n["industryKind_" + k] || k))), br(),
label(i18n.industryMaterials || "Materials"),
br(), textarea({ name: "materialsText", rows: "3", placeholder: i18n.industryMaterialsPlaceholder || "solar-panel:2:120\nbattery:1:80\nkit:1:15" }, materialsText), br(),
label(i18n.industryLaborHours || "Labor hours"),
br(), input({ type: "number", name: "laborHours", min: "0", step: "0.01", value: bp.laborHours != null ? bp.laborHours : "" }), br(),
button({ type: "submit" }, isEdit ? (i18n.industryUpdateButton || "Update") : (i18n.industryCreateBlueprintButton || "Create blueprint"))
)
)
}
const renderBuildForm = (fc, blueprints, build, mode) => {
const b = build || {}
const isEdit = mode === "edit"
const today = new Date().toISOString().slice(0, 10)
return div({ class: "industry-form industry-build-form" },
h2(isEdit ? (i18n.industryEditBuild || "Edit build") : (i18n.industryNewBuild || "Propose a build")),
form({ method: "POST", action: isEdit ? `/industry/builds/${encodeURIComponent(b.id)}/update` : `/industry/${encodeURIComponent(fc.id)}/builds`, enctype: "multipart/form-data" },
label(i18n.industryBuildTitle || "Title"),
br(), input({ type: "text", name: "title", required: true, maxlength: "120", value: safeText(b.title) }), br(),
label(i18n.industryBlueprint || "Blueprint"),
br(), isEdit
? input({ type: "text", disabled: true, value: safeText(b.blueprintName) })
: select({ name: "blueprintId", required: true }, safeArr(blueprints).map(bp => option({ value: bp.id }, safeText(bp.name)))), br(),
label(i18n.industryBuildStart || "Start date"),
br(), input({ type: "date", name: "startDate", required: true, value: b.startDate || "", ...(isEdit ? {} : { min: today }) }), br(),
label(i18n.industryBuildEnd || "End date"),
br(), input({ type: "date", name: "endDate", required: true, value: b.endDate || "", min: today }), br(),
label(i18n.industryBuildNotes || "Notes"),
br(), textarea({ name: "notes", rows: "3", maxlength: "1000" }, safeText(b.notes)), br(),
button({ type: "submit" }, isEdit ? (i18n.industryUpdateButton || "Update") : (i18n.industryProposeBuildButton || "Propose build"))
)
)
}
const renderBuildsSection = (fc, builds, blueprints, isMember, spreadMap = new Map()) => {
const list = safeArr(builds)
return div({ class: "industry-section" },
h2(i18n.industryBuilds || "Builds"),
list.length ? div({ class: "industry-blueprints" }, ...list.map((b) => renderBuildCard(b, { withActions: true, withBlueprintChips: false, withProposer: false, spread: spreadMap.get(b.id), actions: renderBuildGovernActions(b, isMember, `/industry/${encodeURIComponent(fc.id)}`) }))) : p({ class: "industry-hint" }, i18n.industryNoBuilds || "No builds yet."),
(isMember && fc.status === "ACTIVE" && !safeArr(blueprints).length)
? p({ class: "industry-hint" }, i18n.industryNeedBlueprint || "Create a blueprint first: every build produces one.")
: null,
(isMember && fc.status === "ACTIVE" && safeArr(blueprints).length) ? renderBuildForm(fc, blueprints, {}, "create") : null
)
}
const renderFacilityJobsSection = (fc, jobs) => {
const list = safeArr(jobs)
if (!list.length) return null
const rows = list.map((j) => li(
a({ href: `/jobs/${encodeURIComponent(j.id || j.key)}` }, safeText(j.title) || (i18n.jobsTitle || "Job")),
j.salary ? span({ class: "industry-hint" }, ` · ${j.salary} ECO · `) : span({ class: "industry-hint" }, " · "),
renderStateChip(String(j.status || "OPEN").toUpperCase() === "OPEN" ? "whole" : "tombstoned", "", String(j.status || "OPEN").toUpperCase())
))
return div({ class: "industry-section" },
h2(i18n.industryJobs || "Jobs"),
ul({ class: "industry-jobs-list" }, ...rows)
)
}
const renderFacilitySide = (fc, returnTo, params = {}) => {
const isSteward = fc.steward === userId
const memberCount = fc.memberCount != null ? fc.memberCount : safeArr(fc.members).length
const chips = [
renderStateChip("whole", "🏭", sectorLabel(fc.sector)),
renderStateChip("half", "⚖", policyLabel(fc.membershipPolicy)),
renderLifespanChip(fc.lifetime, i18n),
renderEcoTax(fc.msgSize, fc.id || fc.key)
].filter(Boolean)
const sideActions = []
for (const act of membershipActions(fc, returnTo)) sideActions.push(act)
if (!isSteward && fc.steward) {
sideActions.push(form({ method: "GET", action: "/pm" },
input({ type: "hidden", name: "recipients", value: fc.steward }),
button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
))
sideActions.push(a({ href: `/reports?filter=create&category=ABUSE&title=${encodeURIComponent(`${i18n.industryTitle || "Industry"}: ${safeText(fc.name)}`)}`, class: "filter-btn" }, i18n.industryReportButton || "Report"))
}
if (isSteward) {
sideActions.push(form({ method: "GET", action: `/industry/edit/${encodeURIComponent(fc.id)}` },
button({ type: "submit", class: "update-btn" }, i18n.industryUpdateButton || "Update")
))
if (memberCount <= 1) {
sideActions.push(form({ method: "POST", action: `/industry/delete/${encodeURIComponent(fc.id)}` },
button({ type: "submit", class: "delete-btn danger-btn" }, i18n.industryDeleteButton || "Delete")
))
}
}
return div({ class: "tribe-side" },
div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, a({ href: `/industry/${encodeURIComponent(fc.id)}` }, safeText(fc.name) || (i18n.industryTitle || "Industry")))),
div({ class: "card-chips-row" }, ...chips),
div({ class: "card-spread-centered" }, renderSpreadButton(fc.id || fc.key, params.spreads)),
fc.image ? renderMediaBlob(fc.image, { class: "tribe-detail-image" }) : null,
fc.description ? p({ class: "tribe-side-description" }, ...renderUrl(safeText(fc.description))) : null,
table({ class: "tribe-info-table" },
tr(td({ class: "tribe-info-value tribe-author-cell", colspan: "4" }, userLink(fc.steward))),
tr(td({ class: "tribe-info-label" }, i18n.industryLaborRate || "Labor rate"), td({ class: "tribe-info-value", colspan: "3" }, `${fc.laborRate || 0} ECO/h`)),
tr(td({ class: "tribe-info-label" }, i18n.industryQuorum || "Quorum"), td({ class: "tribe-info-value", colspan: "3" }, String(fc.quorum))),
tr(td({ class: "tribe-info-label" }, i18n.industryMajority || "Majority"), td({ class: "tribe-info-value", colspan: "3" }, `${Math.round((fc.majority || 0.5) * 100)}%`))
),
fc.mapUrl ? renderMapEmbedWithZoom(params.mapData, fc.mapUrl, returnTo, params.zoom) : null,
safeArr(fc.tags).length
? div({ class: "tribe-side-tags" }, safeArr(fc.tags).map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)))
: null,
renderStatusBlock(fc, returnTo),
h2({ class: "tribe-members-count" }, `${i18n.industryMembers || "Members"}: ${memberCount}`),
renderInviteSection(fc, returnTo),
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null,
renderDissolveBlock(fc, returnTo)
)
}
exports.singleFacilityView = async (facility, filter, params = {}) => {
const fc = facility || {}
const f = String(filter || "ALL").toUpperCase()
const returnTo = `/industry/${encodeURIComponent(fc.id)}?filter=${encodeURIComponent(f)}`
const isMember = safeArr(fc.members).includes(userId)
const facilitySide = renderFacilitySide(fc, returnTo, params)
const facilityMain = div({ class: "tribe-main" },
renderBlueprintsSection(fc, params.blueprints, isMember, params.childSpreadMap || new Map()),
renderBuildsSection(fc, params.builds, params.blueprints, isMember, params.childSpreadMap || new Map()),
renderFacilityJobsSection(fc, params.facilityJobs),
renderOpinionsVoting('/industry/opinions', fc.id || fc.key, fc.opinions, null, fc.opinions_inhabitants),
div({ class: "industry-section" },
h2(i18n.industryMembers || "Members"),
ul({ class: "industry-members-list" }, safeArr(fc.members).map((mid) => li(
userLink(mid),
span({ class: "industry-role" }, ` · ${mid === fc.steward ? (i18n.industrySteward || "Steward") : (i18n.industryMember || "Member")}`)
)))
),
renderPendingApplicants(fc, returnTo)
)
return template(
i18n.industryTitle || "Industry",
section(
div({ class: "card-header activity-card-header" },
span(),
renderContentActions(fc.id || fc.key, returnTo)
),
div({ class: "tags-header" }, h2(i18n.industryTitle || "Industry"), p(i18n.industryDescription || "Network-owned production facilities.")),
div({ class: "filters" },
form({ method: "GET", action: "/industry", 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] || x.key))
.concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.industryCreateFacility || "Create facility"))
)
),
div({ class: "tribe-details" }, facilitySide, facilityMain)
)
)
}
exports.blueprintEditView = async (bp, fc) => template(
i18n.industryTitle || "Industry",
section(
div({ class: "card-header activity-card-header" },
span(),
renderContentActions(fc.id || fc.key, `/industry/${encodeURIComponent(fc.id)}`)
),
div({ class: "tags-header" }, h2(i18n.industryTitle || "Industry"), p(i18n.industryDescription || "Network-owned production facilities.")),
div({ class: "filters" },
form({ method: "GET", action: "/industry", class: "ui-toolbar ui-toolbar--filters" },
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: "filter-btn" }, i18n[x.i18n] || x.key))
.concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.industryCreateFacility || "Create facility"))
)
),
renderBlueprintForm(fc, bp, "edit")
)
)
exports.buildEditView = async (b, fc) => template(
i18n.industryTitle || "Industry",
section(
div({ class: "card-header activity-card-header" },
span(),
renderContentActions(b.id, `/industry/build/${encodeURIComponent(b.id)}`)
),
div({ class: "tags-header" }, h2(i18n.industryTitle || "Industry"), p(i18n.industryDescription || "Network-owned production facilities.")),
div({ class: "filters" },
form({ method: "GET", action: "/industry", class: "ui-toolbar ui-toolbar--filters" },
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: "filter-btn" }, i18n[x.i18n] || x.key))
.concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.industryCreateFacility || "Create facility"))
)
),
renderBuildForm(fc, [], b, "edit")
)
)
exports.singleBlueprintView = async (blueprint, params = {}) => {
const bp = blueprint || {}
const isMember = safeArr(bp.members).includes(userId)
const facilityHref = `/industry/${encodeURIComponent(bp.facilityId || "")}`
const returnTo = `/industry/blueprint/${encodeURIComponent(bp.id)}`
const actions = renderBlueprintGovernActions(bp.facilityId, bp, isMember, returnTo)
return template(
i18n.industryTitle || "Industry",
section(
div({ class: "filters" },
form({ method: "GET", action: "/industry", class: "ui-toolbar ui-toolbar--filters" },
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: "filter-btn" }, i18n[x.i18n] || x.key))
.concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.industryCreateFacility || "Create facility"))
)
)
),
section(
div({ class: "shop-detail" },
(bp.author && String(bp.author) !== String(userId))
? div({ class: "bookmark-topbar transfer-topbar-single" },
div({ class: "bookmark-topbar-left" },
form({ method: "GET", action: "/pm" }, input({ type: "hidden", name: "recipients", value: bp.author }), button({ type: "submit", class: "filter-btn" }, i18n.privateMessage))
)
)
: null,
div({ class: "card-chips-row" },
renderSpreadButton(bp.id, params.spreads),
renderStateChip("half", bp.outKind === "digital" ? "💾" : "📦", i18n["industryKind_" + bp.outKind] || bp.outKind),
renderStateChip("half", "⚖", String(bp.license || "copyleft").toUpperCase())
),
p({ class: "industry-facility-line" }, span({ class: "industry-meta-label" }, `${i18n.industryFacility || "Facility"}: `), a({ href: facilityHref }, safeText(bp.facilityName))),
h2(safeText(bp.name)),
bp.image ? div({ class: "shop-detail-media" }, renderMediaBlob(bp.image, { class: "post-image" })) : null,
bp.description ? p({ class: "tribe-card-description" }, ...renderUrl(safeText(bp.description))) : null,
div({ class: "industry-meta" },
p(span({ class: "industry-meta-label" }, `${i18n.industryAuthor || "Author"}: `), userLink(bp.author))
),
renderBlueprintLaborHours(bp),
safeArr(bp.materials).length ? div({ class: "industry-materials" }, ul({ class: "industry-materials-list" }, bp.materials.map(m => li(`${m.qty || 0} × ${safeText(m.item)}${m.price != null ? ` = ${m.price} ECO` : ""}`)))) : null,
renderBlueprintEstimate(bp, i18n.industryBuildingPrice || "Building price"),
safeArr(bp.skills).length ? p(span({ class: "industry-meta-label" }, `${i18n.industrySkills || "Skills"}: `), bp.skills.join(", ")) : null,
actions.length ? div({ class: "industry-actions" }, ...actions) : null,
safeArr(bp.builds).length
? div({ class: "industry-section" },
h2(i18n.industryBuilds || "Builds"),
ul({ class: "industry-builds-list" }, bp.builds.map(b => li(
a({ href: `/industry/build/${encodeURIComponent(b.id)}` }, safeText(b.title)),
" ", renderBuildStatusChip(b.status)
)))
)
: null
)
)
)
}
exports.singleBuildView = async (build, params = {}) => {
const b = build || {}
const isMember = safeArr(b.members).includes(userId)
const isSteward = b.steward === userId
const isProposer = b.proposer === userId
const status = String(b.status || "PROPOSED").toUpperCase()
const returnTo = `/industry/build/${encodeURIComponent(b.id)}`
const facilityHref = `/industry/${encodeURIComponent(b.facilityId)}`
const contribRows = safeArr(b.contributions).map(c => tr(
td(userLink(c.author)),
td(i18n["industryKind_" + c.kind] || c.kind),
td(c.kind === "labor" ? `${c.hours} h` : c.kind === "material" ? `${c.value} ECO · ${safeText(c.item)}` : `${c.eco} ECO`),
td(safeText(c.note))
))
const canUpdateBuild = isMember && (isProposer || isSteward) && !b.distributed && b.updateApproved !== false
const govRows = []
if ((status === "PROPOSED" || status === "REJECTED") && isMember) {
govRows.push(form({ method: "POST", action: `/industry/builds/${encodeURIComponent(b.id)}/vote`, class: "industry-build-vote" },
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ type: "submit", name: "choice", value: "yes", class: "subscribe-btn" }, i18n.industryApproveBuild || "Approve"),
button({ type: "submit", name: "choice", value: "no", class: "unsubscribe-btn" }, i18n.industryReject || "Reject")
))
}
const isReopen = status === "COMPLETED" || status === "FAILED"
const lockedByDistribution = status === "COMPLETED" && b.distributed
const transitions = ((!isMember || lockedByDistribution) ? [] : (BUILD_TRANSITIONS[status] || [])).filter(nextS => {
const stewardOnly = nextS === "COMPLETED" || nextS === "FAILED" || isReopen
return isSteward || (isProposer && !stewardOnly)
})
if (transitions.length) {
govRows.push(form({ method: "POST", action: `/industry/builds/${encodeURIComponent(b.id)}/status`, class: "project-control-form project-control-form--status" },
input({ type: "hidden", name: "returnTo", value: returnTo }),
select({ name: "status", class: "project-control-select" },
transitions.map(nextS => option({ value: nextS }, buildStatusLabel(nextS)))
),
button({ type: "submit", class: "status-btn project-control-btn" }, i18n.industryAdvanceTo || "Set to")
))
}
if (canUpdateBuild) {
govRows.push(form({ method: "GET", action: `/industry/builds/${encodeURIComponent(b.id)}/edit` },
button({ type: "submit", class: "update-btn" }, i18n.industryUpdateButton || "Update")
))
}
if (isMember && isProposer && b.deleteApproved !== false) {
govRows.push(form({ method: "POST", action: `/industry/builds/${encodeURIComponent(b.id)}/delete` },
button({ type: "submit", class: "delete-btn danger-btn" }, i18n.industryDeleteButton || "Delete")
))
}
if (isMember && (b.facilityMembers || 1) > 1) {
govRows.push(renderGovernVoteButton(b.facilityId, "buildUpdate", b.id, b.updateVoters, b.updateYes, b.voteNeed, "industryVoteUpdateButton", "Vote update", returnTo))
govRows.push(renderGovernVoteButton(b.facilityId, "buildDelete", b.id, b.deleteVoters, b.deleteYes, b.voteNeed, "industryVoteDeleteButton", "Vote delete", returnTo))
}
const kind = ["labor", "material", "eco"].includes(String(params.kind)) ? String(params.kind) : "labor"
const contributeAction = `/industry/builds/${encodeURIComponent(b.id)}/contribute`
const contributeBlock = (CONTRIBUTABLE_VIEW.has(status) && isMember)
? div({ class: "industry-section", id: "contribute" },
h2(i18n.industryContribute || "Contribute"),
form({ method: "GET", action: `/industry/build/${encodeURIComponent(b.id)}#contribute`, class: "industry-form industry-contrib-kind" },
label(i18n.industryKindLabel || "Kind"),
select({ name: "kind", class: "project-control-select" },
option({ value: "labor", ...(kind === "labor" ? { selected: true } : {}) }, i18n.industryKind_labor || "Labor"),
option({ value: "material", ...(kind === "material" ? { selected: true } : {}) }, i18n.industryKind_material || "Material"),
option({ value: "eco", ...(kind === "eco" ? { selected: true } : {}) }, i18n.industryKind_eco || "Funds")
),
button({ type: "submit", class: "create-button" }, i18n.apply || "Apply")
),
form({ method: "POST", action: contributeAction, class: "industry-form industry-contrib-form-single" },
input({ type: "hidden", name: "kind", value: kind }),
kind === "labor" ? [
label(i18n.industryHours || "Hours"),
input({ type: "number", name: "hours", min: "0.01", step: "0.01", required: true, class: "industry-input-num" })
] : null,
kind === "material" ? [
label(i18n.industryMaterialItem || "Material"),
textarea({ name: "item", rows: "2", maxlength: "80", required: true }),
label(i18n.industryMaterialValue || "Value (ECO)"),
input({ type: "number", name: "value", min: "0.01", step: "0.01", required: true, class: "industry-input-num" })
] : null,
kind === "eco" ? [
label(i18n.industryEcoAmount || "Amount (ECO)"),
input({ type: "number", name: "eco", min: "0.000001", step: "0.000001", required: true, class: "industry-input-num" })
] : null,
label(i18n.industryNote || "Note"),
textarea({ name: "note", rows: "2", maxlength: "140" }),
button({ type: "submit", class: "filter-btn" }, i18n.industryContributeButton || "Contribute")
)
)
: null
const distributeBlock = (status === "COMPLETED" && isSteward && !b.distributed)
? div({ class: "industry-section" },
h2(i18n.industryDistribute || "Distribute"),
form({ method: "POST", action: `/industry/builds/${encodeURIComponent(b.id)}/distribute` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
label(i18n.industryOutputValue || "Output value (ECO, e.g. from sale)"),
br(), input({ type: "number", name: "outputValue", min: "0", step: "0.000001", value: "0" }), br(),
button({ type: "submit", class: "subscribe-btn" }, i18n.industryDistributeButton || "Distribute by shares")
),
p({ class: "industry-hint" }, `${i18n.industryTreasury || "Treasury"}: ${b.treasury || 0} ECO`)
)
: null
const allocationBlock = b.distributed && b.allocation
? div({ class: "industry-section" },
h2(i18n.industryDistribution || "Distribution"),
p(`${i18n.industryPot || "Pot"}: ${(b.allocation.pot || 0).toFixed(2)} ECO`)
)
: null
const buildLabel = `${i18n.industryBuild || "Build"}: ${safeText(b.title)}`
const facilityName = safeText(b.facilityName)
const productName = safeText(b.blueprintName) || safeText(b.title)
const crossTags = [facilityName, productName].filter(Boolean).map(x => x.toLowerCase().replace(/\s+/g, "-")).join(", ")
const crossDates = [
b.startDate ? `${i18n.industryBuildStart || "Start date"}: ${moment(b.startDate).format("YYYY-MM-DD")}` : null,
b.endDate ? `${i18n.industryBuildEnd || "End date"}: ${moment(b.endDate).format("YYYY-MM-DD")}` : null
].filter(Boolean).join(" · ")
const crossContext = [
`${i18n.industryFacility || "Facility"}: ${facilityName}`,
b.blueprintName ? `${i18n.industryBlueprint || "Blueprint"}: ${safeText(b.blueprintName)}` : null,
crossDates || null,
b.estTotal != null ? `${i18n.industryFinalPrice || "Final price"}: ${fmtEco(b.estTotal)}` : null
].filter(Boolean).join("\n")
const crossDescription = [safeText(b.notes), crossContext].filter(Boolean).join("\n\n")
const crossLinks = isMember
? div({ class: "industry-actions" },
a({ href: `/tasks?filter=create&returnTo=${encodeURIComponent(returnTo)}&title=${encodeURIComponent(buildLabel)}&description=${encodeURIComponent(crossDescription)}`, class: "filter-btn" }, i18n.industryCreateTask || "Create Task"),
a({ href: `/jobs?filter=CREATE&industry=${encodeURIComponent(b.facilityId || "")}&title=${encodeURIComponent(buildLabel)}&description=${encodeURIComponent(crossDescription)}&tasks=${encodeURIComponent(safeText(b.notes))}&salary=${encodeURIComponent(b.estLaborCost != null ? b.estLaborCost : "")}`, class: "filter-btn" }, i18n.industryPostJob || "Create Job"),
a({ href: `/projects?filter=CREATE&title=${encodeURIComponent(buildLabel)}&description=${encodeURIComponent(crossDescription)}&goal=${encodeURIComponent(b.estTotal != null ? b.estTotal : "")}`, class: "filter-btn" }, i18n.industrySendToProjects || "Create Project"),
a({ href: `/market?filter=create&industry=${encodeURIComponent(b.facilityId || "")}&title=${encodeURIComponent(productName)}&description=${encodeURIComponent(crossDescription)}&price=${encodeURIComponent(b.estTotal != null ? b.estTotal : "")}&tags=${encodeURIComponent(crossTags)}`, class: "filter-btn" }, i18n.industrySellOnMarket || "Send to Market"),
(!isSteward && b.steward) ? a({ href: `/courts?filter=open&method=MEDIATION&respondentId=${encodeURIComponent(b.steward)}&titleSuffix=${encodeURIComponent(buildLabel)}`, class: "filter-btn" }, i18n.industryOpenDispute || "Open dispute") : null
)
: null
return template(
i18n.industryTitle || "Industry",
section(
div({ class: "filters" },
form({ method: "GET", action: "/industry", class: "ui-toolbar ui-toolbar--filters" },
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: "filter-btn" }, i18n[x.i18n] || x.key))
.concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.industryCreateFacility || "Create facility"))
)
)
),
section(
div({ class: "shop-detail" },
(b.proposer && String(b.proposer) !== String(userId))
? div({ class: "bookmark-topbar transfer-topbar-single" },
div({ class: "bookmark-topbar-left" },
form({ method: "GET", action: "/pm" }, input({ type: "hidden", name: "recipients", value: b.proposer }), button({ type: "submit", class: "filter-btn" }, i18n.privateMessage))
)
)
: null,
b.image ? div({ class: "shop-detail-media" }, renderMediaBlob(b.image, { class: "post-image" })) : null,
h2(safeText(b.title) || (i18n.industryBuild || "Build")),
div({ class: "card-chips-row" }, renderSpreadButton(b.id, params.spreads), renderBuildStatusChip(status)),
div({ class: "industry-meta" },
b.blueprintName ? p(span({ class: "industry-meta-label" }, `${i18n.industryBlueprint || "Blueprint"}: `), safeText(b.blueprintName)) : null,
b.blueprintImage ? renderMediaBlob(b.blueprintImage, { class: "post-image" }) : null,
p(span({ class: "industry-meta-label" }, `${i18n.industryProposer || "Proposer"}: `), userLink(b.proposer)),
b.notes ? p({ class: "industry-build-notes" }, span({ class: "industry-meta-label" }, `${i18n.industryBuildNotes || "Notes"}: `), safeText(b.notes)) : null,
div({ class: "industry-dates-row" },
b.startDate ? p(span({ class: "industry-meta-label" }, `${i18n.industryBuildStart || "Start date"}: `), moment(b.startDate).format("YYYY-MM-DD")) : null,
b.endDate ? p(span({ class: "industry-meta-label" }, `${i18n.industryBuildEnd || "End date"}: `), moment(b.endDate).format("YYYY-MM-DD")) : null,
(b.endDate && status !== "COMPLETED" && status !== "FAILED")
? p(span({ class: "industry-meta-label" }, `${i18n.industryTimeLeft || "Time left"}: `), `${Math.max(0, Math.ceil((new Date(b.endDate) - Date.now()) / 86400000))}d`)
: null
)
),
renderBlueprintEstimate(b, i18n.industryFinalPrice || "Final price"),
govRows.length ? div({ class: "industry-actions" }, ...govRows) : null,
crossLinks,
safeArr(b.contributions).length
? div({ class: "industry-section" },
h2(i18n.industryContributions || "Contributions"),
table({ class: "industry-table" },
thead(tr(th(i18n.industryMember || "Member"), th(i18n.industryKindLabel || "Kind"), th(i18n.industryAmount || "Amount"), th(i18n.industryNote || "Note"))),
tbody(...contribRows)
)
)
: null,
contributeBlock,
distributeBlock,
allocationBlock
)
)
)
}

View file

@ -232,6 +232,7 @@ const renderJobList = exports.renderJobList = (jobs, filter, params = {}) => {
renderJobStatusChip(job.status),
isHidden ? renderJobHiddenChip() : null,
isSubscribed ? renderJobAppliedChip() : null,
job.industry ? a({ href: `/industry/${encodeURIComponent(job.industry)}` }, renderStateChip("whole", "🏭", String(i18n.industryTitle || "Industry").toUpperCase())) : null,
renderLifespanChip(job.lifetime, i18n)
].filter(Boolean)
const isExchange = String(job.job_type || "").toLowerCase() === "exchange"
@ -278,6 +279,7 @@ const renderJobForm = (job = {}, mode = "create") => {
enctype: "multipart/form-data"
},
input({ type: "hidden", name: "returnTo", value: "/jobs?filter=MINE" }),
job.industry ? input({ type: "hidden", name: "industry", value: job.industry }) : null,
label(i18n.jobType),
br(),
select(
@ -481,7 +483,15 @@ exports.jobsView = async (jobsOrCVs, filter = "ALL", params = {}) => {
)
: filter === "CREATE" || filter === "EDIT"
? (() => {
const jobToEdit = filter === "EDIT" ? (Array.isArray(jobsOrCVs) ? jobsOrCVs[0] : {}) : {}
const jobToEdit = filter === "EDIT"
? (Array.isArray(jobsOrCVs) ? jobsOrCVs[0] : {})
: {
...(params.industry ? { industry: params.industry } : {}),
...(params.title ? { title: params.title } : {}),
...(params.description ? { description: params.description } : {}),
...(params.tasks ? { tasks: params.tasks } : {}),
...(params.salary ? { salary: params.salary } : {})
}
return renderJobForm(jobToEdit, filter === "EDIT" ? "edit" : "create")
})()
: section(
@ -639,6 +649,7 @@ exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {})
renderJobStatusChip(job.status),
visibility === 'HIDDEN' ? renderJobHiddenChip() : null,
isSubscribed ? renderJobAppliedChip() : null,
job.industry ? a({ href: `/industry/${encodeURIComponent(job.industry)}` }, renderStateChip("whole", "🏭", String(i18n.industryTitle || "Industry").toUpperCase())) : null,
renderLifespanChip(job.lifetime, i18n),
renderEcoTax(job.msgSize, job.id),
renderReachChip(isClearnet, i18n)

View file

@ -25,10 +25,11 @@ const legacyView = async () => {
form(
{
action: "/legacy/export",
method: "POST",
method: "GET",
id: "exportForm"
},
label(i18n.exportPasswordLabel),
br(),
input({
type: "password",
name: "password",

View file

@ -23,7 +23,7 @@ const getUserId = async () => {
return userId;
};
const { a, article, br, body, button, details, div, em, footer, form, h1, h2, h3, head, header, hr, html, img, input, label, li, link, main, meta, nav, option, p, pre, section, select, span, summary, table, td, textarea, title, tr, ul, strong, script, video: videoHyperaxe, audio: audioHyperaxe } = require("../server/node_modules/hyperaxe");
const { a, article, br, body, button, details, div, em, footer, form, h1, h2, h3, head, header, hr, html, img, input, label, li, link, main, meta, nav, option, p, pre, section, select, span, summary, table, td, textarea, title, tr, ul, strong, video: videoHyperaxe, audio: audioHyperaxe } = require("../server/node_modules/hyperaxe");
const lodash = require("../server/node_modules/lodash");
const markdown = require("./markdown");
@ -112,67 +112,6 @@ const renderContentActions = (msgId, viewHref) => {
};
exports.renderContentActions = renderContentActions;
const emptyState = ({ icon, title, text, ctaHref, ctaLabel }) => div(
{ class: "empty-state" },
icon ? span({ class: "empty-state-icon" }, icon) : "",
title ? p({ class: "empty-state-title" }, title) : "",
text ? p({ class: "empty-state-text" }, text) : "",
(ctaHref && ctaLabel) ? a({ class: "empty-state-cta filter-btn", href: ctaHref }, ctaLabel) : ""
);
exports.emptyState = emptyState;
exports.bottomBarPickerView = () => {
const cfg = getConfig();
const modOn = (m) => !m || cfg.modules[m] === 'on' || cfg.modules[m] === undefined;
const MAX = 4;
const pinnedKeys = cfg.bottomBarPins || [];
const pinned = pinnedKeys
.map(key => exports.PINNABLE_MODULES.find(m => m.key === key))
.filter(Boolean);
const full = pinned.length >= MAX;
const actionForm = (action, key, cls, node) => form({ method: 'POST', action: '/settings/bottombar', class: cls },
input({ type: 'hidden', name: 'action', value: action }),
input({ type: 'hidden', name: 'key', value: key }),
node
);
const pinnedRow = (m) => actionForm('remove', m.key, 'bb-manage-form',
button({ type: 'submit', class: 'bb-manage-btn', 'aria-label': i18n.bbRemove || 'Remove' },
span({ class: 'bb-manage-ico' }, m.glyph),
span({ class: 'bb-manage-lbl' }, i18n[m.labelKey] || m.key),
span({ class: 'bb-manage-x' }, '✕')
)
);
const addBtn = (m) => actionForm('add', m.key, 'bb-pick-form',
button({ type: 'submit', class: 'bb-pick-btn' },
span({ class: 'bb-pick-ico' }, m.glyph),
span({ class: 'bb-pick-lbl' }, i18n[m.labelKey] || m.key)
)
);
const addable = exports.PINNABLE_MODULES.filter(m => modOn(m.mod) && !pinnedKeys.includes(m.key));
return template(
i18n.bbManage || 'Edit bar',
section(
div({ class: 'tags-header' },
h2(i18n.bbPickTitle || 'Customize the bar'),
p(i18n.bbPickDesc || 'Choose up to 4 shortcuts for the bottom bar.')
),
div({ class: 'bb-manage' },
h3(`${i18n.bbYourBar || 'Your bar'} (${pinned.length}/${MAX})`),
pinned.length
? div({ class: 'bb-manage-list' }, ...pinned.map(pinnedRow))
: p({ class: 'bb-manage-empty' }, i18n.bbPickNone || 'None')
),
div({ class: 'bb-manage-add' },
h3(i18n.bbAdd || 'Add'),
full
? p({ class: 'bb-manage-full' }, i18n.bbFull || 'Maximum reached')
: div({ class: 'bb-pick-grid' }, ...addable.map(addBtn))
),
a({ class: 'filter-btn bb-manage-done', href: '/activity' }, i18n.bbDone || 'Done')
)
);
};
exports.renderStateChip = renderStateChip;
exports.renderOpenClosedChip = renderOpenClosedChip;
exports.renderVisibilityChip = renderVisibilityChip;
@ -575,6 +514,21 @@ const renderWalletLink = () => {
return "";
};
const renderDevLink = () => {
const devMod = getConfig().modules.devMod === "on";
if (devMod) {
return [
navLink({
href: "/dev",
emoji: "⌘",
text: i18n.developer,
class: "dev-link enabled"
})
];
}
return "";
};
const renderLegacyLink = () => {
const legacyMod = getConfig().modules.legacyMod === "on";
if (legacyMod) {
@ -826,6 +780,19 @@ const renderProjectsLink = () => {
: "";
};
const renderIndustryLink = () => {
const industryMod = getConfig().modules.industryMod === "on";
return industryMod
? [
navLink({
href: "/industry",
emoji: "ꖴ",
text: i18n.industryTitle
})
]
: "";
};
const renderBankingLink = () => {
const bankingMod = getConfig().modules.bankingMod === "on";
return bankingMod
@ -1134,6 +1101,8 @@ const renderTasksLink = () => {
: "";
};
// === Oasis UX (portado de 0.8.7_UX): topbar + bottombar + hive ===
const renderMobileTopbar = () => div(
{ class: "oasis-mobile-topbar" },
a({ class: "omt-logo", href: "/" }, img({ src: "/assets/images/snh-oasis.jpg", alt: "Oasis" })),
@ -1223,16 +1192,6 @@ const renderBottomBar = () => {
);
};
const RADIAL_ITEMS = [
{ key: 'search', href: '/search', glyph: 'ꔅ', labelKey: 'searchTitle', mod: null },
{ key: 'chats', href: '/chats', glyph: 'ꖒ', labelKey: 'chatsTitle', mod: 'chatsMod' },
{ key: 'tribes', href: '/tribes', glyph: 'ꖥ', labelKey: 'tribesTitle', mod: 'tribesMod' },
{ key: 'market', href: '/market', glyph: 'ꕻ', labelKey: 'marketTitle', mod: 'marketMod' },
{ key: 'events', href: '/events', glyph: 'ꕆ', labelKey: 'eventsLabel', mod: 'eventsMod' },
{ key: 'forum', href: '/forum', glyph: 'ꕒ', labelKey: 'forumTitle', mod: 'forumMod' },
{ key: 'agenda', href: '/agenda', glyph: 'ꗤ', labelKey: 'agendaTitle', mod: 'agendaMod' },
];
const renderHiveNav = () => {
if (process.env.OASIS_MOBILE !== '1') return "";
const cfg = getConfig();
@ -1299,14 +1258,9 @@ const template = (titlePrefix, ...elements) => {
const currentConfig = getConfig();
const theme = currentConfig.themes.current || "Dark-SNH";
const uxMode = currentConfig.ux?.current === "ainav" ? "ainav" : "blocks";
const baseTheme = theme === "OasisMobileUX" ? "Dark-SNH" : theme;
const mobileUXActive = (process.env.OASIS_MOBILE === '1') || (theme === "OasisMobileUX");
const mobileUXLink = mobileUXActive
? link({ rel: "stylesheet", href: "/assets/themes/OasisMobileUX.css", ...(process.env.OASIS_MOBILE === '1' ? {} : { media: "(max-width: 768px)" }) })
: "";
const themeLink = link({
rel: "stylesheet",
href: `/assets/themes/${baseTheme}.css`
href: `/assets/themes/${theme}.css`
});
const nodes = html(
{ lang: "en" },
@ -1315,7 +1269,6 @@ const template = (titlePrefix, ...elements) => {
link({ rel: "stylesheet", href: "/assets/styles/style.css" }),
themeLink,
link({ rel: "stylesheet", href: "/assets/styles/mobile.css", ...(process.env.OASIS_MOBILE === '1' ? {} : { media: "(max-width: 768px)" }) }),
mobileUXLink,
link({ rel: "icon", href: "/assets/images/favicon.svg" }),
meta({ charset: "utf-8" }),
meta({ name: "description", content: i18n.oasisDescription }),
@ -1323,8 +1276,7 @@ const template = (titlePrefix, ...elements) => {
name: "viewport",
content: toAttributes({
width: "device-width",
"initial-scale": 1,
"viewport-fit": "cover"
"initial-scale": 1
})
})
),
@ -1334,50 +1286,6 @@ const template = (titlePrefix, ...elements) => {
div(
{ class: uxMode === "ainav" ? "header ainav-only" : "header" },
renderMobileTopbar(),
div(
{ class: "top-bar-left" },
a(
{ class: "logo-icon", href: "/" },
img({
class: "logo-icon",
src: "/assets/images/snh-oasis.jpg",
alt: "Oasis Logo"
})
),
uxMode === "ainav" ? nav(
ul(
(() => {
const inboxCount = sharedState.getInboxCount();
const badge = inboxCount > 0 ? span({ class: 'inbox-badge' }, String(inboxCount)) : '';
return li(
a({ href: "/inbox" },
span({ class: "emoji" }, "☂"), nbsp, i18n.inbox, badge
)
);
})(),
navLink({ href: "/settings", emoji: "⚙", text: i18n.settings }),
navLink({ href: "/invites", emoji: "ꔮ", text: i18n.invites })
)
) : nav(
ul(
(() => {
const inboxCount = sharedState.getInboxCount();
const badge = inboxCount > 0 ? span({ class: 'inbox-badge' }, String(inboxCount)) : '';
return li(
a({ href: "/inbox" },
span({ class: "emoji" }, "☂"), nbsp, i18n.inbox, badge
)
);
})(),
navLink({
href: "/pm",
emoji: "ꕕ",
text: i18n.privateMessage
}),
navLink({ href: "/publish", emoji: "❂", text: i18n.publish })
)
)
),
div(
{ class: "oasis-mobile-menus" },
label({ for: "nav-left-toggle", class: "oasis-menu-btn" },
@ -1404,26 +1312,7 @@ const template = (titlePrefix, ...elements) => {
button({ type: 'submit', class: 'ai-ask-btn' }, '➤')
)
);
})(),
uxMode === "ainav" ? div(
{ class: "top-bar-right" },
nav(
ul(
navLink({ href: "/activity", emoji: "ꔙ", text: i18n.activityTitle }),
navLink({ href: "/graphos", emoji: "ꕢ", text: i18n.graphos }),
navLink({ href: "/peers", emoji: "⧖", text: i18n.peers })
)
)
) : div(
{ class: "top-bar-right" },
nav(
ul(
navLink({ href: "/search", emoji: "ꔅ", text: i18n.searchTitle }),
renderGraphosLink(),
navLink({ href: "/peers", emoji: "⧖", text: i18n.peers })
)
)
)
})()
),
(() => {
const updateFlagPath = path.join(__dirname, '../server/.update_required');
@ -1437,6 +1326,24 @@ const template = (titlePrefix, ...elements) => {
}
return null;
})(),
(() => {
try {
const onboarding = require('../models/onboarding_model');
if (!onboarding.bannerVisible(config && config.path)) return null;
return div(
{ class: "update-banner welcome-banner" },
span({ class: "update-banner-icon" }, "🌴"),
span({ class: "update-banner-text" }, i18n.welcomeBannerText),
a({ href: "/welcome", class: "update-banner-link" }, i18n.welcomeBannerAction),
form(
{ method: "POST", action: "/welcome/dismiss", class: "welcome-banner-close" },
button({ type: "submit", class: "welcome-banner-close-btn" }, "✕")
)
);
} catch (_) {
return null;
}
})(),
div(
{ class: uxMode === "ainav" ? "main-content ainav-only" : "main-content" },
uxMode === "ainav" ? null : div(
@ -1511,6 +1418,7 @@ const template = (titlePrefix, ...elements) => {
renderBankingLink(),
renderMarketLink(),
renderProjectsLink(),
renderIndustryLink(),
renderJobsLink(),
renderShopsLink(),
renderTransfersLink()
@ -1527,6 +1435,7 @@ const template = (titlePrefix, ...elements) => {
emoji: "ꖸ",
text: i18n.blockchain
}),
renderDevLink(),
renderCipherLink(),
renderInvitesLink(),
renderLegacyLink(),
@ -1618,7 +1527,6 @@ const template = (titlePrefix, ...elements) => {
)
),
renderBottomBar(),
script({ src: "/js/oasis-mobile-nav.js", defer: "defer" }),
renderFooter()
)
);
@ -1628,23 +1536,69 @@ const template = (titlePrefix, ...elements) => {
exports.template = template;
exports.bottomBarPickerView = () => {
const cfg = getConfig();
const modOn = (m) => !m || cfg.modules[m] === 'on' || cfg.modules[m] === undefined;
const MAX = 4;
const pinnedKeys = cfg.bottomBarPins || [];
const pinned = pinnedKeys
.map(key => exports.PINNABLE_MODULES.find(m => m.key === key))
.filter(Boolean);
const full = pinned.length >= MAX;
const actionForm = (action, key, cls, node) => form({ method: 'POST', action: '/settings/bottombar', class: cls },
input({ type: 'hidden', name: 'action', value: action }),
input({ type: 'hidden', name: 'key', value: key }),
node
);
const pinnedRow = (m) => actionForm('remove', m.key, 'bb-manage-form',
button({ type: 'submit', class: 'bb-manage-btn', 'aria-label': i18n.bbRemove || 'Remove' },
span({ class: 'bb-manage-ico' }, m.glyph),
span({ class: 'bb-manage-lbl' }, i18n[m.labelKey] || m.key),
span({ class: 'bb-manage-x' }, '✕')
)
);
const addBtn = (m) => actionForm('add', m.key, 'bb-pick-form',
button({ type: 'submit', class: 'bb-pick-btn' },
span({ class: 'bb-pick-ico' }, m.glyph),
span({ class: 'bb-pick-lbl' }, i18n[m.labelKey] || m.key)
)
);
const addable = exports.PINNABLE_MODULES.filter(m => modOn(m.mod) && !pinnedKeys.includes(m.key));
return template(
i18n.bbManage || 'Edit bar',
section(
div({ class: 'tags-header' },
h2(i18n.bbPickTitle || 'Customize the bar'),
p(i18n.bbPickDesc || 'Choose up to 4 shortcuts for the bottom bar.')
),
div({ class: 'bb-manage' },
h3(`${i18n.bbYourBar || 'Your bar'} (${pinned.length}/${MAX})`),
pinned.length
? div({ class: 'bb-manage-list' }, ...pinned.map(pinnedRow))
: p({ class: 'bb-manage-empty' }, i18n.bbPickNone || 'None')
),
div({ class: 'bb-manage-add' },
h3(i18n.bbAdd || 'Add'),
full
? p({ class: 'bb-manage-full' }, i18n.bbFull || 'Maximum reached')
: div({ class: 'bb-pick-grid' }, ...addable.map(addBtn))
),
a({ class: 'filter-btn bb-manage-done', href: '/activity' }, i18n.bbDone || 'Done')
)
);
};
exports.ainavHomeView = ({ recentTags = [] } = {}) => {
const currentConfig = getConfig();
const theme = currentConfig.themes.current || "Dark-SNH";
const baseTheme = theme === "OasisMobileUX" ? "Dark-SNH" : theme;
const mobileUXActive = (process.env.OASIS_MOBILE === '1') || (theme === "OasisMobileUX");
const mobileUXLink = mobileUXActive
? link({ rel: "stylesheet", href: "/assets/themes/OasisMobileUX.css", ...(process.env.OASIS_MOBILE === '1' ? {} : { media: "(max-width: 768px)" }) })
: "";
const placeholder = i18n.aiNavPlaceholder || 'Where do you want to go?';
const nodes = html(
{ lang: "en" },
head(
title(placeholder, " | Oasis"),
link({ rel: "stylesheet", href: "/assets/styles/style.css" }),
link({ rel: "stylesheet", href: `/assets/themes/${baseTheme}.css` }),
link({ rel: "stylesheet", href: `/assets/themes/${theme}.css` }),
link({ rel: "stylesheet", href: "/assets/styles/mobile.css", ...(process.env.OASIS_MOBILE === '1' ? {} : { media: "(max-width: 768px)" }) }),
mobileUXLink,
link({ rel: "icon", href: "/assets/images/favicon.svg" }),
meta({ charset: "utf-8" }),
meta({ name: "description", content: i18n.oasisDescription }),
@ -3156,7 +3110,12 @@ exports.mentionsView = ({ messages, myFeedId }) => {
);
};
exports.privateView = async (messagesInput, filter, decrypted = null) => {
exports.privateView = async (messagesInput, filter, decrypted = null, notice = '') => {
const noticeText = notice === 'unavailable'
? (i18n.fileShareUnavailable || 'This file is not available right now. Try again later.')
: notice === 'badkey'
? (i18n.pmCrypterBadKey || 'Your shared key is incorrect!')
: ''
const messagesRaw = Array.isArray(messagesInput) ? messagesInput : messagesInput.messages
const messages = (messagesRaw || []).filter(m => m && m.key && m.value && m.value.content && m.value.content.type === 'post' && m.value.content.private === true)
const userId = await getUserId()
@ -3169,7 +3128,8 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
const hrefFor = {
job: (id) => `/jobs/${encodeURIComponent(id)}`,
project: (id) => `/projects/${encodeURIComponent(id)}`,
market: (id) => `/market/${encodeURIComponent(id)}`
market: (id) => `/market/${encodeURIComponent(id)}`,
shopProduct: (id) => `/shops/product/${encodeURIComponent(id)}`
}
const clickableCardProps = (href, extraClass = '') => {
@ -3184,6 +3144,15 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
const chip = (txt) => span({ class: 'chip' }, txt)
const fmtBytes = (n) => {
const b = Number(n) || 0
if (b < 1024) return `${b} B`
const units = ['KB', 'MB', 'GB', 'TB']
let v = b / 1024, i = 0
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1)} ${units[i]}`
}
const e2eChip = () => span({ class: 'pm-exposition-chip pm-exposition-encrypted' },
span({ class: 'pm-exposition-icon' }, '🔒'),
span({ class: 'pm-exposition-text' }, i18n.encryptedChipLabel || 'E2E')
@ -3229,17 +3198,7 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
try { return Buffer.byteLength(JSON.stringify(m && m.value), 'utf8'); } catch (_) { return 0; }
}
function bubbleMeta({ sentAt, subjectRaw, crypter, msgSize, msgKey }) {
const ecoChip = msgSize ? renderEcoTax(msgSize, msgKey) : null
return div({ class: 'pm-bubble-meta' },
subjectRaw ? span({ class: 'pm-bubble-subject' }, subjectRaw) : null,
span({ class: 'pm-bubble-time' }, moment(sentAt).format('DD/MM HH:mm')),
span({ class: 'pm-bubble-lock', title: crypter ? '2xE2E' : 'E2E' }, crypter ? '🔒🔒' : '🔒'),
ecoChip ? span({ class: 'pm-bubble-eco' }, ecoChip) : null
)
}
function actions({ key, replyId, subjectRaw, text }) {
function actions({ key, replyId, subjectRaw, text, extra = null }) {
const stop = { onclick: 'event.stopPropagation()' }
const subjectReply = /^(\s*RE:\s*)/i.test(subjectRaw || '') ? (subjectRaw || '') : `RE: ${subjectRaw || ''}`
const isSelf = replyId === userId
@ -3250,6 +3209,7 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
input({ type: 'hidden', name: 'quote', value: text || '' }),
button({ type: 'submit', class: 'pm-btn reply-btn' }, i18n.pmReply.toUpperCase())
),
extra || null,
form({ method: 'POST', action: `/inbox/delete/${encodeURIComponent(key)}`, class: 'pm-action-form', ...stop },
button({ type: 'submit', class: 'pm-btn delete-btn danger-btn' }, i18n.privateDelete.toUpperCase())
)
@ -3293,6 +3253,10 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
const m = str.match(/\/market\/([%A-Za-z0-9/+._=-]+\.sha256)/)
return m ? m[1] : ''
}
if (kind === 'shopProduct') {
const m = str.match(/\/shops\/product\/([%A-Za-z0-9/+._=-]+\.sha256)/)
return m ? m[1] : ''
}
return ''
}
@ -3315,15 +3279,27 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
}
}
flushQuote()
return parts.join('<br>')
const mdLinks = []
const masked = parts.join('<br>')
.replace(/\[([^\]\n]{1,120})\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)/g, (match, label, href) => {
const idx = mdLinks.length
const safeHref = String(href).replace(/"/g, '%22')
const ext = /^https?:/.test(href) ? ' target="_blank" rel="noopener noreferrer"' : ''
mdLinks.push(`<a class="oasis-md-link" href="${safeHref}"${ext}>${label}</a>`)
return `\u0000MD${idx}\u0000`
})
return masked
.replace(/(@[a-zA-Z0-9/+._=-]+\.ed25519)/g, (match, id) => `<a class="user-link" href="/author/${encodeURIComponent(id)}">${userLinkLabel(id)}</a>`)
.replace(/\/jobs\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="job-link" href="${hrefFor.job(id)}">${match}</a>`)
.replace(/\/projects\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="project-link" href="${hrefFor.project(id)}">${match}</a>`)
.replace(/\/market\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="market-link" href="${hrefFor.market(id)}">${match}</a>`)
.replace(/\/calendars\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="calendar-link" href="/calendars/${encodeURIComponent(id)}">${match}</a>`)
.replace(/\/ai\/ask\?[^\s<"]+/g, (match) => `<a class="ai-ask-link" href="${match}">${match}</a>`)
.replace(/(?<![A-Za-z0-9_])\/(profile|inbox|invites|peers|tribes|inhabitants|publish|activity|settings|modules|banking|larp|melody|audios|videos|images|documents|bookmarks|torrents|forum|feed|fediverse|events|tasks|votes|reports|market|jobs|projects|shops|pixelia|opinions|trending|agenda|cv|favorites|stats|blockexplorer|wallet|chats|pads|maps|calendars|ai|games)(?![A-Za-z0-9_\/])/g, (match) => `<a class="oasis-path-link" href="${match}">${match}</a>`)
.replace(/\/tribe\/([%A-Za-z0-9/+._=-]+\.sha256)(\?section=[a-zA-Z]+)?/g, (match) => `<a class="tribe-link" href="${match}">${match}</a>`)
.replace(/\/larp\/([a-zA-Z]+)/g, (match) => `<a class="larp-link" href="${match}">${match}</a>`)
.replace(/(?<![A-Za-z0-9_])\/(profile|inbox|invites|peers|tribes|inhabitants|publish|activity|settings|modules|banking|larp|parliament|courts|melody|audios|videos|images|documents|bookmarks|torrents|forum|feed|fediverse|events|tasks|votes|reports|market|jobs|projects|industry|shops|pixelia|opinions|trending|agenda|cv|favorites|stats|blockexplorer|wallet|chats|pads|maps|calendars|ai|games|search)(?![A-Za-z0-9_\/])/g, (match) => `<a class="oasis-path-link" href="${match}">${match}</a>`)
.replace(/(https?:\/\/[^\s<"]+)/g, (match) => `<a href="${match}" target="_blank" rel="noopener noreferrer">${match}</a>`)
.replace(/\u0000MD(\d+)\u0000/g, (m, i) => mdLinks[Number(i)] !== undefined ? mdLinks[Number(i)] : m)
}
const threads = {}
@ -3374,7 +3350,7 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
const href = jobId ? hrefFor.job(jobId) : null
return div(
clickableCardProps(href, `job-notification thread-level-0`),
headerLine({ sentAt, from, toLinks, subject: type, msgKey: key, msgSize }),
headerLine({ sentAt, from, toLinks, subject: titleH, msgKey: key, msgSize }),
h2({ class: 'pm-title' }, `${icon} ${i18n.pmBotJobs} · ${titleH}`),
p(
i18n.pmInhabitantWithId, ' ',
@ -3398,7 +3374,7 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
const href = projectId ? hrefFor.project(projectId) : null
return div(
clickableCardProps(href, `project-${isFollow ? 'follow' : 'unfollow'}-notification thread-level-0`),
headerLine({ sentAt, from, toLinks, subject: type, msgKey: key, msgSize }),
headerLine({ sentAt, from, toLinks, subject: titleH, msgKey: key, msgSize }),
h2({ class: 'pm-title' }, `${icon} ${i18n.pmBotProjects} · ${titleH}`),
p(
i18n.pmInhabitantWithId, ' ',
@ -3412,23 +3388,28 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
)
}
function MarketSoldCard({ sentAt, from, toLinks, subject, text, key, msgSize }) {
function MarketSoldCard({ sentAt, from, toLinks, subject, text, key, msgSize, variant = 'market' }) {
const isShop = variant === 'shop'
const itemTitle = quoted(subject) || quoted(text) || 'item'
const buyerId = (text.match(/OASIS ID:\s*([\w=/+.-]+)/) || [])[1] || from
const price = (text.match(/for:\s*\$([\d.]+)/) || [])[1] || ''
const price = (text.match(/for:\s*\$?([\d.]+)/) || [])[1] || ''
const marketId = pickLink(text, 'market')
const href = marketId ? hrefFor.market(marketId) : null
const shopProductId = pickLink(text, 'shopProduct')
const href = isShop ? (shopProductId ? hrefFor.shopProduct(shopProductId) : null) : (marketId ? hrefFor.market(marketId) : null)
const icon = isShop ? '🛍️' : '💰'
const botLabel = isShop ? i18n.pmBotShops : i18n.pmBotMarket
const soldTitle = isShop ? (i18n.inboxShopSoldTitle || 'Product Sold') : i18n.inboxMarketItemSoldTitle
return div(
clickableCardProps(href, 'market-sold-notification thread-level-0'),
headerLine({ sentAt, from, toLinks, subject, msgKey: key, msgSize }),
h2({ class: 'pm-title' }, `💰 ${i18n.pmBotMarket} · ${i18n.inboxMarketItemSoldTitle}`),
clickableCardProps(href, (isShop ? 'shop-sold-notification' : 'market-sold-notification') + ' thread-level-0'),
headerLine({ sentAt, from, toLinks, subject: soldTitle, msgKey: key, msgSize }),
h2({ class: 'pm-title' }, `${icon} ${botLabel} · ${soldTitle}`),
p(
i18n.pmYourItem, ' ',
href ? a({ class: 'market-link', href }, `"${itemTitle}"`) : `"${itemTitle}"`,
href ? a({ class: isShop ? 'shop-link' : 'market-link', href }, `"${itemTitle}"`) : `"${itemTitle}"`,
' ',
i18n.pmHasBeenSoldTo, ' ',
linkAuthor(buyerId),
price ? ` ${i18n.pmFor} $${price}.` : '.'
price ? ` ${i18n.pmFor} ${price} ECO.` : '.'
),
actions({ key, replyId: buyerId, subjectRaw: itemTitle, text })
)
@ -3441,7 +3422,7 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
const href = projectId ? hrefFor.project(projectId) : null
return div(
clickableCardProps(href, 'project-pledge-notification thread-level-0'),
headerLine({ sentAt, from, toLinks, subject: 'PROJECT_PLEDGE', msgKey: key, msgSize }),
headerLine({ sentAt, from, toLinks, subject: i18n.inboxProjectPledgedTitle, msgKey: key, msgSize }),
h2({ class: 'pm-title' }, `💚 ${i18n.pmBotProjects} · ${i18n.inboxProjectPledgedTitle}`),
p(
i18n.pmInhabitantWithId, ' ',
@ -3455,6 +3436,41 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
)
}
function PoliticalBotCard({ subjectU, sentAt, from, toLinks, text, key, msgSize }) {
const titleMap = {
LARP_RULING: i18n.politicalBotLarpTitle || 'The ruling house of the L.A.R.P has changed.',
PARLIAMENT_GOV: i18n.politicalBotParliamentTitle || 'A new government cycle has begun in the Parliament.',
TRIBE_GOV: i18n.politicalBotTribeTitle || 'A new government cycle has begun in one of your Tribes.'
}
const title = titleMap[subjectU] || (i18n.pmBotPolitical || 'PoliticalBot')
return div(
{ class: 'pm-card political-bot-notification thread-level-0' },
headerLine({ sentAt, from, toLinks, subject: title, msgKey: key, msgSize }),
h2({ class: 'pm-title' }, `🏛️ ${i18n.pmBotPolitical} · ${title}`),
div({ class: 'message-text', innerHTML: sanitizeHtml(clickableLinks(text || '')) }),
actions({ key, replyId: from, subjectRaw: title, text })
)
}
function IndustryBotCard({ subjectU, sentAt, from, toLinks, text, key, msgSize }) {
const titleMap = {
INDUSTRY_ADMITTED: i18n.industryBotAdmittedTitle || 'You have been admitted to a facility.',
INDUSTRY_APPLICATION: i18n.industryBotApplicationTitle || 'New membership application in your facility.',
INDUSTRY_INVITED: i18n.industryBotInvitedTitle || 'You have been invited to a facility.',
INDUSTRY_DISSOLVED: i18n.industryBotDissolvedTitle || 'A facility you belong to has been dissolved.',
INDUSTRY_BUILD_APPROVED: i18n.industryBotBuildApprovedTitle || 'A build has been approved.',
INDUSTRY_DISTRIBUTED: i18n.industryBotDistributedTitle || 'A build output has been distributed.'
}
const title = titleMap[subjectU] || (i18n.pmBotIndustry || 'IndustryBot')
return div(
{ class: 'pm-card industry-bot-notification thread-level-0' },
headerLine({ sentAt, from, toLinks, subject: title, msgKey: key, msgSize }),
h2({ class: 'pm-title' }, `🏭 ${i18n.pmBotIndustry || 'IndustryBot'} · ${title}`),
div({ class: 'message-text', innerHTML: sanitizeHtml(clickableLinks(text || '')) }),
actions({ key, replyId: from, subjectRaw: title, text })
)
}
const { renderEncryptedChip } = require('./clearnet_view');
return template(
i18n.private,
@ -3463,6 +3479,7 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
div({ class: 'title-with-chip' }, h2(i18n.private), renderEncryptedChip(i18n)),
p(i18n.privateDescription)
),
noticeText ? div({ class: 'pm-form-error-msg' }, p('✗ ' + noticeText)) : null,
(() => {
const pmVis = getConfig().pmVisibility === 'mutuals' ? 'mutuals' : 'whole'
const pmVisLabel = pmVis === 'mutuals' ? i18n.settingsPmVisibilityMutuals : i18n.settingsPmVisibilityWhole
@ -3526,7 +3543,35 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
const toLinks = Array.isArray(content.to) ? content.to.map(addr => linkAuthor(addr)) : []
const level = threadLevel(subjectRaw)
const msgSize = msgSizeBytes(msg)
const isMob = process.env.OASIS_MOBILE === '1'
if (content.fileShare && typeof content.fileShare === 'object') {
const fsp = content.fileShare
const dblKey = (decrypted && decrypted.key === msg.key) ? decrypted : null
return div(
{ class: 'pm-card normal-pm pm-fileshare-card' },
headerLine({ sentAt, from: fromResolved, toLinks, subject: subjectRaw, msgKey: msg.key, msgSize, crypter: !!fsp.crypter }),
div({ class: 'pm-fileshare-info' },
span({ class: 'pm-fileshare-icon' }, '📎'),
span({ class: 'pm-fileshare-name' }, String(fsp.filename || 'file')),
span({ class: 'pm-fileshare-meta' }, `${fmtBytes(fsp.size)} · ${String(fsp.mime || 'application/octet-stream')}`)
),
fsp.crypter
? form({ method: 'GET', action: `/inbox/file/${encodeURIComponent(msg.key)}`, class: 'pm-fileshare-download-form' },
dblKey && dblKey.error ? div({ class: 'pm-form-error-msg' }, p('✗ ' + (i18n.pmCrypterBadKey || 'Your shared key is incorrect!'))) : null,
input({ type: 'text', name: 'key', placeholder: 'd66d7d32f4d30f34812aee3d01347154', minlength: 32, maxlength: 32, size: 34, required: true, class: 'pm-crypter-key-input' }),
button({ type: 'submit', class: 'pm-btn pm-fileshare-download' }, (i18n.fileShareDownload || 'Download').toUpperCase())
)
: null,
actions({
key: msg.key, replyId: fromResolved, subjectRaw, text: '',
extra: fsp.crypter
? null
: form({ method: 'GET', action: `/inbox/file/${encodeURIComponent(msg.key)}`, class: 'pm-action-form', onclick: 'event.stopPropagation()' },
button({ type: 'submit', class: 'pm-btn pm-fileshare-download' }, (i18n.fileShareDownload || 'Download').toUpperCase())
)
})
)
}
if (subjectU === 'JOB_SUBSCRIBED' || subjectU === 'JOB_UNSUBSCRIBED') {
return JobCard({ type: subjectU, sentAt, from: fromResolved, toLinks, text, key: msg.key, msgSize })
@ -3535,7 +3580,16 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
return ProjectFollowCard({ type: subjectU, sentAt, from: fromResolved, toLinks, text, key: msg.key, msgSize })
}
if (subjectU === 'MARKET_SOLD') {
return MarketSoldCard({ sentAt, from: fromResolved, toLinks, subject: subjectRaw, text, key: msg.key, msgSize })
return MarketSoldCard({ sentAt, from: fromResolved, toLinks, subject: subjectRaw, text, key: msg.key, msgSize, variant: 'market' })
}
if (subjectU === 'SHOP_SOLD') {
return MarketSoldCard({ sentAt, from: fromResolved, toLinks, subject: subjectRaw, text, key: msg.key, msgSize, variant: 'shop' })
}
if (subjectU === 'LARP_RULING' || subjectU === 'PARLIAMENT_GOV' || subjectU === 'TRIBE_GOV') {
return PoliticalBotCard({ subjectU, sentAt, from: fromResolved, toLinks, text, key: msg.key, msgSize })
}
if (subjectU === 'INDUSTRY_ADMITTED' || subjectU === 'INDUSTRY_APPLICATION' || subjectU === 'INDUSTRY_INVITED' || subjectU === 'INDUSTRY_DISSOLVED' || subjectU === 'INDUSTRY_BUILD_APPROVED' || subjectU === 'INDUSTRY_DISTRIBUTED') {
return IndustryBotCard({ subjectU, sentAt, from: fromResolved, toLinks, text, key: msg.key, msgSize })
}
if (subjectU === 'PROJECT_PLEDGE' || content.meta?.type === 'project-pledge') {
return ProjectPledgeCard({ sentAt, from: fromResolved, toLinks, content, text, key: msg.key, msgSize })
@ -3544,48 +3598,23 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
if (content.crypter === true) {
const dec = (decrypted && decrypted.key === msg.key) ? decrypted : null
const shownText = dec && typeof dec.text === 'string' ? dec.text : ''
const cbody = dec && typeof dec.text === 'string'
? div({ class: 'message-text', innerHTML: sanitizeHtml(clickableLinks(dec.text)) })
: dec && dec.error
? div({ class: 'pm-form-error-msg' }, p('✗ ' + i18n.pmCrypterBadKey))
: null
const cform = dec && typeof dec.text === 'string'
? null
: form({ method: 'POST', action: '/inbox/decrypt', class: 'pm-crypter-decrypt-form' },
input({ type: 'hidden', name: 'id', value: msg.key }),
input({ type: 'hidden', name: 'returnFilter', value: filter || 'all' }),
input({ type: 'text', name: 'key', placeholder: 'd66d7d32f4d30f34812aee3d01347154', minlength: 32, maxlength: 32, size: 34, required: true, class: 'pm-crypter-key-input' }),
button({ type: 'submit', class: 'pm-btn pm-crypter-decrypt-btn' }, (i18n.pmCrypterDecryptButton || 'Decrypt').toUpperCase())
)
const cacts = actions({ key: msg.key, replyId: fromResolved, subjectRaw, text: shownText })
if (isMob) {
const mine = isSent(msg)
return div(
{ class: `pm-card normal-pm pm-crypter-card pm-bubble ${mine ? 'pm-sent' : 'pm-recv'} thread-level-${level}` },
mine ? null : div({ class: 'pm-bubble-sender' }, linkAuthor(fromResolved)),
cbody,
cform,
bubbleMeta({ sentAt, subjectRaw, crypter: true, msgSize, msgKey: msg.key }),
cacts
)
}
return div(
{ class: 'pm-card normal-pm pm-crypter-card' },
headerLine({ sentAt, from: fromResolved, toLinks, subject: subjectRaw, msgKey: msg.key, msgSize, crypter: true }),
cbody,
cform,
cacts
)
}
if (isMob) {
const mine = isSent(msg)
return div(
{ class: `pm-card normal-pm pm-bubble ${mine ? 'pm-sent' : 'pm-recv'} thread-level-${level}` },
mine ? null : div({ class: 'pm-bubble-sender' }, linkAuthor(fromResolved)),
div({ class: 'message-text', innerHTML: sanitizeHtml(clickableLinks(text)) }),
bubbleMeta({ sentAt, subjectRaw, crypter: false, msgSize, msgKey: msg.key }),
actions({ key: msg.key, replyId: fromResolved, subjectRaw, text })
dec && typeof dec.text === 'string'
? div({ class: 'message-text', innerHTML: sanitizeHtml(clickableLinks(dec.text)) })
: dec && dec.error
? div({ class: 'pm-form-error-msg' }, p('✗ ' + i18n.pmCrypterBadKey))
: null,
dec && typeof dec.text === 'string'
? null
: form({ method: 'POST', action: '/inbox/decrypt', class: 'pm-crypter-decrypt-form' },
input({ type: 'hidden', name: 'id', value: msg.key }),
input({ type: 'hidden', name: 'returnFilter', value: filter || 'all' }),
input({ type: 'text', name: 'key', placeholder: 'd66d7d32f4d30f34812aee3d01347154', minlength: 32, maxlength: 32, size: 34, required: true, class: 'pm-crypter-key-input' }),
button({ type: 'submit', class: 'pm-btn pm-crypter-decrypt-btn' }, (i18n.pmCrypterDecryptButton || 'Decrypt').toUpperCase())
),
actions({ key: msg.key, replyId: fromResolved, subjectRaw, text: shownText })
)
}
@ -3608,7 +3637,7 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
threadGroups[tid].push(msg)
}
if (!threadOrder.length) return emptyState({ icon: "✉", title: i18n.noPrivateMessages, text: i18n.emptyConnectHint, ctaHref: "/invites", ctaLabel: i18n.emptyConnectCta })
if (!threadOrder.length) return p({ class: 'empty' }, i18n.noPrivateMessages)
return threadOrder.map(tid => {
const msgs = threadGroups[tid]
@ -3741,7 +3770,7 @@ exports.publishView = (preview, text, contentWarning) => {
cols: "50",
placeholder: i18n.publishWarningPlaceholder,
class: "publish-textarea",
maxlength: "8096"
maxlength: "7000"
},
text || ""
),

View file

@ -411,6 +411,7 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
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" }),
((itemEdit && itemEdit.industry) || params.industry) ? input({ type: "hidden", name: "industry", value: (itemEdit && itemEdit.industry) || params.industry }) : null,
label(i18n.marketItemType),
br(),
select(
@ -422,12 +423,12 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
br(),
label(i18n.marketItemTitle),
br(),
input({ type: "text", name: "title", id: "title", value: (itemEdit && itemEdit.title) || "", required: true }),
input({ type: "text", name: "title", id: "title", value: (itemEdit && itemEdit.title) || params.title || "", required: true }),
br(),
br(),
label(i18n.marketItemDescription),
br(),
textarea({ name: "description", id: "description", placeholder: i18n.marketItemDescriptionPlaceholder, rows: "6", required: true }, (itemEdit && itemEdit.description) || ""),
textarea({ name: "description", id: "description", placeholder: i18n.marketItemDescriptionPlaceholder, rows: "6", required: true }, (itemEdit && itemEdit.description) || params.description || ""),
br(),
br(),
label(i18n.marketCreateFormImageLabel),
@ -447,7 +448,7 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
br(),
label(i18n.marketItemStock),
br(),
input({ type: "number", name: "stock", id: "stock", value: (itemEdit && itemEdit.stock) || 1, required: true, min: "1", step: "1" }),
input({ type: "number", name: "stock", id: "stock", value: (itemEdit && itemEdit.stock) || params.stock || 1, required: true, min: "1", step: "1" }),
br(),
br(),
label(i18n.mapLocationTitle || "Map Location"),
@ -466,12 +467,12 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
br(),
label(i18n.marketItemPrice),
br(),
input({ type: "number", name: "price", id: "price", value: (itemEdit && itemEdit.price) || "", required: true, step: "0.000001", min: "0.000001" }),
input({ type: "number", name: "price", id: "price", value: (itemEdit && itemEdit.price) || params.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(", ")) || "" }),
input({ type: "text", name: "tags", id: "tags", placeholder: i18n.marketItemTagsPlaceholder, value: (itemEdit && itemEdit.tags && itemEdit.tags.join(", ")) || params.tags || "" }),
br(),
br(),
label(i18n.marketItemDeadline),
@ -549,6 +550,7 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
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,
item.industry ? a({ href: `/industry/${encodeURIComponent(item.industry)}` }, renderStateChip("whole", "🏭", String(i18n.industryTitle || "Industry").toUpperCase())) : null,
renderLifespanChip(item.lifetime, i18n)
),
div({ class: "market-card-price card-date-highlight" }, `${item.price} ECO`),
@ -618,6 +620,7 @@ exports.singleMarketView = async (item, filter, comments = [], params = {}) => {
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,
item.industry ? a({ href: `/industry/${encodeURIComponent(item.industry)}` }, renderStateChip("whole", "🏭", String(i18n.industryTitle || "Industry").toUpperCase())) : null,
renderLifespanChip(item.lifetime, i18n),
renderEcoTax(item.msgSize, item.id)
].filter(Boolean)

View file

@ -15,6 +15,7 @@ const modulesView = () => {
{ 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: 'dev', label: i18n.modulesDevLabel, description: i18n.modulesDevDescription },
{ name: 'docs', label: i18n.modulesDocsLabel, description: i18n.modulesDocsDescription },
{ name: 'events', label: i18n.modulesEventsLabel, description: i18n.modulesEventsDescription },
{ name: 'favorites', label: i18n.modulesFavoritesLabel, description: i18n.modulesFavoritesDescription },
@ -24,10 +25,12 @@ const modulesView = () => {
{ 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: 'industry', label: i18n.modulesIndustryLabel, description: i18n.modulesIndustryDescription },
{ 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: 'larp', label: i18n.modulesLarpLabel, description: i18n.modulesLarpDescription },
{ name: 'latest', label: i18n.modulesLatestLabel, description: i18n.modulesLatestDescription },
{ name: 'legacy', label: i18n.modulesLegacyLabel, description: i18n.modulesLegacyDescription },
{ name: 'logs', label: i18n.modulesLogsLabel, description: i18n.modulesLogsDescription },
{ name: 'maps', label: i18n.modulesMapLabel, description: i18n.modulesMapDescription },
{ name: 'market', label: i18n.modulesMarketLabel, description: i18n.modulesMarketDescription },
@ -37,23 +40,22 @@ const modulesView = () => {
{ 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: 'projects', label: i18n.modulesProjectsLabel, description: i18n.modulesProjectsDescription },
{ 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: 'topics', label: i18n.modulesTopicsLabel, description: i18n.modulesTopicsDescription },
{ 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 }
{ name: 'wallet', label: i18n.modulesWalletLabel, description: i18n.modulesWalletDescription }
];
const moduleStates = modules.reduce((acc, mod) => {
@ -87,7 +89,7 @@ const modulesView = () => {
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'],
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', 'industry'],
full: modules.map(m => m.name)
};

View file

@ -19,12 +19,58 @@ const detailHref = (type, key) => {
case 'feed': return `/feed/${encodeURIComponent(key)}`;
case 'votes': return `/votes/${encodeURIComponent(key)}`;
case 'transfer': return `/transfers/${encodeURIComponent(key)}`;
case 'industry': return `/industry/${encodeURIComponent(key)}`;
case 'project': return `/projects/${encodeURIComponent(key)}`;
case 'report': return `/reports/${encodeURIComponent(key)}`;
case 'task': return `/tasks/${encodeURIComponent(key)}`;
case 'event': return `/events/${encodeURIComponent(key)}`;
case 'shopProduct': return `/shops/product/${encodeURIComponent(key)}`;
default: return null;
}
};
const renderContentHtml = (content, key) => {
switch (content.type) {
case 'industry':
return div({ class: 'opinion-industry' },
div({ class: 'card-section industry' },
div({ class: 'card-field' },
span({ class: 'card-label' }, i18n.industryName + ':'),
span({ class: 'card-value' }, content.name || '')
),
content.sector ? div({ class: 'card-field' },
span({ class: 'card-label' }, i18n.industrySector + ':'),
span({ class: 'card-value' }, String(content.sector).toUpperCase())
) : "",
content.membershipPolicy ? div({ class: 'card-field' },
span({ class: 'card-label' }, i18n.industryMembershipPolicy + ':'),
span({ class: 'card-value' }, String(i18n['industryPolicy_' + content.membershipPolicy] || content.membershipPolicy).toUpperCase())
) : "",
content.description ? p(...renderUrl(content.description)) : null,
Array.isArray(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 'industryBlueprint':
return div({ class: 'opinion-industry' },
div({ class: 'card-section industry' },
div({ class: 'card-field' },
span({ class: 'card-label' }, i18n.industryBlueprint + ':'),
span({ class: 'card-value' }, content.name || '')
),
content.outKind ? div({ class: 'card-field' },
span({ class: 'card-label' }, i18n.industryOutputKind + ':'),
span({ class: 'card-value' }, String(i18n['industryKind_' + content.outKind] || content.outKind).toUpperCase())
) : "",
content.laborHours ? div({ class: 'card-field' },
span({ class: 'card-label' }, i18n.industryLaborHours + ':'),
span({ class: 'card-value' }, String(content.laborHours))
) : "",
content.description ? p(...renderUrl(content.description)) : null
)
);
case 'bookmark':
return div({ class: 'opinion-bookmark' },
div({ class: 'card-section bookmark' },
@ -212,7 +258,7 @@ const renderContentHtml = (content, key) => {
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]') })
span({ class: 'card-value', innerHTML: sanitizeHtml(content.title || content.name || content.text || content.description || '[no content]') })
)
)
);

View file

@ -4,8 +4,9 @@ const { template, i18n, userLink} = require('./main_views');
const TERM_DAYS = 60;
const fmt = (d) => moment(d).format('YYYY-MM-DD HH:mm:ss');
const fmt = (d) => d ? moment(d).format('YYYY-MM-DD HH:mm:ss') : '—';
const timeLeft = (end) => {
if (!end) return '—';
const diff = moment(end).diff(moment());
if (diff <= 0) return '0d 00:00:00';
const dur = moment.duration(diff);
@ -75,8 +76,8 @@ const Tabs = (active) =>
);
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 termStart = g && g.since ? g.since : null;
const termEnd = g && g.end ? g.end : null;
const methodKeyRaw = g && g.method ? String(g.method) : 'ANARCHY';
const methodKey = methodKeyRaw.toUpperCase();
const i18nMeth = i18n[`parliamentMethod${methodKey}`];
@ -118,8 +119,8 @@ const GovHeader = (g) => {
};
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 termStart = g && g.since ? g.since : null;
const termEnd = g && g.end ? g.end : null;
const actorLabel =
g.powerType === 'tribe'
? (i18n.parliamentActorInPowerTribe || i18n.parliamentActorInPower || 'TRIBE RULING')
@ -950,8 +951,8 @@ const parliamentView = async (state) => {
powerType: 'none',
powerId: null,
powerTitle: 'ANARCHY',
since: moment().toISOString(),
end: moment().add(TERM_DAYS, 'days').toISOString(),
since: null,
end: null,
inhabitantsTotal: Number(inhabitantsTotal ?? 0) || 0
};

View file

@ -1,12 +1,21 @@
const { div, h2, p, section, button, form, input, textarea, br, label, pre, span, strong } = require("../server/node_modules/hyperaxe");
const { div, h2, p, section, button, form, input, textarea, br, label, pre, span, strong, a } = 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) => {
exports.pmView = async (initialRecipients = '', initialSubject = '', initialText = '', showPreview = false, sentKey = '', crypterError = false, crypterPreview = null, recipientError = false, fileError = '', fileSharePreview = null) => {
const title = i18n.pmSendTitle;
const description = i18n.pmDescription;
const textLen = (initialText || '').length;
const fileErrorText = {
recipient: i18n.pmInvalidRecipients || 'Invalid Oasis ID',
mutual: i18n.fileShareMutualError || 'You can only share files with habitants with mutual support.',
nofile: i18n.fileShareNoFile || 'No file selected.',
size: i18n.fileShareTooLarge || 'The file exceeds the allowed size.',
failed: i18n.fileShareFailed || 'Could not prepare the file.',
send: i18n.fileShareSendError || 'Could not send the file.'
}[fileError] || '';
const { renderEncryptedChip, renderDoubleEncryptionChip } = require('./clearnet_view');
return template(
title,
@ -28,13 +37,12 @@ exports.pmView = async (initialRecipients = '', initialSubject = '', initialText
: null,
section(
div({ class: "pm-form" },
form({ method: "POST", action: "/pm", id: "pm-form" },
label({ for: "recipients" }, i18n.pmLimitsHint),
br(),
h2({ class: "pm-section-title" }, i18n.pmComposeTitle || 'Send a message'),
form({ method: "POST", action: "/pm/preview", id: "pm-form" },
input({
type: "text",
name: "recipients",
placeholder: i18n.pmRecipientsHint,
placeholder: `${i18n.pmRecipientsHint}. ${i18n.pmLimitsHint}`,
required: true,
value: initialRecipients,
maxlength: "511"
@ -46,17 +54,17 @@ exports.pmView = async (initialRecipients = '', initialSubject = '', initialText
br(),
label({ for: "text" }, i18n.pmText),
br(),
textarea({ name: "text", rows: "6", cols: "50", id: "pm-text", maxlength: "8096" }, initialText),
textarea({ name: "text", rows: "6", cols: "50", id: "pm-text", maxlength: "7000", placeholder: i18n.pmTextPlaceholder || '' }, initialText),
div({ class: "pm-crypter-row" },
label({ for: "pm-crypter" },
input({ type: "checkbox", name: "crypter", value: "1", id: "pm-crypter" }),
input({ type: "checkbox", name: "crypter", value: "1", id: "pm-crypter", ...(crypterPreview ? { checked: true } : {}) }),
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)
button({ type: "submit", class: "pm-btn" }, i18n.pmPreview),
button({ type: "reset", class: "pm-btn danger-btn" }, i18n.pmReset || 'Reset')
)
)
),
@ -66,10 +74,11 @@ exports.pmView = async (initialRecipients = '', initialSubject = '', initialText
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: "title-with-chip" }, h2(i18n.pmCrypterCipherLabel), renderDoubleEncryptionChip(i18n)),
p({ class: "pm-key-hint" }, i18n.pmSharedKeyHint),
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" },
@ -79,18 +88,91 @@ exports.pmView = async (initialRecipients = '', initialSubject = '', 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({ class: "pm-actions" },
button({ type: "submit", class: "pm-btn" }, i18n.pmSend),
a({ href: "/pm", class: "pm-btn danger-btn" }, i18n.pmCancel || 'Cancel')
)
)
)
: 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`),
p({ id: "pm-preview-count", class: "pm-preview-count" }, `${textLen}/7000`),
div({ id: "pm-preview-content", class: "pm-preview-content" },
pre({ class: "pm-pre" }, initialText || '')
),
form({ method: "POST", action: "/pm", class: "pm-send-form" },
input({ type: "hidden", name: "recipients", value: initialRecipients }),
input({ type: "hidden", name: "subject", value: initialSubject }),
input({ type: "hidden", name: "text", value: initialText }),
div({ class: "pm-actions" },
button({ type: "submit", class: "pm-btn" }, i18n.pmSend),
a({ href: "/pm", class: "pm-btn danger-btn" }, i18n.pmCancel || 'Cancel')
)
)
))
: null
)
),
section({ id: "fileshare" },
div({ class: "pm-form pm-fileshare-form-wrap" },
h2({ class: "pm-section-title" }, i18n.fileShareTitle || 'Share a file'),
fileErrorText ? div({ class: "pm-form-error-msg" }, p('✗ ' + fileErrorText)) : null,
form({ method: "POST", action: "/pm/file/preview#fileshare", enctype: "multipart/form-data", class: "pm-fileshare-form" },
input({ id: "fs-recipient", type: "text", name: "recipient", placeholder: i18n.fileShareRecipientPlaceholder || 'Enter Oasis ID (@....ed25519)', required: true, value: initialRecipients, maxlength: "120" }),
br(),
label({ for: "fs-subject" }, i18n.pmSubject),
br(),
input({ id: "fs-subject", type: "text", name: "subject", placeholder: i18n.pmSubjectHint, value: initialSubject, maxlength: "150" }),
br(),
label({ for: "fs-file" }, i18n.fileShareFileLabel || 'File'),
br(),
input({ id: "fs-file", type: "file", name: "file", required: true }),
div({ class: "pm-crypter-row" },
label({ for: "fs-crypter" },
input({ type: "checkbox", name: "crypter", value: "1", id: "fs-crypter", ...(fileSharePreview && fileSharePreview.crypter ? { checked: true } : {}) }),
renderDoubleEncryptionChip(i18n)
)
),
div({ class: "pm-actions-block" },
div({ class: "pm-actions" },
button({ type: "submit", class: "pm-btn" }, i18n.pmPreview),
button({ type: "reset", class: "pm-btn danger-btn" }, i18n.pmReset || 'Reset')
)
)
),
fileSharePreview
? div({ class: "pm-preview pm-fileshare-preview" },
div({ class: "title-with-chip" }, h2(i18n.pmPreviewTitle), renderEncryptedChip(i18n)),
fileSharePreview.crypter
? div(
div({ class: "title-with-chip" }, h2(i18n.pmCrypterCipherLabel), renderDoubleEncryptionChip(i18n)),
p({ class: "pm-key-hint" }, i18n.pmSharedKeyHint),
div({ class: "pm-sent-key" }, input({ type: "text", readonly: true, value: fileSharePreview.sharedKey, class: "pm-sent-key-value" }))
)
: null,
div({ class: "pm-fileshare-info" },
span({ class: "pm-fileshare-icon" }, '📎'),
span({ class: "pm-fileshare-name" }, String(fileSharePreview.filename || 'file')),
span({ class: "pm-fileshare-meta" }, `${fileSharePreview.sizeLabel || ''} · ${String(fileSharePreview.mime || 'application/octet-stream')}`)
),
form({ method: "POST", action: "/pm/file", class: "pm-fileshare-send-form" },
input({ type: "hidden", name: "recipient", value: fileSharePreview.recipient }),
input({ type: "hidden", name: "subject", value: fileSharePreview.subject || '' }),
input({ type: "hidden", name: "manifestBlobId", value: fileSharePreview.manifestBlobId }),
input({ type: "hidden", name: "keyHex", value: fileSharePreview.keyHex }),
input({ type: "hidden", name: "filename", value: fileSharePreview.filename || 'file' }),
input({ type: "hidden", name: "mime", value: fileSharePreview.mime || 'application/octet-stream' }),
input({ type: "hidden", name: "size", value: String(fileSharePreview.size || 0) }),
fileSharePreview.crypter ? input({ type: "hidden", name: "crypter", value: "1" }) : null,
fileSharePreview.crypter ? input({ type: "hidden", name: "sharedKey", value: fileSharePreview.sharedKey }) : null,
div({ class: "pm-actions" },
button({ type: "submit", class: "pm-btn" }, i18n.pmSend),
a({ href: "/pm", class: "pm-btn danger-btn" }, i18n.pmCancel || 'Cancel')
)
)
)
: null
)
)
)
);

View file

@ -530,7 +530,7 @@ exports.projectsView = async (projectsOrForm, filter, _unused, params = {}) => {
),
f === "CREATE" || f === "EDIT"
? (() => {
const prToEdit = f === "EDIT" ? (safeArr(projectsOrForm)[0] || {}) : {}
const prToEdit = f === "EDIT" ? (safeArr(projectsOrForm)[0] || {}) : (params.prefill || {})
return renderProjectForm(prToEdit, f === "EDIT" ? "edit" : "create")
})()
: (f === "BACKERS"

View file

@ -482,7 +482,7 @@ exports.reportView = async (reports, filter, reportId, createCategory, params =
? div(
label(i18n.reportsTitleLabel),
br(),
input({ type: "text", name: "title", required: true, value: "", form: "report-create-form" }),
input({ type: "text", name: "title", required: true, value: params.prefillTitle || "", form: "report-create-form" }),
br(),
br(),
form(

View file

@ -1,5 +1,5 @@
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, emptyState } = require('./main_views');
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');
@ -11,6 +11,13 @@ const decodeMaybe = (s) => {
try { return decodeURIComponent(String(s || '')); } catch { return String(s || ''); }
};
const industryStatusLabel = (status) => {
const s = String(status || 'ACTIVE').toUpperCase();
if (s === 'PAUSED') return i18n.industryStatusPaused || 'PAUSED';
if (s === 'DISSOLVED') return i18n.industryStatusDissolved || 'DISSOLVED';
return i18n.industryStatusActive || 'WORKING';
};
const rewriteHashtagLinks = (html) => {
const s = String(html || '');
return s.replace(/href=(["'])(?:https?:\/\/[^"']+)?\/hashtag\/([^"'?#]+)([^"']*)\1/g, (m, q, tag, rest) => {
@ -34,7 +41,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
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"
"bankWallet", "bankClaim", "project", "job", "industry", "industryBlueprint", "forum", "vote", "contact", "pub", "map", "shop", "shopProduct", "chat", "pad", "all"
];
const filterSelect = select(
@ -84,6 +91,8 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
case 'report': return `/reports/${encodeURIComponent(contentId)}`;
case 'project': return `/projects/${encodeURIComponent(contentId)}`;
case 'job': return `/jobs/${encodeURIComponent(contentId)}`;
case 'industry': return `/industry/${encodeURIComponent(contentId)}`;
case 'industryBlueprint': return `/industry/blueprint/${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)}` : '#';
@ -448,6 +457,19 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
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 'industry':
return div({ class: 'search-industry' },
content.name ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.industryName + ':'), span({ class: 'card-value' }, content.name)) : null,
content.sector ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.industrySector + ':'), span({ class: 'card-value' }, String(content.sector).toUpperCase())) : null,
content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.industryStatusLabel + ':'), span({ class: 'card-value' }, industryStatusLabel(content.status))) : null,
content.description ? div({ class: "card-field card-field-stacked" }, span({ class: "card-value" }, content.description)) : null
);
case "industryBlueprint":
return div({ class: 'search-industry' },
content.name ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.industryBlueprint + ':'), span({ class: 'card-value' }, content.name)) : null,
content.outKind ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.industryOutputKind + ':'), span({ class: 'card-value' }, String(i18n['industryKind_' + content.outKind] || content.outKind).toUpperCase())) : null,
content.description ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-value' }, content.description)) : 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,
@ -601,7 +623,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
})
)
)
: emptyState({ icon: "🔍", title: i18n.noResultsFound });
: div({ class: 'no-results' }, p(i18n.noResultsFound));
let html = template(
hashtag ? `#${hashtag}` : i18n.searchTitle,

View file

@ -37,8 +37,7 @@ const settingsView = ({ version, aiPrompt, fediverseAccount, fediverseError }) =
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"),
option({ value: "OasisMobileUX", selected: theme === "OasisMobileUX" ? true : undefined }, "Oasis-Mobile-UX")
option({ value: "OasisMobile", selected: theme === "OasisMobile" ? true : undefined }, "Oasis-Mobile")
];
const languageOption = (longName, shortName) => {

View file

@ -36,7 +36,7 @@ exports.statsView = (stats, filter) => {
const description = i18n.statsDescription;
const modes = ['ALL', 'MINE', 'TOMBSTONE'];
const types = [
'bookmark', 'event', 'task', 'votes', 'report', 'feed', 'project',
'bookmark', 'event', 'task', 'votes', 'report', 'feed', 'project', 'industry', 'industryBlueprint',
'image', 'torrent', 'audio', 'video', 'document', 'transfer', 'post', 'tribe',
'market', 'forum', 'job', 'aiExchange', 'map', 'shop', 'shopProduct',
'chat', 'chatMessage', 'pad', 'padEntry', 'gameScore', 'calendar', 'calendarDate', 'calendarNote',
@ -51,6 +51,8 @@ exports.statsView = (stats, filter) => {
report: i18n.statsReport,
feed: i18n.statsFeed,
project: i18n.statsProject,
industry: i18n.industryTitle,
industryBlueprint: i18n.industryBlueprints,
image: i18n.statsImage,
torrent: i18n.statsTorrent,
audio: i18n.statsAudio,

View file

@ -309,9 +309,9 @@ exports.taskView = async (tasks, filter, taskId, returnTo, params = {}) => {
{ 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(),
input({ type: "text", name: "title", required: true, value: currentFilter === "edit" ? (editTask.title || "") : (params.prefillTitle || "") }), br(),
label(i18n.taskDescriptionLabel), br(),
textarea({ name: "description", required: true, placeholder: i18n.taskDescriptionPlaceholder, rows: "4" }, currentFilter === "edit" ? (editTask.description || "") : ""), br(),
textarea({ name: "description", required: true, placeholder: i18n.taskDescriptionPlaceholder, rows: "4" }, currentFilter === "edit" ? (editTask.description || "") : (params.prefillDescription || "")), br(),
label(i18n.uploadMedia), br(),
input({ type: "file", name: "image", accept: "image/*" }), br(),br(),
label(i18n.taskStartTimeLabel), br(),

View file

@ -98,6 +98,27 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new
div({ id: `pdf-container-${item.key}`, class: 'pdf-viewer-container', 'data-pdf-url': `/blob/${encodeURIComponent(url)}` })
)
);
} else if (c.type === 'industry') {
contentHtml = div({ class: 'trending-industry' },
div({ class: 'card-section industry' },
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.industryName + ':'), span({ class: 'card-value' }, c.name || '')),
c.sector ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.industrySector + ':'), span({ class: 'card-value' }, String(c.sector).toUpperCase())) : "",
c.membershipPolicy ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.industryMembershipPolicy + ':'), span({ class: 'card-value' }, String(i18n['industryPolicy_' + c.membershipPolicy] || c.membershipPolicy).toUpperCase())) : "",
c.description ? p(...renderUrl(c.description)) : null,
Array.isArray(c.tags) && c.tags.length
? div({ class: 'card-tags' }, c.tags.map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)))
: null
)
);
} else if (c.type === 'industryBlueprint') {
contentHtml = div({ class: 'trending-industry' },
div({ class: 'card-section industry' },
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.industryBlueprint + ':'), span({ class: 'card-value' }, c.name || '')),
c.outKind ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.industryOutputKind + ':'), span({ class: 'card-value' }, String(i18n['industryKind_' + c.outKind] || c.outKind).toUpperCase())) : "",
c.laborHours ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.industryLaborHours + ':'), span({ class: 'card-value' }, String(c.laborHours))) : "",
c.description ? p(...renderUrl(c.description)) : null
)
);
} else if (c.type === 'feed') {
const { text, refeeds } = c;
contentHtml = div({ class: 'trending-feed' },
@ -142,7 +163,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new
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]')) })
span({ class: 'card-value', innerHTML: sanitizeHtml(renderTextWithStyles(c.title || c.name || c.text || c.description || '[no content]')) })
)
)
);
@ -157,7 +178,13 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new
document: 'documents',
feed: 'feed',
votes: 'votes',
transfer: 'transfers'
transfer: 'transfers',
industry: 'industry',
project: 'projects',
report: 'reports',
task: 'tasks',
event: 'events',
shopProduct: 'shops/product'
};
const detailHref = detailPaths[c.type]
? `/${detailPaths[c.type]}/${encodeURIComponent(item.key)}`
@ -224,8 +251,9 @@ exports.trendingView = (items, filter, categories = opinionCategories, spreadMap
const baseFilters = ['TOP', 'ALL', 'MINE', 'RECENT'];
const contentFilters = [
['votes', 'feed', 'transfer'],
['bookmark', 'image', 'video', 'audio', 'document', 'torrent']
['feed', 'votes', 'event', 'task', 'report'],
['project', 'industry', 'transfer', 'shopProduct'],
['audio', 'bookmark', 'document', 'image', 'torrent', 'video']
];
let filteredItems = items.filter(item => {

View file

@ -507,6 +507,34 @@ const renderTribeActivitySection = (tribe, sectionData) => {
return div({ class: 'tribe-content-list tribe-content-list-spaced' },
activities.slice(0, 50).map(item => {
if (item.encrypted) return div({ class: 'card card-rpg' }, div({ class: 'tribe-card-body' }, p({ class: 'tribe-meta-label' }, i18n.tribeContentEncrypted || 'Encrypted content')));
if (item.contentType === 'chatThread') {
const replies = Array.isArray(item.replies) ? item.replies : [];
const tDate = item.timestamp ? new Date(item.timestamp).toLocaleString() : '';
const headerText = item.tribeName
? `[${String(i18n.typeChat || 'CHAT').toUpperCase()} · THREAD · ${item.tribeName}]`
: `[${String(i18n.typeChat || 'CHAT').toUpperCase()} · THREAD]`;
return div({ class: 'card card-rpg tribe-card-padded chat-thread' },
div({ class: 'card-header' },
h2({ class: 'card-label' }, headerText),
a({ href: item.directUrl, class: 'filter-btn' }, i18n.viewDetails || 'View Details')
),
div({ class: 'tribe-card-body' },
item.title ? div({ class: 'card-field' }, span({ class: 'card-value' }, item.title)) : null,
item.description ? p(String(item.description).substring(0, 200)) : null,
replies.map(r => div({ class: 'thread-reply-item' },
r.text ? p({ class: 'post-text', style: 'white-space:pre-wrap;' }, String(r.text)) : null,
div({ class: 'card-footer thread-reply-footer' },
span({ class: 'date-link' }, r.createdAt ? new Date(r.createdAt).toLocaleString() : ''),
userLink(r.author, '')
)
))
),
p({ class: 'card-footer' },
span({ class: 'date-link' }, `${tDate} ${i18n.performed || ''} `),
userLink(item.author, item.authorName)
)
);
}
const date = item.timestamp ? new Date(item.timestamp).toLocaleString() : '';
const typeLabel = item.contentType === 'media' && item.mediaType
? activityMediaTypeName(item.mediaType)
@ -1719,8 +1747,9 @@ const governanceFilterBar = (tribeId, currentFilter, showPublish) => {
);
};
const fmtTermDate = (d) => moment(d).format('YYYY-MM-DD HH:mm:ss');
const fmtTermDate = (d) => d ? moment(d).format('YYYY-MM-DD HH:mm:ss') : '—';
const fmtTimeLeft = (end) => {
if (!end) return '—';
const diff = moment(end).diff(moment());
if (diff <= 0) return '0d 00:00:00';
const dur = moment.duration(diff);
@ -1743,9 +1772,9 @@ const governmentCard = (tribe, term, leaders) => {
const isAnarchy = method === 'ANARCHY';
const i18nMeth = i18n[`parliamentMethod${method}`];
const methodLabel = (i18nMeth && String(i18nMeth).trim() ? String(i18nMeth) : method).toUpperCase();
const termStart = term.startAt || moment().toISOString();
const termEnd = term.endAt || moment(termStart).add(60, 'days').toISOString();
const population = Array.isArray(tribe.members) ? tribe.members.length : 0;
const termStart = term.startAt || null;
const termEnd = term.endAt || null;
const population = tribe.memberCount != null ? tribe.memberCount : (Array.isArray(tribe.members) ? tribe.members.length : 0);
const winnerVotes = Number(term.winnerVotes || 0);
const totalVotes = Number(term.totalVotes || 0);
const votesDisplay = `${winnerVotes} (${totalVotes})`;

170
src/views/welcome_view.js Normal file
View file

@ -0,0 +1,170 @@
const fs = require("fs")
const path = require("path")
const { form, button, div, h2, p, section, a, span, select, option, label, input, textarea, img, strong } = require("../server/node_modules/hyperaxe")
const { template, i18n } = require("./main_views")
const { config } = require("../server/SSB_server.js")
const FEED_TEXT_MIN = Number(config?.feed?.minLength ?? 1)
const FEED_TEXT_MAX = Number(config?.feed?.maxLength ?? 280)
const LANGUAGES = [
["English", "en"],
["Español", "es"],
["Français", "fr"],
["Euskara", "eu"],
["Deutsch", "de"],
["Italiano", "it"],
["Português", "pt"],
["中文", "zh"],
["العربية", "ar"],
["हिन्दी", "hi"],
["Русский", "ru"]
]
const snhInvite = () => {
try {
const raw = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "configs", "snh-invite-code.json"), "utf8"))
if (!raw || !raw.code) return null
return { name: String(raw.name || "").trim(), code: String(raw.code) }
} catch (_) {
return null
}
}
const stepChip = (done) => span(
{ class: done ? "welcome-chip welcome-chip-done" : "welcome-chip" },
done ? `✓ ${i18n.welcomeStepDone || "done"}` : (i18n.welcomeStepPending || "pending")
)
const linkAction = (href, label) => div({ class: "welcome-action" },
a({ href, class: "filter-btn" }, label)
)
const languageAction = (lang) => form({ method: "POST", action: "/language", class: "welcome-action welcome-language-form" },
select({ name: "language" }, LANGUAGES.map(([label, code]) => option({ value: code, ...(code === lang ? { selected: true } : {}) }, label))),
button({ type: "submit", class: "filter-btn" }, i18n.setLanguage || "Set Language")
)
const stepContent = (key, lang, profile) => {
if (key === "language") return {
title: i18n.welcomeStepLanguageTitle || "Choose your language",
text: i18n.welcomeStepLanguageText || "Oasis speaks different languages. You can change it whenever you want.",
action: languageAction(lang)
}
if (key === "profile") return {
title: i18n.welcomeStepProfileTitle || "Edit your profile",
text: i18n.welcomeStepProfileText || "Pick a name, add a description and set a picture so other inhabitants can recognise you.",
action: form({ method: "POST", action: "/welcome/profile", enctype: "multipart/form-data", class: "welcome-profile-form" },
label(i18n.profileName || "Name"),
input({ type: "text", name: "name", maxlength: "80", value: profile.name || "", required: true }),
label(i18n.profileDescription || "Description"),
textarea({ name: "description", rows: "3", maxlength: "600" }, profile.description || ""),
label(i18n.profileImage || "Image"),
img({ class: "welcome-profile-avatar", src: profile.image ? `/image/256/${encodeURIComponent(profile.image)}` : "/assets/images/default-avatar.png" }),
input({ type: "file", name: "image", accept: "image/*" }),
div({ class: "welcome-action" },
button({ type: "submit", class: "filter-btn" }, i18n.welcomeStepProfileAction || "Save Profile")
)
)
}
if (key === "federation") {
const invite = snhInvite()
return {
title: invite && invite.name ? `${i18n.welcomeJoinPub || "Join"} ${invite.name}` : (i18n.welcomeStepFederationTitle || "Join Main Network"),
text: i18n.welcomeStepFederationText || "Connect to our pub to meet other inhabitants and start replicating.",
action: div({ class: "welcome-action" },
invite
? form({ method: "POST", action: "/settings/invite/accept" },
input({ type: "hidden", name: "invite", value: invite.code }),
button({ type: "submit", class: "filter-btn" }, i18n.welcomeJoinPubButton || "Join Pub")
)
: a({ href: "/invites", class: "filter-btn" }, i18n.welcomeStepFederationAction || "Go to invites")
)
}
}
if (key === "larp") return {
title: i18n.welcomeStepLarpTitle || "Join L.A.R.P.",
text: i18n.welcomeStepLarpText || "Join our live action role playing game.",
action: div({ class: "welcome-action" },
form({ method: "POST", action: "/larp/join" },
input({ type: "hidden", name: "house", value: "academia" }),
input({ type: "hidden", name: "returnTo", value: "/welcome" }),
button({ type: "submit", class: "filter-btn" }, i18n.welcomeStepLarpAction || "Join \"The Academy\"")
)
)
}
if (key === "backup") return {
title: i18n.welcomeStepBackupTitle || "Backup your ID",
text: i18n.welcomeStepBackupText || "Your identity is a key file on this device.",
extra: profile.id ? div({ class: "welcome-oasisid" }, a({ class: "user-link", href: `/author/${encodeURIComponent(profile.id)}` }, String(profile.id))) : null,
warning: i18n.welcomeStepBackupWarning || "IF YOU LOSE IT, NOBODY CAN RECOVER IT FOR YOU.",
action: linkAction("/legacy", i18n.welcomeStepBackupAction || "Backup!")
}
return {
title: i18n.welcomeStepGreetingTitle || "Send a \"Hello world!\"",
text: i18n.welcomeStepGreetingText || "Send a message so other inhabitants can discover you.",
action: form({ method: "POST", action: "/welcome/greeting", class: "welcome-greeting-form" },
textarea({
name: "text",
required: true,
minlength: String(FEED_TEXT_MIN),
maxlength: String(FEED_TEXT_MAX),
rows: 4,
cols: 50,
placeholder: i18n.feedPlaceholder
}),
div({ class: "welcome-action" },
button({ type: "submit", class: "filter-btn" }, i18n.welcomeStepGreetingAction || "Send Feed")
)
)
}
}
exports.welcomeView = async (status, currentLanguage, profile = {}) => {
const steps = (status && status.steps) || {}
const usable = (status && status.usable) || []
const lang = String(currentLanguage || "en")
return template(
i18n.welcomeTitle || "Welcome",
section(
div({ class: "tags-header" },
h2(i18n.welcomeTitle || "Welcome"),
p(i18n.welcomeDescription || "A few things worth doing before you start.")
),
div({ class: "welcome-progress" },
p(span({ class: "welcome-progress-label" }, `${i18n.welcomeProgress || "Completed"}: `), `${status.done}/${status.total}`),
status.pending
? form({ method: "POST", action: "/welcome/dismiss" },
button({ type: "submit", class: "filter-btn" }, i18n.welcomeSkip || "Skip for now")
)
: null
),
status.complete
? div({ class: "welcome-complete" },
h2(i18n.welcomeCompleteTitle || "All set."),
p(i18n.welcomeCompleteText || "Your node is ready. The network grows with the people you follow."),
div({ class: "welcome-action" }, a({ href: "/inhabitants", class: "filter-btn" }, i18n.welcomeFindPeople || "Find inhabitants"))
)
: null,
div({ class: "welcome-steps" },
usable.map((key, idx) => {
const done = steps[key] === true
const content = stepContent(key, lang, profile || {})
return div({ class: done ? "welcome-step welcome-step-done" : "welcome-step" },
div({ class: "welcome-step-head" },
span({ class: "welcome-step-number" }, String(idx + 1)),
h2({ class: "welcome-step-title" }, content.title),
stepChip(done)
),
content.text ? p({ class: "welcome-step-text" }, content.text) : null,
content.extra || null,
content.warning ? p({ class: "welcome-step-warning" }, strong(content.warning)) : null,
content.action,
content.note ? p({ class: "welcome-step-note" }, content.note) : null
)
})
)
)
)
}