From 8d21216362cdc9627ce163f14af2b72ecf9fe9d2 Mon Sep 17 00:00:00 2001 From: s1to Date: Sat, 1 Aug 2026 13:41:09 +0200 Subject: [PATCH] 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. --- package.json | 2 +- src/backend/backend.js | 868 ++++++++++++++++-- src/backend/fileshare_crypto.js | 74 ++ src/backend/pm_policy.js | 9 + src/client/assets/styles/style.css | 147 ++- src/client/assets/themes/OasisMobileUX.css | 10 + src/client/assets/translations/oasis_ar.js | 368 +++++++- src/client/assets/translations/oasis_de.js | 368 +++++++- src/client/assets/translations/oasis_en.js | 372 +++++++- src/client/assets/translations/oasis_es.js | 368 +++++++- src/client/assets/translations/oasis_eu.js | 368 +++++++- src/client/assets/translations/oasis_fr.js | 368 +++++++- src/client/assets/translations/oasis_hi.js | 368 +++++++- src/client/assets/translations/oasis_it.js | 368 +++++++- src/client/assets/translations/oasis_pt.js | 368 +++++++- src/client/assets/translations/oasis_ru.js | 368 +++++++- src/client/assets/translations/oasis_zh.js | 368 +++++++- src/configs/config-manager.js | 3 +- src/configs/oasis-config.json | 12 +- src/models/activity_model.js | 103 ++- src/models/agenda_model.js | 15 +- src/models/banking_model.js | 42 + src/models/calendars_model.js | 8 +- src/models/chats_model.js | 8 +- src/models/dev_model.js | 343 +++++++ src/models/fileshare_model.js | 200 +++++ src/models/industry_model.js | 993 +++++++++++++++++++++ src/models/inhabitants_model.js | 2 +- src/models/jobs_model.js | 2 + src/models/legacy_model.js | 6 +- src/models/logs_model.js | 9 +- src/models/maps_model.js | 8 +- src/models/market_model.js | 7 +- src/models/onboarding_model.js | 181 ++++ src/models/opinions_model.js | 26 +- src/models/pads_model.js | 8 +- src/models/parliament_model.js | 53 +- src/models/pm_model.js | 35 +- src/models/search_model.js | 14 +- src/models/stats_model.js | 10 +- src/models/tags_model.js | 4 + src/models/trending_model.js | 54 +- src/models/tribes_model.js | 2 +- src/server/package-lock.json | 2 +- src/server/package.json | 2 +- src/views/activity_view.js | 205 ++++- src/views/agenda_view.js | 11 + src/views/banking_views.js | 5 +- src/views/blockchain_view.js | 6 +- src/views/courts_view.js | 14 +- src/views/dev_view.js | 275 ++++++ src/views/industry_view.js | 991 ++++++++++++++++++++ src/views/jobs_view.js | 13 +- src/views/legacy_view.js | 3 +- src/views/main_views.js | 473 +++++----- src/views/market_view.js | 13 +- src/views/modules_view.js | 14 +- src/views/opinions_view.js | 48 +- src/views/parliament_view.js | 15 +- src/views/pm_view.js | 108 ++- src/views/projects_view.js | 2 +- src/views/report_view.js | 2 +- src/views/search_view.js | 28 +- src/views/settings_view.js | 3 +- src/views/stats_view.js | 4 +- src/views/task_view.js | 4 +- src/views/trending_view.js | 36 +- src/views/tribes_view.js | 37 +- src/views/welcome_view.js | 170 ++++ 69 files changed, 8974 insertions(+), 820 deletions(-) create mode 100644 src/backend/fileshare_crypto.js create mode 100644 src/backend/pm_policy.js create mode 100644 src/models/dev_model.js create mode 100644 src/models/fileshare_model.js create mode 100644 src/models/industry_model.js create mode 100644 src/models/onboarding_model.js create mode 100644 src/views/dev_view.js create mode 100644 src/views/industry_view.js create mode 100644 src/views/welcome_view.js diff --git a/package.json b/package.json index 00bb761..d3ab71a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@krakenslab/oasis", - "version": "0.8.7", + "version": "0.9.0", "description": "Oasis - Social Networking Utopia", "repository": { "type": "git", diff --git a/src/backend/backend.js b/src/backend/backend.js index 44492f0..a405956 100644 --- a/src/backend/backend.js +++ b/src/backend/backend.js @@ -148,6 +148,13 @@ const sendErrorPage = (ctx, message, { title, status } = {}) => { ctx.body = errorView({ title, message, backHref }); }; +const exceedsSsbLimit = (content, extraOverhead = 0) => { + try { return Buffer.byteLength(JSON.stringify(content), 'utf8') + 512 + extraOverhead > 8192; } + catch (_) { return false; } +}; + +const isSsbTooLargeError = (e) => e && /8192|must not be larger/i.test(String(e && e.message)); + const safeRefererRedirect = (ctx, fallback = '/') => { const ref = ctx.request.header.referer; if (!ref) { ctx.redirect(fallback); return; } @@ -576,15 +583,36 @@ const extractBlobId = (md) => md ? (md.match(/\((&[^)]+)\)/)?.[1] ?? null) : nul const exportmodeModel = require('../models/exportmode_model'); const panicmodeModel = require('../models/panicmode_model'); const cipherModel = require('../models/cipher_model'); +const pmPolicy = require('./pm_policy'); const legacyModel = require('../models/legacy_model'); +const devModel = require('../models/dev_model'); const walletModel = require('../models/wallet_model') const pmModel = require('../models/pm_model')({ cooler, isPublic: config.public }); +const fileshareModel = require('../models/fileshare_model')({ cooler }); +const FILESHARE_MAX_SIZE = (getConfig().fileShare && Number(getConfig().fileShare.maxSize)) || (1024 * 1024 * 1024); +const FILESHARE_TTL_MS = (((getConfig().fileShare && Number(getConfig().fileShare.ttlDays)) || 30)) * 24 * 60 * 60 * 1000; +const runFileshareCleanup = async () => { + try { + const ssbClient = await cooler.open(); + const me = ssbClient.id; + const msgs = await pmModel.listAllPrivate(); + const mine = (msgs || []) + .filter(m => m && m.value && m.value.author === me && m.value.content && m.value.content.fileShare) + .map(m => ({ pointer: m.value.content.fileShare, sentAt: m.value.content.sentAt })); + if (mine.length) await fileshareModel.pruneExpired(mine, FILESHARE_TTL_MS, Date.now()); + } catch (_) {} +}; +const fsCleanupTimer = setTimeout(() => { runFileshareCleanup(); }, 120000); +if (fsCleanupTimer.unref) fsCleanupTimer.unref(); +const fsCleanupInterval = setInterval(() => { runFileshareCleanup(); }, 12 * 60 * 60 * 1000); +if (fsCleanupInterval.unref) fsCleanupInterval.unref(); const bookmarksModel = require("../models/bookmarking_model")({ cooler, isPublic: config.public }); const opinionsModel = require('../models/opinions_model')({ cooler, isPublic: config.public }); const tasksModel = require('../models/tasks_model')({ cooler, isPublic: config.public, pmModel }); const votesModel = require('../models/votes_model')({ cooler, isPublic: config.public }); const ssbConfig = require('../server/ssb_config'); const tribeCrypto = require('../models/crypto')(ssbConfig.path, 'tribes'); +const onboardingModel = require('../models/onboarding_model')({ cooler, ssbPath: ssbConfig.path }); const chatCrypto = require('../models/crypto')(ssbConfig.path, 'chats'); const padCrypto = require('../models/crypto')(ssbConfig.path, 'pads'); const mapCrypto = require('../models/crypto')(ssbConfig.path, 'maps'); @@ -612,7 +640,8 @@ const padsModel = require('../models/pads_model')({ cooler, cipherModel, tribeCr const tagsModel = require('../models/tags_model')({ cooler, isPublic: config.public, padsModel, tribesModel }); const tribesContentModel = require('../models/tribes_content_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel }); const searchModel = require('../models/search_model')({ cooler, isPublic: config.public, padsModel, tribeCrypto, tribesModel }); -const activityModel = require('../models/activity_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel, padsModel }); +const industryModel = require("../models/industry_model")({ cooler, isPublic: config.public }); +const activityModel = require('../models/activity_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel, padsModel, industryModel }); const pixeliaModel = require('../models/pixelia_model')({ cooler, isPublic: config.public }); const melodyModel = require('../models/melody_model')({ cooler }); const marketModel = require('../models/market_model')({ cooler, isPublic: config.public, tribeCrypto }); @@ -621,7 +650,7 @@ const jobsModel = require('../models/jobs_model')({ cooler, isPublic: config.pub const shopsModel = require('../models/shops_model')({ cooler, isPublic: config.public, tribeCrypto }); const chatsModel = require('../models/chats_model')({ cooler, tribeCrypto, chatCrypto, tribesModel }); const projectsModel = require("../models/projects_model")({ cooler, isPublic: config.public }); -const agendaModel = require("../models/agenda_model")({ cooler, isPublic: config.public, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel }); +const agendaModel = require("../models/agenda_model")({ cooler, isPublic: config.public, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel, industryModel }); const mapsModel = require("../models/maps_model")({ cooler, isPublic: config.public, tribeCrypto, mapCrypto, tribesModel }); const gamesModel = require('../models/games_model')({ cooler }); const bankingModel = require("../models/banking_model")({ services: { cooler }, isPublic: config.public }); @@ -972,12 +1001,6 @@ const getOasisVersion = async (feedId) => { if (OASIS_VERSION && mine !== OASIS_VERSION) { ssb.publish({ type: 'oasisVersion', version: OASIS_VERSION, updatedAt: new Date().toISOString() }, () => {}); } - try { - const img = await about.image(ssb.id); - global.__OASIS_SELF_AVATAR__ = (typeof img === 'string' && img[0] === '&') - ? getAvatarUrl(img) - : '/assets/images/default-avatar.png'; - } catch (_) {} } catch (_) {} })(); async function renderBlobMarkdown(text, mentions = {}, myFeedId, myUsername) { @@ -1289,7 +1312,18 @@ const koaBodyMiddleware = koaBody({ maxFileSize: maxSize, hash: 'sha256', }, - parsedMethods: ['POST'], + parsedMethods: ['POST'], +}); +const koaBodyFileshare = koaBody({ + multipart: true, + formidable: { + uploadDir: blobsPath, + keepExtensions: true, + maxFieldsSize: maxSize, + maxFileSize: FILESHARE_MAX_SIZE, + hash: false, + }, + parsedMethods: ['POST'], }); const resolveCommentComponents = async function (ctx) { let parentId; @@ -1350,6 +1384,8 @@ const { voteView } = require("../views/vote_view"); const { bookmarkView, singleBookmarkView } = require("../views/bookmark_view"); const { feedView, feedCreateView, singleFeedView } = require("../views/feed_view"); const { legacyView } = require("../views/legacy_view"); +const { devTreeView, devFileView, devSearchView, devMapView } = require("../views/dev_view"); +const { welcomeView } = require("../views/welcome_view"); const { opinionsView } = require("../views/opinions_view"); const { peersView } = require("../views/peers_view"); const { graphosView } = require("../views/graphos_view"); @@ -1372,6 +1408,7 @@ const { chatsView, singleChatView, renderChatInvitePage } = require("../views/ch const { padsView, singlePadView, renderPadInvitePage } = require("../views/pads_view"); const { calendarsView, singleCalendarView, renderCalendarInvitePage } = require("../views/calendars_view"); const { projectsView, singleProjectView, clearnetProjectView } = require("../views/projects_view") +const { industryView, singleFacilityView, singleBuildView, singleBlueprintView, blueprintEditView, buildEditView } = require("../views/industry_view") const { renderBankingView, renderSingleAllocationView, renderEpochView } = require("../views/banking_views") const { favoritesView } = require("../views/favorites_view"); const { logsView } = require("../views/logs_view"); @@ -1508,7 +1545,7 @@ router ctx.body = await popularView({ messages, prefix: nav(div({ class: "filters" }, ul(['day','week','month','year'].map(p => li(form({ method: "GET", action: `/public/popular/${p}` }, button({ type: "submit", class: "filter-btn" }, t[p]))))))), spreadMap }); }) .get("/modules", async (ctx) => { - const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'fediverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts']; + const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'fediverse', 'invites', 'wallet', 'legacy', 'dev', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'industry', 'shops', 'banking', 'parliament', 'courts']; const cfg = getConfig().modules; ctx.body = modulesView(modules.reduce((acc, m) => { acc[`${m}Mod`] = cfg[`${m}Mod`]; return acc; }, {})); }) @@ -1776,7 +1813,7 @@ router const { bucket: lastActivityBucket } = inhabitantsModel.bucketLastActivity(fullLastTs || null); const profileItems = await fetchProfileItems(feedId, rawPrefs); const profileFilterType = String(ctx.query.type || '').toLowerCase(); - const profileSpreadable = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','transfer','job','report','chat','chatMessage','pad','padEntry','forum','map']); + const profileSpreadable = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','industry','industryBuild','industryBlueprint','transfer','job','report','chat','chatMessage','pad','padEntry','forum','map']); const profileSpreadKeys = (allActions || []).filter(a => a && a.id && typeof a.id === 'string' && a.id.startsWith('%') && /\.sha256$/.test(a.id) && profileSpreadable.has(a.type)).map(a => a.id); const spreadMap = await spreads.forMessages(profileSpreadKeys).catch(() => new Map()); await warmAuthorNames(allActions, sanitizedMsgs, profileItems); @@ -2084,16 +2121,17 @@ router ctx.body = await createCVView(cv, true) }) .get('/pm', async ctx => { - const { recipients = '', subject = '', quote = '', preview = '' } = ctx.query; + const { recipients = '', subject = '', quote = '', preview = '', fileerror = '' } = ctx.query; const quoted = quote ? quote.split('\n').map(l => '> ' + l).join('\n') + '\n\n' : ''; const showPreview = preview === '1'; - ctx.body = await pmView(recipients, subject, quoted, showPreview); + ctx.body = await pmView(recipients, subject, quoted, showPreview, '', false, null, false, String(fileerror || '')); }) .get('/inbox', async ctx => { if (!checkMod(ctx, 'inboxMod')) { ctx.redirect('/modules'); return; } const messages = await buildInboxMessages(); await refreshInboxCount(messages); - ctx.body = await privateView({ messages }, ctx.query.filter || undefined); + const notice = ctx.query.filestatus === 'unavailable' ? 'unavailable' : (ctx.query.filekey === 'bad' ? 'badkey' : ''); + ctx.body = await privateView({ messages }, ctx.query.filter || undefined, null, notice); }) .post('/inbox/decrypt', koaBody(), async ctx => { if (!checkMod(ctx, 'inboxMod')) { ctx.redirect('/modules'); return; } @@ -2122,7 +2160,7 @@ router await enrichMsgSize(reports); const spreadMap = await spreads.forMessages((reports || []).map(x => x && (x.id || x.key))); await warmAuthorNames(reports); - ctx.body = await reportView(reports, filter, null, ctx.query.category || '', { spreadMap }); + ctx.body = await reportView(reports, filter, null, ctx.query.category || '', { spreadMap, prefillTitle: ctx.query.title || '', prefillDescription: ctx.query.description || '' }); }) .get('/reports/edit/:id', async ctx => { const report = await reportsModel.getReportById(ctx.params.id); @@ -2349,7 +2387,11 @@ router inhabitantsModel.listInhabitants({ filter: 'all', includeInactive: true }) ]); const inhabitantsTotal = Array.isArray(inhabitantsAll) ? inhabitantsAll.length : 0; - const governmentCard = governmentCardRaw ? { ...governmentCardRaw, inhabitantsTotal } : null; + let governmentCard = governmentCardRaw ? { ...governmentCardRaw, inhabitantsTotal, expired: false } : null; + if (!governmentCard) { + const latest = await parliamentModel.getLatestGovernmentCard().catch(() => null); + if (latest) governmentCard = { ...latest, inhabitantsTotal }; + } const leader = pickLeader(candidatures || []); const getActorMeta = async (type, id) => (type === 'tribe' || type === 'inhabitant') ? parliamentModel.getActorMeta({ targetType: type, targetId: id }) : null; const leaderMeta = leader ? await getActorMeta(leader.targetType || leader.powerType || 'inhabitant', leader.targetId || leader.powerId) : null; @@ -2390,7 +2432,7 @@ router try { await courtsModel.sweepCases(); } catch (_) {} const filter = String(ctx.query.filter || 'cases').toLowerCase(), search = String(ctx.query.search || '').trim(); const currentUserId = await courtsModel.getCurrentUserId(); - const state = { filter, search, cases: [], myCases: [], trials: [], history: [], nominations: [], userId: currentUserId }; + const state = { filter, search, cases: [], myCases: [], trials: [], history: [], nominations: [], userId: currentUserId, prefill: { titleSuffix: ctx.query.titleSuffix || '', respondentId: ctx.query.respondentId || '', method: (ctx.query.method || '').toUpperCase(), titlePreset: ctx.query.titlePreset || '' } }; const searchFilter = (items) => !search ? items : items.filter(c => [c.title, c.description].some(s => String(s || '').toLowerCase().includes(search.toLowerCase()))); if (filter === 'cases') state.cases = searchFilter((await courtsModel.listCases('open')).map(c => ({ ...c, respondent: c.respondentId || c.respondent }))); if (filter === 'mycases' || filter === 'actions') { @@ -2515,7 +2557,34 @@ router ...allMapsRaw.filter(m => tribeChainSet.has(m.tribeId)).map(toStandalone('map', m => `/maps/${encodeURIComponent(m.key || m.id)}`)), ...allTorrentsRaw.filter(t => tribeChainSet.has(t.tribeId)).map(toStandalone('torrent', t => `/torrents/${encodeURIComponent(t.rootId || t.key)}`)) ]; - const combined = [...allContent, ...subContent, ...standaloneItems]; + const subTribeNameById = new Map(subTribes.map(st => [st.id, st.title])); + const chatThreadItems = []; + for (const c of allChatsRaw) { + const inChain = tribeChainSet.has(c.tribeId); + const inSub = subTribeNameById.has(c.tribeId); + if (!inChain && !inSub) continue; + const root = c.rootId || c.key; + if (!root) continue; + let msgs = []; + try { msgs = await chatsModel.listMessages(root); } catch (_) { msgs = []; } + const withText = (msgs || []).filter(m => m && typeof m.text === 'string' && m.text.trim()); + if (!withText.length) continue; + withText.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); + const last = withText[withText.length - 1]; + chatThreadItems.push({ + contentType: 'chatThread', + id: root, + title: c.title || '', + author: c.author, + createdAt: last.createdAt, + _ts: Date.parse(last.createdAt) || 0, + directUrl: `/chats/${encodeURIComponent(root)}`, + ...(inSub ? { tribeName: subTribeNameById.get(c.tribeId) } : {}), + description: c.description || '', + replies: withText.slice(-6).map(m => ({ author: m.author, text: m.text, createdAt: m.createdAt })) + }); + } + const combined = [...allContent, ...subContent, ...standaloneItems, ...chatThreadItems]; const allInhabitants = await inhabitantsModel.listInhabitants({ filter: 'all', includeInactive: true }); const allMembers = [...new Set([...tribe.members, ...subTribes.flatMap(st => st.members || [])])]; const memberMap = new Map(allInhabitants.filter(u => allMembers.includes(u.id)).map(u => [u.id, u])); @@ -2648,12 +2717,21 @@ router const isCreator = tribe.author === uid; const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid); if (isCreator) { try { await parliamentModel.tribe.ensureTerm(tribe.id); } catch (_) {} } - const [term, candidatures, rules, globalTermBase] = await Promise.all([ + let [term, candidatures, rules, globalTermBase] = await Promise.all([ parliamentModel.tribe.getCurrentTerm(tribe.id).catch(() => null), parliamentModel.tribe.listCandidatures(tribe.id).catch(() => []), parliamentModel.tribe.listRules(tribe.id).catch(() => []), parliamentModel.getCurrentTerm().catch(() => null) ]); + if (term && term.startAt) { + const tribeTermDays = parliamentModel.tribe.TERM_DAYS || 60; + let cStart = moment(term.startAt); + let cEnd = term.endAt ? moment(term.endAt) : cStart.clone().add(tribeTermDays, 'days'); + const now = moment(); + let rolled = false; + while (cEnd.isValid() && cEnd.isSameOrBefore(now)) { cStart = cEnd.clone(); cEnd = cEnd.clone().add(tribeTermDays, 'days'); rolled = true; } + if (rolled) term = { ...term, startAt: cStart.toISOString(), endAt: cEnd.toISOString(), method: 'ANARCHY', powerType: 'none', powerId: null, leaders: [], winnerVotes: 0, totalVotes: 0 }; + } const globalStart = globalTermBase?.startAt || null; const alreadyPublishedThisGlobalCycle = await parliamentModel.tribe.hasCandidatureInGlobalCycle(tribe.id, globalStart).catch(() => false); const leaders = Array.isArray(term?.leaders) ? term.leaders : []; @@ -2751,7 +2829,7 @@ router } allActions = await applyListFilters(allActions, ctx); const 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','chat','chatMessage','pad','padEntry','forum','map']); + const SPREADABLE = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','industry','industryBuild','industryBlueprint','transfer','job','report','chat','chatMessage','pad','padEntry','forum','map']); const targets = (allActions || []).filter(a => a && a.id && typeof a.id === 'string' && a.id.startsWith('%') && /\.sha256$/.test(a.id) && SPREADABLE.has(a.type)); const results = await Promise.all(targets.map(a => spreads.forMessage(a.id).catch(() => null))); targets.forEach((a, i) => { if (results[i]) spreadMap.set(a.id, results[i]); }); @@ -2808,7 +2886,6 @@ router inhabitantsModel.getLastActivityTimestampByUserId(myFeedId).catch(() => null) ]); const larpHouse = larpHouseKey ? { key: larpHouseKey, ...larpModel.getHouse(larpHouseKey) } : null; - global.__OASIS_SELF_AVATAR__ = getAvatarUrl(image); const stats = await inhabitantsModel.getInhabitantStats(myFeedId, myFeedId).catch(() => ({})); const userActions = (allActions || []).filter(a => a && a.author === myFeedId && a.type !== 'tombstone' && a.type !== 'post'); const normTs = t => { const n = Number(t || 0); return !isFinite(n) || n <= 0 ? 0 : n < 1e12 ? n * 1000 : n; }; @@ -2819,7 +2896,7 @@ router const baseUrl = resolveExternalBaseUrl(ctx); const profileItems = await fetchProfileItems(myFeedId, rawPrefs); const profileFilterType = String(ctx.query.type || '').toLowerCase(); - const profileSpreadable = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','transfer','job','report','chat','chatMessage','pad','padEntry','forum','map']); + const profileSpreadable = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','industry','industryBuild','industryBlueprint','transfer','job','report','chat','chatMessage','pad','padEntry','forum','map']); const profileSpreadKeys = (allActions || []).filter(a => a && a.id && typeof a.id === 'string' && a.id.startsWith('%') && /\.sha256$/.test(a.id) && profileSpreadable.has(a.type)).map(a => a.id); const spreadMap = await spreads.forMessages(profileSpreadKeys).catch(() => new Map()); ctx.body = await authorView({ feedId: myFeedId, oasisVersion: OASIS_VERSION || await getOasisVersion(myFeedId), messages: sanitizeMessages(messages), firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship: { me: true }, ecoAddress, karmaScore: bankData.karmaScore, estimatedUBI: bankData.estimatedUBI || 0, lastClaimedDate: bankData.lastClaimedDate || null, totalClaimed: bankData.totalClaimed || 0, carbonGrams, larpHouse, lastActivityBucket, visibilityPrefs, stats, baseUrl, userActions, allActions, profileItems, profileFilterType, gpgFingerprint, spreadMap, fediverseConfigured: fediverseModel.hasAccount() }); @@ -3471,7 +3548,8 @@ router const houseKey = String((ctx.request.body && ctx.request.body.house) || '').toLowerCase(); if (houseKey !== 'academia') { ctx.redirect('/larp'); return; } try { await larpModel.publishJoin('academia'); } catch (_) {} - ctx.redirect('/larp/academia'); + const larpReturnTo = String((ctx.request.body && ctx.request.body.returnTo) || ''); + ctx.redirect(larpReturnTo === '/welcome' ? '/welcome' : '/larp/academia'); }) .post("/larp/leave", koaBody(), async (ctx) => { if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules'); @@ -3497,7 +3575,10 @@ router const myFeedId = await meta.myFeedId(); const myHouseKey = await larpModel.getUserHouse(myFeedId).catch(() => null); if (myHouseKey !== houseKey || houseKey === 'academia') { ctx.redirect('/larp'); return; } - try { await larpModel.publishHousePost({ house: houseKey, text }); } catch (_) {} + try { await larpModel.publishHousePost({ house: houseKey, text }); } + catch (e) { + if (isSsbTooLargeError(e)) { sendErrorPage(ctx, require('../views/main_views').i18n.publishTooLong || 'Your post is too long. Please shorten it.', { status: 400 }); return; } + } ctx.redirect(`/larp/${encodeURIComponent(houseKey)}`); }) .get("/invites", async (ctx) => { @@ -3753,10 +3834,93 @@ router await warmAuthorNames(forumObj, forumMsgs); ctx.body = await singleForumView(forumObj, forumMsgs, ctx.query.filter, isReply ? ctx.params.forumId : null, { spreads: spreadInfo }); }) + .get('/welcome', async (ctx) => { + const lang = ctx.cookies.get('language') || getConfig().language || 'en'; + const available = { + language: true, + profile: true, + federation: checkMod(ctx, 'invitesMod'), + larp: checkMod(ctx, 'larpMod'), + backup: checkMod(ctx, 'legacyMod'), + greeting: checkMod(ctx, 'feedMod') + }; + try { + const status = await onboardingModel.status(available); + ctx.body = await welcomeView(status, lang, status.profile); + } catch (error) { sendErrorPage(ctx, error.message || String(error), { status: 500 }); } + }) + .post('/welcome/profile', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + const body = ctx.request.body || {}; + const imageFile = ctx.request.files?.image; + const mime = imageFile?.mimetype || imageFile?.type || ''; + const image = mime.startsWith('image/') && imageFile?.filepath + ? await promisesFs.readFile(imageFile.filepath).catch(() => undefined) + : undefined; + try { + await post.publishProfileEdit({ + name: stripDangerousTags(String(body.name || '')).trim(), + description: stripDangerousTags(String(body.description || '')).trim(), + image + }); + } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }); return; } + ctx.redirect('/welcome'); + }) + .post('/welcome/greeting', koaBody(), async (ctx) => { + const text = stripDangerousTags(String((ctx.request.body || {}).text || '')); + try { + const mentions = await extractMentions(text); + await feedModel.createFeed(text, mentions); + } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }); return; } + ctx.redirect('/welcome'); + }) + .post('/welcome/dismiss', koaBody(), async (ctx) => { + try { onboardingModel.dismiss(); } catch (_) {} + safeRefererRedirect(ctx, '/'); + }) .get('/legacy', async (ctx) => { if (!checkMod(ctx, 'legacyMod')) return ctx.redirect('/modules'); try { ctx.body = await legacyView(); } catch (error) { sendErrorPage(ctx, error.message); } }) + .get('/dev', async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + if (!checkMod(ctx, 'devMod')) { ctx.redirect('/modules'); return; } + try { + const group = String(ctx.query.group || ''); + const tree = group ? devModel.listGroup(group) : devModel.listTree(ctx.query.path || ''); + ctx.body = await devTreeView(tree, devModel.projectStats()); + } catch (_) { ctx.redirect('/dev'); } + }) + .get('/dev/file', async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + if (!checkMod(ctx, 'devMod')) { ctx.redirect('/modules'); return; } + try { + ctx.body = await devFileView(devModel.readFile(ctx.query.path || '', { from: ctx.query.from })); + } catch (_) { ctx.redirect('/dev'); } + }) + .get('/dev/raw', async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + if (!checkMod(ctx, 'devMod')) { ctx.redirect('/modules'); return; } + try { + const file = devModel.rawFile(ctx.query.path || ''); + ctx.set('Content-Type', 'text/plain; charset=utf-8'); + ctx.body = file.content; + } catch (_) { ctx.redirect('/dev'); } + }) + .get('/dev/search', async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + if (!checkMod(ctx, 'devMod')) { ctx.redirect('/modules'); return; } + const ext = String(ctx.query.ext || ''); + try { + ctx.body = await devSearchView(devModel.searchCode(ctx.query.q || '', { ext }), ext); + } catch (_) { ctx.redirect('/dev'); } + }) + .get('/dev/map', async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + if (!checkMod(ctx, 'devMod')) { ctx.redirect('/modules'); return; } + try { + ctx.body = await devMapView(devModel.moduleMap()); + } catch (_) { ctx.redirect('/dev'); } + }) .get('/bookmarks', async (ctx) => { if (!checkMod(ctx, 'bookmarksMod')) return ctx.redirect('/modules'); const filter = qf(ctx), q = ctx.query.q || '', sort = ctx.query.sort || 'recent', viewerId = getViewerId(); @@ -3789,7 +3953,7 @@ router await enrichMsgSize(tasks); const spreadMap = await spreads.forMessages((tasks || []).map(x => x && (x.id || x.key))); await warmAuthorNames(tasks); - ctx.body = await taskView(tasks, filter, null, ctx.query.returnTo, { spreadMap }); + ctx.body = await taskView(tasks, filter, null, ctx.query.returnTo, { spreadMap, prefillTitle: ctx.query.title || '', prefillDescription: ctx.query.description || '' }); }) .get('/tasks/edit/:id', async ctx => { const id = ctx.params.id; @@ -3889,7 +4053,7 @@ router } const spreadMap = await spreads.forMessages((marketItems || []).map(x => x && (x.id || x.key))); await warmAuthorNames(marketItems); - ctx.body = await marketView(marketItems, filter, null, { q, minPrice, maxPrice, sort, spreadMap }); + ctx.body = await marketView(marketItems, filter, null, { q, minPrice, maxPrice, sort, spreadMap, industry: ctx.query.industry || "", title: ctx.query.title || "", description: ctx.query.description || "", price: ctx.query.price || "", tags: ctx.query.tags || "", stock: ctx.query.stock || "" }); }) .get("/market/edit/:id", async (ctx) => { if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; } @@ -3933,6 +4097,11 @@ router minSalary: ctx.query.minSalary ?? '', maxSalary: ctx.query.maxSalary ?? '', sort: ctx.query.sort || 'recent', + industry: ctx.query.industry || '', + title: ctx.query.title || '', + description: ctx.query.description || '', + tasks: ctx.query.tasks || '', + salary: ctx.query.salary || '', viewerPrefs } if (filter === 'CREATE') { @@ -4568,7 +4737,12 @@ router const filter = String(ctx.query.filter || "ALL").toUpperCase() const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); if (filter === "CREATE") { - ctx.body = await projectsView([], "CREATE", null, { viewerPrefs }) + const prefill = { + title: String(ctx.query.title || ""), + description: String(ctx.query.description || ""), + goal: String(ctx.query.goal || "") + } + ctx.body = await projectsView([], "CREATE", null, { viewerPrefs, prefill }) return } const modelFilter = filter === "BACKERS" ? "ALL" : filter @@ -4615,6 +4789,242 @@ router ctx.type = 'text/html'; ctx.body = await clearnetProjectView(project); }) + .get("/industry", async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + const filter = String(ctx.query.filter || "ALL").toUpperCase() + if (filter === "CREATE" || filter === "RULES") { ctx.body = await industryView([], filter); return } + if (filter === "BLUEPRINTS" || filter === "BUILDS") { + const q = String(ctx.query.search || "").trim().toLowerCase() + let items = filter === "BLUEPRINTS" + ? await industryModel.listAllBlueprints().catch(() => []) + : await industryModel.listAllBuilds().catch(() => []) + if (q) items = items.filter(x => [x.name, x.title, x.description, x.notes, x.facilityName].some(v => String(v || "").toLowerCase().includes(q))) + const spreadMap = await spreads.forMessages((items || []).map(x => x && (x.id || x.key))).catch(() => new Map()) + ctx.body = await industryView(items, filter, { spreadMap }) + return + } + const search = String(ctx.query.search || "").trim() + const sector = String(ctx.query.sector || "").trim().toLowerCase() + let facilities = await industryModel.listFacilities(filter) + if (sector) facilities = facilities.filter(x => x.sector === sector) + if (search) { + const q = search.toLowerCase() + facilities = facilities.filter(x => [x.name, x.description, x.sector].some(v => String(v || "").toLowerCase().includes(q))) + } + try { facilities = await lifetime.enrichAndFilter(facilities); } catch (_) {} + await enrichMsgSize(facilities) + const spreadMap = await spreads.forMessages((facilities || []).map(x => x && (x.id || x.key))); + await warmAuthorNames(facilities); + ctx.body = await industryView(facilities, filter, { spreadMap, search, sector }) + }) + .get("/industry/edit/:id", async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + const fc = await industryModel.getFacilityById(ctx.params.id) + ctx.body = await industryView([fc], "EDIT") + }) + .get("/industry/build/:id", async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + try { + const build = await industryModel.getBuild(ctx.params.id) + ctx.body = await singleBuildView(build, { kind: ctx.query.kind, spreads: await spreads.forMessage(build.id).catch(() => null) }) + } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 404 }) } + }) + .get("/industry/builds/:id/edit", async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + try { + const build = await industryModel.getBuild(ctx.params.id) + const fc = await industryModel.getFacilityById(build.facilityId) + ctx.body = await buildEditView(build, fc) + } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 404 }) } + }) + .get("/industry/blueprint/:id", async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + try { + const bp = await industryModel.getBlueprint(ctx.params.id) + ctx.body = await singleBlueprintView(bp, { spreads: await spreads.forMessage(bp.id).catch(() => null) }) + } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 404 }) } + }) + .get("/industry/blueprints/:id/edit", async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + try { + const bp = await industryModel.getBlueprint(ctx.params.id) + const fc = await industryModel.getFacilityById(bp.facility) + ctx.body = await blueprintEditView(bp, fc) + } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 404 }) } + }) + .get("/industry/:id", async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + const filter = String(ctx.query.filter || "ALL").toUpperCase() + const facility = await industryModel.getFacilityById(ctx.params.id) + const zoom = parseInt(ctx.query.zoom) || 2; + const [mapData, blueprints, builds, allJobs] = await Promise.all([ + resolveMapUrl(facility.mapUrl), + industryModel.listBlueprints(ctx.params.id).catch(() => []), + industryModel.listBuilds(ctx.params.id, 'ALL').catch(() => []), + checkMod(ctx, 'jobsMod') ? jobsModel.listJobs('ALL', getViewerId()).catch(() => []) : Promise.resolve([]) + ]) + const facilityRef = facility.root || facility.id + const facilityJobs = (allJobs || []).filter(j => j && j.industry === facilityRef) + await enrichMsgSize([facility]) + await enrichItemLifetime(facility, { key: facility.id || facility.key }) + const childSpreadMap = await spreads.forMessages([...(blueprints || []), ...(builds || [])].map(x => x && (x.id || x.key))).catch(() => new Map()) + ctx.body = await singleFacilityView(facility, filter, { mapData, zoom, blueprints, builds, facilityJobs, childSpreadMap, spreads: await spreads.forMessage(facility.id).catch(() => null) }) + }) + .post("/industry/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + try { + const image = ctx.request.files?.image ? await handleBlobUpload(ctx, "image") : null + const res = await industryModel.createFacility({ ...(ctx.request.body || {}), image }) + ctx.redirect(`/industry/${encodeURIComponent(res.key)}`) + } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } + }) + .post("/industry/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + try { + const image = ctx.request.files?.image ? await handleBlobUpload(ctx, "image") : undefined + await industryModel.updateFacility(ctx.params.id, { ...(ctx.request.body || {}), ...(image !== undefined ? { image } : {}) }) + ctx.redirect(`/industry/${encodeURIComponent(ctx.params.id)}`) + } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } + }) + .post("/industry/delete/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + try { await industryModel.deleteFacility(ctx.params.id); ctx.redirect('/industry?filter=MINE') } + catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } + }) + .post("/industry/join/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + try { await industryModel.joinFacility(ctx.params.id); safeRefererRedirect(ctx, `/industry/${encodeURIComponent(ctx.params.id)}`) } + catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } + }) + .post("/industry/apply/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + try { + const before = await industryModel.getFacilityById(ctx.params.id).catch(() => null) + await industryModel.joinFacility(ctx.params.id) + if (before && before.membershipPolicy === 'vote') { + const me = getViewerId() + for (const m of (before.members || [])) { if (m !== me) { try { await pmModel.sendMessage([m], "INDUSTRY_APPLICATION", `A new habitant requested to join "${before.name}" -> /industry/${ctx.params.id}`); } catch (_) {} } } + } + safeRefererRedirect(ctx, `/industry/${encodeURIComponent(ctx.params.id)}`) + } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } + }) + .post("/industry/leave/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + try { await industryModel.leaveFacility(ctx.params.id); safeRefererRedirect(ctx, `/industry/${encodeURIComponent(ctx.params.id)}`) } + catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } + }) + .post("/industry/invite/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + const invitee = ctx.request.body.invitee + try { + await industryModel.inviteToFacility(ctx.params.id, invitee) + try { const fc = await industryModel.getFacilityById(ctx.params.id); await pmModel.sendMessage([invitee], "INDUSTRY_INVITED", `You have been invited to join "${fc.name}" -> /industry/${ctx.params.id}`); } catch (_) {} + safeRefererRedirect(ctx, `/industry/${encodeURIComponent(ctx.params.id)}`) + } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } + }) + .post("/industry/govern/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + const subject = ctx.request.body.subject, ref = ctx.request.body.ref + try { + const before = await industryModel.getFacilityById(ctx.params.id).catch(() => null) + await industryModel.voteGovernance(ctx.params.id, subject, ref, ctx.request.body.choice) + const after = await industryModel.getFacilityById(ctx.params.id).catch(() => null) + const me = getViewerId() + if (subject === 'admit' && ref && before && after && !before.members.includes(ref) && after.members.includes(ref)) { + try { await pmModel.sendMessage([ref], "INDUSTRY_ADMITTED", `You have been admitted to "${after.name}" -> /industry/${ctx.params.id}`); } catch (_) {} + } + if (subject === 'dissolve' && before && after && before.status !== 'DISSOLVED' && after.status === 'DISSOLVED') { + for (const m of (after.members || [])) { if (m !== me) { try { await pmModel.sendMessage([m], "INDUSTRY_DISSOLVED", `The facility "${after.name}" has been dissolved by collective vote -> /industry/${ctx.params.id}`); } catch (_) {} } } + } + safeRefererRedirect(ctx, `/industry/${encodeURIComponent(ctx.params.id)}`) + } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } + }) + .post('/industry/opinions/:id/:category', koaBody(), async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + try { await industryModel.createOpinion(ctx.params.id, ctx.params.category); } + catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }); return; } + safeRefererRedirect(ctx, `/industry/${encodeURIComponent(ctx.params.id)}`) + }) + .post("/industry/:fid/blueprints", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + try { + const image = ctx.request.files?.image ? await handleBlobUpload(ctx, "image") : null + await industryModel.createBlueprint(ctx.params.fid, { ...(ctx.request.body || {}), image }) + safeRefererRedirect(ctx, `/industry/${encodeURIComponent(ctx.params.fid)}`) + } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } + }) + .post("/industry/blueprints/:id/update", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + try { + const image = ctx.request.files?.image ? await handleBlobUpload(ctx, "image") : undefined + await industryModel.updateBlueprint(ctx.params.id, { ...(ctx.request.body || {}), ...(image !== undefined ? { image } : {}) }) + const bp = await industryModel.getBlueprint(ctx.params.id).catch(() => null) + ctx.redirect(bp ? `/industry/${encodeURIComponent(bp.facility)}` : '/industry') + } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } + }) + .post("/industry/blueprints/:id/delete", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + const bp = await industryModel.getBlueprint(ctx.params.id).catch(() => null) + try { await industryModel.deleteBlueprint(ctx.params.id) } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }); return } + ctx.redirect(bp && bp.facilityId ? `/industry/${encodeURIComponent(bp.facilityId)}` : '/industry?filter=BLUEPRINTS') + }) + .post("/industry/:fid/builds", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + try { + const image = ctx.request.files?.image ? await handleBlobUpload(ctx, "image") : null + const res = await industryModel.createBuild(ctx.params.fid, { ...(ctx.request.body || {}), image }) + ctx.redirect(`/industry/build/${encodeURIComponent(res.key)}`) + } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } + }) + .post("/industry/builds/:id/update", koaBody(), async (ctx) => { + if (!checkMod(ctx, "industryMod")) { ctx.redirect("/modules"); return; } + try { + await industryModel.updateBuild(ctx.params.id, ctx.request.body || {}) + safeRefererRedirect(ctx, `/industry/build/${encodeURIComponent(ctx.params.id)}`) + } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } + }) + .post("/industry/builds/:id/delete", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + try { await industryModel.deleteBuild(ctx.params.id); ctx.redirect('/industry?filter=BUILDS') } + catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } + }) + .post("/industry/builds/:id/vote", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + try { + const before = await industryModel.getBuild(ctx.params.id).catch(() => null) + await industryModel.voteBuild(ctx.params.id, ctx.request.body.choice) + const after = await industryModel.getBuild(ctx.params.id).catch(() => null) + if (before && after && before.status !== 'APPROVED' && after.status === 'APPROVED' && after.proposer !== getViewerId()) { + try { await pmModel.sendMessage([after.proposer], "INDUSTRY_BUILD_APPROVED", `Your build "${after.title}" has been approved -> /industry/build/${ctx.params.id}`); } catch (_) {} + } + safeRefererRedirect(ctx, `/industry/build/${encodeURIComponent(ctx.params.id)}`) + } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } + }) + .post("/industry/builds/:id/status", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + try { await industryModel.setBuildStatus(ctx.params.id, ctx.request.body.status); safeRefererRedirect(ctx, `/industry/build/${encodeURIComponent(ctx.params.id)}`) } + catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } + }) + .post("/industry/builds/:id/contribute", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body || {} + try { + await industryModel.contribute(ctx.params.id, b) + safeRefererRedirect(ctx, `/industry/build/${encodeURIComponent(ctx.params.id)}`) + } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } + }) + .post("/industry/builds/:id/distribute", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; } + try { + const viewer = getViewerId() + const res = await industryModel.distributeBuild(ctx.params.id, { outputValue: ctx.request.body.outputValue }) + for (const alloc of res.plan.allocations) { + if (!alloc || !alloc.to || alloc.to === viewer || !(alloc.amount > 0)) continue + try { await pmModel.sendMessage([alloc.to], "INDUSTRY_DISTRIBUTED", `Build "${res.build.title}" allocated you ${alloc.amount.toFixed(2)} ECO -> /industry/build/${ctx.params.id}`) } catch (_) {} + } + safeRefererRedirect(ctx, `/industry/build/${encodeURIComponent(ctx.params.id)}`) + } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } + }) .get("/banking", async (ctx) => { if (!checkMod(ctx, 'bankingMod')) { ctx.redirect('/modules'); return; } const userId = getViewerId(); @@ -5156,7 +5566,6 @@ router if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } const raw = String(ctx.query?.q || ctx.query?.prompt || '').trim(); if (!raw) { ctx.redirect('/'); return; } - if (!checkMod(ctx, 'aiNavMod')) { ctx.redirect('/search?query=' + encodeURIComponent(raw)); return; } const routesIndex = require('../AI/routes_index'); const { aiNavResultsView } = require('../views/main_views'); const isModuleEnabled = (modName) => checkMod(ctx, modName); @@ -5240,10 +5649,11 @@ router const viewer = getViewerId(); for (const rid of recipientsArr) { if (rid === viewer) continue; - let rel; + let rel = null; try { rel = await friend.getRelationship(rid); } catch (e) { rel = null; } - const mutual = !!(rel && rel.following && rel.followsMe); - if (!mutual) ctx.throw(403, 'You can only send private messages to habitants with mutual support.'); + if (!pmPolicy.isRecipientAllowed({ pmVisibility: cfgNow.pmVisibility, viewerId: viewer, recipientId: rid, relationship: rel })) { + ctx.throw(403, 'You can only send private messages to habitants with mutual support.'); + } } } const cleanSubject = stripDangerousTags(subject); @@ -5270,7 +5680,15 @@ router ctx.body = await pmView('', '', '', false, key); return; } - await pmModel.sendMessage(recipientsArr, cleanSubject, cleanText); + try { + await pmModel.sendMessage(recipientsArr, cleanSubject, cleanText); + } catch (e) { + if (isSsbTooLargeError(e)) { + sendErrorPage(ctx, require('../views/main_views').i18n.publishTooLong || 'Your message is too long. Please shorten it.', { status: 400 }); + return; + } + throw e; + } await refreshInboxCount(); ctx.redirect('/inbox?filter=sent'); }) @@ -5293,6 +5711,144 @@ router } ctx.body = await pmView(recipients, subject, text, true); }) + .post('/pm/file/preview', koaBodyFileshare, async ctx => { + const b = ctx.request.body || {}; + const file = ctx.request.files && (ctx.request.files.file || ctx.request.files.blob); + const cleanup = () => { try { if (file && file.filepath) fs.unlinkSync(file.filepath); } catch (_) {} }; + const recipient = String(b.recipient || '').trim(); + if (!ssbRef.isFeedId(recipient)) { cleanup(); ctx.redirect('/pm?fileerror=recipient#fileshare'); return; } + const cfgNow = getConfig(); + if (cfgNow.pmVisibility === 'mutuals' && recipient !== getViewerId()) { + let rel = null; + try { rel = await friend.getRelationship(recipient); } catch (_) { rel = null; } + if (!pmPolicy.isRecipientAllowed({ pmVisibility: cfgNow.pmVisibility, viewerId: getViewerId(), recipientId: recipient, relationship: rel })) { + cleanup(); ctx.redirect('/pm?fileerror=mutual#fileshare'); return; + } + } + const fmtB = (n) => { const x = Number(n) || 0; if (x < 1024) return x + ' B'; const u = ['KB','MB','GB','TB']; let v = x / 1024, i = 0; while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } return v.toFixed(v >= 100 || i === 0 ? 0 : 1) + ' ' + u[i]; }; + if (!file || !file.filepath || !file.size) { cleanup(); ctx.redirect('/pm?fileerror=nofile#fileshare'); return; } + if (file.size > FILESHARE_MAX_SIZE) { cleanup(); ctx.redirect('/pm?fileerror=size#fileshare'); return; } + let pointer; + try { + pointer = await fileshareModel.createShareFromFile({ + filepath: file.filepath, + filename: file.originalFilename || file.name || 'file', + mime: file.mimetype || null + }); + } catch (_) { cleanup(); ctx.redirect('/pm?fileerror=failed#fileshare'); return; } + cleanup(); + const useCrypter = !!b.crypter; + const sharedKey = useCrypter ? cipherModel.generateKey() : ''; + ctx.body = await pmView(recipient, stripDangerousTags(b.subject || ''), '', false, '', false, null, false, '', { + recipient, subject: stripDangerousTags(b.subject || ''), + manifestBlobId: pointer.manifestBlobId, keyHex: pointer.key, + filename: pointer.filename, mime: pointer.mime, size: pointer.size, + sizeLabel: fmtB(pointer.size), crypter: useCrypter, sharedKey + }); + }) + .post('/pm/file', koaBodyFileshare, async ctx => { + const b = ctx.request.body || {}; + const file = ctx.request.files && (ctx.request.files.file || ctx.request.files.blob); + const cleanup = () => { try { if (file && file.filepath) fs.unlinkSync(file.filepath); } catch (_) {} }; + const recipient = String(b.recipient || '').trim(); + if (!ssbRef.isFeedId(recipient)) { cleanup(); ctx.redirect('/pm?fileerror=recipient#fileshare'); return; } + const cfgNow = getConfig(); + if (cfgNow.pmVisibility === 'mutuals' && recipient !== getViewerId()) { + let rel = null; + try { rel = await friend.getRelationship(recipient); } catch (_) { rel = null; } + if (!pmPolicy.isRecipientAllowed({ pmVisibility: cfgNow.pmVisibility, viewerId: getViewerId(), recipientId: recipient, relationship: rel })) { + cleanup(); ctx.redirect('/pm?fileerror=mutual#fileshare'); return; + } + } + if (!file && b.manifestBlobId) { + let pointer = { + type: 'fileShare', v: 1, key: String(b.keyHex || ''), + manifestBlobId: String(b.manifestBlobId), filename: String(b.filename || 'file'), + mime: String(b.mime || 'application/octet-stream'), size: Number(b.size) || 0 + }; + const useCrypter = !!b.crypter; + if (useCrypter) { + const sk = String(b.sharedKey || ''); + if (sk.length >= 32) { const { encryptedText } = cipherModel.encryptData(pointer.key, sk); pointer = { ...pointer, key: encryptedText, crypter: true }; } + } + try { await pmModel.sendFileShare([recipient], stripDangerousTags(b.subject || ''), pointer, useCrypter); } + catch (_) { ctx.redirect('/pm?fileerror=send#fileshare'); return; } + await refreshInboxCount(); + ctx.redirect('/inbox?filter=sent'); return; + } + if (!file || !file.filepath || !file.size) { cleanup(); ctx.redirect('/pm?fileerror=nofile#fileshare'); return; } + if (file.size > FILESHARE_MAX_SIZE) { cleanup(); ctx.redirect('/pm?fileerror=size#fileshare'); return; } + let pointer; + try { + pointer = await fileshareModel.createShareFromFile({ + filepath: file.filepath, + filename: file.originalFilename || file.name || 'file', + mime: file.mimetype || null + }); + } catch (_) { cleanup(); ctx.redirect('/pm?fileerror=failed#fileshare'); return; } + cleanup(); + const useCrypter = !!b.crypter; + let sharedKey = ''; + if (useCrypter) { + sharedKey = (typeof b.crypterKey === 'string' && b.crypterKey.length >= 32) ? b.crypterKey : cipherModel.generateKey(); + const { encryptedText } = cipherModel.encryptData(pointer.key, sharedKey); + pointer = { ...pointer, key: encryptedText, crypter: true }; + } + try { + await pmModel.sendFileShare([recipient], stripDangerousTags(b.subject || ''), pointer, useCrypter); + } catch (_) { ctx.redirect('/pm?fileerror=send#fileshare'); return; } + await refreshInboxCount(); + if (useCrypter) { ctx.body = await pmView('', '', '', false, sharedKey); return; } + ctx.redirect('/inbox?filter=sent'); + }) + .get('/inbox/file/:id', async ctx => { + if (!checkMod(ctx, 'inboxMod')) { ctx.redirect('/modules'); return; } + const messages = await buildInboxMessages(); + const msg = messages.find(m => m && m.key === ctx.params.id); + const fileShare = msg && msg.value && msg.value.content && msg.value.content.fileShare; + if (!fileShare) { ctx.redirect('/inbox'); return; } + let pointer = fileShare; + if (fileShare.crypter) { + const key = ctx.query && ctx.query.key; + if (typeof key !== 'string' || !key) { ctx.redirect('/inbox'); return; } + try { + pointer = { ...fileShare, key: cipherModel.decryptData(fileShare.key, key) }; + } catch (_) { ctx.redirect('/inbox?filekey=bad'); return; } + } + if (!(await fileshareModel.ensureAvailable(pointer))) { ctx.redirect('/inbox?filestatus=unavailable'); return; } + const safeName = String(fileShare.filename || 'file').replace(/["\r\n\\]/g, ''); + ctx.type = fileShare.mime || 'application/octet-stream'; + ctx.set('Content-Disposition', `attachment; filename="${safeName}"`); + const stream = fileshareModel.readShareStream(pointer); + if (msg.value.author && msg.value.author !== getViewerId()) { + stream.on('end', () => { fileshareModel.removeLocalBlobs(pointer).catch(() => {}); }); + } + ctx.body = stream; + }) + .post('/inbox/file/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'inboxMod')) { ctx.redirect('/modules'); return; } + const { key } = ctx.request.body || {}; + const messages = await buildInboxMessages(); + const msg = messages.find(m => m && m.key === ctx.params.id); + const fileShare = msg && msg.value && msg.value.content && msg.value.content.fileShare; + if (!fileShare) { ctx.redirect('/inbox'); return; } + let pointer = fileShare; + if (fileShare.crypter) { + if (typeof key !== 'string' || !key) { ctx.redirect('/inbox'); return; } + try { + pointer = { ...fileShare, key: cipherModel.decryptData(fileShare.key, key) }; + } catch (_) { ctx.redirect('/inbox?filekey=bad'); return; } + } + if (!(await fileshareModel.ensureAvailable(pointer))) { ctx.redirect('/inbox?filestatus=unavailable'); return; } + const safeName = String(fileShare.filename || 'file').replace(/["\r\n\\]/g, ''); + ctx.type = fileShare.mime || 'application/octet-stream'; + ctx.set('Content-Disposition', `attachment; filename="${safeName}"`); + const stream = fileshareModel.readShareStream(pointer); + if (msg.value.author && msg.value.author !== getViewerId()) { + stream.on('end', () => { fileshareModel.removeLocalBlobs(pointer).catch(() => {}); }); + } + ctx.body = stream; + }) .post('/inbox/delete/:id', koaBody(), async ctx => { await pmModel.deleteMessageById(ctx.params.id); await refreshInboxCount(); @@ -5405,7 +5961,18 @@ router const b = ctx.request.body, text = stripDangerousTags(b.text?.toString().trim() || ""), cw = stripDangerousTags(b.contentWarning?.toString().trim() || ""); let mentions = []; try { mentions = JSON.parse(b.mentions || "[]"); } catch { mentions = await extractMentions(text); } - await post.root({ text, mentions, contentWarning: cw.length > 0 ? cw : undefined }); + const approxContent = { type: 'post', text, ...(Array.isArray(mentions) && mentions.length ? { mentions } : {}), ...(cw.length > 0 ? { contentWarning: cw } : {}) }; + const tooLongMsg = require('../views/main_views').i18n.publishTooLong || 'Your post is too long. Please shorten it.'; + if (exceedsSsbLimit(approxContent)) { + sendErrorPage(ctx, tooLongMsg, { status: 400 }); + return; + } + try { + await post.root({ text, mentions, contentWarning: cw.length > 0 ? cw : undefined }); + } catch (e) { + if (isSsbTooLargeError(e)) { sendErrorPage(ctx, tooLongMsg, { status: 400 }); return; } + throw e; + } try { activityModel.invalidateCache(); } catch (_) {} ctx.redirect("/public/latest"); }) @@ -5592,21 +6159,25 @@ router ctx.redirect(safeReturnTo(ctx, '/forum', ['/forum'])); } }) - .post('/legacy/export', koaBody(), async (ctx) => { + .get('/legacy/export', async (ctx) => { if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } - const pw = ctx.request.body.password; + const pw = ctx.query && ctx.query.password; if (!pw || pw.length < 32) return ctx.redirect('/legacy'); try { - ctx.body = { message: 'Data exported successfully!', file: await legacyModel.exportData({ password: pw }) }; - ctx.redirect('/legacy'); - } catch (error) { ctx.status = 500; ctx.body = { error: `Error: ${error.message}` }; ctx.redirect('/legacy'); } + const { filename, data } = await legacyModel.exportData({ password: pw }); + ctx.set('Content-Type', 'application/octet-stream'); + ctx.set('Content-Disposition', `attachment; filename="${filename}"`); + ctx.set('Content-Length', String(data.length)); + ctx.body = data; + try { onboardingModel.markStep('backup'); } catch (_) {} + } catch (error) { sendErrorPage(ctx, error.message); } }) - .post('/legacy/import', koaBody({ - multipart: true, - formidable: { - keepExtensions: true, - uploadDir: '/tmp', - } + .post('/legacy/import', koaBody({ + multipart: true, + formidable: { + keepExtensions: true, + uploadDir: os.tmpdir(), + } }), async (ctx) => { const uploadedFile = ctx.request.files?.uploadedFile, pw = ctx.request.body.importPassword; if (!uploadedFile) { ctx.body = { error: 'No file uploaded' }; return ctx.redirect('/legacy'); } @@ -6558,7 +7129,7 @@ router const b = ctx.request.body, image = await handleBlobUpload(ctx, "image"), parsedStock = parseInt(String(b.stock || "0"), 10); if (!parsedStock || parsedStock <= 0) ctx.throw(400, "Stock must be a positive number."); const pickLast = v => Array.isArray(v) ? v[v.length - 1] : v, shpVal = pickLast(b.includesShipping); - await marketModel.createItem(b.item_type, stripDangerousTags(b.title), stripDangerousTags(b.description), image, b.price, b.tags, b.item_status, b.deadline, shpVal === "1" || shpVal === "on" || shpVal === true || shpVal === "true", parsedStock, stripDangerousTags(b.mapUrl), {}, b.visibility); + await marketModel.createItem(b.item_type, stripDangerousTags(b.title), stripDangerousTags(b.description), image, b.price, b.tags, b.item_status, b.deadline, shpVal === "1" || shpVal === "on" || shpVal === true || shpVal === "true", parsedStock, stripDangerousTags(b.mapUrl), { industry: stripDangerousTags(b.industry || "") }, b.visibility); ctx.redirect(safeReturnTo(ctx, "/market", ["/market"])); }) .post("/market/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { @@ -6634,7 +7205,7 @@ router .post('/jobs/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; - await jobsModel.createJob({ job_type: stripDangerousTags(b.job_type), title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), requirements: stripDangerousTags(b.requirements), languages: stripDangerousTags(b.languages), job_time: b.job_time, tasks: stripDangerousTags(b.tasks), location: stripDangerousTags(b.location), vacants: b.vacants ? parseInt(b.vacants, 10) : 1, salary: b.salary != null && b.salary !== '' ? parseFloat(String(b.salary).replace(',', '.')) : 0, hoursOffered: b.hoursOffered != null && b.hoursOffered !== '' ? parseFloat(String(b.hoursOffered).replace(',', '.')) : 0, hoursRequested: b.hoursRequested != null && b.hoursRequested !== '' ? parseFloat(String(b.hoursRequested).replace(',', '.')) : 0, exchangeSkill: stripDangerousTags(b.exchangeSkill || ''), tags: b.tags, image: imageBlob, mapUrl: stripDangerousTags(b.mapUrl), visibility: b.visibility, clearnetPublic: b.clearnetPublic }); + await jobsModel.createJob({ job_type: stripDangerousTags(b.job_type), title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), requirements: stripDangerousTags(b.requirements), languages: stripDangerousTags(b.languages), job_time: b.job_time, tasks: stripDangerousTags(b.tasks), location: stripDangerousTags(b.location), vacants: b.vacants ? parseInt(b.vacants, 10) : 1, salary: b.salary != null && b.salary !== '' ? parseFloat(String(b.salary).replace(',', '.')) : 0, hoursOffered: b.hoursOffered != null && b.hoursOffered !== '' ? parseFloat(String(b.hoursOffered).replace(',', '.')) : 0, hoursRequested: b.hoursRequested != null && b.hoursRequested !== '' ? parseFloat(String(b.hoursRequested).replace(',', '.')) : 0, exchangeSkill: stripDangerousTags(b.exchangeSkill || ''), tags: b.tags, image: imageBlob, mapUrl: stripDangerousTags(b.mapUrl), industry: stripDangerousTags(b.industry || ''), visibility: b.visibility, clearnetPublic: b.clearnetPublic }); ctx.redirect(safeReturnTo(ctx, '/jobs?filter=MINE', ['/jobs'])); }) .post('/jobs/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { @@ -6810,6 +7381,11 @@ router await shopsModel.buyProduct(ctx.params.id); try { const pr = await shopsModel.getProductById(ctx.params.id).catch(() => null); + if (pr && pr.author && String(pr.author) !== String(getViewerId())) { + try { + await pmModel.sendMessage([pr.author], "SHOP_SOLD", `product "${pr.title}" has been sold -> /shops/product/${ctx.params.id} OASIS ID: ${getViewerId()} for: ${pr.price} ECO`); + } catch (_) {} + } await mediaFavorites.addFavorite('shopProducts', (pr && pr.rootId) || ctx.params.id); } catch (_) {} if (checkMod(ctx, 'marketMod')) { @@ -7394,6 +7970,28 @@ router console.log("oasis@version: running install.sh...", shOut, shErr); safeRefererRedirect(ctx, '/settings'); }) + .get("/settings/bottombar", async (ctx) => { + ctx.body = require("../views/main_views").bottomBarPickerView(); + }) + .post("/settings/bottombar", koaBody(), async (ctx) => { + const cfg = getConfig(); + const MAX = 4; + const body = ctx.request.body || {}; + const action = String(body.action || '').trim(); + const key = String(body.key || '').trim(); + const catalog = require("../views/main_views").PINNABLE_MODULES || []; + let pins = Array.isArray(cfg.bottomBarPins) ? cfg.bottomBarPins.slice() : []; + if (action === 'add') { + if (key && catalog.some(m => m.key === key) && !pins.includes(key) && pins.length < MAX) pins.push(key); + } else if (action === 'remove') { + pins = pins.filter(k => k !== key); + } else if (action === 'clear') { + pins = []; + } + cfg.bottomBarPins = pins.slice(0, MAX); + saveConfig(cfg); + ctx.redirect('/settings/bottombar'); + }) .post("/settings/theme", koaBody(), async (ctx) => { const theme = String(ctx.request.body.theme || "").trim(), cfg = getConfig(); cfg.themes.current = theme || "Dark-SNH"; @@ -7407,6 +8005,7 @@ router cfg.language = lang; fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2)); ctx.cookies.set("language", lang, { maxAge: 365 * 24 * 60 * 60 * 1000, httpOnly: true, sameSite: 'strict', secure: ctx.secure }); + try { onboardingModel.markStep('language'); } catch (_) {} safeRefererRedirect(ctx, '/settings'); }) .post("/settings/conn/start", koaBody(), async ctx => { await meta.connStart(); ctx.redirect("/peers"); }) @@ -7427,7 +8026,7 @@ router } catch (_) {} } try { await meta.acceptInvite(invite); } catch (_) {} - ctx.redirect("/invites"); + safeRefererRedirect(ctx, "/invites"); }) .post("/invites/inhabitant/follow", koaBody(), async (ctx) => { const feedId = String(ctx.request.body?.feedId || '').trim(); @@ -7864,11 +8463,11 @@ router .post("/settings/rebuild", async ctx => { meta.rebuild(); ctx.redirect("/settings"); }) .post("/modules/preset", koaBody(), async (ctx) => { if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } - const ALL_MODULES = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'fediverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts', 'logs', 'torrents']; + const ALL_MODULES = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'fediverse', 'invites', 'wallet', 'legacy', 'dev', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'industry', 'shops', 'banking', 'parliament', 'courts', 'logs', 'torrents']; 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: ALL_MODULES }; const preset = String(ctx.request.body.preset || ''); @@ -7881,34 +8480,12 @@ router }) .post("/save-modules", koaBody(), async (ctx) => { if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } - const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'fediverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'graphos', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts', 'logs', 'torrents']; + const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'fediverse', 'invites', 'wallet', 'legacy', 'dev', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'graphos', 'jobs', 'projects', 'industry', 'shops', 'banking', 'parliament', 'courts', 'logs', 'torrents']; const cfg = getConfig(); modules.forEach(mod => cfg.modules[`${mod}Mod`] = ctx.request.body[`${mod}Form`] === 'on' ? 'on' : 'off'); saveConfig(cfg); ctx.redirect(`/modules`); }) - .get("/settings/bottombar", async (ctx) => { - ctx.body = require("../views/main_views").bottomBarPickerView(); - }) - .post("/settings/bottombar", koaBody(), async (ctx) => { - const cfg = getConfig(); - const MAX = 4; - const body = ctx.request.body || {}; - const action = String(body.action || '').trim(); - const key = String(body.key || '').trim(); - const catalog = require("../views/main_views").PINNABLE_MODULES || []; - let pins = Array.isArray(cfg.bottomBarPins) ? cfg.bottomBarPins.slice() : []; - if (action === 'add') { - if (key && catalog.some(m => m.key === key) && !pins.includes(key) && pins.length < MAX) pins.push(key); - } else if (action === 'remove') { - pins = pins.filter(k => k !== key); - } else if (action === 'clear') { - pins = []; - } - cfg.bottomBarPins = pins.slice(0, MAX); - saveConfig(cfg); - ctx.redirect('/settings/bottombar'); - }) .post("/settings/ai", koaBody(), async (ctx) => { const aiPrompt = String(ctx.request.body.ai_prompt || "").trim(); if (aiPrompt.length > 128) { sendErrorPage(ctx, "Prompt too long. Must be 128 characters or fewer.", { status: 400 }); return; } @@ -8036,6 +8613,7 @@ const middleware = [ try { await refreshInboxCount(); } catch (_) {} try { await calendarsModel.checkDueReminders(); } catch (_) {} try { await tasksModel.checkDueReminders(); } catch (_) {} + try { await checkPoliticalChanges(); } catch (_) {} try { const peers = await meta.connectedPeers(); sharedState.setOnlinePeerCount(Array.isArray(peers) ? peers.length : 0); @@ -8137,7 +8715,9 @@ async function sendWelcomePmIfFirstLaunch() { welcomePmAttempted = false; return; } - try { fs.writeFileSync(flagPath, ownId + '\n' + new Date().toISOString() + '\n'); } catch (e) { + try { + if (!onboardingModel.begin(ownId)) console.error('[welcome-pm] flag write failed'); + } catch (e) { console.error('[welcome-pm] flag write failed:', e && e.message ? e.message : e); } } catch (err) { @@ -8145,8 +8725,148 @@ async function sendWelcomePmIfFirstLaunch() { welcomePmAttempted = false; } } -setTimeout(() => { sendWelcomePmIfFirstLaunch(); }, 20000); +let welcomePmRetries = 0; +const welcomePmTick = async () => { + if (onboardingModel.firstContactSeen()) return; + await sendWelcomePmIfFirstLaunch(); + if (onboardingModel.firstContactSeen()) return; + if (welcomePmRetries++ >= 30) return; + setTimeout(welcomePmTick, 4000); +}; +setTimeout(welcomePmTick, 3000); +const pbFmtDate = (iso) => iso ? moment(iso).format('YYYY-MM-DD HH:mm:ss') : ''; +const pbFmtRemaining = (endIso) => { + if (!endIso) return ''; + const ms = moment(endIso).diff(moment()); + if (ms <= 0) return '0d 00:00:00'; + const d = Math.floor(ms / 86400000); + const h = Math.floor((ms % 86400000) / 3600000); + const mi = Math.floor((ms % 3600000) / 60000); + const se = Math.floor((ms % 60000) / 1000); + const pad = n => String(n).padStart(2, '0'); + return `${d}d ${pad(h)}:${pad(mi)}:${pad(se)}`; +}; +const pbFill = (tpl, vars) => String(tpl || '').replace(/\{(\w+)\}/g, (m, k) => (k in vars ? vars[k] : m)); +const pbTitleCase = (s) => { const t = String(s || ''); return t ? t.charAt(0).toUpperCase() + t.slice(1).toLowerCase() : ''; }; +const pbAtId = (id) => id ? '@' + String(id).replace(/^@/, '') : ''; +function buildLarpRulingMsg(data = {}, t = {}) { + const house = data.house || {}; + const name = house.name || data.houseKey || ''; + const intro = pbFill(t.politicalBotLarpIntro || 'Now {house} rules during this cycle: {cycle}', { + house: `[${name}](/larp/${encodeURIComponent(data.houseKey || '')})`, + cycle: `[${data.cycleFormatted || ''}](/larp)` + }); + const lines = [ + intro, '', name, house.description || '', + `${t.larpRolesLabel || 'Roles'}: ${house.roles || ''}`, + `${t.larpFunctionLabel || 'Function'}: ${house.function || ''}`, + `${t.larpMonthLabel || 'Governance cycle'}: ${house.month != null ? house.month : ''}`, + `${t.larpMembersCount || 'Members'}: ${data.memberCount || 0}`, + house.motto ? `“${house.motto}”` : '' + ]; + return { subject: 'LARP_RULING', body: lines.filter(l => l != null && l !== '').join('\n') }; +} +function pbGovBlock(gov = {}, t = {}) { + const lines = [`${t.parliamentGovernmentCard || 'Current Government'}: ${String(gov.method || '').toUpperCase()}`]; + if (gov.leaderId) lines.push(`${t.politicalBotLeaderLabel || 'Leader'}: ${pbAtId(gov.leaderId)}`); + lines.push(`${t.parliamentLegSince || 'CYCLE SINCE'}: ${pbFmtDate(gov.since)}`); + lines.push(`${t.parliamentLegEnd || 'CYCLE END'}: ${pbFmtDate(gov.end)}`); + lines.push(`${t.parliamentTimeRemaining || 'Time remaining'}: ${pbFmtRemaining(gov.end)}`); + lines.push(`${t.parliamentPopulation || 'Population'}: ${gov.population || 0}`); + return lines; +} +function buildParliamentGovMsg(gov = {}, t = {}) { + const govLink = `[${pbTitleCase(gov.method)}](/parliament)`; + const intro = gov.leaderId + ? pbFill(t.politicalBotParliamentIntroLeader || 'The current government is {gov}, led by {leader}', { gov: govLink, leader: pbAtId(gov.leaderId) }) + : pbFill(t.politicalBotParliamentIntro || 'The current government is {gov}', { gov: govLink }); + return { subject: 'PARLIAMENT_GOV', body: [intro, ''].concat(pbGovBlock(gov, t)).join('\n') }; +} +function buildTribeGovMsg(gov = {}, t = {}) { + const govLink = `[${pbTitleCase(gov.method)}](/tribe/${encodeURIComponent(gov.tribeId || '')}?section=governance)`; + const vars = { tribe: gov.tribeName || '', gov: govLink, leader: pbAtId(gov.leaderId) }; + const intro = gov.leaderId + ? pbFill(t.politicalBotTribeIntroLeader || 'The current government in the Tribe {tribe} is {gov}, led by {leader}', vars) + : pbFill(t.politicalBotTribeIntro || 'The current government in the Tribe {tribe} is {gov}', vars); + return { subject: 'TRIBE_GOV', body: [intro, ''].concat(pbGovBlock(gov, t)).join('\n') }; +} +let politicalCheckRunning = false; +async function checkPoliticalChanges() { + if (politicalCheckRunning) return; + politicalCheckRunning = true; + try { + const ssbClient = await cooler.open().catch(() => null); + if (!ssbClient || !ssbClient.id) return; + const ownId = ssbClient.id; + const i18nAll = require('../client/assets/translations/i18n'); + const lang = (getConfig() && getConfig().language) || 'en'; + const t = i18nAll[lang] || i18nAll.en; + const announced = await pmModel.sentRefs().catch(() => new Set()); + const alreadyAnnounced = (subject, ref) => announced.has(`${subject}|${ref}`); + const send = async (subject, body, ref) => { + if (!ref || alreadyAnnounced(subject, ref)) return; + try { await pmModel.sendMessage([], subject, body, false, ref); announced.add(`${subject}|${ref}`); } + catch (e) { console.error('[political-bot] send failed:', e && e.message); } + }; + + try { + const houseKey = larpModel.getGoverningHouseKey(); + if (houseKey) { + let cycleFormatted = ''; + try { cycleFormatted = larpModel.computeCycle().formatted; } catch (_) {} + const ref = `${houseKey}:${cycleFormatted}`; + if (!alreadyAnnounced('LARP_RULING', ref)) { + const house = (larpModel.getHouse && larpModel.getHouse(houseKey)) || {}; + let memberCount = 0; + try { const hc = await larpModel.listHousesWithCounts(); const h = (hc || []).find(x => x.key === houseKey); memberCount = h ? (h.memberCount || 0) : 0; } catch (_) {} + const { subject, body } = buildLarpRulingMsg({ houseKey, house, memberCount, cycleFormatted }, t); + await send(subject, body, ref); + } + } + } catch (e) { console.error('[political-bot] larp:', e && e.message); } + + try { + const card = await parliamentModel.getLatestGovernmentCard().catch(() => null); + if (card) { + const ref = String(card.id || card.since || card.method || ''); + if (ref && !alreadyAnnounced('PARLIAMENT_GOV', ref)) { + let population = 0; + try { const inh = await inhabitantsModel.listInhabitants({ filter: 'all', includeInactive: true }); population = Array.isArray(inh) ? inh.length : 0; } catch (_) {} + const leaderId = (card.powerType === 'inhabitant' && card.powerId) ? card.powerId : null; + const { subject, body } = buildParliamentGovMsg({ method: card.method, since: card.since, end: card.end, population, leaderId }, t); + await send(subject, body, ref); + } + } + } catch (e) { console.error('[political-bot] parliament:', e && e.message); } + + try { + let tribes = []; + try { tribes = await tribesModel.listAll(); } catch (_) { tribes = []; } + for (const tribe of (tribes || [])) { + if (!tribe || !tribe.id) continue; + if (!Array.isArray(tribe.members) || !tribe.members.includes(ownId)) continue; + if (tribe.parentTribeId) continue; + let term = null; + try { term = await parliamentModel.tribe.getCurrentTerm(tribe.id); } catch (_) { term = null; } + if (!term) continue; + const ref = `${tribe.id}:${String(term.id || term.startAt || term.method || '')}`; + if (!alreadyAnnounced('TRIBE_GOV', ref)) { + const lead = Array.isArray(term.leaders) && term.leaders.length ? term.leaders[0] : null; + const leaderId = lead && typeof lead === 'object' ? (lead.id || lead.powerId || null) : lead; + const population = Array.isArray(tribe.members) ? tribe.members.length : 0; + const { subject, body } = buildTribeGovMsg({ method: term.method, since: term.startAt, end: term.endAt, population, leaderId, tribeId: tribe.id, tribeName: tribe.title || tribe.name || tribe.id }, t); + await send(subject, body, ref); + } + } + } catch (e) { console.error('[political-bot] tribe:', e && e.message); } + + } catch (err) { + console.error('[political-bot] unexpected:', err && err.message); + } finally { + politicalCheckRunning = false; + } +} async function logClearnetStatus() { try { const ssbClient = await cooler.open(); diff --git a/src/backend/fileshare_crypto.js b/src/backend/fileshare_crypto.js new file mode 100644 index 0000000..d6bef73 --- /dev/null +++ b/src/backend/fileshare_crypto.js @@ -0,0 +1,74 @@ +const crypto = require('crypto'); + +const ALG = 'aes-256-gcm'; +const IV_LEN = 12; +const TAG_LEN = 16; +const KEY_LEN = 32; +const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024; + +const generateFileKey = () => crypto.randomBytes(KEY_LEN); + +const keyToHex = (key) => Buffer.isBuffer(key) ? key.toString('hex') : String(key); + +const keyFromHex = (hex) => { + const buf = Buffer.from(String(hex), 'hex'); + if (buf.length !== KEY_LEN) throw new Error('Invalid file key'); + return buf; +}; + +const encryptChunk = (plaintext, key) => { + const iv = crypto.randomBytes(IV_LEN); + const cipher = crypto.createCipheriv(ALG, key, iv); + const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, ct]); +}; + +const decryptChunk = (payload, key) => { + if (!Buffer.isBuffer(payload) || payload.length < IV_LEN + TAG_LEN) throw new Error('Corrupt chunk'); + const iv = payload.subarray(0, IV_LEN); + const tag = payload.subarray(IV_LEN, IV_LEN + TAG_LEN); + const ct = payload.subarray(IV_LEN + TAG_LEN); + const decipher = crypto.createDecipheriv(ALG, key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ct), decipher.final()]); +}; + +const sha256Hex = (buffer) => crypto.createHash('sha256').update(buffer).digest('hex'); + +const splitBuffer = (buffer, chunkSize = DEFAULT_CHUNK_SIZE) => { + const size = Math.max(1, Number(chunkSize) || DEFAULT_CHUNK_SIZE); + const out = []; + for (let off = 0; off < buffer.length; off += size) { + out.push(buffer.subarray(off, Math.min(off + size, buffer.length))); + } + if (!out.length) out.push(Buffer.alloc(0)); + return out; +}; + +const buildManifest = ({ filename, mime, size, chunkSize, chunkHashes, plainSha256 }) => ({ + v: 1, + alg: ALG, + filename: String(filename || 'file'), + mime: String(mime || 'application/octet-stream'), + size: Number(size) || 0, + chunkSize: Number(chunkSize) || DEFAULT_CHUNK_SIZE, + chunks: Array.isArray(chunkHashes) ? chunkHashes.slice() : [], + plainSha256: plainSha256 || null +}); + +const encryptManifest = (manifest, key) => encryptChunk(Buffer.from(JSON.stringify(manifest), 'utf8'), key); + +const decryptManifest = (payload, key) => { + const json = decryptChunk(payload, key).toString('utf8'); + const m = JSON.parse(json); + if (!m || m.v !== 1 || !Array.isArray(m.chunks)) throw new Error('Invalid manifest'); + return m; +}; + +module.exports = { + ALG, IV_LEN, TAG_LEN, KEY_LEN, DEFAULT_CHUNK_SIZE, + generateFileKey, keyToHex, keyFromHex, + encryptChunk, decryptChunk, sha256Hex, splitBuffer, + buildManifest, encryptManifest, decryptManifest +}; diff --git a/src/backend/pm_policy.js b/src/backend/pm_policy.js new file mode 100644 index 0000000..bfb511e --- /dev/null +++ b/src/backend/pm_policy.js @@ -0,0 +1,9 @@ +const isMutual = (relationship) => !!(relationship && relationship.following && relationship.followsMe); + +const isRecipientAllowed = ({ pmVisibility, viewerId, recipientId, relationship } = {}) => { + if (recipientId && viewerId && recipientId === viewerId) return true; + if (pmVisibility !== 'mutuals') return true; + return isMutual(relationship); +}; + +module.exports = { isRecipientAllowed, isMutual }; diff --git a/src/client/assets/styles/style.css b/src/client/assets/styles/style.css index eca02f8..caaeb7c 100644 --- a/src/client/assets/styles/style.css +++ b/src/client/assets/styles/style.css @@ -3310,6 +3310,8 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0; .pm-card.normal-pm { padding: 10px 12px; border-radius: 8px; margin-bottom: 10px; border: 1px solid #ffa300; background: transparent } .pm-btn { padding: 6px 16px; border-radius: 6px; font-size: 12px; font-weight: 700; letter-spacing: 0.5px; cursor: pointer; border: 1px solid #ffa300; background: transparent; color: #ffa300; transition: background 0.15s, color 0.15s } .pm-btn:hover { background: #ffa300; color: #000 } +a.pm-btn { text-decoration: none; display: inline-block; line-height: 1.2 } +a.pm-btn:hover { text-decoration: none } .pm-thread { margin-bottom: 12px } .pm-thread-details { margin-top: 0 } .pm-thread-details[open] .pm-thread-icon { display: inline-block; transform: rotate(90deg) } @@ -3333,7 +3335,10 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0; .pm-actions { display: flex; gap: 10px; + align-items: center; + flex-wrap: wrap; } +.pm-actions .pm-btn { margin: 0 } .pm-crypter-row { border: none; background: transparent; @@ -6109,7 +6114,7 @@ a.chip-link:hover .pm-exposition-chip{filter:brightness(1.2)} .pm-exposition-text{line-height:1;} .shop-title-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:6px;padding:0;background:transparent;border-radius:0;box-shadow:none} .shop-title-row h2{margin:0} -.card-chips-row{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 10px 0;padding:0;border:none;background:transparent;border-radius:0;box-shadow:none} +.card-chips-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:0 0 10px 0;padding:0;border:none;background:transparent;border-radius:0;box-shadow:none} .tribe-card-body .job-price-line,.tribe-card-body .job-meta-line,.tribe-card-body .job-card-view-btn,.tribe-card-body .card-comments-summary,.tribe-card-body .tribe-card-members{padding:0;background:transparent;border-radius:0;box-shadow:none;border:none} .tribe-card-body .job-price-line{margin:4px 0} .tribe-card-body .job-card-view-btn{margin-top:8px;margin-bottom:0} @@ -6439,3 +6444,143 @@ a.user-link{padding:8px 16px;border-radius:5px;text-align:center;font-weight:bol .activitySpreadInhabitant2,.activityVotePost{padding:8px 16px;border-radius:5px;font-weight:bold;text-decoration:none;display:inline-block;border:2px solid transparent} .danger-btn,.delete-btn{border:1px solid #d9534f;color:#d9534f;background:transparent} .danger-btn:hover,.delete-btn:hover{background:#d9534f;color:#000} +.pm-fileshare-form-wrap{margin-top:16px} +.pm-fileshare-form input[type=file]{display:block;margin:10px 0 15px} +.pm-fileshare-info{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin:8px 0} +.pm-fileshare-icon{font-size:1.2em} +.pm-fileshare-name{font-weight:bold;word-break:break-all} +.pm-fileshare-meta{font-size:.85em;opacity:.75} +.pm-fileshare-download-form{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;border:0;background:transparent;padding:0} +.pm-fileshare-download{text-decoration:none;display:inline-block} +.industry-form{margin:0 auto} +.industry-governance{margin-top:16px} +.industry-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:12px 0} +.industry-build-vote{display:flex;flex-wrap:wrap;gap:8px;align-items:center} +.industry-estimate,.industry-materials,.industry-blueprint-meta,.industry-meta{border:none;background:transparent;padding:0;box-shadow:none;border-radius:0;margin:8px 0} +.industry-estimate p{margin:2px 0} +.industry-estimate-total{font-weight:700;margin-top:6px} +.industry-section{margin-top:20px} +.industry-applicants,.industry-members-list{list-style:none;padding:0;margin:8px 0} +.industry-applicants li,.industry-members-list li{padding:6px 0;display:flex;flex-wrap:wrap;gap:8px;align-items:center} +.industry-vote-form,.industry-invite-form{display:flex;flex-wrap:wrap;gap:6px;align-items:center;border:0;background:transparent;padding:0} +.industry-invite-form input{flex:1 1 240px} +.industry-vote-count{opacity:.85} +.industry-hint{opacity:.75;font-style:italic} +.industry-meta{margin:12px 0} +.industry-meta p{margin:4px 0} +.industry-meta-label{font-weight:bold} +.industry-card-desc,.industry-detail-desc{margin:8px 0} +.industry-side-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px} +.search-industry .card-field{margin:4px 0} +.industry-form select,.industry-form input[type="number"],.industry-form input[type="file"],.industry-form input[type="date"]{margin-bottom:15px} +.industry-form-media,.industry-detail-media{margin:8px 0} +.industry-hero-image,.tribe-card-hero-image{max-width:100%;height:auto;border-radius:6px} +.industry-search{margin:8px 0} +.industry-card-noimage{display:flex;align-items:center;justify-content:center;min-height:120px;font-size:48px;opacity:.5} +.industry-bot-notification .pm-title{margin-top:4px} +.industry-table{width:100%;border-collapse:collapse;margin:8px 0} +.industry-table th,.industry-table td{text-align:left;padding:6px 8px;border-bottom:1px solid rgba(128,128,128,.3)} +.industry-contrib-form{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:6px 0} +.industry-contrib-form-single label{display:block;margin:10px 0 4px 0} +.industry-contrib-form-single input,.industry-contrib-form-single textarea,.industry-contrib-form-single select{width:100%;box-sizing:border-box;display:block;margin:0} +.industry-contrib-form-single .industry-input-num{width:auto;max-width:220px} +.industry-contrib-form-single button{margin-top:14px} +.industry-contrib-kind{display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin:0 0 6px 0;border:none;background:transparent;padding:0;box-shadow:none} +.industry-contrib-kind label{margin:0} +.industry-contrib-kind select{width:auto;min-width:190px;margin:0;height:38px;box-sizing:border-box} +.industry-contrib-kind button{margin:0;height:38px;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center} +.industry-blueprint-card{border:1px solid rgba(128,128,128,.3);border-radius:6px;padding:10px;margin:8px 0} +.industry-card-wrap{position:relative;border:1px solid rgba(128,128,128,.3);border-radius:6px;padding:10px;margin:8px 0;background:transparent;box-shadow:none} +.industry-card-wrap .industry-blueprint-card{border:none;background:transparent;padding:0;margin:0;box-shadow:none;border-radius:0} +.industry-card-wrap > .card-header{position:absolute;top:8px;right:10px;margin:0;padding:0;border:none;background:transparent;box-shadow:none;z-index:1} +.industry-card-wrap > .card-header > span:empty{display:none} +.industry-card-wrap > .industry-blueprint-card{padding:0 96px 0 0} +.industry-actions form,.industry-actions .project-control-form--status{margin:0;padding:0;border:none;background:transparent;display:flex;align-items:center;gap:10px} +.industry-actions .project-control-select,.industry-actions .project-control-btn,.industry-actions select,.industry-actions button{margin:0} +.industry-actions button,.industry-actions .project-control-select{display:inline-flex;align-items:center;justify-content:center;height:38px;box-sizing:border-box} +.card-chips-row .spread-form{margin:0;padding:0;border:none;background:transparent;display:flex;align-items:center} +.card-chips-row .spread-form .spread-btn{margin:0} +.industry-build-notes{margin:22px 0} +.industry-dates-row{border:none;background:transparent;padding:0;box-shadow:none;border-radius:0;margin:8px 0} +.industry-dates-row p{margin:0} +.dev-breadcrumb,.dev-stats,.dev-file-meta,.dev-file-head,.dev-listing,.dev-pager,.dev-actions,.dev-result-text,.dev-result-head,.dev-desktop{border:none;background:transparent;padding:0;box-shadow:none;border-radius:0} +.dev-stats{margin:12px 0} +.dev-stats p,.dev-file-meta p{margin:4px 0;line-height:1.9} +.dev-label{font-weight:bold} +.dev-sep{opacity:.45} +.dev-hint{opacity:.75;font-style:italic} +.dev-breadcrumb{margin:14px 0;word-break:break-all} +.dev-breadcrumb-sep{opacity:.45} +.dev-breadcrumb-current{font-weight:bold} +.dev-desktop{display:grid;grid-template-columns:repeat(auto-fill,minmax(118px,1fr));gap:12px;margin:16px 0} +.dev-icon{display:flex;flex-direction:column;align-items:center;justify-content:flex-start;text-align:center;gap:5px;padding:12px 6px;border:1px solid rgba(128,128,128,.28);border-radius:8px;text-decoration:none;word-break:break-word;min-height:104px} +.dev-icon:hover{border-color:rgba(128,128,128,.7)} +.dev-icon-glyph{font-size:30px;line-height:1} +.dev-icon-name{font-size:.9em;line-height:1.25} +.dev-icon-meta{font-size:.76em;opacity:.6} +.dev-icon-off{opacity:.5;cursor:default} +.dev-icon-off:hover{border-color:rgba(128,128,128,.28)} +.dev-file-head{display:flex;align-items:center;gap:10px;margin:14px 0 4px 0} +.dev-file-glyph{font-size:26px} +.dev-file-name{margin:0;word-break:break-all} +.dev-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:14px 0} +.dev-actions form{margin:0;padding:0;border:none;background:transparent} +.dev-actions button{margin:0} +.dev-code{overflow-x:auto;border:1px solid rgba(128,128,128,.3);border-radius:6px;padding:8px;margin:12px 0} +.dev-code-table{border-collapse:collapse;width:100%} +.dev-code-num a{text-decoration:none} +.dev-code-line pre{margin:0;white-space:pre} +.dev-code-line code{font-family:monospace;font-size:.92em} +.dev-pager{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0} +.dev-list{list-style:none;padding:0;margin:14px 0} +.dev-entry{padding:9px 0;border-bottom:1px solid rgba(128,128,128,.2);word-break:break-all} +.dev-result-head{display:flex;align-items:center;gap:8px} +.dev-result-glyph{font-size:18px} +.dev-result-text{margin:5px 0 0 26px;overflow-x:auto} +.dev-result-text pre{margin:0} +.dev-table{width:100%;border-collapse:collapse;margin:8px 0} +.dev-table th,.dev-table td{text-align:left;padding:6px 8px;border-bottom:1px solid rgba(128,128,128,.3);word-break:break-all} +.filters .dev-toolbar{border:none !important;background:transparent !important;padding:0 !important;margin:0 !important;box-shadow:none !important;border-radius:0 !important} +.dev-search{border:none !important;background:transparent !important;padding:0 !important;box-shadow:none !important;border-radius:0 !important;margin:14px 0} +.dev-search .filter-box{margin:0} +.dev-search .filter-box__controls{border:none !important;background:transparent !important;padding:0 !important;box-shadow:none !important;border-radius:0 !important;margin-top:12px;display:flex;flex-wrap:wrap;gap:10px;align-items:center} +.dev-search .filter-box__select{width:auto;min-width:170px;margin:0;height:38px} +.dev-search .filter-btn{margin:0;height:38px;padding:0 20px;display:inline-flex;align-items:center;justify-content:center} +.dev-code-row td{border:none;vertical-align:top} +.dev-code-num{text-align:right;padding:0 14px 0 4px;width:1%;min-width:52px;white-space:nowrap;opacity:.5;user-select:none;border-right:1px solid rgba(128,128,128,.25) !important} +.dev-code-line{padding-left:16px} +.welcome-banner .update-banner-icon{animation:none} +.welcome-banner .welcome-banner-close{margin:0;padding:0;border:none;background:transparent;box-shadow:none;display:inline-flex;align-items:center} +.welcome-banner-close-btn{background:transparent;border:none;color:inherit;cursor:pointer;font-size:1em;line-height:1;padding:0 4px;margin:0;opacity:.6} +.welcome-banner-close-btn:hover{opacity:1} +.welcome-progress,.welcome-steps,.welcome-complete,.welcome-footer,.welcome-action,.welcome-step-head{border:none !important;background:transparent !important;padding:0 !important;box-shadow:none !important;border-radius:0 !important} +.welcome-progress{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px;margin:12px 0} +.welcome-progress p{margin:0} +.welcome-progress form{margin:0;padding:0;border:none;background:transparent} +.welcome-progress-label{font-weight:bold} +.welcome-optional{opacity:.75;font-style:italic;margin:0 0 18px 0} +.welcome-steps{display:flex;flex-direction:column;gap:14px;margin:16px 0} +.welcome-step{border:1px solid rgba(128,128,128,.3) !important;border-radius:8px;padding:14px 16px !important;margin:0} +.welcome-step-done{opacity:.72} +.welcome-step-head{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:0 0 6px 0} +.welcome-step-number{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;border:1px solid rgba(128,128,128,.5);font-size:.85em;flex:0 0 auto} +.welcome-step-title{margin:0;font-size:1.05em} +.welcome-step-text{margin:0 0 12px 0} +.welcome-chip{border:1px solid rgba(128,128,128,.4);border-radius:12px;padding:2px 10px;font-size:.78em;opacity:.8} +.welcome-chip-done{opacity:1;font-weight:bold} +.welcome-action{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:0} +.welcome-action form,.welcome-language-form{margin:0;padding:0;border:none;background:transparent} +.welcome-language-form select{width:auto;min-width:170px;height:38px;box-sizing:border-box;margin:0} +.welcome-action .filter-btn{margin:0;height:38px;padding:0 20px;display:inline-flex;align-items:center;justify-content:center} +.welcome-complete{margin:16px 0} +.welcome-footer{margin:20px 0 0 0} +.welcome-footer form{margin:0;padding:0;border:none;background:transparent} +.welcome-profile-form,.welcome-greeting-form{margin:0;padding:0;border:none;background:transparent;box-shadow:none} +.welcome-profile-form label{display:block;margin:10px 0 4px 0} +.welcome-profile-form input[type="text"],.welcome-profile-form textarea,.welcome-greeting-form textarea{width:100%;box-sizing:border-box;display:block;margin:0} +.welcome-profile-form input[type="file"]{margin:0;width:auto} +.welcome-profile-form .welcome-action,.welcome-greeting-form .welcome-action{margin-top:14px} +.welcome-profile-avatar{display:block;width:96px;height:96px;object-fit:cover;border-radius:6px;margin:0 0 10px 0} +.welcome-step-warning{margin:0 0 12px 0} +.welcome-oasisid{border:none !important;background:transparent !important;padding:0 !important;box-shadow:none !important;margin:0 0 14px 0;word-break:break-all} +.welcome-oasisid .user-link{user-select:all} diff --git a/src/client/assets/themes/OasisMobileUX.css b/src/client/assets/themes/OasisMobileUX.css index a43279c..9aaf186 100644 --- a/src/client/assets/themes/OasisMobileUX.css +++ b/src/client/assets/themes/OasisMobileUX.css @@ -775,3 +775,13 @@ button, input[type="submit"], input[type="button"], .bb-pick-ico { font-size: 26px !important; color: #FFA500 !important; line-height: 1 !important; } .bb-pick-lbl { font-size: 11px !important; text-align: center !important; line-height: 1.2 !important; } + +/* Ocultar los checkboxes nativos del drawer (nav-left/right-toggle) — si no, salen 2 cuadrados blancos arriba */ +.oasis-nav-side-toggle { + position: absolute !important; + width: 1px !important; + height: 1px !important; + opacity: 0 !important; + pointer-events: none !important; + margin: 0 !important; +} diff --git a/src/client/assets/translations/oasis_ar.js b/src/client/assets/translations/oasis_ar.js index 3319b82..f6cf822 100644 --- a/src/client/assets/translations/oasis_ar.js +++ b/src/client/assets/translations/oasis_ar.js @@ -1,26 +1,6 @@ const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); module.exports = { ar: { - bbAdd: "إضافة", - bbQuickActions: "إجراءات سريعة", - bbPickTitle: "تثبيت وحدة", - bbPickDesc: "اختر وحدة لخانة + في الشريط السفلي.", - bbPickNone: "لا شيء", - bbManage: "تعديل", - bbYourBar: "شريطك", - bbRemove: "إزالة", - bbFull: "تم بلوغ الحد الأقصى", - bbDone: "تم", - activityChangeFilter: "تغيير التصفية", - emptyConnectHint: "اتصل بـ pub للمزامنة مع الشبكة.", - emptyConnectCta: "الاتصال بـ pub", - menuCommunity: "المجتمع", - activityGroupCommunity: "المجتمع والحوكمة", - activityGroupOrg: "التنظيم", - activityGroupComm: "التواصل", - activityGroupEconomy: "الاقتصاد", - activityGroupMedia: "المحتوى والوسائط", - activityGroupOther: "أخرى", languageName: "العربية", shopOrderStatusPaid: "تم استلام الدفع", shopOrderStatusReceived: "تم الاستلام", @@ -428,7 +408,8 @@ module.exports = { publish: "كتابة", contentWarningPlaceholder: "أضف موضوعًا للمنشور (اختياري)", privateWarningPlaceholder: "أضف سكانًا لإرسال منشور خاص (اختياري)", - publishWarningPlaceholder: "...", + publishWarningPlaceholder: "النص محدود بـ 7000 حرفًا.", + publishTooLong: "منشورك طويل جدًا. الرجاء تقصيره.", publishCustomDescription: [ "تذكّر: بسبب تقنية البلوكتشين، بمجرد نشر المنشور لا يمكن تعديله أو حذفه.", ], @@ -493,7 +474,89 @@ module.exports = { passwordLengthInfo: "يجب أن تكون كلمة المرور 32 حرفًا على الأقل.", passwordImport: "اكتب كلمة المرور لفك تشفير البيانات التي سيتم حفظها في مجلد النظام الرئيسي (الاسم: secret)", exportPasswordPlaceholder: "استخدم أحرفًا صغيرة وكبيرة وأرقامًا ورموزًا", - fileInfo: "سيتم حفظ مفتاحك السري المشفر في مجلد النظام الرئيسي (الاسم: oasis.enc)", + fileInfo: "سيتم تنزيل مفتاحك السري المشفر إلى جهازك (الاسم: oasis.enc)", + developer: "التطوير", + devTitle: "التطوير", + welcomeBannerText: "أهلًا بك في أواسيس. اتبع هذه الخطوات السريعة لإعداد شخصيتك.", + welcomeBannerAction: "ابدأ!", + welcomeTitle: "أهلًا بك", + welcomeStepGreetingAction: "إرسال", + welcomeJoinPubButton: "انضم إلى الـ pub", + welcomeStepLarpAction: "انضم إلى «الأكاديمية»", + welcomeStepLarpText: "انضم إلى لعبة تقمص الأدوار الحية الخاصة بنا.", + welcomeStepLarpTitle: "انضم إلى L.A.R.P.", + welcomeStepGreetingTitle: "أرسل «مرحبًا يا عالم!»", + welcomeStepGreetingText: "أرسل رسالة ليتمكن بقية السكان من اكتشافك.", + welcomeJoinPub: "انضم إلى", + welcomeDescription: "بعض الأمور التي يُستحسن فعلها قبل البدء.", + welcomeProgress: "مكتمل", + welcomeStepDone: "تم", + welcomeStepPending: "معلّق", + welcomeSkip: "ليس الآن", + welcomeStepLanguageTitle: "اختر لغتك", + welcomeStepLanguageText: "أواسيس يتحدث عدة لغات. يمكنك تغييرها متى شئت.", + welcomeStepProfileTitle: "حرّر ملفك الشخصي", + welcomeStepProfileText: "اختر اسمًا وأضف وصفًا وضع صورة ليتعرّف عليك بقية السكان.", + welcomeStepProfileAction: "حفظ الملف", + welcomeStepFederationTitle: "انضم إلى الشبكة الرئيسية", + welcomeStepFederationText: "اتصل بالـ pub الخاص بنا للقاء سكان آخرين والبدء في المزامنة.", + welcomeStepFederationAction: "الذهاب إلى الدعوات", + welcomeStepBackupTitle: "انسخ معرّفك احتياطيًا", + welcomeStepBackupText: "هويتك ملف مفاتيح على هذا الجهاز.", + welcomeStepBackupWarning: "إذا ضاع، لا يستطيع أحد استعادته لك.", + welcomeStepBackupAction: "انسخ احتياطيًا!", + welcomeCompleteTitle: "كل شيء جاهز.", + welcomeCompleteText: "عقدتك جاهزة. من هنا تنمو الشبكة مع من تتابعهم.", + welcomeFindPeople: "ابحث عن السكان", + devFilterFiles: "الملفات", + devFilterSearch: "بحث", + devFilterModules: "الوحدات", + devFilterTranslations: "الترجمات", + devFilterStyles: "الأنماط", + devFilterTests: "الاختبارات", + devVersion: "الإصدار", + devNodeVersion: "Node", + devStatsTests: "مجموعات الاختبار", + devParentFolder: "المجلد الأعلى", + devOpenFolder: "فتح", + devDescription: "استكشف الشيفرة المصدرية التي تعمل على هذه العقدة.", + devTreeTitle: "الملفات", + devSearchTitle: "البحث في الشيفرة", + devMapTitle: "الوحدات", + devRoot: "الجذر", + devRootButton: "الجذر", + devSearchPlaceholder: "النص المطلوب البحث عنه في الشيفرة", + devAnyExtension: "أي ملف", + devSearchButton: "بحث", + devStatsFiles: "الملفات", + devStatsLines: "الأسطر", + devStatsModules: "الوحدات", + devStatsLanguages: "اللغات", + devNotViewable: "غير قابل للعرض", + devEmptyFolder: "هذا المجلد فارغ.", + devSize: "الحجم", + devShowing: "يعرض", + devLine: "سطر", + devReportBug: "الإبلاغ عن خلل", + devCreateTask: "إنشاء مهمة", + devRaw: "RAW", + devReportContext: "وُجد أثناء قراءة الشيفرة المصدرية لأواسيس", + devTaskPrefix: "مراجعة", + devPrevPage: "الأسطر السابقة", + devNextPage: "الأسطر التالية", + devMatches: "تطابقات", + devFilesScanned: "ملفات مفحوصة", + devNoResults: "لا توجد تطابقات بعد.", + devColModule: "الوحدة", + devColState: "الحالة", + devColModel: "النموذج", + devColView: "العرض", + devColRoutes: "المسارات", + devColTests: "الاختبارات", + devOn: "مفعّل", + devOff: "معطّل", + modulesDevLabel: "التطوير", + modulesDevDescription: "وحدة لإدارة شيفرة أواسيس المصدرية.", themeIntro: "اختر سمة.", setTheme: "تعيين السمة", @@ -695,7 +758,7 @@ module.exports = { spreadLabel: "نشر", spreadHint: "نشر هذا لداعميك (يتم النسخ عبر تغذيتك).", welcomePmSubject: "Hello World!", - welcomePmBody: "مرحبًا بك في أوازيس — شبكة اجتماعية حرة، نِدّ لِنِد، اتحادية ومشفّرة، ذات بنية موزّعة بالدعوة فقط.\n\nبعض الأماكن المثيرة للاهتمام للبدء:\n\n- /profile — عدّل صورتك الرمزية واسمك ووصفك والوحدات التي تظهر في جانبك العام.\n- /invites — أضف pub للاتحاد مع بقية الشبكة.\n- /peers — اطّلع على من هو متصل، أعد مسح شبكتك المحلية، صدّر أو استورد قوائم الأقران.\n- /inhabitants — اكتشف الصور الرمزية للآخرين وزرها.\n- /tribes — أنشئ مجموعات خاصة بتشفير من طرف إلى طرف أو انضم إليها.\n- /inbox — اقرأ وأرسل الرسائل الخاصة.\n- /activity — اطّلع على النشاط الأخير، نشاطك ونشاط شبكتك.\n- /publish — اكتب منشورًا أو شارك صورة أو صوتًا أو فيديو أو مستندًا.\n\nبعض الأماكن المثيرة للاهتمام للاتصال:\n\n- /fediverse — أدر حساباتك الأخرى في الفيديفيرس، بما في ذلك إرسال واستقبال المحتوى.\n\nأُرسلت هذه الرسالة بشكل خاص منك إلى نفسك، لذا لم يلزم أي اتصال لاستلامها.", + welcomePmBody: "مرحبًا بك في أوازيس — شبكة اجتماعية حرة، نِدّ لِنِد، اتحادية ومشفّرة، ذات بنية موزّعة بالدعوة فقط.\n\nبعض الأماكن المثيرة للاهتمام للبدء:\n\n- /profile — عدّل صورتك الرمزية واسمك ووصفك والوحدات التي تظهر في جانبك العام.\n- /invites — أضف pub للاتحاد مع بقية الشبكة.\n- /peers — اطّلع على من هو متصل، أعد مسح شبكتك المحلية، صدّر أو استورد قوائم الأقران.\n- /inhabitants — اكتشف الصور الرمزية للآخرين وزرها.\n- /tribes — أنشئ مجموعات خاصة بتشفير من طرف إلى طرف أو انضم إليها.\n- /inbox — اقرأ وأرسل الرسائل الخاصة.\n- /activity — اطّلع على النشاط الأخير، نشاطك ونشاط شبكتك.\n- /publish — اكتب منشورًا أو شارك صورة أو صوتًا أو فيديو أو مستندًا.\n- /search — ابحث في محتوى الشبكة حسب النوع.\n\nبعض الأماكن المثيرة للاهتمام للاتصال:\n\n- /fediverse — أدر حساباتك الأخرى في الفيديفيرس، بما في ذلك إرسال واستقبال المحتوى.\n\nأُرسلت هذه الرسالة بشكل خاص منك إلى نفسك، لذا لم يلزم أي اتصال لاستلامها.", profileVisibilityTitle: "ظهور الملف الشخصي العام", profileVisibilityHint: "اختر الحقول التي يراها السكان الآخرون في ملفك الشخصي. افتراضيًا، يظهر Karma فقط.", profileSensorsSectionTitle: "المستشعرات", @@ -1226,6 +1289,9 @@ module.exports = { eventButton: "الأحداث", taskButton: "المهام", votesButton: "التصويتات", + industryButton: "الصناعة", + projectButton: "المشاريع", + shopProductButton: "المنتجات", reportButton: "التقارير", feedButton: "التغذية", marketButton: "السوق", @@ -1254,7 +1320,7 @@ module.exports = { trendingItemStatus: "حالة العنصر", trendingTotalVotes: "إجمالي الأصوات", trendingTotalOpinions: "إجمالي الآراء", - trendingNoContentMessage: "لا يتوفر محتوى رائج بعد.", + trendingNoContentMessage: "لا توجد اتجاهات بعد.", trendingAuthor: "بواسطة", trendingCreatedAtLabel: "أُنشئ في", trendingTotalCount: "العدد الإجمالي", @@ -1443,7 +1509,7 @@ module.exports = { transfersCreatedAt: "أُنشئ في", transfersConfirmations: "التأكيدات", transfersContainingBlock: "الكتلة المحتوية", - transfersExportContract: "إنشاء العقد", + transfersExportContract: "عقد ذكي", transfersConfirmButton: "تأكيد التحويل", transfersNoItems: "لم يتم العثور على تحويلات.", transfersMineSectionTitle: "تحويلاتك", @@ -1569,7 +1635,7 @@ module.exports = { cvDeleteButton: "حذف", cvNoCV: "لم يتم العثور على سيرة ذاتية.", blogSubject: "الموضوع", - blogMessage: "النص محدود بـ 8096 حرفًا.", + blogMessage: "الرسالة", blogImage: "رفع وسائط (الحد الأقصى: 50 ميغابايت)", blogPublish: "معاينة", noPopularMessages: "لم تُنشر رسائل شائعة بعد", @@ -2204,6 +2270,7 @@ module.exports = { agendaFilterReports: "التقارير", agendaFilterTransfers: "التحويلات", agendaFilterJobs: "الوظائف", + agendaFilterIndustry: "الصناعة", agendaFilterProjects: "المشاريع", agendaFilterCalendars: "التقويمات", agendaNoItems: "لم يتم العثور على إسنادات.", @@ -2325,16 +2392,31 @@ module.exports = { privateMessage: "رسالة خاصة", pmSendTitle: "الرسائل الخاصة", pmSend: "إرسال!", - pmDescription: "استخدم هذا النموذج لإرسال رسالة مشفرة إلى سكان آخرين.", + pmCancel: "إلغاء", + pmReset: "إعادة تعيين", + pmSharedKeyHint: "شارك هذا المفتاح مع المستلم عبر قناة أخرى. فهو ضروري لفك التشفير.", + pmDescription: "استخدم هذا النموذج لإرسال رسالة/ملف مشفّر إلى سكان آخرين.", + pmComposeTitle: "إرسال رسالة", pmRecipients: "المستلمون", pmRecipientsHint: "أدخل معرّفات Oasis مفصولة بفواصل", pmLevelOfExposition: "Level of exposition", - pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmLimitsHint: "حتى 7 مستلمين لكل رسالة.", + pmTextPlaceholder: "النص محدود بـ 7000 حرفًا.", pmSubject: "الموضوع", pmSubjectHint: "أدخل موضوع الرسالة", pmText: "الرسالة", pmCrypterChip: "2xE2E", pmCrypterDecryptButton: "فك التشفير", + fileShareTitle: "مشاركة ملف", + fileShareRecipientPlaceholder: "@...=.ed25519", + fileShareFileLabel: "ملف", + fileShareDownload: "تنزيل", + fileShareMutualError: "يمكنك مشاركة الملفات فقط مع السكان ذوي الدعم المتبادل.", + fileShareNoFile: "لم يتم اختيار أي ملف.", + fileShareTooLarge: "الملف يتجاوز الحجم المسموح.", + fileShareFailed: "تعذّر تحضير الملف.", + fileShareSendError: "تعذّر إرسال الملف.", + fileShareUnavailable: "هذا الملف غير متاح حاليًا. حاول لاحقًا.", pmCrypterBadKey: "المفتاح المشترك غير صحيح!", pmCrypterTooLong: "الرسالة طويلة جدًا للتشفير باستخدام Crypter. يُرجى اختصارها والمحاولة مرة أخرى.", pmCrypterCipherLabel: "الرسالة المعاد تشفيرها", @@ -2355,15 +2437,27 @@ module.exports = { pmNoSubject: "(بدون موضوع)", pmSubjectLabel: "الموضوع:", pmBodyLabel: "النص", - pmBotJobs: "42-JobsBOT", - pmBotProjects: "42-ProjectsBOT", - pmBotMarket: "42-MarketBOT", + pmBotJobs: "JobsBot", + pmBotProjects: "ProjectsBot", + pmBotMarket: "MarketBot", + pmBotShops: "ShopsBot", + pmBotPolitical: "PoliticalBot", + politicalBotLarpTitle: "لقد تغيّر البيت الحاكم في L.A.R.P.", + politicalBotParliamentTitle: "بدأت دورة حكم جديدة في البرلمان.", + politicalBotTribeTitle: "بدأت دورة حكم جديدة في إحدى قبائلك.", + politicalBotLarpIntro: "الآن يحكم {house} خلال هذه الدورة: {cycle}", + politicalBotParliamentIntro: "الحكومة الحالية هي {gov}", + politicalBotParliamentIntroLeader: "الحكومة الحالية هي {gov} يمارسها {leader}", + politicalBotTribeIntro: "الحكومة الحالية في قبيلة {tribe} هي {gov}", + politicalBotTribeIntroLeader: "الحكومة الحالية في قبيلة {tribe} هي {gov} يمارسها {leader}", + politicalBotLeaderLabel: "القائد", inboxJobSubscribedTitle: "اشتراك جديد في عرض عملك", pmInhabitantWithId: "ساكن بمعرّف OASIS:", pmHasSubscribedToYourJobOffer: "اشترك في عرض عملك", inboxProjectCreatedTitle: "تم إنشاء مشروع جديد", pmHasCreatedAProject: "أنشأ مشروعًا", inboxMarketItemSoldTitle: "تم بيع عنصر", + inboxShopSoldTitle: "تم بيع المنتج", pmYourItem: "عنصرك", pmHasBeenSoldTo: "تم بيعه إلى", pmFor: "مقابل", @@ -2398,7 +2492,7 @@ module.exports = { visitContent: "زيارة المحتوى", banking: 'المصرفية', bankingTitle: 'المصرفية', - bankingDescription: 'استكشف القيمة الحالية لـ ECOin وتخصيص الدخل الأساسي الشامل المقابل، الموزّع لكل حقبة بناءً على المشاركة والثقة.', + bankingDescription: "اقتصاد ECOin: الرصيد، الدخل الأساسي، الضرائب وقيمة الصناعة.", bankOverview: 'نظرة عامة', bankEpochs: 'الحقب', bankRules: 'القواعد', @@ -2432,6 +2526,10 @@ module.exports = { bankEpochId: 'معرّف الحقبة', bankRuleHash: 'بصمة لقطة القواعد', bankViewEpoch: 'عرض الحقبة', + bankYourIndustryBalance: "حصتك الصناعية", + bankYourUbiMonth: "دخلك الأساسي (هذا الشهر)", + bankYourFundsMonth: "أموالك (هذا الشهر)", + bankIndustryBalance: "الإنتاج الصناعي (تقديري)", bankUserBalance: 'رصيدك', ecoWalletNotConfigured: 'محفظة ECOin غير مُعدّة', editWallet: 'تعديل المحفظة', @@ -2471,7 +2569,7 @@ module.exports = { pubIdPlaceholder: "@PUB_ID.ed25519", bankUbiAvailableNo: "لا توجد أموال!", bankUbiAvailableOk: "متوفر!", - bankUbiAvailability: "توفر UBI", + bankUbiAvailability: "الدخل الأساسي (الشبكة)", bankAlreadyClaimedThisMonth: "تم المطالبة بالفعل هذا الشهر", bankUbiThisMonth: "الدخل الأساسي (هذا الشهر)", bankUbiLastClaimed: "الدخل الأساسي (آخر مطالبة)", @@ -2901,6 +2999,208 @@ module.exports = { jobsTagsLabel: "الوسوم", jobsTagsPlaceholder: "وسم1، وسم2، وسم3", noJobsMatch: "لا توجد وظائف تطابق بحثك.", + industrySearchPlaceholder: "ابحث عن المصانع…", + industryAllSectors: "جميع القطاعات", + industryVisitButton: "زيارة الصناعة", + industryAdvanceTo: "تعيين إلى", + industryAmount: "المبلغ", + industryApproveBuild: "الموافقة", + industryBackToFacility: "← المصنع", + industryBlueprint: "مخطط", + industryBlueprintLabel: "مخطط صناعي", + industryBlueprints: "مخططات", + industryBuild: "بناء", + industryBuildNotes: "ملاحظات", + industryBuildStart: "تاريخ البدء", + industryVoteUpdateButton: "التصويت للتحديث", + industryVoteDeleteButton: "التصويت للحذف", + industryRevokeVote: "سحب", + industryTimeLeft: "الوقت المتبقي", + industryBuildEnd: "تاريخ الانتهاء", + industryBuilds: "عمليات البناء", + industryBuildTitle: "العنوان", + industryContribute: "المساهمة", + industryContributeEco: "المساهمة بـ ECO", + industryContributeLabor: "المساهمة بالعمل", + industryContributeButton: "ساهم", + industryContributeMaterial: "المساهمة بالمواد", + industryContributions: "المساهمات", + industryContributors: "المساهمون", + industryCreateBlueprintButton: "إنشاء مخطط", + industryDistribute: "توزيع", + industryDistributeButton: "التوزيع حسب الحصص", + industryDistribution: "التوزيع", + industryEcoAmount: "المبلغ (ECO)", + industryEcoContribHint: "تُحوَّل مساهمات ECO إلى القيّم بصفته أمين خزينة البناء.", + industryEditBlueprint: "تعديل المخطط", + industryFacility: "المصنع", + industryKindLabel: "النوع", + industryKind_labor: "العمل", + industryKind_material: "مادة", + industryKind_eco: "أموال", + industryLaborHours: "العمل (ساعات)", + industryHours: "ساعات", + industryLicense: "الترخيص", + industryMaterialItem: "مادة", + industryMaterials: "المواد", + industryMaterialsHint: "مادة واحدة في كل سطر بالتنسيق الاسم:الكمية:السعر.", + industryEstMaterials: "إجمالي المواد", + industryEstLabor: "إجمالي العمل", + industryEstTaxes: "الضرائب", + industryEstTotal: "السعر المقدر", + industryBuildingPrice: "سعر التصنيع", + industryFinalPrice: "السعر النهائي", + industryBlueprintDescPlaceholder: "المواصفات والأبعاد والوزن والوثائق…", + industryMaterialValue: "القيمة (ECO)", + industryMember: "عضو", + industryNewBlueprint: "مخطط جديد", + industryNewBuild: "اقتراح بناء", + industryEditBuild: "تحرير الإنتاج", + industryNoBlueprint: "— لا شيء —", + industryNeedBlueprint: "أنشئ مخططًا أولاً: كل إنتاج يصنع واحدًا.", + industryNoBlueprints: "لا توجد مخططات بعد.", + industryNoBuilds: "لا توجد عمليات بناء بعد.", + industryNote: "ملاحظة", + industryOutput: "المخرجات", + industryOutputItem: "اسم المنتج", + industryOutputKind: "نوع المنتج", + industryOutputQty: "الوحدات لكل إنتاج", + industryOutputUnit: "وحدة القياس", + industryOutputValue: "قيمة المخرجات (ECO، مثلاً من البيع)", + industryPayout: "الدفع", + industryPoints: "Karma", + industryPostJob: "إنشاء وظيفة", + industryPot: "الوعاء", + industryProposeBuildButton: "اقتراح البناء", + industryProposer: "المقترح", + industryAuthor: "المؤلف", + industrySellOnMarket: "إرسال إلى السوق", + industrySendToProjects: "إنشاء مشروع", + industryShare: "حصة", + industryShares: "حصص", + industrySkills: "المهارات", + industryTreasury: "الخزينة", + industryKind_physical: "مادي", + industryKind_digital: "رقمي", + industryBuildStatus_PROPOSED: "مقترح", + industryBuildStatus_REJECTED: "مرفوض", + industryBuildStatus_APPROVED: "معتمد", + industryBuildStatus_STOCKING: "تجهيز", + industryBuildStatus_IN_PRODUCTION: "قيد الإنتاج", + industryBuildStatus_COMPLETED: "مكتمل", + industryBuildStatus_FAILED: "فاشل", + pmBotIndustry: "IndustryBot", + industryBotAdmittedTitle: "تم قبولك في مصنع.", + industryBotApplicationTitle: "طلب عضوية جديد في مصنعك.", + industryBotInvitedTitle: "تمت دعوتك إلى مصنع.", + industryBotDissolvedTitle: "تم حل مصنع تنتمي إليه.", + industryBotBuildApprovedTitle: "تمت الموافقة على بناء.", + industryBotDistributedTitle: "تم توزيع مخرجات بناء.", + industryFilterRules: "القواعد", + industryRulesTitle: "القواعد", + industryRulesIntro: "تتيح Industry للسكان امتلاك وسائل الإنتاج جماعيًا: المصنع ورشة مملوكة للشبكة لا يملكها أحد بشكل خاص.", + industryRulesSteward: "المؤسس هو القيّم — راعٍ لا مالك: لا يمكنه بيع المصنع أو خصخصته أو حله بمفرده.", + industryRulesMembership: "تحدد سياسة العضوية كيفية الانضمام: مفتوح (فورًا)، بالتصويت (بقبول أصوات الأعضاء) أو بالدعوة فقط (يجب أن يدعوك عضو أولاً).", + industryRulesQuorum: "النصاب هو الحد الأدنى من الأعضاء الذين يجب أن يصوّتوا لتُحتسب القرار، حتى لا يقرر شخص واحد في مصنع صغير.", + industryRulesMajority: "الأغلبية هي نسبة الأصوات اللازمة (0.5 = نصف، 1 = إجماع)؛ يمر القرار عندما تبلغ أصوات نعم على الأقل max(النصاب، الأعضاء × الأغلبية).", + industryRulesDecisions: "يصوّت الأعضاء لقبول الجدد والموافقة على عمليات البناء وحل المصنع؛ لا يمكن للقيّم تجاوز هذه القرارات الجماعية.", + industryRulesBlueprints: "المخططات وصفات حرة (COPYLEFT) — المدخلات (مواد، ساعات عمل، مهارات) والمخرجات (سلعة مادية أو رقمية) — ويمكن لأي شخص نسخها.", + industryRulesBuilds: "البناء أمر إنتاج: مقترح ← معتمد (بالتصويت) ← تجهيز ← قيد الإنتاج ← مكتمل، ولا يقبل مساهمات إلا بعد الاعتماد.", + industryRulesContributions: "يساهم الأعضاء بالعمل (ساعات) أو المواد (قيمة ECO معلنة) أو ECO في البناء؛ كل مساهمة تكسب Karma.", + industryRulesLaborRate: "أجر العمل هو قيمة ECO لساعة عمل في هذا المصنع؛ يحوّل الساعات إلى Karma لمقارنة الجهد ورأس المال بعدل: Karma = ساعات × الأجر + قيمة المواد + ECO.", + industryRulesShares: "حصتك في البناء هي Karma الخاص بك ÷ إجمالي Karma، وتحدد مقدار قيمة المخرجات التي تحصل عليها.", + industryRulesDistribution: "عند اكتمال الإنتاج، يوزّع الوصي المبلغ (أموال الإنتاج بالإضافة إلى قيمة البيع) على المساهمين بنسبة حصصهم.", + industryRulesTreasury: "يحتفظ القيّم بمساهمات ECO كأمين لخزينة البناء حتى التوزيع؛ تُسجَّل جميع الحركات على الشبكة ويمكن أن تذهب النزاعات إلى Courts.", + industryJobs: "وظائف", + industryNoJobs: "لا توجد وظائف بعد.", + industryCreatePosition: "إنشاء وظيفة", + industryViewCV: "السيرة الذاتية", + industryReportButton: "إبلاغ", + industryCreateTask: "إنشاء مهمة", + industryOpenDispute: "فتح نزاع", + industryMaterialsPlaceholder: "solar-panel:2:120\nbattery:1:80\nkit:1:15", + industryOutputUnitPlaceholder: "مثل وحدة، كجم، ترخيص", + industryOutputItemPlaceholder: "مثل روبوت", + industryOutputQtyPlaceholder: "مثل 5", + industrySkillsPlaceholder: "مثل لحام، شبكات، تركيب-شمسي", + industryCreateInvite: "إنشاء دعوة", + industryPauseVotes: "أصوات الحالة", + industryTitle: "الصناعة", + industryDescription: "وحدة لإدارة وسائل الإنتاج بشكل جماعي.", + industryLabel: "الصناعة", + typeIndustryBuild: "بناء", + typeIndustryBlueprint: "مخطط", + typeIndustryAllocation: "توزيع", + typeIndustry: "الصناعة", + modulesIndustryLabel: "الصناعة", + modulesIndustryDescription: "وحدة لإدارة وسائل الإنتاج بشكل جماعي.", + industryFilterAll: "الكل", + industryFilterMember: "عضو", + industryFilterBlueprints: "مخططات", + industryFilterBuilds: "إنتاجات", + industryFilterMine: "لي", + industryFilterActive: "يعمل", + industryFilterPaused: "موقوفة", + industryFilterDissolved: "منحلة", + industryAllTitle: "الصناعة", + industryMemberTitle: "المصانع التي أنتمي إليها", + industryMineTitle: "مصانعي", + industryActiveTitle: "يعمل", + industryPausedTitle: "المصانع الموقوفة", + industryDissolvedTitle: "المصانع المنحلة", + industryNoFacilitiesFound: "لم يتم العثور على مصانع.", + industryCreateFacility: "إنشاء مصنع", + industryMembers: "الأعضاء", + industryMemberBadge: "عضو", + industryName: "الاسم", + industryNamePlaceholder: "اسم المصنع", + industryDescriptionLabel: "الوصف", + industryDescriptionPlaceholder: "ماذا ينتج هذا المصنع؟", + industrySector: "القطاع", + industryMembershipPolicy: "سياسة العضوية", + industryQuorum: "النصاب", + industryMajority: "الأغلبية", + industryLaborRate: "أجر العمل", + industryCreateButton: "إنشاء مصنع", + industryUpdateButton: "تحديث", + industrySteward: "القيم", + industryStatusActive: "يعمل", + industryStatusPaused: "موقوف", + industryStatusDissolved: "منحل", + industryStatusLabel: "الحالة", + industryJoinButton: "انضمام", + industryApplyButton: "طلب الانضمام", + industryLeaveButton: "مغادرة", + industryInviteButton: "دعوة", + industryInvitePlaceholder: "@...=.ed25519", + industryInviteNeeded: "تحتاج إلى دعوة للانضمام.", + industryPauseButton: "صوّت للإيقاف", + industryResumeButton: "صوّت للاستئناف", + industryEditButton: "تعديل", + industryDeleteButton: "حذف", + industryBackToList: "← الصناعة", + industryPendingApplicants: "طلبات معلقة", + industryVotesYes: "نعم", + industryVotesNo: "لا", + industryAlreadyVoted: "تم التصويت", + industryAdmit: "قبول", + industryReject: "رفض", + industryDissolveTitle: "حل المصنع", + industryDissolveHint: "يتطلب الحل تصويتًا جماعيًا؛ لا يمكن للقيم الحل بمفرده.", + industryDissolveVote: "التصويت للحل", + industrySector_software: "برمجيات", + industrySector_hardware: "عتاد", + industrySector_agriculture: "زراعة", + industrySector_textile: "نسيج", + industrySector_energy: "طاقة", + industrySector_food: "غذاء", + industrySector_construction: "بناء", + industrySector_media: "إعلام", + industrySector_services: "خدمات", + industrySector_other: "أخرى", + industryPolicy_open: "مفتوح", + industryPolicy_vote: "بالتصويت", + industryPolicy_invite: "بالدعوة فقط", projectsTitle: "المشاريع", projectsDescription: "أنشئ ومُوّل وتابع مشاريع يقودها المجتمع في شبكتك.", projectCreateProject: "إنشاء مشروع", @@ -3260,6 +3560,8 @@ module.exports = { chatOpenTitle: "Open Chats", chatClosedTitle: "Closed Chats", chatDescription: "الوصف", + chatMessageReply: "رد", + chatMessageLabel: "رسالة", chatCategory: "الفئة", chatStatus: "الحالة", chatFilterAll: "الكل", diff --git a/src/client/assets/translations/oasis_de.js b/src/client/assets/translations/oasis_de.js index 0cc9ae8..4b25923 100644 --- a/src/client/assets/translations/oasis_de.js +++ b/src/client/assets/translations/oasis_de.js @@ -1,26 +1,6 @@ const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); module.exports = { de: { - bbAdd: "Hinzufügen", - bbQuickActions: "Schnellaktionen", - bbPickTitle: "Modul anheften", - bbPickDesc: "Wähle ein Modul für den +-Platz in der unteren Leiste.", - bbPickNone: "Keins", - bbManage: "Bearbeiten", - bbYourBar: "Deine Leiste", - bbRemove: "Entfernen", - bbFull: "Maximum erreicht", - bbDone: "Fertig", - activityChangeFilter: "Filter ändern", - emptyConnectHint: "Verbinde dich mit einem Pub, um dich mit dem Netzwerk zu synchronisieren.", - emptyConnectCta: "Mit einem Pub verbinden", - menuCommunity: "Gemeinschaft", - activityGroupCommunity: "Gemeinschaft & Governance", - activityGroupOrg: "Organisation", - activityGroupComm: "Kommunikation", - activityGroupEconomy: "Wirtschaft", - activityGroupMedia: "Inhalte & Medien", - activityGroupOther: "Sonstiges", languageName: "Deutsch", shopOrderStatusPaid: "Zahlung erhalten", shopOrderStatusReceived: "Erhalten", @@ -426,7 +406,8 @@ module.exports = { publish: "Schreiben", contentWarningPlaceholder: "Betreff zum Beitrag hinzufügen (optional)", privateWarningPlaceholder: "Bewohner hinzufügen, um einen privaten Beitrag zu senden (optional)", - publishWarningPlaceholder: "...", + publishWarningPlaceholder: "Text auf 7000 Zeichen begrenzt.", + publishTooLong: "Dein Beitrag ist zu lang. Bitte kürze ihn.", publishCustomDescription: [ "ACHTUNG: Aufgrund der Blockchain-Technologie kann ein veröffentlichter Beitrag nicht bearbeitet oder gelöscht werden.", ], @@ -491,7 +472,89 @@ module.exports = { passwordLengthInfo: "Das Passwort muss mindestens 32 Zeichen lang sein.", passwordImport: "Gib dein Passwort ein, um die Daten zu entschlüsseln", exportPasswordPlaceholder: "Klein-, Großbuchstaben, Zahlen und Symbole verwenden", - fileInfo: "Dein verschlüsselter Schlüssel wird im Systemverzeichnis gespeichert (Name: oasis.enc)", + fileInfo: "Dein verschlüsselter Schlüssel wird auf dein Gerät heruntergeladen (Name: oasis.enc)", + developer: "Entwicklung", + devTitle: "Entwicklung", + welcomeBannerText: "Willkommen bei OASIS. Folge diesen kurzen Schritten, um deinen Avatar einzurichten.", + welcomeBannerAction: "Los geht es!", + welcomeTitle: "Willkommen", + welcomeStepGreetingAction: "Senden", + welcomeJoinPubButton: "Pub beitreten", + welcomeStepLarpAction: "\"Der Akademie\" beitreten", + welcomeStepLarpText: "Mach mit bei unserem Live-Rollenspiel.", + welcomeStepLarpTitle: "L.A.R.P. beitreten", + welcomeStepGreetingTitle: "Sende ein \"Hallo Welt!\"", + welcomeStepGreetingText: "Sende eine Nachricht, damit andere Bewohner dich entdecken können.", + welcomeJoinPub: "Beitreten:", + welcomeDescription: "Ein paar Dinge, die sich vor dem Start lohnen.", + welcomeProgress: "Erledigt", + welcomeStepDone: "fertig", + welcomeStepPending: "offen", + welcomeSkip: "Jetzt nicht", + welcomeStepLanguageTitle: "Wähle deine Sprache", + welcomeStepLanguageText: "Oasis spricht mehrere Sprachen. Du kannst sie jederzeit ändern.", + welcomeStepProfileTitle: "Bearbeite dein Profil", + welcomeStepProfileText: "Wähle einen Namen, füge eine Beschreibung hinzu und setze ein Bild, damit andere Bewohner dich erkennen.", + welcomeStepProfileAction: "Profil speichern", + welcomeStepFederationTitle: "Tritt dem Hauptnetzwerk bei", + welcomeStepFederationText: "Verbinde dich mit unserem Pub, um andere Bewohner zu treffen und zu replizieren.", + welcomeStepFederationAction: "Zu den Einladungen", + welcomeStepBackupTitle: "Sichere deine ID", + welcomeStepBackupText: "Deine Identität ist eine Schlüsseldatei auf diesem Gerät.", + welcomeStepBackupWarning: "WENN SIE VERLOREN GEHT, KANN SIE NIEMAND WIEDERHERSTELLEN.", + welcomeStepBackupAction: "Sicherung!", + welcomeCompleteTitle: "Alles bereit.", + welcomeCompleteText: "Dein Knoten ist bereit. Von hier an wächst das Netzwerk mit den Menschen, denen du folgst.", + welcomeFindPeople: "Bewohner finden", + devFilterFiles: "DATEIEN", + devFilterSearch: "SUCHEN", + devFilterModules: "MODULE", + devFilterTranslations: "ÜBERSETZUNGEN", + devFilterStyles: "STILE", + devFilterTests: "TESTS", + devVersion: "Version", + devNodeVersion: "Node", + devStatsTests: "Test-Suites", + devParentFolder: "Übergeordneter Ordner", + devOpenFolder: "öffnen", + devDescription: "Erkunde den Quellcode, der auf diesem Knoten läuft.", + devTreeTitle: "Dateien", + devSearchTitle: "Code durchsuchen", + devMapTitle: "Module", + devRoot: "Wurzel", + devRootButton: "WURZEL", + devSearchPlaceholder: "Im Code zu suchender Text", + devAnyExtension: "Beliebige Datei", + devSearchButton: "Suchen", + devStatsFiles: "Dateien", + devStatsLines: "Zeilen", + devStatsModules: "Module", + devStatsLanguages: "Sprachen", + devNotViewable: "nicht anzeigbar", + devEmptyFolder: "Dieser Ordner ist leer.", + devSize: "Größe", + devShowing: "Anzeige", + devLine: "Zeile", + devReportBug: "Fehler Melden", + devCreateTask: "Aufgabe Erstellen", + devRaw: "RAW", + devReportContext: "Beim Lesen des Oasis-Quellcodes gefunden", + devTaskPrefix: "Prüfen", + devPrevPage: "Vorherige Zeilen", + devNextPage: "Nächste Zeilen", + devMatches: "Treffer", + devFilesScanned: "Dateien geprüft", + devNoResults: "Noch keine Treffer.", + devColModule: "Modul", + devColState: "Status", + devColModel: "Modell", + devColView: "Ansicht", + devColRoutes: "Routen", + devColTests: "Tests", + devOn: "an", + devOff: "aus", + modulesDevLabel: "Entwicklung", + modulesDevDescription: "Modul zur Verwaltung des Oasis-Quellcodes.", themeIntro: "Wähle ein Design.", setTheme: "Design festlegen", language: "Sprache", @@ -690,7 +753,7 @@ module.exports = { spreadLabel: "Verbreiten", spreadHint: "Verbreite dies an deine Unterstützer (über deinen Feed).", welcomePmSubject: "Hello World!", - welcomePmBody: "Willkommen bei Oasis — ein freies, peer-to-peer, föderiertes und verschlüsseltes soziales Netzwerk mit einer verteilten Architektur nur mit Einladung.\n\nEinige interessante Orte zum Anfangen:\n\n- /profile — Bearbeite deinen Avatar, Namen, deine Beschreibung und welche Module auf deiner öffentlichen Seite erscheinen.\n- /invites — Füge einen Pub hinzu, um dich mit dem weiteren Netzwerk zu föderieren.\n- /peers — Sieh, wer verbunden ist, scanne dein LAN erneut, exportiere oder importiere Peer-Listen.\n- /inhabitants — Entdecke und besuche die Avatare anderer Personen.\n- /tribes — Erstelle private Gruppen mit Ende-zu-Ende-Verschlüsselung oder tritt ihnen bei.\n- /inbox — Lies und sende private Nachrichten.\n- /activity — Sieh die jüngste Aktivität, deine und die deines Netzwerks.\n- /publish — Schreibe einen Beitrag oder teile ein Bild, Audio, Video oder Dokument.\n\nEinige interessante Orte zum Verbinden:\n\n- /fediverse — Verwalte deine anderen Fediverse-Konten, einschließlich Senden und Empfangen von Inhalten.\n\nDiese Nachricht wurde privat von dir an dich selbst gesendet, daher war keine Verbindung nötig, um sie zu empfangen.", + welcomePmBody: "Willkommen bei Oasis — ein freies, peer-to-peer, föderiertes und verschlüsseltes soziales Netzwerk mit einer verteilten Architektur nur mit Einladung.\n\nEinige interessante Orte zum Anfangen:\n\n- /profile — Bearbeite deinen Avatar, Namen, deine Beschreibung und welche Module auf deiner öffentlichen Seite erscheinen.\n- /invites — Füge einen Pub hinzu, um dich mit dem weiteren Netzwerk zu föderieren.\n- /peers — Sieh, wer verbunden ist, scanne dein LAN erneut, exportiere oder importiere Peer-Listen.\n- /inhabitants — Entdecke und besuche die Avatare anderer Personen.\n- /tribes — Erstelle private Gruppen mit Ende-zu-Ende-Verschlüsselung oder tritt ihnen bei.\n- /inbox — Lies und sende private Nachrichten.\n- /activity — Sieh die jüngste Aktivität, deine und die deines Netzwerks.\n- /publish — Schreibe einen Beitrag oder teile ein Bild, Audio, Video oder Dokument.\n- /search — Durchsuche die Inhalte des Netzwerks nach Typ.\n\nEinige interessante Orte zum Verbinden:\n\n- /fediverse — Verwalte deine anderen Fediverse-Konten, einschließlich Senden und Empfangen von Inhalten.\n\nDiese Nachricht wurde privat von dir an dich selbst gesendet, daher war keine Verbindung nötig, um sie zu empfangen.", profileVisibilityTitle: "Sichtbarkeit des öffentlichen Profils", profileVisibilityHint: "Wähle, welche Felder andere Bewohner in deinem Profil sehen. Standardmäßig wird nur Karma angezeigt.", profileSensorsSectionTitle: "Sensoren", @@ -1220,6 +1283,9 @@ module.exports = { eventButton: "TERMINE", taskButton: "AUFGABEN", votesButton: "ABSTIMMUNGEN", + industryButton: "INDUSTRIE", + projectButton: "PROJEKTE", + shopProductButton: "PRODUKTE", reportButton: "BERICHTE", feedButton: "FEED", marketButton: "MARKT", @@ -1248,7 +1314,7 @@ module.exports = { trendingItemStatus: "Status", trendingTotalVotes: "Stimmen gesamt", trendingTotalOpinions: "Meinungen gesamt", - trendingNoContentMessage: "Noch keine Trendinhalte vorhanden.", + trendingNoContentMessage: "Noch keine Trends vorhanden.", trendingAuthor: "Von", trendingCreatedAtLabel: "Erstellt am", trendingTotalCount: "Gesamtzahl", @@ -1437,7 +1503,7 @@ module.exports = { transfersCreatedAt: "Erstellt am", transfersConfirmations: "BESTÄTIGUNGEN", transfersContainingBlock: "Enthaltender Block", - transfersExportContract: "Vertrag erstellen", + transfersExportContract: "Smart Contract", transfersConfirmButton: "Überweisung bestätigen", transfersNoItems: "Keine Überweisungen gefunden.", transfersMineSectionTitle: "Deine Überweisungen", @@ -1563,7 +1629,7 @@ module.exports = { cvDeleteButton: "Löschen", cvNoCV: "Kein Lebenslauf gefunden.", blogSubject: "Betreff", - blogMessage: "Text auf 8096 Zeichen begrenzt.", + blogMessage: "Nachricht", blogImage: "Medien hochladen (max: 50MB)", blogPublish: "Vorschau", noPopularMessages: "Noch keine beliebten Nachrichten veröffentlicht", @@ -2201,6 +2267,7 @@ module.exports = { agendaFilterReports: "BERICHTE", agendaFilterTransfers: "ÜBERWEISUNGEN", agendaFilterJobs: "STELLEN", + agendaFilterIndustry: "INDUSTRIE", agendaFilterProjects: "PROJEKTE", agendaFilterCalendars: "KALENDER", agendaNoItems: "Keine Zuweisungen gefunden.", @@ -2322,16 +2389,31 @@ module.exports = { privateMessage: "PN", pmSendTitle: "Private Nachrichten", pmSend: "Senden!", - pmDescription: "Verwende dieses Formular, um eine verschlüsselte Nachricht an andere Bewohner zu senden.", + pmCancel: "Abbrechen", + pmReset: "Zurücksetzen", + pmSharedKeyHint: "Teile diesen Schlüssel mit dem Empfänger über einen anderen Kanal. Er wird zum Entschlüsseln benötigt.", + pmDescription: "Verwende dieses Formular, um eine verschlüsselte Nachricht/Datei an andere Bewohner zu senden.", + pmComposeTitle: "Nachricht senden", pmRecipients: "Empfänger", pmRecipientsHint: "Oasis-IDs durch Kommas getrennt eingeben", pmLevelOfExposition: "Level of exposition", - pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmLimitsHint: "Bis zu 7 Empfänger pro Nachricht.", + pmTextPlaceholder: "Text auf 7000 Zeichen begrenzt.", pmSubject: "Betreff", pmSubjectHint: "Nachrichtenbetreff eingeben", pmText: "Nachricht", pmCrypterChip: "2xE2E", pmCrypterDecryptButton: "Entschlüsseln", + fileShareTitle: "Datei teilen", + fileShareRecipientPlaceholder: "@...=.ed25519", + fileShareFileLabel: "Datei", + fileShareDownload: "Herunterladen", + fileShareMutualError: "Du kannst Dateien nur mit Bewohnern mit gegenseitiger Unterstützung teilen.", + fileShareNoFile: "Keine Datei ausgewählt.", + fileShareTooLarge: "Die Datei überschreitet die zulässige Größe.", + fileShareFailed: "Die Datei konnte nicht vorbereitet werden.", + fileShareSendError: "Die Datei konnte nicht gesendet werden.", + fileShareUnavailable: "Diese Datei ist gerade nicht verfügbar. Versuche es später erneut.", pmCrypterBadKey: "Der geteilte Schlüssel ist falsch!", pmCrypterTooLong: "Die Nachricht ist zu lang, um mit dem Crypter verschlüsselt zu werden. Bitte kürze sie und versuche es erneut.", pmCrypterCipherLabel: "Erneut verschlüsselte Nachricht", @@ -2352,15 +2434,27 @@ module.exports = { pmNoSubject: "(kein Betreff)", pmSubjectLabel: "Betreff:", pmBodyLabel: "Inhalt", - pmBotJobs: "42-StellenBOT", - pmBotProjects: "42-ProjekteBOT", - pmBotMarket: "42-MarktBOT", + pmBotJobs: "JobsBot", + pmBotProjects: "ProjectsBot", + pmBotMarket: "MarketBot", + pmBotShops: "ShopsBot", + pmBotPolitical: "PoliticalBot", + politicalBotLarpTitle: "Das regierende Haus des L.A.R.P hat gewechselt.", + politicalBotParliamentTitle: "Im Parlament hat ein neuer Regierungszyklus begonnen.", + politicalBotTribeTitle: "In einem deiner Stämme hat ein neuer Regierungszyklus begonnen.", + politicalBotLarpIntro: "Jetzt regiert {house} in diesem Zyklus: {cycle}", + politicalBotParliamentIntro: "Die aktuelle Regierung ist {gov}", + politicalBotParliamentIntroLeader: "Die aktuelle Regierung ist {gov}, ausgeübt von {leader}", + politicalBotTribeIntro: "Die aktuelle Regierung im Stamm {tribe} ist {gov}", + politicalBotTribeIntroLeader: "Die aktuelle Regierung im Stamm {tribe} ist {gov}, ausgeübt von {leader}", + politicalBotLeaderLabel: "Anführer", inboxJobSubscribedTitle: "Neues Abonnement deines Stellenangebots", pmInhabitantWithId: "Bewohner mit OASIS-ID:", pmHasSubscribedToYourJobOffer: "hat dein Stellenangebot abonniert", inboxProjectCreatedTitle: "Neues Projekt erstellt", pmHasCreatedAProject: "hat ein Projekt erstellt", inboxMarketItemSoldTitle: "Artikel verkauft", + inboxShopSoldTitle: "Produkt verkauft", pmYourItem: "Dein Artikel", pmHasBeenSoldTo: "wurde verkauft an", pmFor: "für", @@ -2395,7 +2489,7 @@ module.exports = { visitContent: "Inhalt besuchen", banking: 'Bankwesen', bankingTitle: 'Bankwesen', - bankingDescription: 'Den aktuellen Wert von ECOin und die entsprechende UBI-Zuweisung erkunden, die pro Epoche basierend auf Teilnahme und Vertrauen verteilt wird.', + bankingDescription: "Die ECOin-Wirtschaft: Guthaben, Grundeinkommen, Steuern und Industriewert.", bankOverview: 'Übersicht', bankEpochs: 'Epochen', bankRules: 'Regeln', @@ -2429,6 +2523,10 @@ module.exports = { bankEpochId: 'Epochen-ID', bankRuleHash: 'Regelversions-Hash', bankViewEpoch: 'Epoche anzeigen', + bankYourIndustryBalance: "Dein Industrieanteil", + bankYourUbiMonth: "Dein BGE (diesen Monat)", + bankYourFundsMonth: "Deine Mittel (diesen Monat)", + bankIndustryBalance: "Industrieproduktion (geschätzt)", bankUserBalance: 'Dein Kontostand', ecoWalletNotConfigured: 'ECOin Wallet nicht konfiguriert', editWallet: 'Geldbörse bearbeiten', @@ -2468,7 +2566,7 @@ module.exports = { pubIdPlaceholder: "@PUB_ID.ed25519", bankUbiAvailableNo: "KEIN GUTHABEN!", bankUbiAvailableOk: "VERFÜGBAR!", - bankUbiAvailability: "UBI-Verfügbarkeit", + bankUbiAvailability: "BGE (Netzwerk)", bankAlreadyClaimedThisMonth: "Diesen Monat bereits beansprucht", bankUbiThisMonth: "UBI (diesen Monat)", bankUbiLastClaimed: "UBI (zuletzt beansprucht)", @@ -2898,6 +2996,208 @@ module.exports = { jobsTagsLabel: "Tags", jobsTagsPlaceholder: "tag1, tag2, tag3", noJobsMatch: "Keine Stellen gefunden.", + industrySearchPlaceholder: "Fabriken suchen…", + industryAllSectors: "Alle Sektoren", + industryVisitButton: "Industrie besuchen", + industryAdvanceTo: "Setzen auf", + industryAmount: "Betrag", + industryApproveBuild: "Genehmigen", + industryBackToFacility: "← Fabrik", + industryBlueprint: "Blueprint", + industryBlueprintLabel: "Industrie-Blueprint", + industryBlueprints: "Blueprints", + industryBuild: "Build", + industryBuildNotes: "Notizen", + industryBuildStart: "Startdatum", + industryVoteUpdateButton: "Update abstimmen", + industryVoteDeleteButton: "Löschung abstimmen", + industryRevokeVote: "Widerrufen", + industryTimeLeft: "Verbleibende Zeit", + industryBuildEnd: "Enddatum", + industryBuilds: "Builds", + industryBuildTitle: "Titel", + industryContribute: "Beitragen", + industryContributeEco: "ECO beitragen", + industryContributeLabor: "Arbeit beitragen", + industryContributeButton: "Beitragen", + industryContributeMaterial: "Material beitragen", + industryContributions: "Beiträge", + industryContributors: "Beitragende", + industryCreateBlueprintButton: "Blueprint erstellen", + industryDistribute: "Verteilen", + industryDistributeButton: "Nach Anteilen verteilen", + industryDistribution: "Verteilung", + industryEcoAmount: "Betrag (ECO)", + industryEcoContribHint: "ECO-Beiträge werden an den Verwalter als Treuhänder der Build-Kasse überwiesen.", + industryEditBlueprint: "Blueprint bearbeiten", + industryFacility: "Fabrik", + industryKindLabel: "Art", + industryKind_labor: "Arbeit", + industryKind_material: "Material", + industryKind_eco: "Mittel", + industryLaborHours: "Arbeit (Stunden)", + industryHours: "Stunden", + industryLicense: "Lizenz", + industryMaterialItem: "Material", + industryMaterials: "Materialien", + industryMaterialsHint: "Ein Material pro Zeile, im Format Name:Menge:Preis.", + industryEstMaterials: "Gesamtmaterialien", + industryEstLabor: "Gesamtarbeit", + industryEstTaxes: "Steuern", + industryEstTotal: "Geschätzter Preis", + industryBuildingPrice: "Baupreis", + industryFinalPrice: "Endpreis", + industryBlueprintDescPlaceholder: "Spezifikationen, Abmessungen, Gewicht, Doku…", + industryMaterialValue: "Wert (ECO)", + industryMember: "Mitglied", + industryNewBlueprint: "Neuer Blueprint", + industryNewBuild: "Build vorschlagen", + industryEditBuild: "Produktion bearbeiten", + industryNoBlueprint: "— keiner —", + industryNeedBlueprint: "Erstelle zuerst einen Bauplan: jede Produktion fertigt einen.", + industryNoBlueprints: "Noch keine Blueprints.", + industryNoBuilds: "Noch keine Builds.", + industryNote: "Notiz", + industryOutput: "Ausgabe", + industryOutputItem: "Produktname", + industryOutputKind: "Produkttyp", + industryOutputQty: "Einheiten pro Produktion", + industryOutputUnit: "Maßeinheit", + industryOutputValue: "Ausgabewert (ECO, z. B. aus Verkauf)", + industryPayout: "Auszahlung", + industryPoints: "Karma", + industryPostJob: "Stelle erstellen", + industryPot: "Topf", + industryProposeBuildButton: "Build vorschlagen", + industryProposer: "Vorschlagender", + industryAuthor: "Autor", + industrySellOnMarket: "An den Markt senden", + industrySendToProjects: "Projekt erstellen", + industryShare: "Anteil", + industryShares: "Anteile", + industrySkills: "Fähigkeiten", + industryTreasury: "Kasse", + industryKind_physical: "Physisch", + industryKind_digital: "Digital", + industryBuildStatus_PROPOSED: "VORGESCHLAGEN", + industryBuildStatus_REJECTED: "ABGELEHNT", + industryBuildStatus_APPROVED: "GENEHMIGT", + industryBuildStatus_STOCKING: "BESTÜCKUNG", + industryBuildStatus_IN_PRODUCTION: "IN PRODUKTION", + industryBuildStatus_COMPLETED: "ABGESCHLOSSEN", + industryBuildStatus_FAILED: "FEHLGESCHLAGEN", + pmBotIndustry: "IndustryBot", + industryBotAdmittedTitle: "Du wurdest in eine Fabrik aufgenommen.", + industryBotApplicationTitle: "Neuer Mitgliedsantrag in deiner Fabrik.", + industryBotInvitedTitle: "Du wurdest in eine Fabrik eingeladen.", + industryBotDissolvedTitle: "Eine Fabrik, der du angehörst, wurde aufgelöst.", + industryBotBuildApprovedTitle: "Ein Build wurde genehmigt.", + industryBotDistributedTitle: "Ein Build-Output wurde verteilt.", + industryFilterRules: "REGELN", + industryRulesTitle: "Regeln", + industryRulesIntro: "Industry lässt die Bewohner die Produktionsmittel gemeinschaftlich besitzen: Eine Fabrik ist eine netzwerkeigene Werkstatt, die niemand privat besitzt.", + industryRulesSteward: "Der Gründer ist der Verwalter — ein Treuhänder, kein Eigentümer: Er kann die Fabrik nicht allein verkaufen, privatisieren oder auflösen.", + industryRulesMembership: "Die Mitgliedschaftsrichtlinie legt fest, wie man beitritt: Offen (sofort), Per Abstimmung (durch die Stimme der Mitglieder) oder Nur mit Einladung (ein Mitglied muss zuerst einladen).", + industryRulesQuorum: "Das Quorum ist die Mindestzahl an Mitgliedern, die abstimmen müssen, damit eine Entscheidung zählt, damit eine kleine Fabrik nicht von einer Person entschieden wird.", + industryRulesMajority: "Die Mehrheit ist der Stimmenanteil zum Bestehen (0,5 = Hälfte, 1 = Einstimmigkeit); eine Entscheidung besteht, wenn die JA-Stimmen mindestens max(Quorum, Mitglieder × Mehrheit) erreichen.", + industryRulesDecisions: "Mitglieder stimmen über Aufnahme, Genehmigung von Builds und Auflösung der Fabrik ab; der Verwalter kann diese kollektiven Entscheidungen nicht übergehen.", + industryRulesBlueprints: "Blueprints sind freie (Copyleft-)Rezepte — die Eingaben (Materialien, Arbeitsstunden, Fähigkeiten) und die Ausgabe (ein physisches oder digitales Gut) — und jeder kann sie forken.", + industryRulesBuilds: "Ein Build ist ein Produktionsauftrag: VORGESCHLAGEN → GENEHMIGT (per Abstimmung) → BESTÜCKUNG → IN PRODUKTION → ABGESCHLOSSEN, und er nimmt Beiträge erst nach Genehmigung an.", + industryRulesContributions: "Mitglieder tragen Arbeit (Stunden), Material (deklarierter ECO-Wert) oder ECO zu einem Build bei; jeder Beitrag bringt Karma.", + industryRulesLaborRate: "Der Arbeitssatz ist der ECO-Wert einer Arbeitsstunde in dieser Fabrik; er wandelt Stunden in Karma um, damit Aufwand und Kapital fair vergleichbar sind: Karma = Stunden × Satz + Materialwert + ECO.", + industryRulesShares: "Dein Anteil an einem Build ist dein Karma ÷ Gesamt-Karma und bestimmt, wie viel des Ausgabewerts du erhältst.", + industryRulesDistribution: "Wenn eine Produktion abgeschlossen ist, verteilt der Steward den Topf (die Mittel der Produktion plus Verkaufswert) anteilig an die Beitragenden.", + industryRulesTreasury: "ECO-Beiträge hält der Verwalter als Treuhänder der Build-Kasse bis zur Verteilung; alle Bewegungen werden im Netzwerk aufgezeichnet und Streitfälle können an die Courts gehen.", + industryJobs: "Stellen", + industryNoJobs: "Noch keine Stellen.", + industryCreatePosition: "Stelle erstellen", + industryViewCV: "Lebenslauf", + industryReportButton: "Melden", + industryCreateTask: "Aufgabe erstellen", + industryOpenDispute: "Streit eröffnen", + industryMaterialsPlaceholder: "solar-panel:2:120\nbattery:1:80\nkit:1:15", + industryOutputUnitPlaceholder: "z. B. Stück, kg, Lizenz", + industryOutputItemPlaceholder: "z. B. Roboter", + industryOutputQtyPlaceholder: "z. B. 5", + industrySkillsPlaceholder: "z. B. Löten, Netzwerke, Solar-Installation", + industryCreateInvite: "Einladung generieren", + industryPauseVotes: "Status-Stimmen", + industryTitle: "Industrie", + industryDescription: "Modul zur gemeinschaftlichen Verwaltung der Produktionsmittel.", + industryLabel: "Industrie", + typeIndustryBuild: "BUILD", + typeIndustryBlueprint: "BLUEPRINT", + typeIndustryAllocation: "VERTEILUNG", + typeIndustry: "INDUSTRIE", + modulesIndustryLabel: "Industrie", + modulesIndustryDescription: "Modul zur gemeinschaftlichen Verwaltung der Produktionsmittel.", + industryFilterAll: "ALLE", + industryFilterMember: "MITGLIED", + industryFilterBlueprints: "BAUPLÄNE", + industryFilterBuilds: "PRODUKTIONEN", + industryFilterMine: "MEINE", + industryFilterActive: "IN BETRIEB", + industryFilterPaused: "PAUSIERT", + industryFilterDissolved: "AUFGELÖST", + industryAllTitle: "Industrie", + industryMemberTitle: "Fabriken, in denen ich bin", + industryMineTitle: "Meine Fabriken", + industryActiveTitle: "In Betrieb", + industryPausedTitle: "Pausierte Fabriken", + industryDissolvedTitle: "Aufgelöste Fabriken", + industryNoFacilitiesFound: "Keine Fabriken gefunden.", + industryCreateFacility: "Fabrik erstellen", + industryMembers: "Mitglieder", + industryMemberBadge: "MITGLIED", + industryName: "Name", + industryNamePlaceholder: "Name der Fabrik", + industryDescriptionLabel: "Beschreibung", + industryDescriptionPlaceholder: "Was produziert diese Fabrik?", + industrySector: "Sektor", + industryMembershipPolicy: "Mitgliedschaftsrichtlinie", + industryQuorum: "Quorum", + industryMajority: "Mehrheit", + industryLaborRate: "Arbeitssatz", + industryCreateButton: "Fabrik erstellen", + industryUpdateButton: "Aktualisieren", + industrySteward: "Verwalter", + industryStatusActive: "IN BETRIEB", + industryStatusPaused: "PAUSIERT", + industryStatusDissolved: "AUFGELÖST", + industryStatusLabel: "Status", + industryJoinButton: "Beitreten", + industryApplyButton: "Beitritt beantragen", + industryLeaveButton: "Verlassen", + industryInviteButton: "Einladen", + industryInvitePlaceholder: "@...=.ed25519", + industryInviteNeeded: "Du brauchst eine Einladung, um beizutreten.", + industryPauseButton: "Für Pause stimmen", + industryResumeButton: "Für Fortsetzung stimmen", + industryEditButton: "Bearbeiten", + industryDeleteButton: "Löschen", + industryBackToList: "← Industrie", + industryPendingApplicants: "Ausstehende Bewerber", + industryVotesYes: "ja", + industryVotesNo: "nein", + industryAlreadyVoted: "abgestimmt", + industryAdmit: "Zulassen", + industryReject: "Ablehnen", + industryDissolveTitle: "Fabrik auflösen", + industryDissolveHint: "Die Auflösung erfordert eine gemeinschaftliche Abstimmung; der Verwalter kann sie nicht allein auflösen.", + industryDissolveVote: "Für Auflösung stimmen", + industrySector_software: "Software", + industrySector_hardware: "Hardware", + industrySector_agriculture: "Landwirtschaft", + industrySector_textile: "Textil", + industrySector_energy: "Energie", + industrySector_food: "Lebensmittel", + industrySector_construction: "Bau", + industrySector_media: "Medien", + industrySector_services: "Dienstleistungen", + industrySector_other: "Andere", + industryPolicy_open: "Offen", + industryPolicy_vote: "Per Abstimmung", + industryPolicy_invite: "Nur mit Einladung", projectsTitle: "Projekte", projectsDescription: "Gemeinschaftsprojekte in deinem Netzwerk erstellen, finanzieren und verfolgen.", projectCreateProject: "Projekt erstellen", @@ -3304,6 +3604,8 @@ module.exports = { chatOpenTitle: "Offene Chats", chatClosedTitle: "Geschlossene Chats", chatDescription: "Beschreibung", + chatMessageReply: "Antwort", + chatMessageLabel: "Nachricht", chatCategory: "Kategorie", chatStatus: "STATUS", chatFilterAll: "ALLE", diff --git a/src/client/assets/translations/oasis_en.js b/src/client/assets/translations/oasis_en.js index e14c720..9af65c7 100644 --- a/src/client/assets/translations/oasis_en.js +++ b/src/client/assets/translations/oasis_en.js @@ -1,26 +1,6 @@ const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); module.exports = { en: { - bbAdd: "Add", - bbQuickActions: "Quick actions", - bbPickTitle: "Pin a module", - bbPickDesc: "Choose a module for the + slot in the bottom bar.", - bbPickNone: "None", - bbManage: "Edit", - bbYourBar: "Your bar", - bbRemove: "Remove", - bbFull: "Maximum reached", - bbDone: "Done", - activityChangeFilter: "Change filter", - emptyConnectHint: "Connect to a pub to sync with the network.", - emptyConnectCta: "Connect to a pub", - menuCommunity: "Community", - activityGroupCommunity: "Community & Governance", - activityGroupOrg: "Organization", - activityGroupComm: "Communication", - activityGroupEconomy: "Economy", - activityGroupMedia: "Content & Media", - activityGroupOther: "Others", languageName: "English", shopOrderStatusPaid: "Payment received", shopOrderStatusReceived: "Received", @@ -441,7 +421,8 @@ module.exports = { publish: "Write", contentWarningPlaceholder: "Add a subject to the post (optional)", privateWarningPlaceholder: "Add inhabitants to send a private post (optional)", - publishWarningPlaceholder: "...", + publishWarningPlaceholder: "Body capped at 7000 characters.", + publishTooLong: "Your post is too long. Please shorten it.", publishCustomDescription: [ "REMEMBER: Due to blockchain technology, once a post is published it cannot be edited or deleted.", ], @@ -512,14 +493,96 @@ module.exports = { passwordLengthInfo: "Password must be at least 32 characters long.", passwordImport: "Write your password to decrypt data that will be saved at your system home (name: secret)", exportPasswordPlaceholder: "Use lowercase, uppercase, numbers & symbols", - fileInfo: "Your encrypted secret key will be saved at your system home (name: oasis.enc)", + fileInfo: "Your encrypted secret key will be downloaded to your device (name: oasis.enc)", + developer: "Developer", + devTitle: "Developer", + welcomeBannerText: "Welcome to OASIS. Follow these quick steps to set up your avatar.", + welcomeBannerAction: "Start wizard!", + welcomeTitle: "Welcome", + welcomeStepGreetingAction: "Send Feed", + welcomeJoinPubButton: "Join Pub", + welcomeStepLarpAction: "Join \"The Academy\"", + welcomeStepLarpText: "Join our live action role playing game.", + welcomeStepLarpTitle: "Join L.A.R.P.", + welcomeStepGreetingTitle: "Send a \"Hello world!\"", + welcomeStepGreetingText: "Send a message so other inhabitants can discover you.", + welcomeJoinPub: "Join", + welcomeDescription: "A few things worth doing before you start.", + welcomeProgress: "Completed", + welcomeStepDone: "done", + welcomeStepPending: "pending", + welcomeSkip: "Skip for now", + welcomeStepLanguageTitle: "Choose your language", + welcomeStepLanguageText: "Oasis speaks different languages. You can change it whenever you want.", + welcomeStepProfileTitle: "Edit your profile", + welcomeStepProfileText: "Pick a name, add a description and set a picture so other inhabitants can recognise you.", + welcomeStepProfileAction: "Save Profile", + welcomeStepFederationTitle: "Join Main Network", + welcomeStepFederationText: "Connect to our pub to meet other inhabitants and start replicating.", + welcomeStepFederationAction: "Go to invites", + welcomeStepBackupTitle: "Backup your ID", + welcomeStepBackupText: "Your identity is a key file on this device.", + welcomeStepBackupWarning: "IF YOU LOSE IT, NOBODY CAN RECOVER IT FOR YOU.", + welcomeStepBackupAction: "Backup!", + welcomeCompleteTitle: "All set.", + welcomeCompleteText: "Your node is ready. From here the network grows with the people you follow.", + welcomeFindPeople: "Find inhabitants", + devFilterFiles: "FILES", + devFilterSearch: "SEARCH", + devFilterModules: "MODULES", + devFilterTranslations: "TRANSLATIONS", + devFilterStyles: "STYLES", + devFilterTests: "TESTS", + devVersion: "Version", + devNodeVersion: "Node", + devStatsTests: "Test suites", + devParentFolder: "Parent folder", + devOpenFolder: "open", + devDescription: "Explore the source code running on this node.", + devTreeTitle: "Files", + devSearchTitle: "Search code", + devMapTitle: "Modules", + devRoot: "root", + devRootButton: "ROOT", + devSearchPlaceholder: "Text to find in the code", + devAnyExtension: "Any file", + devSearchButton: "Search", + devStatsFiles: "Files", + devStatsLines: "Lines", + devStatsModules: "Modules", + devStatsLanguages: "Languages", + devNotViewable: "not viewable", + devEmptyFolder: "This folder is empty.", + devSize: "Size", + devShowing: "Showing", + devLine: "line", + devReportBug: "Report Bug", + devCreateTask: "Create Task", + devRaw: "RAW", + devReportContext: "Found while reading the Oasis source code", + devTaskPrefix: "Review", + devPrevPage: "Previous lines", + devNextPage: "Next lines", + devMatches: "matches", + devFilesScanned: "files scanned", + devNoResults: "No matches, yet.", + devColModule: "Module", + devColState: "State", + devColModel: "Model", + devColView: "View", + devColRoutes: "Routes", + devColTests: "Tests", + devOn: "on", + devOff: "off", + modulesDevLabel: "Developer", + modulesDevDescription: "Module to manage the Oasis source code.", themeIntro: "Choose a theme.", setTheme: "Set theme", language: "Language", languageDescription: "If you'd like to use another language, select it here.", - setLanguage: "Set language", + setLanguage: "Set Language", peerConnections: "Peers", peerConnectionsIntro: "Manage all your connections with other peers.", peerConnectionsTitle: "Connections", @@ -714,7 +777,7 @@ module.exports = { spreadLabel: "Spread", spreadHint: "Spread this to your supporters (replicates via your feed).", welcomePmSubject: "Hello World!", - welcomePmBody: "Welcome to Oasis — a libre, peer-to-peer, federated and encrypted social network with a distributed invite-only architecture.\n\nSome interesting places to start:\n\n- /profile — Edit your avatar, name, description and which modules appear on your public side.\n- /settings — Tune your local instance: language, theme, modules, privacy and sync.\n- /invites — Add a pub to federate with the wider network.\n- /peers — See who is connected, rescan your LAN, export or import peer lists.\n- /inhabitants — Discover and visit other people's avatars.\n- /tribes — Create or join private groups with end-to-end encryption.\n- /inbox — Read and send private messages.\n- /banking — See your ECO balance, UBI, ECO tax and the wealth-redistribution rules.\n- /activity — See recent activity, yours and your network's.\n- /publish — Write a post or share an image, audio, video or document.\n\nSome interesting places to connect:\n\n- /fediverse — Manage your other fediverse accounts, including sending and receiving content.\n\nThis message was sent privately from you to yourself, so no connection was required to receive it.", + welcomePmBody: "Welcome to Oasis — a libre, peer-to-peer, federated and encrypted social network with a distributed invite-only architecture.\n\nSome interesting places to start:\n\n- /profile — Edit your avatar, name, description and which modules appear on your public side.\n- /settings — Tune your local instance: language, theme, modules, privacy and sync.\n- /invites — Add a pub to federate with the wider network.\n- /peers — See who is connected, rescan your LAN, export or import peer lists.\n- /inhabitants — Discover and visit other people's avatars.\n- /tribes — Create or join private groups with end-to-end encryption.\n- /inbox — Read and send private messages.\n- /banking — See your ECO balance, UBI, ECO tax and the wealth-redistribution rules.\n- /activity — See recent activity, yours and your network's.\n- /publish — Write a post or share an image, audio, video or document.\n- /search — Search the network's content by type.\n\nSome interesting places to connect:\n\n- /fediverse — Manage your other fediverse accounts, including sending and receiving content.\n\nThis message was sent privately from you to yourself, so no connection was required to receive it.", indexingTitle: "Synchronizing", indexingMessage: "Oasis is trying to syncronize a huge network of inhabitants. Just wait!", indexingRefreshNote: "This page refreshes every 10 seconds.", @@ -1000,7 +1063,7 @@ module.exports = { bankTaxesExample: "Example", bankTaxesUserNoteOtherParams: "ECO Tax is currently derived from the carbon footprint of each block, but it may incorporate other parameters over time — energy spent on replication, redundant storage across peers, blob bandwidth, computational cost of decryption, mining footprint of associated transactions, or any other measurable load the network agrees to value.", bankTaxesUserAmount: "Your ECO tax (price to return)", - bankOverviewYourTaxes: "Your taxes", + bankOverviewYourTaxes: "Your Taxes", bankTaxesTotalBlocks: "Total blocks", bankTaxesTotalBytes: "Total bytes", bankTaxesTotalCarbon: "Total carbon", @@ -1351,6 +1414,9 @@ module.exports = { eventButton: "EVENTS", taskButton: "TASKS", votesButton: "VOTATIONS", + industryButton: "INDUSTRY", + projectButton: "PROJECTS", + shopProductButton: "PRODUCTS", reportButton: "REPORTS", feedButton: "FEED", marketButton: "MARKET", @@ -1379,7 +1445,7 @@ module.exports = { trendingItemStatus: "Item Status", trendingTotalVotes: "Total Votes", trendingTotalOpinions: "Total Opinions", - trendingNoContentMessage: "No trending content available, yet.", + trendingNoContentMessage: "No trending available, yet.", trendingAuthor: "By", trendingCreatedAtLabel: "Created At", trendingTotalCount: "Total Count", @@ -1568,7 +1634,7 @@ module.exports = { transfersCreatedAt: "Created At", transfersConfirmations: "CONFIRMATIONS", transfersContainingBlock: "Containing block", - transfersExportContract: "Create Contract", + transfersExportContract: "Smart Contract", transfersConfirmButton: "Confirm Transfer", transfersNoItems: "No transfers found.", transfersMineSectionTitle: "Your Transfers", @@ -1694,7 +1760,7 @@ module.exports = { cvDeleteButton: "Delete", cvNoCV: "No CV found.", blogSubject: "Subject", - blogMessage: "Body capped at 8096 characters.", + blogMessage: "Message", blogImage: "Upload media (max-size: 50MB)", blogPublish: "Preview", noPopularMessages: "No popular messages published, yet", @@ -2487,6 +2553,7 @@ module.exports = { agendaFilterReports: "REPORTS", agendaFilterTransfers: "TRANSFERS", agendaFilterJobs: "JOBS", + agendaFilterIndustry: "INDUSTRY", agendaFilterProjects: "PROJECTS", agendaFilterCalendars: "CALENDARS", agendaNoItems: "No assignments found.", @@ -2608,16 +2675,31 @@ module.exports = { privateMessage: "PM", pmSendTitle: "Private Messages", pmSend: "Send!", - pmDescription: "Use this form to send an encrypted message to other inhabitants.", + pmCancel: "Cancel", + pmReset: "Reset", + pmSharedKeyHint: "Share this key with the recipient out of band. It is required to decrypt.", + pmDescription: "Use this form to send an encrypted message/file to other inhabitants.", + pmComposeTitle: "Send a message", pmRecipients: "Recipients", pmRecipientsHint: "Enter Oasis IDs separated by commas", pmLevelOfExposition: "Level of exposition", - pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmLimitsHint: "Up to 7 recipients per message.", + pmTextPlaceholder: "Body capped at 7000 characters.", pmSubject: "Subject", pmSubjectHint: "Enter the message subject", pmText: "Message", pmCrypterChip: "2xE2E", pmCrypterDecryptButton: "Decrypt", + fileShareTitle: "Share a file", + fileShareRecipientPlaceholder: "@...=.ed25519", + fileShareFileLabel: "File", + fileShareDownload: "Download", + fileShareMutualError: "You can only share files with inhabitants with mutual support.", + fileShareNoFile: "No file selected.", + fileShareTooLarge: "The file exceeds the allowed size.", + fileShareFailed: "Could not prepare the file.", + fileShareSendError: "Could not send the file.", + fileShareUnavailable: "This file is not available right now. Try again later.", pmCrypterBadKey: "Your shared key is incorrect!", pmCrypterTooLong: "The message is too long to encrypt with the Crypter. Please shorten it and try again.", pmCrypterCipherLabel: "Re-encrypted message", @@ -2638,15 +2720,27 @@ module.exports = { pmNoSubject: "(no subject)", pmSubjectLabel: "Subject:", pmBodyLabel: "Body", - pmBotJobs: "42-JobsBOT", - pmBotProjects: "42-ProjectsBOT", - pmBotMarket: "42-MarketBOT", + pmBotJobs: "JobsBot", + pmBotProjects: "ProjectsBot", + pmBotMarket: "MarketBot", + pmBotShops: "ShopsBot", + pmBotPolitical: "PoliticalBot", + politicalBotLarpTitle: "The ruling house of the L.A.R.P has changed.", + politicalBotParliamentTitle: "A new government cycle has begun in the Parliament.", + politicalBotTribeTitle: "A new government cycle has begun in one of your Tribes.", + politicalBotLarpIntro: "Now {house} rules during this cycle: {cycle}", + politicalBotParliamentIntro: "The current government is {gov}", + politicalBotParliamentIntroLeader: "The current government is {gov}, led by {leader}", + politicalBotTribeIntro: "The current government in the Tribe {tribe} is {gov}", + politicalBotTribeIntroLeader: "The current government in the Tribe {tribe} is {gov}, led by {leader}", + politicalBotLeaderLabel: "Leader", inboxJobSubscribedTitle: "New subscription to your job offer", pmInhabitantWithId: "Inhabitant with OASIS ID:", pmHasSubscribedToYourJobOffer: "has subscribed to your job offer", inboxProjectCreatedTitle: "New project created", pmHasCreatedAProject: "has created a project", inboxMarketItemSoldTitle: "Item Sold", + inboxShopSoldTitle: "Product Sold", pmYourItem: "Your item", pmHasBeenSoldTo: "has been sold to", pmFor: "for", @@ -2681,7 +2775,7 @@ module.exports = { visitContent: "Visit Content", banking: 'Banking', bankingTitle: 'Banking', - bankingDescription: 'Explore the current value of ECOin and the corresponding UBI allocation, distributed per epoch based on participation and trust.', + bankingDescription: "The ECOin economy: balance, UBI, taxes and industry value.", bankOverview: 'Overview', bankEpochs: 'Epochs', bankRules: 'Rules', @@ -2715,6 +2809,10 @@ module.exports = { bankEpochId: 'Epoch ID', bankRuleHash: 'Rules Snapshot Hash', bankViewEpoch: 'View Epoch', + bankYourIndustryBalance: "Your Industry Share", + bankYourUbiMonth: "Your UBI (this month)", + bankYourFundsMonth: "Your Funds (this month)", + bankIndustryBalance: "Industry Production (estimated)", bankUserBalance: 'Your Balance', ecoWalletNotConfigured: 'ECOin Wallet not configured', editWallet: 'Edit wallet', @@ -2754,7 +2852,7 @@ module.exports = { pubIdPlaceholder: "@PUB_ID.ed25519", bankUbiAvailableNo: "NO FUNDS!", bankUbiAvailableOk: "AVAILABLE!", - bankUbiAvailability: "UBI availability", + bankUbiAvailability: "UBI (network)", bankAlreadyClaimedThisMonth: "Already claimed this month", bankUbiThisMonth: "UBI (this month)", bankUbiLastClaimed: "UBI (last claimed)", @@ -3184,6 +3282,208 @@ module.exports = { jobsTagsLabel: "Tags", jobsTagsPlaceholder: "tag1, tag2, tag3", noJobsMatch: "No jobs match your search.", + industrySearchPlaceholder: "Search facilities…", + industryAllSectors: "All sectors", + industryVisitButton: "Visit Industry", + industryAdvanceTo: "Set to", + industryAmount: "Amount", + industryApproveBuild: "Approve", + industryBackToFacility: "← Facility", + industryBlueprint: "Blueprint", + industryBlueprintLabel: "Industry Blueprint", + industryBlueprints: "Blueprints", + industryBuild: "Build", + industryBuildNotes: "Notes", + industryBuildStart: "Start date", + industryVoteUpdateButton: "Vote update", + industryVoteDeleteButton: "Vote delete", + industryRevokeVote: "Revoke", + industryTimeLeft: "Time left", + industryBuildEnd: "End date", + industryBuilds: "Builds", + industryBuildTitle: "Title", + industryContribute: "Contribute", + industryContributeEco: "Contribute ECO", + industryContributeLabor: "Contribute labor", + industryContributeButton: "Contribute", + industryContributeMaterial: "Contribute material", + industryContributions: "Contributions", + industryContributors: "contributors", + industryCreateBlueprintButton: "Create Blueprint", + industryDistribute: "Distribute", + industryDistributeButton: "Distribute by Shares", + industryDistribution: "Distribution", + industryEcoAmount: "Amount (ECO)", + industryEcoContribHint: "ECO contributions are transferred to the steward as the build's treasury custodian.", + industryEditBlueprint: "Edit Blueprint", + industryFacility: "Facility", + industryKindLabel: "Kind", + industryKind_labor: "Labor", + industryKind_material: "Material", + industryKind_eco: "Funds", + industryLaborHours: "Labor (hours)", + industryHours: "Hours", + industryLicense: "License", + industryMaterialItem: "Material", + industryMaterials: "Materials", + industryMaterialsHint: "One material per line, written as name:quantity:price.", + industryEstMaterials: "Total Materials", + industryEstLabor: "Total Labor", + industryEstTaxes: "Taxes", + industryEstTotal: "Estimated price", + industryBuildingPrice: "Building price", + industryFinalPrice: "Final price", + industryBlueprintDescPlaceholder: "Specs, dimensions, weight, docs…", + industryMaterialValue: "Value (ECO)", + industryMember: "Member", + industryNewBlueprint: "New Blueprint", + industryNewBuild: "Propose a Build", + industryEditBuild: "Edit build", + industryNoBlueprint: "— none —", + industryNeedBlueprint: "Create a blueprint first: every build produces one.", + industryNoBlueprints: "No blueprints yet.", + industryNoBuilds: "No builds yet.", + industryNote: "Note", + industryOutput: "Output", + industryOutputItem: "Product name", + industryOutputKind: "Product type", + industryOutputQty: "Units per build", + industryOutputUnit: "Unit of measure", + industryOutputValue: "Output value (ECO, e.g. from sale)", + industryPayout: "Payout", + industryPoints: "Karma", + industryPostJob: "Create Job", + industryPot: "Pot", + industryProposeBuildButton: "Propose Build", + industryProposer: "Proposer", + industryAuthor: "Author", + industrySellOnMarket: "Send to Market", + industrySendToProjects: "Create Project", + industryShare: "Share", + industryShares: "Shares", + industrySkills: "Skills", + industryTreasury: "Treasury", + industryKind_physical: "Physical", + industryKind_digital: "Digital", + industryBuildStatus_PROPOSED: "PROPOSED", + industryBuildStatus_REJECTED: "REJECTED", + industryBuildStatus_APPROVED: "APPROVED", + industryBuildStatus_STOCKING: "STOCKING", + industryBuildStatus_IN_PRODUCTION: "IN PRODUCTION", + industryBuildStatus_COMPLETED: "COMPLETED", + industryBuildStatus_FAILED: "FAILED", + pmBotIndustry: "IndustryBot", + industryBotAdmittedTitle: "You have been admitted to a facility.", + industryBotApplicationTitle: "New membership application in your facility.", + industryBotInvitedTitle: "You have been invited to a facility.", + industryBotDissolvedTitle: "A facility you belong to has been dissolved.", + industryBotBuildApprovedTitle: "A build has been approved.", + industryBotDistributedTitle: "A build output has been distributed.", + industryFilterRules: "RULES", + industryRulesTitle: "Rules", + industryRulesIntro: "Industry lets inhabitants collectively own the means of production: a Facility is a network-owned workshop that nobody owns privately.", + industryRulesSteward: "The founder is the steward — a caretaker, not an owner: they cannot sell, privatize or dissolve the Facility on their own.", + industryRulesMembership: "Membership policy sets how people join: Open (join instantly), By vote (admitted by the members' vote) or Invite only (a member must invite them first).", + industryRulesQuorum: "Quorum is the minimum number of members that must vote for a decision to count, so a small Facility isn't decided by a single person.", + industryRulesMajority: "Majority is the fraction of votes needed to pass (0.5 = half, 1 = unanimity); a decision passes when YES votes reach at least max(quorum, members × majority).", + industryRulesDecisions: "Members vote to admit newcomers, approve builds and dissolve the Facility; the steward cannot override these collective decisions.", + industryRulesBlueprints: "Blueprints are libre (COPYLEFT) recipes — the inputs (materials, labor hours, skills) and the output (a physical or digital good) — and anyone can fork them.", + industryRulesBuilds: "A Build is a production order: PROPOSED → APPROVED (by vote) → STOCKING → IN PRODUCTION → COMPLETED, and it only accepts contributions once approved.", + industryRulesContributions: "Members contribute labor (hours), materials (declared ECO value) or ECO to a build; each contribution earns karma.", + industryRulesLaborRate: "Labor rate is the ECO value of one work hour in this Facility; it turns hours into karma so effort and capital compare fairly: karma = hours × labor rate + material value + ECO.", + industryRulesShares: "Your share of a build is your karma ÷ total karma, and it sets how much of the output value you receive.", + industryRulesDistribution: "When a build is completed the steward distributes the pot (the build's funds plus any sale value) among contributors, proportional to their shares.", + industryRulesTreasury: "ECO contributions are held by the steward as the build's treasury custodian until distribution; all movements are recorded on the network and disputes can go to Courts.", + industryJobs: "Jobs", + industryNoJobs: "No positions yet.", + industryCreatePosition: "Create position", + industryViewCV: "CV", + industryReportButton: "Report", + industryCreateTask: "Create Task", + industryOpenDispute: "Open dispute", + industryMaterialsPlaceholder: "solar-panel:2:120\nbattery:1:80\nkit:1:15", + industryOutputUnitPlaceholder: "e.g. unit, kg, license", + industryOutputItemPlaceholder: "e.g. robot", + industryOutputQtyPlaceholder: "e.g. 5", + industrySkillsPlaceholder: "e.g. soldering, networking, solar-installation", + industryCreateInvite: "Generate invite", + industryPauseVotes: "Status votes", + industryTitle: "Industry", + industryDescription: "Module to manage the means of production collectively.", + industryLabel: "Industry", + typeIndustryBuild: "BUILD", + typeIndustryBlueprint: "BLUEPRINT", + typeIndustryAllocation: "DISTRIBUTION", + typeIndustry: "INDUSTRY", + modulesIndustryLabel: "Industry", + modulesIndustryDescription: "Module to manage the means of production collectively.", + industryFilterAll: "ALL", + industryFilterMember: "MEMBER", + industryFilterBlueprints: "BLUEPRINTS", + industryFilterBuilds: "BUILDS", + industryFilterMine: "MINE", + industryFilterActive: "WORKING", + industryFilterPaused: "PAUSED", + industryFilterDissolved: "DISSOLVED", + industryAllTitle: "Industry", + industryMemberTitle: "Facilities I'm in", + industryMineTitle: "My facilities", + industryActiveTitle: "Working", + industryPausedTitle: "Paused facilities", + industryDissolvedTitle: "Dissolved facilities", + industryNoFacilitiesFound: "No facilities found.", + industryCreateFacility: "Create Facility", + industryMembers: "Members", + industryMemberBadge: "MEMBER", + industryName: "Name", + industryNamePlaceholder: "Facility name", + industryDescriptionLabel: "Description", + industryDescriptionPlaceholder: "What does this facility produce?", + industrySector: "Sector", + industryMembershipPolicy: "Membership policy", + industryQuorum: "Quorum", + industryMajority: "Majority", + industryLaborRate: "Labor rate", + industryCreateButton: "Create Facility", + industryUpdateButton: "Update", + industrySteward: "Steward", + industryStatusActive: "WORKING", + industryStatusPaused: "PAUSED", + industryStatusDissolved: "DISSOLVED", + industryStatusLabel: "Status", + industryJoinButton: "Join", + industryApplyButton: "Request to join", + industryLeaveButton: "Leave", + industryInviteButton: "Invite", + industryInvitePlaceholder: "@...=.ed25519", + industryInviteNeeded: "You need an invitation to join.", + industryPauseButton: "Vote to pause", + industryResumeButton: "Vote to resume", + industryEditButton: "Edit", + industryDeleteButton: "Delete", + industryBackToList: "← Industry", + industryPendingApplicants: "Pending applicants", + industryVotesYes: "yes", + industryVotesNo: "no", + industryAlreadyVoted: "voted", + industryAdmit: "Admit", + industryReject: "Reject", + industryDissolveTitle: "Dissolve facility", + industryDissolveHint: "Dissolution requires a collective vote; the steward cannot dissolve alone.", + industryDissolveVote: "Vote to dissolve", + industrySector_software: "Software", + industrySector_hardware: "Hardware", + industrySector_agriculture: "Agriculture", + industrySector_textile: "Textile", + industrySector_energy: "Energy", + industrySector_food: "Food", + industrySector_construction: "Construction", + industrySector_media: "Media", + industrySector_services: "Services", + industrySector_other: "Other", + industryPolicy_open: "Open", + industryPolicy_vote: "By vote", + industryPolicy_invite: "Invite only", projectsTitle: "Projects", projectsDescription: "Create, fund, and follow community-driven projects in your network.", projectCreateProject: "Create Project", @@ -3617,6 +3917,8 @@ module.exports = { chatOpenTitle: "Open Chats", chatClosedTitle: "Closed Chats", chatDescription: "Description", + chatMessageReply: "Reply", + chatMessageLabel: "Message", chatCategory: "Category", chatStatus: "STATUS", chatFilterAll: "ALL", diff --git a/src/client/assets/translations/oasis_es.js b/src/client/assets/translations/oasis_es.js index 0faa594..b380e20 100644 --- a/src/client/assets/translations/oasis_es.js +++ b/src/client/assets/translations/oasis_es.js @@ -1,26 +1,6 @@ const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); module.exports = { es: { - bbAdd: "Añadir", - bbQuickActions: "Acciones rápidas", - bbPickTitle: "Fijar un módulo", - bbPickDesc: "Elige un módulo para el hueco + de la barra inferior.", - bbPickNone: "Ninguno", - bbManage: "Editar", - bbYourBar: "Tu barra", - bbRemove: "Quitar", - bbFull: "Máximo alcanzado", - bbDone: "Hecho", - activityChangeFilter: "Cambiar filtro", - emptyConnectHint: "Conéctate a un pub para sincronizar con la red.", - emptyConnectCta: "Conéctate a un pub", - menuCommunity: "Comunidad", - activityGroupCommunity: "Comunidad y Gobernanza", - activityGroupOrg: "Organización", - activityGroupComm: "Comunicación", - activityGroupEconomy: "Economía", - activityGroupMedia: "Contenido y Media", - activityGroupOther: "Otros", languageName: "Castellano", shopOrderStatusPaid: "Pago recibido", shopOrderStatusReceived: "Recibido", @@ -419,7 +399,8 @@ module.exports = { publish: "Publicar", contentWarningPlaceholder: "Añade un título a tu post (opcional)", privateWarningPlaceholder: "Añade habitantes para enviar un post privado (opcional)", - publishWarningPlaceholder: "...", + publishWarningPlaceholder: "Cuerpo limitado a 7000 caracteres.", + publishTooLong: "Tu publicación es demasiado larga. Acórtala, por favor.", publishCustomDescription: [ "RECUERDA: Debido a la tecnología blockchain, una vez has publicado algo, no puede ser editado ni borrado. Piénsalo bien!.", ], @@ -484,7 +465,89 @@ module.exports = { passwordLengthInfo: "La contraseña debe tener mínimo 32 caracteres de longitud.", passwordImport: "Escribe tu contraseña para descifrar los datos que serán guardados en tu sistema (nombre: secret)", exportPasswordPlaceholder: "Utiliza minúsculas, mayúsculas, números y símbolos", - fileInfo: "Tu secreto cifrado será guardado en tu sistema (nombre: oasis.enc)", + fileInfo: "Tu secreto cifrado se descargará en tu dispositivo (nombre: oasis.enc)", + developer: "Desarrollo", + devTitle: "Desarrollo", + welcomeBannerText: "Bienvenida a OASIS. Sigue estos pasos rápidos para configurar tu avatar.", + welcomeBannerAction: "¡Empezar!", + welcomeTitle: "Bienvenida", + welcomeStepGreetingAction: "Enviar feed", + welcomeJoinPubButton: "Unirse al pub", + welcomeStepLarpAction: "Unirse a \"La Academia\"", + welcomeStepLarpText: "Únete a nuestro juego de rol de acción real.", + welcomeStepLarpTitle: "Únete al L.A.R.P.", + welcomeStepGreetingTitle: "Envía un \"Hola mundo!\"", + welcomeStepGreetingText: "Envía un mensaje para que otros habitantes puedan descubrirte.", + welcomeJoinPub: "Unirse a", + welcomeDescription: "Unas pocas cosas que conviene hacer antes de empezar.", + welcomeProgress: "Completado", + welcomeStepDone: "hecho", + welcomeStepPending: "pendiente", + welcomeSkip: "Ahora no", + welcomeStepLanguageTitle: "Elige tu idioma", + welcomeStepLanguageText: "Oasis habla varios idiomas. Puedes cambiarlo cuando quieras.", + welcomeStepProfileTitle: "Edita tu perfil", + welcomeStepProfileText: "Elige un nombre, añade una descripción y pon una imagen para que el resto de habitantes te reconozca.", + welcomeStepProfileAction: "Guardar perfil", + welcomeStepFederationTitle: "Únete a la red principal", + welcomeStepFederationText: "Conéctate a nuestro pub para encontrar a otros habitantes y empezar a replicar.", + welcomeStepFederationAction: "Ir a invitaciones", + welcomeStepBackupTitle: "Copia de seguridad de tu ID", + welcomeStepBackupText: "Tu identidad es un fichero de claves en este dispositivo.", + welcomeStepBackupWarning: "SI LO PIERDES, NADIE PUEDE RECUPERARLO POR TI.", + welcomeStepBackupAction: "¡Copia de seguridad!", + welcomeCompleteTitle: "Todo listo.", + welcomeCompleteText: "Tu nodo está preparado. A partir de aquí la red crece con la gente a la que sigues.", + welcomeFindPeople: "Buscar habitantes", + devFilterFiles: "FICHEROS", + devFilterSearch: "BUSCAR", + devFilterModules: "MÓDULOS", + devFilterTranslations: "TRADUCCIONES", + devFilterStyles: "ESTILOS", + devFilterTests: "TESTS", + devVersion: "Versión", + devNodeVersion: "Node", + devStatsTests: "Suites de tests", + devParentFolder: "Carpeta superior", + devOpenFolder: "abrir", + devDescription: "Explora el código que se está ejecutando en este nodo.", + devTreeTitle: "Ficheros", + devSearchTitle: "Buscar en el código", + devMapTitle: "Módulos", + devRoot: "raíz", + devRootButton: "RAÍZ", + devSearchPlaceholder: "Texto a buscar en el código", + devAnyExtension: "Cualquier fichero", + devSearchButton: "Buscar", + devStatsFiles: "Ficheros", + devStatsLines: "Líneas", + devStatsModules: "Módulos", + devStatsLanguages: "Idiomas", + devNotViewable: "no visualizable", + devEmptyFolder: "Esta carpeta está vacía.", + devSize: "Tamaño", + devShowing: "Mostrando", + devLine: "línea", + devReportBug: "Reportar Bug", + devCreateTask: "Crear Tarea", + devRaw: "RAW", + devReportContext: "Encontrado leyendo el código de Oasis", + devTaskPrefix: "Revisar", + devPrevPage: "Líneas anteriores", + devNextPage: "Líneas siguientes", + devMatches: "coincidencias", + devFilesScanned: "ficheros revisados", + devNoResults: "Todavía no hay coincidencias.", + devColModule: "Módulo", + devColState: "Estado", + devColModel: "Modelo", + devColView: "Vista", + devColRoutes: "Rutas", + devColTests: "Tests", + devOn: "activo", + devOff: "inactivo", + modulesDevLabel: "Desarrollo", + modulesDevDescription: "Módulo para gestionar el código de Oasis.", themeIntro: "Elige un tema.", setTheme: "Establecer tema", @@ -682,7 +745,7 @@ module.exports = { spreadLabel: "Difundir", spreadHint: "Difunde esto a quienes te apoyan (se replica vía tu feed).", welcomePmSubject: "Hello World!", - welcomePmBody: "Bienvenido a Oasis — una red social libre, peer-to-peer, federada y cifrada, con una arquitectura distribuida y solo por invitación.\n\nAlgunos lugares interesantes para empezar:\n\n- /profile — Edita tu avatar, nombre, descripción y qué módulos se muestran en tu cara pública.\n- /invites — Añade un pub para federarte con el resto de la red.\n- /peers — Mira quién está conectado, reescanea tu LAN, exporta o importa listas de peers.\n- /inhabitants — Descubre y visita los avatares de otras personas.\n- /tribes — Crea o únete a grupos privados con cifrado de extremo a extremo.\n- /inbox — Lee y envía mensajes privados.\n- /activity — Mira la actividad reciente, tuya y de tu red.\n- /publish — Escribe un post o comparte una imagen, audio, vídeo o documento.\n\nAlgunos lugares interesantes para conectar:\n\n- /fediverse — Gestiona tus otras cuentas del fediverso, incluyendo enviar y recibir contenido.\n\nEste mensaje se envió de forma privada desde ti hacia ti, por eso no hizo falta conexión para recibirlo.", + welcomePmBody: "Bienvenido a Oasis — una red social libre, peer-to-peer, federada y cifrada, con una arquitectura distribuida y solo por invitación.\n\nAlgunos lugares interesantes para empezar:\n\n- /profile — Edita tu avatar, nombre, descripción y qué módulos se muestran en tu cara pública.\n- /invites — Añade un pub para federarte con el resto de la red.\n- /peers — Mira quién está conectado, reescanea tu LAN, exporta o importa listas de peers.\n- /inhabitants — Descubre y visita los avatares de otras personas.\n- /tribes — Crea o únete a grupos privados con cifrado de extremo a extremo.\n- /inbox — Lee y envía mensajes privados.\n- /activity — Mira la actividad reciente, tuya y de tu red.\n- /publish — Escribe un post o comparte una imagen, audio, vídeo o documento.\n- /search — Busca el contenido de la red por tipo.\n\nAlgunos lugares interesantes para conectar:\n\n- /fediverse — Gestiona tus otras cuentas del fediverso, incluyendo enviar y recibir contenido.\n\nEste mensaje se envió de forma privada desde ti hacia ti, por eso no hizo falta conexión para recibirlo.", profileVisibilityTitle: "Visibilidad del perfil público", profileVisibilityHint: "Marca qué campos ven otros habitantes en tu perfil. Por defecto solo se muestra Karma.", profileSensorsSectionTitle: "Sensores", @@ -1214,6 +1277,9 @@ module.exports = { eventButton: "EVENTOS", taskButton: "TAREAS", votesButton: "GOBIERNO", + industryButton: "INDUSTRIA", + projectButton: "PROYECTOS", + shopProductButton: "PRODUCTOS", reportButton: "INFORMES", feedButton: "FEED", marketButton: "MERCADO", @@ -1242,7 +1308,7 @@ module.exports = { trendingItemStatus: "Estado del Artículo", trendingTotalVotes: "Total de Votos", trendingTotalOpinions: "Total de Opiniones", - trendingNoContentMessage: "No hay tendencias disponibles, aún.", + trendingNoContentMessage: "Todavía no hay tendencias.", trendingAuthor: "Por", trendingCreatedAtLabel: "Creado el", trendingTotalCount: "Total de Contadores", @@ -1431,7 +1497,7 @@ module.exports = { transfersCreatedAt: "Creado el", transfersConfirmations: "CONFIRMACIONES", transfersContainingBlock: "Bloque contenedor", - transfersExportContract: "Crear contrato", + transfersExportContract: "Contrato inteligente", transfersConfirmButton: "Confirmar Transferencia", transfersNoItems: "No se encontraron transferencias.", transfersMineSectionTitle: "Tus Transferencias", @@ -1557,7 +1623,7 @@ module.exports = { cvDeleteButton: "Eliminar", cvNoCV: "No se encontró ningún CV.", blogSubject: "Asunto", - blogMessage: "Cuerpo limitado a 8096 caracteres.", + blogMessage: "Mensaje", blogImage: "Subir contenido multimedia (max: 50MB)", blogPublish: "Vista previa", noPopularMessages: "No se han publicado mensajes populares, aún", @@ -2193,6 +2259,7 @@ module.exports = { agendaFilterReports: "INFORMES", agendaFilterTransfers: "TRANSFERENCIAS", agendaFilterJobs: "TRABAJOS", + agendaFilterIndustry: "INDUSTRIA", agendaFilterProjects: "PROYECTOS", agendaFilterCalendars: "CALENDARIOS", agendaNoItems: "No se encontraron asignaciones.", @@ -2314,16 +2381,31 @@ module.exports = { privateMessage: "MP", pmSendTitle: "Mensajes Privados", pmSend: "Enviar!", - pmDescription: "Usa este formulario para enviar un mensaje cifrado a otros habitantes.", + pmCancel: "Cancelar", + pmReset: "Restablecer", + pmSharedKeyHint: "Comparte esta clave con el destinatario por otro canal. Es necesaria para descifrar.", + pmDescription: "Usa este formulario para enviar un mensaje/fichero cifrado a otros habitantes.", + pmComposeTitle: "Enviar un mensaje", pmRecipients: "Destinatarios", pmRecipientsHint: "Introduce los IDs de Oasis separados por comas", pmLevelOfExposition: "Level of exposition", - pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmLimitsHint: "Hasta 7 destinatarios por mensaje.", + pmTextPlaceholder: "Cuerpo limitado a 7000 caracteres.", pmSubject: "Asunto", pmSubjectHint: "Introduce el asunto del mensaje", pmText: "Mensaje", pmCrypterChip: "2xE2E", pmCrypterDecryptButton: "Descifrar", + fileShareTitle: "Compartir un fichero", + fileShareRecipientPlaceholder: "@...=.ed25519", + fileShareFileLabel: "Fichero", + fileShareDownload: "Descargar", + fileShareMutualError: "Solo puedes compartir ficheros con habitantes que te apoyen mutuamente.", + fileShareNoFile: "No se ha seleccionado ningún fichero.", + fileShareTooLarge: "El fichero supera el tamaño permitido.", + fileShareFailed: "No se pudo preparar el fichero.", + fileShareSendError: "No se pudo enviar el fichero.", + fileShareUnavailable: "Este fichero no está disponible ahora mismo. Inténtalo más tarde.", pmCrypterBadKey: "¡La clave compartida es incorrecta!", pmCrypterTooLong: "El mensaje es demasiado largo para cifrarlo con el Crypter. Acórtalo e inténtalo de nuevo.", pmCrypterCipherLabel: "Mensaje recifrado", @@ -2352,15 +2434,27 @@ module.exports = { pmNoSubject: "(sin asunto)", pmSubjectLabel: "Asunto:", pmBodyLabel: "Cuerpo", - pmBotJobs: "42-EmpleosBOT", - pmBotProjects: "42-ProyectosBOT", - pmBotMarket: "42-MercadoBOT", + pmBotJobs: "JobsBot", + pmBotProjects: "ProjectsBot", + pmBotMarket: "MarketBot", + pmBotShops: "ShopsBot", + pmBotPolitical: "PoliticalBot", + politicalBotLarpTitle: "La casa gobernante del L.A.R.P ha cambiado.", + politicalBotParliamentTitle: "Ha comenzado un nuevo ciclo de gobierno en el Parlamento.", + politicalBotTribeTitle: "Ha comenzado un nuevo ciclo de gobierno en una de tus Tribus.", + politicalBotLarpIntro: "Ahora gobierna {house} durante este ciclo: {cycle}", + politicalBotParliamentIntro: "El gobierno actual es {gov}", + politicalBotParliamentIntroLeader: "El gobierno actual es {gov} ejercida por {leader}", + politicalBotTribeIntro: "El gobierno actual en la Tribu {tribe} es {gov}", + politicalBotTribeIntroLeader: "El gobierno actual en la Tribu {tribe} es {gov} ejercida por {leader}", + politicalBotLeaderLabel: "Líder", inboxJobSubscribedTitle: "Nueva suscripción a tu oferta de trabajo", pmInhabitantWithId: "Habitante con ID OASIS:", pmHasSubscribedToYourJobOffer: "se ha suscrito a tu oferta de trabajo", inboxProjectCreatedTitle: "Nuevo proyecto creado", pmHasCreatedAProject: "ha creado un proyecto", inboxMarketItemSoldTitle: "Artículo vendido", + inboxShopSoldTitle: "Producto vendido", pmYourItem: "Tu artículo", pmHasBeenSoldTo: "se ha vendido a", pmFor: "por", @@ -2393,7 +2487,7 @@ module.exports = { blockchainBack: 'Volver al Blockexplorer', banking: 'Banking', bankingTitle: 'Banking', - bankingDescription: 'Explora el valor actual de ECOin y la asignación de RBU correspondiente, distribuida semanalmente en función de la participación y la confianza.', + bankingDescription: "La economía de ECOin: balance, RBU, tasas y valor de industria.", bankOverview: 'Resumen', bankEpochs: 'Épocas', bankRules: 'Reglas', @@ -2427,6 +2521,10 @@ module.exports = { bankEpochId: 'ID de época', bankRuleHash: 'Hash del snapshot de reglas', bankViewEpoch: 'Ver época', + bankYourIndustryBalance: "Tu parte de la industria", + bankYourUbiMonth: "Tu RBU (este mes)", + bankYourFundsMonth: "Tus fondos (este mes)", + bankIndustryBalance: "Producción industrial (estimada)", bankUserBalance: 'Tu saldo', ecoWalletNotConfigured: 'Cartera de ECOin no configurada', editWallet: 'Editar cartera', @@ -2466,7 +2564,7 @@ module.exports = { pubIdPlaceholder: "@PUB_ID.ed25519", bankUbiAvailableNo: "SIN FONDOS!", bankUbiAvailableOk: "¡DISPONIBLE!", - bankUbiAvailability: "Disponibilidad de UBI", + bankUbiAvailability: "RBU (red)", bankAlreadyClaimedThisMonth: "Ya reclamado este mes", bankUbiThisMonth: "UBI (este mes)", bankUbiLastClaimed: "UBI (última reclamada)", @@ -2896,6 +2994,208 @@ module.exports = { jobsTagsLabel: "Etiquetas", jobsTagsPlaceholder: "etiqueta1, etiqueta2, etiqueta3", noJobsMatch: "No hay ofertas que coincidan con tu búsqueda.", + industrySearchPlaceholder: "Buscar fábricas…", + industryAllSectors: "Todos los sectores", + industryVisitButton: "Visitar Industria", + industryAdvanceTo: "Cambiar a", + industryAmount: "Cantidad", + industryApproveBuild: "Aprobar", + industryBackToFacility: "← Fábrica", + industryBlueprint: "Blueprint", + industryBlueprintLabel: "Blueprint de Industria", + industryBlueprints: "Blueprints", + industryBuild: "Build", + industryBuildNotes: "Notas", + industryBuildStart: "Fecha de inicio", + industryVoteUpdateButton: "Votar actualización", + industryVoteDeleteButton: "Votar borrado", + industryRevokeVote: "Revocar", + industryTimeLeft: "Tiempo restante", + industryBuildEnd: "Fecha de fin", + industryBuilds: "Builds", + industryBuildTitle: "Título", + industryContribute: "Contribuir", + industryContributeEco: "Contribuir ECO", + industryContributeLabor: "Contribuir trabajo", + industryContributeButton: "Contribuir", + industryContributeMaterial: "Contribuir material", + industryContributions: "Contribuciones", + industryContributors: "colaboradores", + industryCreateBlueprintButton: "Crear blueprint", + industryDistribute: "Distribuir", + industryDistributeButton: "Distribuir por cuotas", + industryDistribution: "Distribución", + industryEcoAmount: "Cantidad (ECO)", + industryEcoContribHint: "Las contribuciones en ECO se transfieren al mayordomo como custodio de la tesorería del build.", + industryEditBlueprint: "Editar blueprint", + industryFacility: "Fábrica", + industryKindLabel: "Tipo", + industryKind_labor: "Mano de obra", + industryKind_material: "Material", + industryKind_eco: "Fondos", + industryLaborHours: "Mano de obra (horas)", + industryHours: "Horas", + industryLicense: "Licencia", + industryMaterialItem: "Material", + industryMaterials: "Materiales", + industryMaterialsHint: "Un material por línea, con el formato nombre:cantidad:precio.", + industryEstMaterials: "Total materiales", + industryEstLabor: "Total mano de obra", + industryEstTaxes: "Tasas", + industryEstTotal: "Precio estimado", + industryBuildingPrice: "Precio de construcción", + industryFinalPrice: "Precio final", + industryBlueprintDescPlaceholder: "Especificaciones, dimensiones, peso, documentación…", + industryMaterialValue: "Valor (ECO)", + industryMember: "Miembro", + industryNewBlueprint: "Nuevo blueprint", + industryNewBuild: "Proponer un build", + industryEditBuild: "Editar producción", + industryNoBlueprint: "— ninguno —", + industryNeedBlueprint: "Crea primero un plano: cada producción fabrica uno.", + industryNoBlueprints: "Aún no hay blueprints.", + industryNoBuilds: "Aún no hay builds.", + industryNote: "Nota", + industryOutput: "Salida", + industryOutputItem: "Nombre del producto", + industryOutputKind: "Tipo de producto", + industryOutputQty: "Unidades por producción", + industryOutputUnit: "Unidad de medida", + industryOutputValue: "Valor de salida (ECO, p. ej. de la venta)", + industryPayout: "Pago", + industryPoints: "Karma", + industryPostJob: "Crear Trabajo", + industryPot: "Bote", + industryProposeBuildButton: "Proponer build", + industryProposer: "Proponente", + industryAuthor: "Autor", + industrySellOnMarket: "Enviar al Mercado", + industrySendToProjects: "Crear Proyecto", + industryShare: "Cuota", + industryShares: "Cuotas", + industrySkills: "Habilidades", + industryTreasury: "Tesorería", + industryKind_physical: "Físico", + industryKind_digital: "Digital", + industryBuildStatus_PROPOSED: "PROPUESTO", + industryBuildStatus_REJECTED: "RECHAZADO", + industryBuildStatus_APPROVED: "APROBADO", + industryBuildStatus_STOCKING: "ABASTECIENDO", + industryBuildStatus_IN_PRODUCTION: "EN PRODUCCIÓN", + industryBuildStatus_COMPLETED: "COMPLETADO", + industryBuildStatus_FAILED: "FALLIDO", + pmBotIndustry: "IndustryBot", + industryBotAdmittedTitle: "Has sido admitido en una fábrica.", + industryBotApplicationTitle: "Nueva solicitud de ingreso en tu fábrica.", + industryBotInvitedTitle: "Has sido invitado a una fábrica.", + industryBotDissolvedTitle: "Una fábrica a la que perteneces ha sido disuelta.", + industryBotBuildApprovedTitle: "Un build ha sido aprobado.", + industryBotDistributedTitle: "Se ha distribuido la salida de un build.", + industryFilterRules: "REGLAS", + industryRulesTitle: "Reglas", + industryRulesIntro: "Industry permite a los habitantes poseer colectivamente los medios de producción: una Fábrica es un taller de la red que nadie posee de forma privada.", + industryRulesSteward: "El fundador es el mayordomo — un custodio, no un dueño: no puede vender, privatizar ni disolver la Fábrica en solitario.", + industryRulesMembership: "La política de membresía define cómo se une la gente: Abierta (entras al instante), Por voto (te admite el voto de los miembros) o Solo invitación (un miembro debe invitarte antes).", + industryRulesQuorum: "El quórum es el número mínimo de miembros que deben votar para que una decisión cuente, para que una Fábrica pequeña no la decida una sola persona.", + industryRulesMajority: "La mayoría es la fracción de votos necesaria para aprobar (0,5 = mitad, 1 = unanimidad); una decisión pasa cuando los SÍ alcanzan al menos max(quórum, miembros × mayoría).", + industryRulesDecisions: "Los miembros votan para admitir nuevos, aprobar builds y disolver la Fábrica; el mayordomo no puede saltarse estas decisiones colectivas.", + industryRulesBlueprints: "Los Blueprints son recetas libres (COPYLEFT) — las entradas (materiales, horas de trabajo, skills) y la salida (un bien físico o digital) — y cualquiera puede forkearlas.", + industryRulesBuilds: "Un Build es una orden de producción: PROPUESTO → APROBADO (por voto) → ABASTECIENDO → EN PRODUCCIÓN → COMPLETADO, y solo acepta contribuciones una vez aprobado.", + industryRulesContributions: "Los miembros aportan trabajo (horas), materiales (valor declarado en ECO) o ECO a un build; cada aporte gana karma.", + industryRulesLaborRate: "La tarifa de trabajo es el valor en ECO de una hora de trabajo en esta Fábrica; convierte horas en karma para comparar esfuerzo y capital de forma justa: karma = horas × tarifa + valor material + ECO.", + industryRulesShares: "Tu cuota en un build es tu karma ÷ karma total, y determina cuánto del valor de salida recibes.", + industryRulesDistribution: "Cuando una producción se completa, el steward reparte el fondo (los fondos de la producción más el valor de venta) entre quienes contribuyeron, en proporción a su participación.", + industryRulesTreasury: "Las aportaciones en ECO las custodia el mayordomo como tesorería del build hasta el reparto; todos los movimientos quedan registrados en la red y las disputas pueden ir a Courts.", + industryJobs: "Empleos", + industryNoJobs: "Aún no hay puestos.", + industryCreatePosition: "Crear puesto", + industryViewCV: "CV", + industryReportButton: "Reportar", + industryCreateTask: "Crear Tarea", + industryOpenDispute: "Abrir disputa", + industryMaterialsPlaceholder: "solar-panel:2:120\nbattery:1:80\nkit:1:15", + industryOutputUnitPlaceholder: "p. ej. unidad, kg, licencia", + industryOutputItemPlaceholder: "p. ej. robot", + industryOutputQtyPlaceholder: "p. ej. 5", + industrySkillsPlaceholder: "p. ej. soldadura, redes, instalación-solar", + industryCreateInvite: "Generar invitación", + industryPauseVotes: "Votos de estado", + industryTitle: "Industria", + industryDescription: "Módulo para gestionar los medios de producción de forma colectiva.", + industryLabel: "Industria", + typeIndustryBuild: "BUILD", + typeIndustryBlueprint: "BLUEPRINT", + typeIndustryAllocation: "DISTRIBUCIÓN", + typeIndustry: "INDUSTRIA", + modulesIndustryLabel: "Industria", + modulesIndustryDescription: "Módulo para gestionar los medios de producción de forma colectiva.", + industryFilterAll: "TODAS", + industryFilterMember: "MIEMBRO", + industryFilterBlueprints: "PLANOS", + industryFilterBuilds: "PRODUCCIONES", + industryFilterMine: "MÍAS", + industryFilterActive: "EN MARCHA", + industryFilterPaused: "PAUSADAS", + industryFilterDissolved: "DISUELTAS", + industryAllTitle: "Industria", + industryMemberTitle: "Fábricas en las que estoy", + industryMineTitle: "Mis fábricas", + industryActiveTitle: "En marcha", + industryPausedTitle: "Fábricas pausadas", + industryDissolvedTitle: "Fábricas disueltas", + industryNoFacilitiesFound: "No se encontraron fábricas.", + industryCreateFacility: "Crear fábrica", + industryMembers: "Miembros", + industryMemberBadge: "MIEMBRO", + industryName: "Nombre", + industryNamePlaceholder: "Nombre de la fábrica", + industryDescriptionLabel: "Descripción", + industryDescriptionPlaceholder: "¿Qué produce esta fábrica?", + industrySector: "Sector", + industryMembershipPolicy: "Política de membresía", + industryQuorum: "Quórum", + industryMajority: "Mayoría", + industryLaborRate: "Tarifa de trabajo", + industryCreateButton: "Crear fábrica", + industryUpdateButton: "Actualizar", + industrySteward: "Mayordomo", + industryStatusActive: "EN MARCHA", + industryStatusPaused: "PAUSADA", + industryStatusDissolved: "DISUELTA", + industryStatusLabel: "Estado", + industryJoinButton: "Unirse", + industryApplyButton: "Solicitar ingreso", + industryLeaveButton: "Salir", + industryInviteButton: "Invitar", + industryInvitePlaceholder: "@...=.ed25519", + industryInviteNeeded: "Necesitas una invitación para unirte.", + industryPauseButton: "Votar pausar", + industryResumeButton: "Votar reanudar", + industryEditButton: "Editar", + industryDeleteButton: "Eliminar", + industryBackToList: "← Industria", + industryPendingApplicants: "Solicitudes pendientes", + industryVotesYes: "sí", + industryVotesNo: "no", + industryAlreadyVoted: "votado", + industryAdmit: "Admitir", + industryReject: "Rechazar", + industryDissolveTitle: "Disolver fábrica", + industryDissolveHint: "La disolución requiere un voto colectivo; el mayordomo no puede disolverla en solitario.", + industryDissolveVote: "Votar disolución", + industrySector_software: "Software", + industrySector_hardware: "Hardware", + industrySector_agriculture: "Agricultura", + industrySector_textile: "Textil", + industrySector_energy: "Energía", + industrySector_food: "Alimentación", + industrySector_construction: "Construcción", + industrySector_media: "Medios", + industrySector_services: "Servicios", + industrySector_other: "Otro", + industryPolicy_open: "Abierta", + industryPolicy_vote: "Por voto", + industryPolicy_invite: "Solo invitación", projectsTitle: "Proyectos", projectsDescription: "Crea, financia y sigue proyectos impulsados por la comunidad en tu red.", projectCreateProject: "Crear Proyecto", @@ -3331,6 +3631,8 @@ module.exports = { chatOpenTitle: "Open Chats", chatClosedTitle: "Closed Chats", chatDescription: "Descripción", + chatMessageReply: "Respuesta", + chatMessageLabel: "Mensaje", chatCategory: "Categoría", chatStatus: "ESTADO", chatFilterAll: "TODOS", diff --git a/src/client/assets/translations/oasis_eu.js b/src/client/assets/translations/oasis_eu.js index f67b698..0c58b44 100644 --- a/src/client/assets/translations/oasis_eu.js +++ b/src/client/assets/translations/oasis_eu.js @@ -1,26 +1,6 @@ const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); module.exports = { eu: { - bbAdd: "Gehitu", - bbQuickActions: "Ekintza azkarrak", - bbPickTitle: "Ainguratu modulu bat", - bbPickDesc: "Aukeratu modulu bat beheko barrako + hutsunerako.", - bbPickNone: "Bat ere ez", - bbManage: "Editatu", - bbYourBar: "Zure barra", - bbRemove: "Kendu", - bbFull: "Gehienekora iritsi da", - bbDone: "Eginda", - activityChangeFilter: "Aldatu iragazkia", - emptyConnectHint: "Konektatu pub batera sarearekin sinkronizatzeko.", - emptyConnectCta: "Konektatu pub batera", - menuCommunity: "Komunitatea", - activityGroupCommunity: "Komunitatea eta gobernantza", - activityGroupOrg: "Antolakuntza", - activityGroupComm: "Komunikazioa", - activityGroupEconomy: "Ekonomia", - activityGroupMedia: "Edukia eta multimedia", - activityGroupOther: "Bestelakoak", languageName: "Basque", shopOrderStatusPaid: "Ordainketa jasota", shopOrderStatusReceived: "Jasota", @@ -421,7 +401,8 @@ module.exports = { publish: "Idatzi", contentWarningPlaceholder: "Gehitu gaia bidalketari (hautazkoa)", privateWarningPlaceholder: "Gehitu bizilagunak mezu pribatua bidaltzeko (hautazkoa)", - publishWarningPlaceholder: "...", + publishWarningPlaceholder: "Gorputza 7000 karakteretara mugatua.", + publishTooLong: "Zure argitalpena luzeegia da. Laburtu ezazu, mesedez.", publishCustomDescription: [ "GOGORATU: Blockchain teknologia dela eta, bidalketa argitaratu ezkero, ezin izango da aldatu ezta ezabatu.", ], @@ -486,7 +467,89 @@ module.exports = { passwordLengthInfo: "Pasahitzak 32 karaketereko luzera izan behar du.", passwordImport: "Idatzi pasahitza zure karpeta nagusian gordeko diren datuak deszifratzeko (izena: secret)", exportPasswordPlaceholder: "Erabili minuskulak, maiuskulak, zenbakiak eta sinboloak.", - fileInfo: "Zifratutako gako sekretua zure etxeko karpetan gordeko da. (izena: oasis.enc)", + fileInfo: "Zure gako sekretu zifratua zure gailura deskargatuko da (izena: oasis.enc)", + developer: "Garapena", + devTitle: "Garapena", + welcomeBannerText: "Ongi etorri OASISera. Jarraitu urrats azkar hauek zure avatarra konfiguratzeko.", + welcomeBannerAction: "Hasi!", + welcomeTitle: "Ongi etorri", + welcomeStepGreetingAction: "Bidali", + welcomeJoinPubButton: "Batu pubera", + welcomeStepLarpAction: "Batu \"Akademiara\"", + welcomeStepLarpText: "Batu gure zuzeneko rol jokora.", + welcomeStepLarpTitle: "Batu L.A.R.P.era", + welcomeStepGreetingTitle: "Bidali \"Kaixo mundua!\" bat", + welcomeStepGreetingText: "Bidali mezu bat beste biztanleek aurki zaitzaten.", + welcomeJoinPub: "Batu:", + welcomeDescription: "Hasi aurretik komeni diren gauza batzuk.", + welcomeProgress: "Osatuta", + welcomeStepDone: "eginda", + welcomeStepPending: "egiteke", + welcomeSkip: "Orain ez", + welcomeStepLanguageTitle: "Aukeratu zure hizkuntza", + welcomeStepLanguageText: "Oasisek hainbat hizkuntza hitz egiten ditu. Nahi duzunean alda dezakezu.", + welcomeStepProfileTitle: "Editatu zure profila", + welcomeStepProfileText: "Aukeratu izena, gehitu deskribapena eta jarri irudi bat beste biztanleek ezagut zaitzaten.", + welcomeStepProfileAction: "Gorde profila", + welcomeStepFederationTitle: "Batu sare nagusira", + welcomeStepFederationText: "Konektatu gure pubera beste biztanleak ezagutu eta erreplikatzen hasteko.", + welcomeStepFederationAction: "Joan gonbidapenetara", + welcomeStepBackupTitle: "Egin zure IDaren babeskopia", + welcomeStepBackupText: "Zure identitatea gailu honetako gako-fitxategi bat da.", + welcomeStepBackupWarning: "GALTZEN BADUZU, INORK EZIN DIZU BERRESKURATU.", + welcomeStepBackupAction: "Babeskopia!", + welcomeCompleteTitle: "Dena prest.", + welcomeCompleteText: "Zure nodoa prest dago. Hemendik aurrera sarea jarraitzen duzun jendearekin hazten da.", + welcomeFindPeople: "Bilatu biztanleak", + devFilterFiles: "FITXATEGIAK", + devFilterSearch: "BILATU", + devFilterModules: "MODULUAK", + devFilterTranslations: "ITZULPENAK", + devFilterStyles: "ESTILOAK", + devFilterTests: "TESTAK", + devVersion: "Bertsioa", + devNodeVersion: "Node", + devStatsTests: "Test multzoak", + devParentFolder: "Goiko karpeta", + devOpenFolder: "ireki", + devDescription: "Arakatu nodo honetan exekutatzen ari den kodea.", + devTreeTitle: "Fitxategiak", + devSearchTitle: "Bilatu kodean", + devMapTitle: "Moduluak", + devRoot: "erroa", + devRootButton: "ERROA", + devSearchPlaceholder: "Kodean bilatzeko testua", + devAnyExtension: "Edozein fitxategi", + devSearchButton: "Bilatu", + devStatsFiles: "Fitxategiak", + devStatsLines: "Lerroak", + devStatsModules: "Moduluak", + devStatsLanguages: "Hizkuntzak", + devNotViewable: "ez ikusgai", + devEmptyFolder: "Karpeta hau hutsik dago.", + devSize: "Tamaina", + devShowing: "Erakusten", + devLine: "lerroa", + devReportBug: "Akatsa Jakinarazi", + devCreateTask: "Ataza Sortu", + devRaw: "RAW", + devReportContext: "Oasis kodea irakurtzean aurkitua", + devTaskPrefix: "Berrikusi", + devPrevPage: "Aurreko lerroak", + devNextPage: "Hurrengo lerroak", + devMatches: "bat etortze", + devFilesScanned: "fitxategi aztertuta", + devNoResults: "Oraindik ez dago bat etortzerik.", + devColModule: "Modulua", + devColState: "Egoera", + devColModel: "Eredua", + devColView: "Ikuspegia", + devColRoutes: "Bideak", + devColTests: "Testak", + devOn: "aktibo", + devOff: "ez-aktibo", + modulesDevLabel: "Garapena", + modulesDevDescription: "Oasis kodea kudeatzeko modulua.", themeIntro: "Hautatu gaia.", setTheme: "Ezarri gaia", @@ -688,7 +751,7 @@ module.exports = { spreadLabel: "Hedatu", spreadHint: "Hedatu zure babesleei (zure feed bidez errepikatzen da).", welcomePmSubject: "Hello World!", - welcomePmBody: "Ongi etorri Oasis-era — sare sozial libre, parekideen arteko, federatu eta zifratua, gonbidapen bidezko arkitektura banatu batekin.\n\nHasteko leku interesgarri batzuk:\n\n- /profile — Editatu zure avatarra, izena, deskribapena eta zein modulu agertuko diren zure alde publikoan.\n- /invites — Gehitu pub bat sarearen gainerakoarekin federatzeko.\n- /peers — Ikusi nor dagoen konektatuta, eskaneatu LAN-a berriz, esportatu edo inportatu peer-zerrendak.\n- /inhabitants — Aurkitu eta bisitatu beste pertsonen avatarrak.\n- /tribes — Sortu edo elkartu muturretik muturrera zifratutako talde pribatuetan.\n- /inbox — Irakurri eta bidali mezu pribatuak.\n- /activity — Ikusi azken jarduera, zurea eta zure sarearena.\n- /publish — Idatzi post bat edo partekatu irudia, audioa, bideoa edo dokumentua.\n\nKonektatzeko leku interesgarri batzuk:\n\n- /fediverse — Kudeatu zure beste fediverso kontuak, edukia bidaltzea eta jasotzea barne.\n\nMezu hau zugandik zuregana modu pribatuan bidali da; horregatik ez zen konexiorik behar jasotzeko.", + welcomePmBody: "Ongi etorri Oasis-era — sare sozial libre, parekideen arteko, federatu eta zifratua, gonbidapen bidezko arkitektura banatu batekin.\n\nHasteko leku interesgarri batzuk:\n\n- /profile — Editatu zure avatarra, izena, deskribapena eta zein modulu agertuko diren zure alde publikoan.\n- /invites — Gehitu pub bat sarearen gainerakoarekin federatzeko.\n- /peers — Ikusi nor dagoen konektatuta, eskaneatu LAN-a berriz, esportatu edo inportatu peer-zerrendak.\n- /inhabitants — Aurkitu eta bisitatu beste pertsonen avatarrak.\n- /tribes — Sortu edo elkartu muturretik muturrera zifratutako talde pribatuetan.\n- /inbox — Irakurri eta bidali mezu pribatuak.\n- /activity — Ikusi azken jarduera, zurea eta zure sarearena.\n- /publish — Idatzi post bat edo partekatu irudia, audioa, bideoa edo dokumentua.\n- /search — Bilatu sarearen edukia motaren arabera.\n\nKonektatzeko leku interesgarri batzuk:\n\n- /fediverse — Kudeatu zure beste fediverso kontuak, edukia bidaltzea eta jasotzea barne.\n\nMezu hau zugandik zuregana modu pribatuan bidali da; horregatik ez zen konexiorik behar jasotzeko.", profileVisibilityTitle: "Profil publikoaren ikusgaitasuna", profileVisibilityHint: "Aukeratu zein eremu ikus ditzaketen beste biztanleek zure profilean. Lehenetsia: Karma soilik.", profileSensorsSectionTitle: "Sentsoreak", @@ -1262,6 +1325,9 @@ module.exports = { eventButton: "EKITALDIAK", taskButton: "ATAZAK", votesButton: "GOBERNUA", + industryButton: "INDUSTRIA", + projectButton: "PROIEKTUAK", + shopProductButton: "PRODUKTUAK", reportButton: "TXOSTENAK", feedButton: "JARIOA", marketButton: "MERKATUA", @@ -1290,7 +1356,7 @@ module.exports = { trendingItemStatus: "Elementuaren Egoera", trendingTotalVotes: "Bozkak Guztira", trendingTotalOpinions: "Iritzial Guztira", - trendingNoContentMessage: "Edukirik ez.", + trendingNoContentMessage: "Oraindik ez dago joerarik.", trendingAuthor: "Nork", trendingCreatedAtLabel: "Noiz", trendingTotalCount: "Kopurua Guztira", @@ -1479,7 +1545,7 @@ module.exports = { transfersCreatedAt: "Noiz", transfersConfirmations: "BERRESPENAK", transfersContainingBlock: "Bloke edukitzailea", - transfersExportContract: "Sortu kontratua", + transfersExportContract: "Kontratu adimenduna", transfersConfirmButton: "Berretsi Transferentzia", transfersNoItems: "Transferentziarik ez.", transfersMineSectionTitle: "Zeure Transferentziak", @@ -1605,7 +1671,7 @@ module.exports = { cvDeleteButton: "Ezabatu", cvNoCV: "CV-rik ez.", blogSubject: "Gaia", - blogMessage: "Edukia 8096 karakteretara mugatuta.", + blogMessage: "Mezua", blogImage: "Multimedia igo (max: 50MB)", blogPublish: "Aurrebista", noPopularMessages: "Pil-pileko mezurike ez, oraindik", @@ -2195,6 +2261,7 @@ module.exports = { agendaFilterReports: "TXOSTENAK", agendaFilterTransfers: "TRANSFERENTZIAK", agendaFilterJobs: "LANPOSTUAK", + agendaFilterIndustry: "INDUSTRIA", agendaFilterProjects: "PROIEKTUAK", agendaFilterCalendars: "EGUTEGIAK", agendaInviteModeLabel: "Egoera", @@ -2316,16 +2383,31 @@ module.exports = { privateMessage: "MP", pmSendTitle: "Mezu Pribatuak", pmSend: "Bidali!", - pmDescription: "Erabili formulario hau beste biztanleei mezu zifratua bidaltzeko.", + pmCancel: "Utzi", + pmReset: "Berrezarri", + pmSharedKeyHint: "Partekatu gako hau hartzailearekin beste bide batetik. Deszifratzeko beharrezkoa da.", + pmDescription: "Erabili formulario hau beste biztanleei mezu/fitxategi zifratu bat bidaltzeko.", + pmComposeTitle: "Bidali mezu bat", pmRecipients: "Hartzaileak", pmRecipientsHint: "Idatzi Oasis IDak, komaz bereizita", pmLevelOfExposition: "Level of exposition", - pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmLimitsHint: "Mezu bakoitzeko 7 hartzaile gehienez.", + pmTextPlaceholder: "Gorputza 7000 karakteretara mugatua.", pmSubject: "Gaia", pmSubjectHint: "Idatzi mezuaren gaia", pmText: "Mezua", pmCrypterChip: "2xE2E", pmCrypterDecryptButton: "Deszifratu", + fileShareTitle: "Partekatu fitxategi bat", + fileShareRecipientPlaceholder: "@...=.ed25519", + fileShareFileLabel: "Fitxategia", + fileShareDownload: "Deskargatu", + fileShareMutualError: "Elkarrekiko laguntza duten biztanleekin soilik parteka ditzakezu fitxategiak.", + fileShareNoFile: "Ez da fitxategirik hautatu.", + fileShareTooLarge: "Fitxategiak baimendutako tamaina gainditzen du.", + fileShareFailed: "Ezin izan da fitxategia prestatu.", + fileShareSendError: "Ezin izan da fitxategia bidali.", + fileShareUnavailable: "Fitxategi hau ez dago erabilgarri une honetan. Saiatu geroago.", pmCrypterBadKey: "Partekatutako gakoa ez da zuzena!", pmCrypterTooLong: "Mezua luzeegia da Crypter-arekin zifratzeko. Laburtu eta saiatu berriro.", pmCrypterCipherLabel: "Berriz zifratutako mezua", @@ -2354,15 +2436,27 @@ module.exports = { pmNoSubject: "(gaia gabe)", pmSubjectLabel: "Gaia:", pmBodyLabel: "Gorputza", - pmBotJobs: "42-JobsBOT", - pmBotProjects: "42-ProjectsBOT", - pmBotMarket: "42-MarketBOT", + pmBotJobs: "JobsBot", + pmBotProjects: "ProjectsBot", + pmBotMarket: "MarketBot", + pmBotShops: "ShopsBot", + pmBotPolitical: "PoliticalBot", + politicalBotLarpTitle: "L.A.R.P-eko etxe gobernatzailea aldatu da.", + politicalBotParliamentTitle: "Gobernu-ziklo berri bat hasi da Parlamentuan.", + politicalBotTribeTitle: "Gobernu-ziklo berri bat hasi da zure Tribuetako batean.", + politicalBotLarpIntro: "Orain {house} ari da gobernatzen ziklo honetan: {cycle}", + politicalBotParliamentIntro: "Egungo gobernua {gov} da", + politicalBotParliamentIntroLeader: "Egungo gobernua {gov} da, {leader}-(e)k gauzatua", + politicalBotTribeIntro: "{tribe} Tribuko egungo gobernua {gov} da", + politicalBotTribeIntroLeader: "{tribe} Tribuko egungo gobernua {gov} da, {leader}-(e)k gauzatua", + politicalBotLeaderLabel: "Liderra", inboxJobSubscribedTitle: "Lan-eskaintzara harpidetza berria", pmInhabitantWithId: "OASIS ID duen biztanlea:", pmHasSubscribedToYourJobOffer: "zure lan-eskaintzara harpidetu da", inboxProjectCreatedTitle: "Proiektu berria sortu da", pmHasCreatedAProject: "proiektu bat sortu du", inboxMarketItemSoldTitle: "Artikulua salduta", + inboxShopSoldTitle: "Produktua salduta", pmYourItem: "Zure artikulua", pmHasBeenSoldTo: "honi saldu zaio", pmFor: "truke", @@ -2395,7 +2489,7 @@ module.exports = { blockchainContentDeleted: "Edukia ezabatu egin da", banking: 'Banking', bankingTitle: 'Banking', - bankingDescription: 'Aztertu ECOin-en egungo balioa eta dagokion RBU esleipena, astero banatzen dena parte-hartzearen eta konfiantzaren arabera.', + bankingDescription: "ECOin ekonomia: saldoa, oinarrizko errenta, zergak eta industria balioa.", bankOverview: 'Laburpena', bankEpochs: 'Epeak', bankRules: 'Arauak', @@ -2429,6 +2523,10 @@ module.exports = { bankEpochId: 'Epearen IDa', bankRuleHash: 'Arauen snapshot hash-a', bankViewEpoch: 'Epea ikusi', + bankYourIndustryBalance: "Zure industria zatia", + bankYourUbiMonth: "Zure OBU (hilabete honetan)", + bankYourFundsMonth: "Zure funtsak (hilabete honetan)", + bankIndustryBalance: "Industria ekoizpena (estimatua)", bankUserBalance: 'Zure saldoa', ecoWalletNotConfigured: 'ECOin poltsa ez dago konfiguratuta', editWallet: 'Poltsa editatu', @@ -2469,7 +2567,7 @@ module.exports = { pubIdPlaceholder: "@PUB_ID.ed25519", bankUbiAvailableNo: "FUNTSIK EZ!", bankUbiAvailableOk: "ESKURAGARRI!", - bankUbiAvailability: "UBI eskuragarritasuna", + bankUbiAvailability: "OBU (sarea)", bankAlreadyClaimedThisMonth: "Hilabete honetan jada aldarrikatu da", bankUbiThisMonth: "UBI (hilabete honetan)", bankUbiLastClaimed: "UBI (azken aldarrikapena)", @@ -2898,6 +2996,208 @@ module.exports = { jobsTagsLabel: "Etiketak", jobsTagsPlaceholder: "etiketa1, etiketa2, etiketa3", noJobsMatch: "Ez dago bilaketarekin bat datorren lan-eskaintzarik.", + industrySearchPlaceholder: "Bilatu fabrikak…", + industryAllSectors: "Sektore guztiak", + industryVisitButton: "Bisitatu industria", + industryAdvanceTo: "Ezarri", + industryAmount: "Kopurua", + industryApproveBuild: "Onartu", + industryBackToFacility: "← Fabrika", + industryBlueprint: "Blueprint", + industryBlueprintLabel: "Industria Blueprint", + industryBlueprints: "Blueprint-ak", + industryBuild: "Build", + industryBuildNotes: "Oharrak", + industryBuildStart: "Hasiera data", + industryVoteUpdateButton: "Bozkatu eguneraketa", + industryVoteDeleteButton: "Bozkatu ezabaketa", + industryRevokeVote: "Errebokatu", + industryTimeLeft: "Geratzen den denbora", + industryBuildEnd: "Amaiera data", + industryBuilds: "Build-ak", + industryBuildTitle: "Izenburua", + industryContribute: "Lagundu", + industryContributeEco: "ECO lagundu", + industryContributeLabor: "Lana lagundu", + industryContributeButton: "Ekarpena egin", + industryContributeMaterial: "Materiala lagundu", + industryContributions: "Ekarpenak", + industryContributors: "laguntzaileak", + industryCreateBlueprintButton: "Sortu blueprint", + industryDistribute: "Banatu", + industryDistributeButton: "Banatu kuoten arabera", + industryDistribution: "Banaketa", + industryEcoAmount: "Kopurua (ECO)", + industryEcoContribHint: "ECO ekarpenak arduradunari transferitzen zaizkio build-aren diruzaintzaren zaindari gisa.", + industryEditBlueprint: "Editatu blueprint", + industryFacility: "Fabrika", + industryKindLabel: "Mota", + industryKind_labor: "Lana", + industryKind_material: "Materiala", + industryKind_eco: "Funtsak", + industryLaborHours: "Lana (orduak)", + industryHours: "Orduak", + industryLicense: "Lizentzia", + industryMaterialItem: "Materiala", + industryMaterials: "Materialak", + industryMaterialsHint: "Material bat lerroko, izena:kopurua:prezioa formatuan.", + industryEstMaterials: "Materialak guztira", + industryEstLabor: "Lana guztira", + industryEstTaxes: "Zergak", + industryEstTotal: "Prezio estimatua", + industryBuildingPrice: "Eraikuntza prezioa", + industryFinalPrice: "Azken prezioa", + industryBlueprintDescPlaceholder: "Zehaztapenak, neurriak, pisua, dokumentazioa…", + industryMaterialValue: "Balioa (ECO)", + industryMember: "Kidea", + industryNewBlueprint: "Blueprint berria", + industryNewBuild: "Proposatu build bat", + industryEditBuild: "Editatu ekoizpena", + industryNoBlueprint: "— bat ere ez —", + industryNeedBlueprint: "Sortu lehenik planoa: ekoizpen bakoitzak bat egiten du.", + industryNoBlueprints: "Oraindik ez dago blueprint-ik.", + industryNoBuilds: "Oraindik ez dago build-ik.", + industryNote: "Oharra", + industryOutput: "Irteera", + industryOutputItem: "Produktuaren izena", + industryOutputKind: "Produktu mota", + industryOutputQty: "Unitateak ekoizpen bakoitzeko", + industryOutputUnit: "Neurri-unitatea", + industryOutputValue: "Irteera balioa (ECO, adib. salmentatik)", + industryPayout: "Ordainketa", + industryPoints: "Karma", + industryPostJob: "Sortu lana", + industryPot: "Poltsa", + industryProposeBuildButton: "Proposatu build", + industryProposer: "Proposatzailea", + industryAuthor: "Egilea", + industrySellOnMarket: "Bidali merkatura", + industrySendToProjects: "Sortu proiektua", + industryShare: "Kuota", + industryShares: "Kuotak", + industrySkills: "Trebetasunak", + industryTreasury: "Diruzaintza", + industryKind_physical: "Fisikoa", + industryKind_digital: "Digitala", + industryBuildStatus_PROPOSED: "PROPOSATUA", + industryBuildStatus_REJECTED: "BAZTERTUA", + industryBuildStatus_APPROVED: "ONARTUA", + industryBuildStatus_STOCKING: "HORNITZEN", + industryBuildStatus_IN_PRODUCTION: "EKOIZPENEAN", + industryBuildStatus_COMPLETED: "OSATUA", + industryBuildStatus_FAILED: "HUTS EGINDA", + pmBotIndustry: "IndustryBot", + industryBotAdmittedTitle: "Fabrika batean onartu zaituzte.", + industryBotApplicationTitle: "Kidetza eskaera berria zure fabrikan.", + industryBotInvitedTitle: "Fabrika batera gonbidatu zaituzte.", + industryBotDissolvedTitle: "Zure fabrika bat desegin da.", + industryBotBuildApprovedTitle: "Build bat onartu da.", + industryBotDistributedTitle: "Build baten irteera banatu da.", + industryFilterRules: "ARAUAK", + industryRulesTitle: "Arauak", + industryRulesIntro: "Industry-k biztanleei ekoizpen bideak modu kolektiboan izatea ahalbidetzen die: Fabrika bat sareko lantegia da, inork pribatuki ez duena.", + industryRulesSteward: "Sortzailea arduraduna da — zaindaria, ez jabea: ezin du Fabrika bakarrik saldu, pribatizatu edo desegin.", + industryRulesMembership: "Kidetza politikak nola sartu erabakitzen du: Irekia (berehala), Botoz (kideen botoak onartuta) edo Gonbidapenez soilik (kide batek lehenik gonbidatu behar zaitu).", + industryRulesQuorum: "Quoruma erabaki batek balio izateko bozkatu behar duten gutxieneko kide kopurua da, Fabrika txiki bat pertsona bakar batek erabaki ez dezan.", + industryRulesMajority: "Gehiengoa onartzeko behar den boto frakzioa da (0,5 = erdia, 1 = aho batez); erabaki bat onartzen da BAI botoek gutxienez max(quoruma, kideak × gehiengoa) lortzen dutenean.", + industryRulesDecisions: "Kideek bozkatzen dute berriak onartzeko, build-ak onartzeko eta Fabrika desegiteko; arduradunak ezin ditu erabaki kolektibo hauek baztertu.", + industryRulesBlueprints: "Blueprint-ak errezeta libreak dira (COPYLEFT) — sarrerak (materialak, lan orduak, trebetasunak) eta irteera (ondasun fisiko edo digital bat) — eta edonork forka ditzake.", + industryRulesBuilds: "Build bat ekoizpen agindua da: PROPOSATUA → ONARTUA (botoz) → HORNITZEN → EKOIZPENEAN → OSATUA, eta onartu ondoren soilik onartzen ditu ekarpenak.", + industryRulesContributions: "Kideek lana (orduak), materialak (adierazitako ECO balioa) edo ECO ematen diote build bati; ekarpen bakoitzak karma irabazten du.", + industryRulesLaborRate: "Lan tarifa Fabrika honetan lan ordu baten ECO balioa da; orduak karma bihurtzen ditu ahalegina eta kapitala modu justuan konparatzeko: karma = orduak × tarifa + material balioa + ECO.", + industryRulesShares: "Build batean zure kuota zure karma ÷ karma guztia da, eta irteera balioaren zenbat jasotzen duzun zehazten du.", + industryRulesDistribution: "Ekoizpen bat amaitzean, stewardak pota (ekoizpenaren funtsak gehi salmenta balioa) banatzen du ekarpena egin dutenen artean, beren partaidetzaren arabera.", + industryRulesTreasury: "ECO ekarpenak arduradunak gordetzen ditu build-aren diruzaintza gisa banaketa arte; mugimendu guztiak sarean erregistratzen dira eta gatazkak Courts-era joan daitezke.", + industryJobs: "Lanpostuak", + industryNoJobs: "Oraindik ez dago lanposturik.", + industryCreatePosition: "Sortu lanpostua", + industryViewCV: "CV", + industryReportButton: "Salatu", + industryCreateTask: "Sortu zeregina", + industryOpenDispute: "Ireki auzia", + industryMaterialsPlaceholder: "solar-panel:2:120\nbattery:1:80\nkit:1:15", + industryOutputUnitPlaceholder: "adib. unitatea, kg, lizentzia", + industryOutputItemPlaceholder: "adib. robota", + industryOutputQtyPlaceholder: "adib. 5", + industrySkillsPlaceholder: "adib. soldadura, sareak, eguzki-instalazioa", + industryCreateInvite: "Sortu gonbidapena", + industryPauseVotes: "Egoera botoak", + industryTitle: "Industria", + industryDescription: "Ekoizpen bideak modu kolektiboan kudeatzeko modulua.", + industryLabel: "Industria", + typeIndustryBuild: "BUILD", + typeIndustryBlueprint: "BLUEPRINT", + typeIndustryAllocation: "BANAKETA", + typeIndustry: "INDUSTRIA", + modulesIndustryLabel: "Industria", + modulesIndustryDescription: "Ekoizpen bideak modu kolektiboan kudeatzeko modulua.", + industryFilterAll: "GUZTIAK", + industryFilterMember: "KIDEA", + industryFilterBlueprints: "PLANOAK", + industryFilterBuilds: "EKOIZPENAK", + industryFilterMine: "NIREAK", + industryFilterActive: "MARTXAN", + industryFilterPaused: "ETENDA", + industryFilterDissolved: "DESEGINDA", + industryAllTitle: "Industria", + industryMemberTitle: "Kide naizen fabrikak", + industryMineTitle: "Nire fabrikak", + industryActiveTitle: "Martxan", + industryPausedTitle: "Etendako fabrikak", + industryDissolvedTitle: "Desegindako fabrikak", + industryNoFacilitiesFound: "Ez da fabrikarik aurkitu.", + industryCreateFacility: "Sortu fabrika", + industryMembers: "Kideak", + industryMemberBadge: "KIDEA", + industryName: "Izena", + industryNamePlaceholder: "Fabrikaren izena", + industryDescriptionLabel: "Deskribapena", + industryDescriptionPlaceholder: "Zer ekoizten du fabrika honek?", + industrySector: "Sektorea", + industryMembershipPolicy: "Kidetza politika", + industryQuorum: "Quoruma", + industryMajority: "Gehiengoa", + industryLaborRate: "Lan tarifa", + industryCreateButton: "Sortu fabrika", + industryUpdateButton: "Eguneratu", + industrySteward: "Arduraduna", + industryStatusActive: "MARTXAN", + industryStatusPaused: "ETENDA", + industryStatusDissolved: "DESEGINDA", + industryStatusLabel: "Egoera", + industryJoinButton: "Elkartu", + industryApplyButton: "Eskatu sartzea", + industryLeaveButton: "Irten", + industryInviteButton: "Gonbidatu", + industryInvitePlaceholder: "@...=.ed25519", + industryInviteNeeded: "Gonbidapena behar duzu elkartzeko.", + industryPauseButton: "Bozkatu gelditzea", + industryResumeButton: "Bozkatu berrekitea", + industryEditButton: "Editatu", + industryDeleteButton: "Ezabatu", + industryBackToList: "← Industria", + industryPendingApplicants: "Zain dauden eskaerak", + industryVotesYes: "bai", + industryVotesNo: "ez", + industryAlreadyVoted: "bozkatuta", + industryAdmit: "Onartu", + industryReject: "Ukatu", + industryDissolveTitle: "Desegin fabrika", + industryDissolveHint: "Desegiteak boto kolektiboa behar du; arduradunak ezin du bakarrik desegin.", + industryDissolveVote: "Bozkatu desegitea", + industrySector_software: "Softwarea", + industrySector_hardware: "Hardwarea", + industrySector_agriculture: "Nekazaritza", + industrySector_textile: "Ehungintza", + industrySector_energy: "Energia", + industrySector_food: "Elikadura", + industrySector_construction: "Eraikuntza", + industrySector_media: "Komunikabideak", + industrySector_services: "Zerbitzuak", + industrySector_other: "Bestelakoa", + industryPolicy_open: "Irekia", + industryPolicy_vote: "Botoz", + industryPolicy_invite: "Gonbidapenez soilik", projectsTitle: "Proiektuak", projectsDescription: "Sortu, finantzatu eta jarraitu komunitateak gidatutako proiektuak zure sarean.", projectCreateProject: "Proiektu Bat Sortu", @@ -3256,6 +3556,8 @@ module.exports = { chatOpenTitle: "Open Chats", chatClosedTitle: "Closed Chats", chatDescription: "Deskribapena", + chatMessageReply: "Erantzuna", + chatMessageLabel: "Mezua", chatCategory: "Kategoria", chatStatus: "EGOERA", chatFilterAll: "GUZTIAK", diff --git a/src/client/assets/translations/oasis_fr.js b/src/client/assets/translations/oasis_fr.js index af3da9d..dab9c05 100644 --- a/src/client/assets/translations/oasis_fr.js +++ b/src/client/assets/translations/oasis_fr.js @@ -1,26 +1,6 @@ const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); module.exports = { fr: { - bbAdd: "Ajouter", - bbQuickActions: "Actions rapides", - bbPickTitle: "Épingler un module", - bbPickDesc: "Choisis un module pour l'emplacement + de la barre inférieure.", - bbPickNone: "Aucun", - bbManage: "Modifier", - bbYourBar: "Ta barre", - bbRemove: "Retirer", - bbFull: "Maximum atteint", - bbDone: "Terminé", - activityChangeFilter: "Changer de filtre", - emptyConnectHint: "Connecte-toi à un pub pour synchroniser avec le réseau.", - emptyConnectCta: "Se connecter à un pub", - menuCommunity: "Communauté", - activityGroupCommunity: "Communauté et gouvernance", - activityGroupOrg: "Organisation", - activityGroupComm: "Communication", - activityGroupEconomy: "Économie", - activityGroupMedia: "Contenu et médias", - activityGroupOther: "Autres", languageName: "Français", shopOrderStatusPaid: "Paiement reçu", shopOrderStatusReceived: "Reçu", @@ -419,7 +399,8 @@ module.exports = { publish: "Publier", contentWarningPlaceholder: "Ajoutez un titre à votre post (optionnel)", privateWarningPlaceholder: "Ajoutez des habitants pour envoyer un post privé (optionnel)", - publishWarningPlaceholder: "...", + publishWarningPlaceholder: "Corps limité à 7000 caractères.", + publishTooLong: "Votre publication est trop longue. Veuillez la raccourcir.", publishCustomDescription: [ "RAPPELEZ-VOUS : En raison de la technologie blockchain, une fois que vous avez publié quelque chose, cela ne peut pas être modifié ni supprimé. Réfléchissez bien !", ], @@ -484,7 +465,89 @@ module.exports = { passwordLengthInfo: "Le mot de passe doit contenir au moins 32 caractères.", passwordImport: "Saisissez votre mot de passe pour déchiffrer les données qui seront enregistrées sur votre système (nom : secret)", exportPasswordPlaceholder: "Utilisez des minuscules, majuscules, chiffres et symboles", - fileInfo: "Votre secret chiffré sera enregistré sur votre système (nom : oasis.enc)", + fileInfo: "Votre secret chiffré sera téléchargé sur votre appareil (nom : oasis.enc)", + developer: "Développement", + devTitle: "Développement", + welcomeBannerText: "Bienvenue sur OASIS. Suivez ces quelques étapes pour configurer votre avatar.", + welcomeBannerAction: "Commencer !", + welcomeTitle: "Bienvenue", + welcomeStepGreetingAction: "Envoyer", + welcomeJoinPubButton: "Rejoindre le pub", + welcomeStepLarpAction: "Rejoindre « L Académie »", + welcomeStepLarpText: "Rejoignez notre jeu de rôle grandeur nature.", + welcomeStepLarpTitle: "Rejoindre le L.A.R.P.", + welcomeStepGreetingTitle: "Envoyez un « Hello world ! »", + welcomeStepGreetingText: "Envoyez un message pour que les autres habitants puissent vous découvrir.", + welcomeJoinPub: "Rejoindre", + welcomeDescription: "Quelques choses à faire avant de commencer.", + welcomeProgress: "Terminé", + welcomeStepDone: "fait", + welcomeStepPending: "en attente", + welcomeSkip: "Pas maintenant", + welcomeStepLanguageTitle: "Choisissez votre langue", + welcomeStepLanguageText: "Oasis parle plusieurs langues. Vous pouvez en changer quand vous voulez.", + welcomeStepProfileTitle: "Modifiez votre profil", + welcomeStepProfileText: "Choisissez un nom, ajoutez une description et mettez une image pour que les autres habitants vous reconnaissent.", + welcomeStepProfileAction: "Enregistrer le profil", + welcomeStepFederationTitle: "Rejoignez le réseau principal", + welcomeStepFederationText: "Connectez-vous à notre pub pour rencontrer d autres habitants et commencer à répliquer.", + welcomeStepFederationAction: "Voir les invitations", + welcomeStepBackupTitle: "Sauvegardez votre ID", + welcomeStepBackupText: "Votre identité est un fichier de clés sur cet appareil.", + welcomeStepBackupWarning: "SI VOUS LE PERDEZ, PERSONNE NE POURRA LE RÉCUPÉRER.", + welcomeStepBackupAction: "Sauvegarder !", + welcomeCompleteTitle: "Tout est prêt.", + welcomeCompleteText: "Votre nœud est prêt. À partir d'ici le réseau grandit avec les gens que vous suivez.", + welcomeFindPeople: "Trouver des habitants", + devFilterFiles: "FICHIERS", + devFilterSearch: "CHERCHER", + devFilterModules: "MODULES", + devFilterTranslations: "TRADUCTIONS", + devFilterStyles: "STYLES", + devFilterTests: "TESTS", + devVersion: "Version", + devNodeVersion: "Node", + devStatsTests: "Suites de tests", + devParentFolder: "Dossier parent", + devOpenFolder: "ouvrir", + devDescription: "Explorez le code source exécuté sur ce nœud.", + devTreeTitle: "Fichiers", + devSearchTitle: "Chercher dans le code", + devMapTitle: "Modules", + devRoot: "racine", + devRootButton: "RACINE", + devSearchPlaceholder: "Texte à chercher dans le code", + devAnyExtension: "Tout fichier", + devSearchButton: "Chercher", + devStatsFiles: "Fichiers", + devStatsLines: "Lignes", + devStatsModules: "Modules", + devStatsLanguages: "Langues", + devNotViewable: "non affichable", + devEmptyFolder: "Ce dossier est vide.", + devSize: "Taille", + devShowing: "Affichage", + devLine: "ligne", + devReportBug: "Signaler un Bug", + devCreateTask: "Créer une Tâche", + devRaw: "RAW", + devReportContext: "Trouvé en lisant le code source d'Oasis", + devTaskPrefix: "Réviser", + devPrevPage: "Lignes précédentes", + devNextPage: "Lignes suivantes", + devMatches: "correspondances", + devFilesScanned: "fichiers analysés", + devNoResults: "Aucune correspondance, pour l'instant.", + devColModule: "Module", + devColState: "État", + devColModel: "Modèle", + devColView: "Vue", + devColRoutes: "Routes", + devColTests: "Tests", + devOn: "actif", + devOff: "inactif", + modulesDevLabel: "Développement", + modulesDevDescription: "Module pour gérer le code source d'Oasis.", themeIntro: "Choisissez un thème.", setTheme: "Définir le thème", @@ -682,7 +745,7 @@ module.exports = { spreadLabel: "Diffuser", spreadHint: "Diffusez ceci à ceux qui vous soutiennent (réplique via votre feed).", welcomePmSubject: "Hello World!", - welcomePmBody: "Bienvenue sur Oasis — un réseau social libre, pair-à-pair, fédéré et chiffré, avec une architecture distribuée sur invitation uniquement.\n\nQuelques endroits intéressants par où commencer :\n\n- /profile — Édite ton avatar, ton nom, ta description et les modules visibles sur ta partie publique.\n- /invites — Ajoute un pub pour fédérer avec le reste du réseau.\n- /peers — Vois qui est connecté, rescanne ton LAN, exporte ou importe des listes de pairs.\n- /inhabitants — Découvre et visite les avatars d'autres personnes.\n- /tribes — Crée ou rejoins des groupes privés chiffrés de bout en bout.\n- /inbox — Lis et envoie des messages privés.\n- /activity — Vois l'activité récente, la tienne et celle de ton réseau.\n- /publish — Écris un post ou partage une image, un audio, une vidéo ou un document.\n\nQuelques endroits intéressants pour se connecter :\n\n- /fediverse — Gérez vos autres comptes du fédiverse, y compris l'envoi et la réception de contenu.\n\nCe message a été envoyé en privé de toi à toi-même : aucune connexion n'était nécessaire pour le recevoir.", + welcomePmBody: "Bienvenue sur Oasis — un réseau social libre, pair-à-pair, fédéré et chiffré, avec une architecture distribuée sur invitation uniquement.\n\nQuelques endroits intéressants par où commencer :\n\n- /profile — Édite ton avatar, ton nom, ta description et les modules visibles sur ta partie publique.\n- /invites — Ajoute un pub pour fédérer avec le reste du réseau.\n- /peers — Vois qui est connecté, rescanne ton LAN, exporte ou importe des listes de pairs.\n- /inhabitants — Découvre et visite les avatars d'autres personnes.\n- /tribes — Crée ou rejoins des groupes privés chiffrés de bout en bout.\n- /inbox — Lis et envoie des messages privés.\n- /activity — Vois l'activité récente, la tienne et celle de ton réseau.\n- /publish — Écris un post ou partage une image, un audio, une vidéo ou un document.\n- /search — Recherchez le contenu du réseau par type.\n\nQuelques endroits intéressants pour se connecter :\n\n- /fediverse — Gérez vos autres comptes du fédiverse, y compris l'envoi et la réception de contenu.\n\nCe message a été envoyé en privé de toi à toi-même : aucune connexion n'était nécessaire pour le recevoir.", profileVisibilityTitle: "Visibilité du profil public", profileVisibilityHint: "Choisissez les champs visibles par les autres habitants sur votre profil. Par défaut, seul Karma est affiché.", profileSensorsSectionTitle: "Capteurs", @@ -1213,6 +1276,9 @@ module.exports = { eventButton: "ÉVÉNEMENTS", taskButton: "TÂCHES", votesButton: "GOUVERNANCE", + industryButton: "INDUSTRIE", + projectButton: "PROJETS", + shopProductButton: "PRODUITS", reportButton: "RAPPORTS", feedButton: "FLUX", marketButton: "MARCHÉ", @@ -1241,7 +1307,7 @@ module.exports = { trendingItemStatus: "Statut de l’article", trendingTotalVotes: "Total des votes", trendingTotalOpinions: "Total des avis", - trendingNoContentMessage: "Aucune tendance disponible pour le moment.", + trendingNoContentMessage: "Pas encore de tendances.", trendingAuthor: "Par", trendingCreatedAtLabel: "Créé le", trendingTotalCount: "Total des compteurs", @@ -1430,7 +1496,7 @@ module.exports = { transfersCreatedAt: "Créé le", transfersConfirmations: "CONFIRMATIONS", transfersContainingBlock: "Bloc contenant", - transfersExportContract: "Créer le contrat", + transfersExportContract: "Contrat intelligent", transfersConfirmButton: "Confirmer le transfert", transfersNoItems: "Aucun transfert trouvé.", transfersMineSectionTitle: "Vos transferts", @@ -1556,7 +1622,7 @@ module.exports = { cvDeleteButton: "Supprimer", cvNoCV: "Aucun CV trouvé.", blogSubject: "Sujet", - blogMessage: "Corps limité à 8096 caractères.", + blogMessage: "Message", blogImage: "Télécharger un média (max: 50 Mo)", blogPublish: "Aperçu", noPopularMessages: "Aucun message populaire publié pour l’instant", @@ -2186,6 +2252,7 @@ module.exports = { agendaFilterReports: "RAPPORTS", agendaFilterTransfers: "TRANSFERTS", agendaFilterJobs: "EMPLOIS", + agendaFilterIndustry: "INDUSTRIE", agendaFilterProjects: "PROJETS", agendaFilterCalendars: "CALENDRIERS", agendaNoItems: "Aucune affectation trouvée.", @@ -2307,16 +2374,31 @@ module.exports = { privateMessage: "MP", pmSendTitle: "Messages privés", pmSend: "Envoyer !", - pmDescription: "Utilisez ce formulaire pour envoyer un message chiffré à d’autres habitants.", + pmCancel: "Annuler", + pmReset: "Réinitialiser", + pmSharedKeyHint: "Partagez cette clé avec le destinataire par un autre canal. Elle est nécessaire pour déchiffrer.", + pmDescription: "Utilisez ce formulaire pour envoyer un message/fichier chiffré à d'autres habitants.", + pmComposeTitle: "Envoyer un message", pmRecipients: "Destinataires", pmRecipientsHint: "Saisissez les IDs Oasis séparés par des virgules", pmLevelOfExposition: "Level of exposition", - pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmLimitsHint: "Jusqu'à 7 destinataires par message.", + pmTextPlaceholder: "Corps limité à 7000 caractères.", pmSubject: "Sujet", pmSubjectHint: "Saisissez l’objet du message", pmText: "Message", pmCrypterChip: "2xE2E", pmCrypterDecryptButton: "Déchiffrer", + fileShareTitle: "Partager un fichier", + fileShareRecipientPlaceholder: "@...=.ed25519", + fileShareFileLabel: "Fichier", + fileShareDownload: "Télécharger", + fileShareMutualError: "Vous ne pouvez partager des fichiers qu'avec des habitants en soutien mutuel.", + fileShareNoFile: "Aucun fichier sélectionné.", + fileShareTooLarge: "Le fichier dépasse la taille autorisée.", + fileShareFailed: "Impossible de préparer le fichier.", + fileShareSendError: "Impossible d'envoyer le fichier.", + fileShareUnavailable: "Ce fichier n'est pas disponible pour le moment. Réessayez plus tard.", pmCrypterBadKey: "La clé partagée est incorrecte !", pmCrypterTooLong: "Le message est trop long pour être chiffré avec le Crypter. Raccourcissez-le et réessayez.", pmCrypterCipherLabel: "Message rechiffré", @@ -2345,15 +2427,27 @@ module.exports = { pmNoSubject: "(sans objet)", pmSubjectLabel: "Objet :", pmBodyLabel: "Corps", - pmBotJobs: "42-EmploisBOT", - pmBotProjects: "42-ProjetsBOT", - pmBotMarket: "42-MarchéBOT", + pmBotJobs: "JobsBot", + pmBotProjects: "ProjectsBot", + pmBotMarket: "MarketBot", + pmBotShops: "ShopsBot", + pmBotPolitical: "PoliticalBot", + politicalBotLarpTitle: "La maison dirigeante du L.A.R.P a changé.", + politicalBotParliamentTitle: "Un nouveau cycle de gouvernement a commencé au Parlement.", + politicalBotTribeTitle: "Un nouveau cycle de gouvernement a commencé dans l'une de tes Tribus.", + politicalBotLarpIntro: "Maintenant {house} gouverne durant ce cycle : {cycle}", + politicalBotParliamentIntro: "Le gouvernement actuel est {gov}", + politicalBotParliamentIntroLeader: "Le gouvernement actuel est {gov}, exercé par {leader}", + politicalBotTribeIntro: "Le gouvernement actuel dans la Tribu {tribe} est {gov}", + politicalBotTribeIntroLeader: "Le gouvernement actuel dans la Tribu {tribe} est {gov}, exercé par {leader}", + politicalBotLeaderLabel: "Chef", inboxJobSubscribedTitle: "Nouvelle inscription à votre offre d’emploi", pmInhabitantWithId: "Habitat avec ID OASIS :", pmHasSubscribedToYourJobOffer: "s’est inscrit à votre offre d’emploi", inboxProjectCreatedTitle: "Nouveau projet créé", pmHasCreatedAProject: "a créé un projet", inboxMarketItemSoldTitle: "Article vendu", + inboxShopSoldTitle: "Produit vendu", pmYourItem: "Votre article", pmHasBeenSoldTo: "a été vendu à", pmFor: "pour", @@ -2386,7 +2480,7 @@ module.exports = { blockchainBack: 'Retour à l’explorateur', banking: 'Banque', bankingTitle: 'Banque', - bankingDescription: 'Explorez la valeur actuelle d’ECOin et l’allocation RBU correspondante, distribuée chaque semaine en fonction de la participation et de la confiance.', + bankingDescription: "L'économie ECOin : solde, revenu de base, taxes et valeur de l'industrie.", bankOverview: 'Aperçu', bankEpochs: 'Époques', bankRules: 'Règles', @@ -2420,6 +2514,10 @@ module.exports = { bankEpochId: 'ID de l’époque', bankRuleHash: 'Hash de l’instantané des règles', bankViewEpoch: 'Voir l’époque', + bankYourIndustryBalance: "Votre part de l'industrie", + bankYourUbiMonth: "Votre RBU (ce mois-ci)", + bankYourFundsMonth: "Vos fonds (ce mois-ci)", + bankIndustryBalance: "Production industrielle (estimée)", bankUserBalance: 'Votre solde', ecoWalletNotConfigured: 'Portefeuille ECOin non configuré', editWallet: 'Modifier le portefeuille', @@ -2460,7 +2558,7 @@ module.exports = { pubIdPlaceholder: "@PUB_ID.ed25519", bankUbiAvailableNo: "PAS DE FONDS!", bankUbiAvailableOk: "DISPONIBLE!", - bankUbiAvailability: "Disponibilité UBI", + bankUbiAvailability: "RBU (réseau)", bankAlreadyClaimedThisMonth: "Déjà réclamé ce mois-ci", bankUbiThisMonth: "RBU (ce mois)", bankUbiLastClaimed: "RBU (dernière réclamation)", @@ -2889,6 +2987,208 @@ module.exports = { jobsTagsLabel: "Étiquettes", jobsTagsPlaceholder: "tag1, tag2, tag3", noJobsMatch: "Aucune offre ne correspond à votre recherche.", + industrySearchPlaceholder: "Rechercher des usines…", + industryAllSectors: "Tous les secteurs", + industryVisitButton: "Visiter l’industrie", + industryAdvanceTo: "Passer à", + industryAmount: "Montant", + industryApproveBuild: "Approuver", + industryBackToFacility: "← Usine", + industryBlueprint: "Blueprint", + industryBlueprintLabel: "Blueprint d’industrie", + industryBlueprints: "Blueprints", + industryBuild: "Build", + industryBuildNotes: "Notes", + industryBuildStart: "Date de début", + industryVoteUpdateButton: "Voter la mise à jour", + industryVoteDeleteButton: "Voter la suppression", + industryRevokeVote: "Révoquer", + industryTimeLeft: "Temps restant", + industryBuildEnd: "Date de fin", + industryBuilds: "Builds", + industryBuildTitle: "Titre", + industryContribute: "Contribuer", + industryContributeEco: "Contribuer en ECO", + industryContributeLabor: "Contribuer du travail", + industryContributeButton: "Contribuer", + industryContributeMaterial: "Contribuer du matériel", + industryContributions: "Contributions", + industryContributors: "contributeurs", + industryCreateBlueprintButton: "Créer un blueprint", + industryDistribute: "Distribuer", + industryDistributeButton: "Distribuer par parts", + industryDistribution: "Distribution", + industryEcoAmount: "Montant (ECO)", + industryEcoContribHint: "Les contributions en ECO sont transférées à l'intendant en tant que dépositaire de la trésorerie du build.", + industryEditBlueprint: "Modifier le blueprint", + industryFacility: "Usine", + industryKindLabel: "Type", + industryKind_labor: "Main-d'œuvre", + industryKind_material: "Matériau", + industryKind_eco: "Fonds", + industryLaborHours: "Main-d'œuvre (heures)", + industryHours: "Heures", + industryLicense: "Licence", + industryMaterialItem: "Matériel", + industryMaterials: "Matériaux", + industryMaterialsHint: "Un matériau par ligne, au format nom:quantité:prix.", + industryEstMaterials: "Total matériaux", + industryEstLabor: "Total main-d'œuvre", + industryEstTaxes: "Taxes", + industryEstTotal: "Prix estimé", + industryBuildingPrice: "Prix de fabrication", + industryFinalPrice: "Prix final", + industryBlueprintDescPlaceholder: "Spécifications, dimensions, poids, documentation…", + industryMaterialValue: "Valeur (ECO)", + industryMember: "Membre", + industryNewBlueprint: "Nouveau blueprint", + industryNewBuild: "Proposer un build", + industryEditBuild: "Modifier la production", + industryNoBlueprint: "— aucun —", + industryNeedBlueprint: "Créez d'abord un plan : chaque production en fabrique un.", + industryNoBlueprints: "Aucun blueprint pour l'instant.", + industryNoBuilds: "Aucun build pour l'instant.", + industryNote: "Note", + industryOutput: "Sortie", + industryOutputItem: "Nom du produit", + industryOutputKind: "Type de produit", + industryOutputQty: "Unités par production", + industryOutputUnit: "Unité de mesure", + industryOutputValue: "Valeur de sortie (ECO, p. ex. de la vente)", + industryPayout: "Paiement", + industryPoints: "Karma", + industryPostJob: "Créer un emploi", + industryPot: "Cagnotte", + industryProposeBuildButton: "Proposer le build", + industryProposer: "Proposant", + industryAuthor: "Auteur", + industrySellOnMarket: "Envoyer au marché", + industrySendToProjects: "Créer un projet", + industryShare: "Part", + industryShares: "Parts", + industrySkills: "Compétences", + industryTreasury: "Trésorerie", + industryKind_physical: "Physique", + industryKind_digital: "Numérique", + industryBuildStatus_PROPOSED: "PROPOSÉ", + industryBuildStatus_REJECTED: "REJETÉ", + industryBuildStatus_APPROVED: "APPROUVÉ", + industryBuildStatus_STOCKING: "APPROVISIONNEMENT", + industryBuildStatus_IN_PRODUCTION: "EN PRODUCTION", + industryBuildStatus_COMPLETED: "TERMINÉ", + industryBuildStatus_FAILED: "ÉCHOUÉ", + pmBotIndustry: "IndustryBot", + industryBotAdmittedTitle: "Vous avez été admis dans une usine.", + industryBotApplicationTitle: "Nouvelle demande d'adhésion dans votre usine.", + industryBotInvitedTitle: "Vous avez été invité dans une usine.", + industryBotDissolvedTitle: "Une usine dont vous faites partie a été dissoute.", + industryBotBuildApprovedTitle: "Un build a été approuvé.", + industryBotDistributedTitle: "La sortie d'un build a été distribuée.", + industryFilterRules: "RÈGLES", + industryRulesTitle: "Règles", + industryRulesIntro: "Industry permet aux habitants de posséder collectivement les moyens de production : une Usine est un atelier du réseau que personne ne possède à titre privé.", + industryRulesSteward: "Le fondateur est l'intendant — un gardien, pas un propriétaire : il ne peut ni vendre, ni privatiser, ni dissoudre l'Usine seul.", + industryRulesMembership: "La politique d'adhésion définit comment on rejoint : Ouverte (adhésion immédiate), Par vote (admis par le vote des membres) ou Sur invitation (un membre doit inviter d'abord).", + industryRulesQuorum: "Le quorum est le nombre minimum de membres qui doivent voter pour qu'une décision compte, afin qu'une petite Usine ne soit pas décidée par une seule personne.", + industryRulesMajority: "La majorité est la fraction de votes nécessaire (0,5 = moitié, 1 = unanimité) ; une décision passe quand les OUI atteignent au moins max(quorum, membres × majorité).", + industryRulesDecisions: "Les membres votent pour admettre de nouveaux venus, approuver les builds et dissoudre l'Usine ; l'intendant ne peut pas passer outre ces décisions collectives.", + industryRulesBlueprints: "Les Blueprints sont des recettes libres (COPYLEFT) — les intrants (matériaux, heures de travail, compétences) et la sortie (un bien physique ou numérique) — et chacun peut les forker.", + industryRulesBuilds: "Un Build est un ordre de production : PROPOSÉ → APPROUVÉ (par vote) → APPROVISIONNEMENT → EN PRODUCTION → TERMINÉ, et il n'accepte des contributions qu'une fois approuvé.", + industryRulesContributions: "Les membres contribuent en travail (heures), matériaux (valeur ECO déclarée) ou ECO à un build ; chaque contribution rapporte du karma.", + industryRulesLaborRate: "Le taux de travail est la valeur ECO d'une heure de travail dans cette Usine ; il convertit les heures en karma pour comparer équitablement effort et capital : karma = heures × taux + valeur matériaux + ECO.", + industryRulesShares: "Votre part d'un build est votre karma ÷ karma total, et fixe la part de la valeur de sortie que vous recevez.", + industryRulesDistribution: "Lorsqu'une production est terminée, le steward répartit le pot (les fonds de la production plus la valeur de vente) entre les contributeurs, au prorata de leurs parts.", + industryRulesTreasury: "Les contributions en ECO sont détenues par l'intendant comme dépositaire de la trésorerie du build jusqu'à la distribution ; tous les mouvements sont enregistrés sur le réseau et les litiges peuvent aller aux Courts.", + industryJobs: "Emplois", + industryNoJobs: "Aucun poste pour l'instant.", + industryCreatePosition: "Créer un poste", + industryViewCV: "CV", + industryReportButton: "Signaler", + industryCreateTask: "Créer une tâche", + industryOpenDispute: "Ouvrir un litige", + industryMaterialsPlaceholder: "solar-panel:2:120\nbattery:1:80\nkit:1:15", + industryOutputUnitPlaceholder: "p. ex. unité, kg, licence", + industryOutputItemPlaceholder: "ex. robot", + industryOutputQtyPlaceholder: "ex. 5", + industrySkillsPlaceholder: "p. ex. soudure, réseaux, installation-solaire", + industryCreateInvite: "Générer une invitation", + industryPauseVotes: "Votes de statut", + industryTitle: "Industrie", + industryDescription: "Module pour gérer collectivement les moyens de production.", + industryLabel: "Industrie", + typeIndustryBuild: "BUILD", + typeIndustryBlueprint: "BLUEPRINT", + typeIndustryAllocation: "DISTRIBUTION", + typeIndustry: "INDUSTRIE", + modulesIndustryLabel: "Industrie", + modulesIndustryDescription: "Module pour gérer collectivement les moyens de production.", + industryFilterAll: "TOUTES", + industryFilterMember: "MEMBRE", + industryFilterBlueprints: "PLANS", + industryFilterBuilds: "PRODUCTIONS", + industryFilterMine: "MIENNES", + industryFilterActive: "EN MARCHE", + industryFilterPaused: "EN PAUSE", + industryFilterDissolved: "DISSOUTES", + industryAllTitle: "Industrie", + industryMemberTitle: "Usines dont je fais partie", + industryMineTitle: "Mes usines", + industryActiveTitle: "En marche", + industryPausedTitle: "Usines en pause", + industryDissolvedTitle: "Usines dissoutes", + industryNoFacilitiesFound: "Aucune usine trouvée.", + industryCreateFacility: "Créer une usine", + industryMembers: "Membres", + industryMemberBadge: "MEMBRE", + industryName: "Nom", + industryNamePlaceholder: "Nom de l'usine", + industryDescriptionLabel: "Description", + industryDescriptionPlaceholder: "Que produit cette usine ?", + industrySector: "Secteur", + industryMembershipPolicy: "Politique d'adhésion", + industryQuorum: "Quorum", + industryMajority: "Majorité", + industryLaborRate: "Taux de travail", + industryCreateButton: "Créer une usine", + industryUpdateButton: "Mettre à jour", + industrySteward: "Intendant", + industryStatusActive: "EN MARCHE", + industryStatusPaused: "EN PAUSE", + industryStatusDissolved: "DISSOUTE", + industryStatusLabel: "État", + industryJoinButton: "Rejoindre", + industryApplyButton: "Demander à rejoindre", + industryLeaveButton: "Quitter", + industryInviteButton: "Inviter", + industryInvitePlaceholder: "@...=.ed25519", + industryInviteNeeded: "Vous avez besoin d'une invitation pour rejoindre.", + industryPauseButton: "Voter la pause", + industryResumeButton: "Voter la reprise", + industryEditButton: "Modifier", + industryDeleteButton: "Supprimer", + industryBackToList: "← Industrie", + industryPendingApplicants: "Candidatures en attente", + industryVotesYes: "oui", + industryVotesNo: "non", + industryAlreadyVoted: "voté", + industryAdmit: "Admettre", + industryReject: "Rejeter", + industryDissolveTitle: "Dissoudre l'usine", + industryDissolveHint: "La dissolution requiert un vote collectif ; l'intendant ne peut pas dissoudre seul.", + industryDissolveVote: "Voter la dissolution", + industrySector_software: "Logiciel", + industrySector_hardware: "Matériel", + industrySector_agriculture: "Agriculture", + industrySector_textile: "Textile", + industrySector_energy: "Énergie", + industrySector_food: "Alimentation", + industrySector_construction: "Construction", + industrySector_media: "Médias", + industrySector_services: "Services", + industrySector_other: "Autre", + industryPolicy_open: "Ouverte", + industryPolicy_vote: "Par vote", + industryPolicy_invite: "Sur invitation", projectsTitle: "Projets", projectsDescription: "Créez, financez et suivez des projets communautaires dans votre réseau.", projectCreateProject: "Créer un projet", @@ -3250,6 +3550,8 @@ module.exports = { chatOpenTitle: "Open Chats", chatClosedTitle: "Closed Chats", chatDescription: "Description", + chatMessageReply: "Réponse", + chatMessageLabel: "Message", chatCategory: "Catégorie", chatStatus: "STATUT", chatFilterAll: "TOUS", diff --git a/src/client/assets/translations/oasis_hi.js b/src/client/assets/translations/oasis_hi.js index 4b66a12..4042c84 100644 --- a/src/client/assets/translations/oasis_hi.js +++ b/src/client/assets/translations/oasis_hi.js @@ -1,26 +1,6 @@ const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); module.exports = { hi: { - bbAdd: "जोड़ें", - bbQuickActions: "त्वरित क्रियाएँ", - bbPickTitle: "एक मॉड्यूल पिन करें", - bbPickDesc: "नीचे की बार में + स्लॉट के लिए एक मॉड्यूल चुनें।", - bbPickNone: "कोई नहीं", - bbManage: "संपादित करें", - bbYourBar: "आपकी बार", - bbRemove: "हटाएँ", - bbFull: "अधिकतम पहुँच गया", - bbDone: "हो गया", - activityChangeFilter: "फ़िल्टर बदलें", - emptyConnectHint: "नेटवर्क से सिंक करने के लिए किसी pub से जुड़ें।", - emptyConnectCta: "pub से जुड़ें", - menuCommunity: "समुदाय", - activityGroupCommunity: "समुदाय और शासन", - activityGroupOrg: "संगठन", - activityGroupComm: "संचार", - activityGroupEconomy: "अर्थव्यवस्था", - activityGroupMedia: "सामग्री और मीडिया", - activityGroupOther: "अन्य", languageName: "हिन्दी", shopOrderStatusPaid: "भुगतान प्राप्त", shopOrderStatusReceived: "प्राप्त", @@ -428,7 +408,8 @@ module.exports = { publish: "लिखें", contentWarningPlaceholder: "पोस्ट में विषय जोड़ें (वैकल्पिक)", privateWarningPlaceholder: "निजी पोस्ट भेजने के लिए निवासी जोड़ें (वैकल्पिक)", - publishWarningPlaceholder: "...", + publishWarningPlaceholder: "मुख्य पाठ 7000 वर्णों तक सीमित।", + publishTooLong: "आपकी पोस्ट बहुत लंबी है। कृपया इसे छोटा करें।", publishCustomDescription: [ "याद रखें: ब्लॉकचेन तकनीक के कारण, एक बार पोस्ट प्रकाशित होने के बाद इसे संपादित या हटाया नहीं जा सकता।", ], @@ -493,7 +474,89 @@ module.exports = { passwordLengthInfo: "पासवर्ड कम से कम 32 अक्षर लंबा होना चाहिए।", passwordImport: "डेटा को डिक्रिप्ट करने के लिए अपना पासवर्ड लिखें जो आपके सिस्टम होम पर सहेजा जाएगा (नाम: secret)", exportPasswordPlaceholder: "लोअरकेस, अपरकेस, संख्याएँ और प्रतीक उपयोग करें", - fileInfo: "आपकी एन्क्रिप्टेड गुप्त कुंजी आपके सिस्टम होम पर सहेजी जाएगी (नाम: oasis.enc)", + fileInfo: "आपकी एन्क्रिप्टेड गुप्त कुंजी आपके डिवाइस पर डाउनलोड की जाएगी (नाम: oasis.enc)", + developer: "विकास", + devTitle: "विकास", + welcomeBannerText: "OASIS में स्वागत है। अपना अवतार सेट करने के लिए इन आसान चरणों का पालन करें।", + welcomeBannerAction: "शुरू करें!", + welcomeTitle: "स्वागत है", + welcomeStepGreetingAction: "भेजें", + welcomeJoinPubButton: "pub से जुड़ें", + welcomeStepLarpAction: "\"अकादमी\" में शामिल हों", + welcomeStepLarpText: "हमारे लाइव एक्शन रोल प्लेइंग खेल में शामिल हों।", + welcomeStepLarpTitle: "L.A.R.P. में शामिल हों", + welcomeStepGreetingTitle: "एक \"Hello world!\" भेजें", + welcomeStepGreetingText: "एक संदेश भेजें ताकि दूसरे निवासी आपको खोज सकें।", + welcomeJoinPub: "जुड़ें:", + welcomeDescription: "शुरू करने से पहले कुछ ज़रूरी बातें।", + welcomeProgress: "पूर्ण", + welcomeStepDone: "हो गया", + welcomeStepPending: "बाकी", + welcomeSkip: "अभी नहीं", + welcomeStepLanguageTitle: "अपनी भाषा चुनें", + welcomeStepLanguageText: "Oasis कई भाषाएँ बोलता है। आप इसे कभी भी बदल सकते हैं।", + welcomeStepProfileTitle: "अपनी प्रोफ़ाइल संपादित करें", + welcomeStepProfileText: "एक नाम चुनें, विवरण जोड़ें और तस्वीर लगाएँ ताकि दूसरे निवासी आपको पहचान सकें।", + welcomeStepProfileAction: "प्रोफ़ाइल सहेजें", + welcomeStepFederationTitle: "मुख्य नेटवर्क से जुड़ें", + welcomeStepFederationText: "दूसरे निवासियों से मिलने और सिंक शुरू करने के लिए हमारे pub से जुड़ें।", + welcomeStepFederationAction: "आमंत्रण देखें", + welcomeStepBackupTitle: "अपनी ID का बैकअप लें", + welcomeStepBackupText: "आपकी पहचान इस डिवाइस पर एक कुंजी फ़ाइल है।", + welcomeStepBackupWarning: "यदि यह खो गई, तो कोई भी इसे वापस नहीं ला सकता।", + welcomeStepBackupAction: "बैकअप!", + welcomeCompleteTitle: "सब तैयार है।", + welcomeCompleteText: "आपका नोड तैयार है। यहाँ से नेटवर्क उन लोगों के साथ बढ़ता है जिन्हें आप फ़ॉलो करते हैं।", + welcomeFindPeople: "निवासी खोजें", + devFilterFiles: "फ़ाइलें", + devFilterSearch: "खोज", + devFilterModules: "मॉड्यूल", + devFilterTranslations: "अनुवाद", + devFilterStyles: "शैलियाँ", + devFilterTests: "टेस्ट", + devVersion: "संस्करण", + devNodeVersion: "Node", + devStatsTests: "टेस्ट सूट", + devParentFolder: "मूल फ़ोल्डर", + devOpenFolder: "खोलें", + devDescription: "इस नोड पर चल रहे स्रोत कोड का अन्वेषण करें।", + devTreeTitle: "फ़ाइलें", + devSearchTitle: "कोड में खोजें", + devMapTitle: "मॉड्यूल", + devRoot: "मूल", + devRootButton: "मूल", + devSearchPlaceholder: "कोड में खोजने के लिए पाठ", + devAnyExtension: "कोई भी फ़ाइल", + devSearchButton: "खोजें", + devStatsFiles: "फ़ाइलें", + devStatsLines: "पंक्तियाँ", + devStatsModules: "मॉड्यूल", + devStatsLanguages: "भाषाएँ", + devNotViewable: "देखने योग्य नहीं", + devEmptyFolder: "यह फ़ोल्डर खाली है।", + devSize: "आकार", + devShowing: "दिखा रहे हैं", + devLine: "पंक्ति", + devReportBug: "बग रिपोर्ट करें", + devCreateTask: "कार्य बनाएँ", + devRaw: "RAW", + devReportContext: "Oasis स्रोत कोड पढ़ते समय मिला", + devTaskPrefix: "समीक्षा", + devPrevPage: "पिछली पंक्तियाँ", + devNextPage: "अगली पंक्तियाँ", + devMatches: "मिलान", + devFilesScanned: "फ़ाइलें जाँची गईं", + devNoResults: "अभी तक कोई मिलान नहीं।", + devColModule: "मॉड्यूल", + devColState: "स्थिति", + devColModel: "मॉडल", + devColView: "दृश्य", + devColRoutes: "रूट", + devColTests: "टेस्ट", + devOn: "चालू", + devOff: "बंद", + modulesDevLabel: "विकास", + modulesDevDescription: "Oasis स्रोत कोड को प्रबंधित करने का मॉड्यूल।", themeIntro: "एक थीम चुनें।", setTheme: "थीम सेट करें", @@ -695,7 +758,7 @@ module.exports = { spreadLabel: "फैलाएँ", spreadHint: "अपने समर्थकों को फैलाएँ (फ़ीड से प्रतिकृति)।", welcomePmSubject: "Hello World!", - welcomePmBody: "Oasis में आपका स्वागत है — एक स्वतंत्र, पीयर-टू-पीयर, फ़ेडरेटेड और एन्क्रिप्टेड सोशल नेटवर्क, जिसकी वास्तुकला वितरित और केवल-आमंत्रण आधारित है।\n\nशुरुआत के लिए कुछ रोचक स्थान:\n\n- /profile — अपना अवतार, नाम, विवरण और कौन से मॉड्यूल आपके सार्वजनिक पक्ष पर दिखेंगे, संपादित करें।\n- /invites — व्यापक नेटवर्क के साथ फेडरेट करने के लिए एक pub जोड़ें।\n- /peers — देखें कौन जुड़ा है, अपनी LAN फिर से स्कैन करें, पीयर सूचियाँ निर्यात या आयात करें।\n- /inhabitants — दूसरों के अवतार खोजें और देखें।\n- /tribes — एंड-टू-एंड एन्क्रिप्शन वाले निजी समूह बनाएँ या उनमें शामिल हों।\n- /inbox — निजी संदेश पढ़ें और भेजें।\n- /activity — हाल की गतिविधि देखें — आपकी और आपके नेटवर्क की।\n- /publish — पोस्ट लिखें, या चित्र, ऑडियो, वीडियो, दस्तावेज़ साझा करें।\n\nकनेक्ट करने के लिए कुछ दिलचस्प जगहें:\n\n- /fediverse — अपने अन्य फ़ेडिवर्स खातों को प्रबंधित करें, जिसमें सामग्री भेजना और प्राप्त करना शामिल है।\n\nयह संदेश आपके द्वारा निजी रूप से स्वयं को भेजा गया था, इसलिए इसे प्राप्त करने के लिए किसी कनेक्शन की आवश्यकता नहीं थी।", + welcomePmBody: "Oasis में आपका स्वागत है — एक स्वतंत्र, पीयर-टू-पीयर, फ़ेडरेटेड और एन्क्रिप्टेड सोशल नेटवर्क, जिसकी वास्तुकला वितरित और केवल-आमंत्रण आधारित है।\n\nशुरुआत के लिए कुछ रोचक स्थान:\n\n- /profile — अपना अवतार, नाम, विवरण और कौन से मॉड्यूल आपके सार्वजनिक पक्ष पर दिखेंगे, संपादित करें।\n- /invites — व्यापक नेटवर्क के साथ फेडरेट करने के लिए एक pub जोड़ें।\n- /peers — देखें कौन जुड़ा है, अपनी LAN फिर से स्कैन करें, पीयर सूचियाँ निर्यात या आयात करें।\n- /inhabitants — दूसरों के अवतार खोजें और देखें।\n- /tribes — एंड-टू-एंड एन्क्रिप्शन वाले निजी समूह बनाएँ या उनमें शामिल हों।\n- /inbox — निजी संदेश पढ़ें और भेजें।\n- /activity — हाल की गतिविधि देखें — आपकी और आपके नेटवर्क की।\n- /publish — पोस्ट लिखें, या चित्र, ऑडियो, वीडियो, दस्तावेज़ साझा करें।\n- /search — नेटवर्क की सामग्री को प्रकार के अनुसार खोजें।\n\nकनेक्ट करने के लिए कुछ दिलचस्प जगहें:\n\n- /fediverse — अपने अन्य फ़ेडिवर्स खातों को प्रबंधित करें, जिसमें सामग्री भेजना और प्राप्त करना शामिल है।\n\nयह संदेश आपके द्वारा निजी रूप से स्वयं को भेजा गया था, इसलिए इसे प्राप्त करने के लिए किसी कनेक्शन की आवश्यकता नहीं थी।", profileVisibilityTitle: "सार्वजनिक प्रोफ़ाइल दृश्यता", profileVisibilityHint: "तय करें कि आपकी प्रोफ़ाइल में अन्य निवासी कौन से क्षेत्र देखें। डिफ़ॉल्ट रूप से केवल Karma दिखाया जाता है।", profileSensorsSectionTitle: "सेंसर", @@ -1226,6 +1289,9 @@ module.exports = { eventButton: "कार्यक्रम", taskButton: "कार्य", votesButton: "मतदान", + industryButton: "उद्योग", + projectButton: "परियोजनाएं", + shopProductButton: "उत्पाद", reportButton: "रिपोर्ट", feedButton: "फ़ीड", marketButton: "बाज़ार", @@ -1254,7 +1320,7 @@ module.exports = { trendingItemStatus: "आइटम स्थिति", trendingTotalVotes: "कुल मत", trendingTotalOpinions: "कुल राय", - trendingNoContentMessage: "अभी तक कोई ट्रेंडिंग सामग्री उपलब्ध नहीं।", + trendingNoContentMessage: "अभी तक कोई ट्रेंडिंग नहीं है।", trendingAuthor: "द्वारा", trendingCreatedAtLabel: "बनाया गया", trendingTotalCount: "कुल संख्या", @@ -1443,7 +1509,7 @@ module.exports = { transfersCreatedAt: "बनाया गया", transfersConfirmations: "पुष्टि", transfersContainingBlock: "धारक ब्लॉक", - transfersExportContract: "अनुबंध बनाएँ", + transfersExportContract: "स्मार्ट अनुबंध", transfersConfirmButton: "स्थानांतरण पुष्टि करें", transfersNoItems: "कोई स्थानांतरण नहीं मिला।", transfersMineSectionTitle: "आपके स्थानांतरण", @@ -1569,7 +1635,7 @@ module.exports = { cvDeleteButton: "हटाएँ", cvNoCV: "कोई CV नहीं मिला।", blogSubject: "विषय", - blogMessage: "मुख्य भाग 8096 वर्णों तक सीमित।", + blogMessage: "संदेश", blogImage: "मीडिया अपलोड करें (अधिकतम-आकार: 50MB)", blogPublish: "पूर्वावलोकन", noPopularMessages: "अभी तक कोई लोकप्रिय संदेश प्रकाशित नहीं हुआ", @@ -2204,6 +2270,7 @@ module.exports = { agendaFilterReports: "रिपोर्ट", agendaFilterTransfers: "स्थानांतरण", agendaFilterJobs: "नौकरियाँ", + agendaFilterIndustry: "उद्योग", agendaFilterProjects: "परियोजनाएँ", agendaFilterCalendars: "कैलेंडर", agendaNoItems: "कोई सौंपे गए आइटम नहीं मिले।", @@ -2325,16 +2392,31 @@ module.exports = { privateMessage: "PM", pmSendTitle: "निजी संदेश", pmSend: "भेजें!", - pmDescription: "अन्य निवासियों को एन्क्रिप्टेड संदेश भेजने के लिए इस फ़ॉर्म का उपयोग करें।", + pmCancel: "रद्द करें", + pmReset: "रीसेट", + pmSharedKeyHint: "इस कुंजी को प्राप्तकर्ता के साथ किसी अन्य माध्यम से साझा करें। डिक्रिप्ट करने के लिए यह आवश्यक है।", + pmDescription: "अन्य निवासियों को एन्क्रिप्टेड संदेश/फ़ाइल भेजने के लिए इस फ़ॉर्म का उपयोग करें।", + pmComposeTitle: "संदेश भेजें", pmRecipients: "प्राप्तकर्ता", pmRecipientsHint: "अल्पविराम से अलग Oasis IDs दर्ज करें", pmLevelOfExposition: "Level of exposition", - pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmLimitsHint: "प्रति संदेश अधिकतम 7 प्राप्तकर्ता।", + pmTextPlaceholder: "मुख्य पाठ 7000 वर्णों तक सीमित।", pmSubject: "विषय", pmSubjectHint: "संदेश विषय दर्ज करें", pmText: "संदेश", pmCrypterChip: "2xE2E", pmCrypterDecryptButton: "डिक्रिप्ट करें", + fileShareTitle: "फ़ाइल साझा करें", + fileShareRecipientPlaceholder: "@...=.ed25519", + fileShareFileLabel: "फ़ाइल", + fileShareDownload: "डाउनलोड करें", + fileShareMutualError: "आप केवल पारस्परिक समर्थन वाले निवासियों के साथ फ़ाइलें साझा कर सकते हैं।", + fileShareNoFile: "कोई फ़ाइल चयनित नहीं।", + fileShareTooLarge: "फ़ाइल अनुमत आकार से बड़ी है।", + fileShareFailed: "फ़ाइल तैयार नहीं की जा सकी।", + fileShareSendError: "फ़ाइल भेजी नहीं जा सकी।", + fileShareUnavailable: "यह फ़ाइल अभी उपलब्ध नहीं है। बाद में पुनः प्रयास करें।", pmCrypterBadKey: "साझा कुंजी गलत है!", pmCrypterTooLong: "यह संदेश Crypter से एन्क्रिप्ट करने के लिए बहुत लंबा है। कृपया इसे छोटा करके पुनः प्रयास करें।", pmCrypterCipherLabel: "पुनः एन्क्रिप्टेड संदेश", @@ -2355,15 +2437,27 @@ module.exports = { pmNoSubject: "(कोई विषय नहीं)", pmSubjectLabel: "विषय:", pmBodyLabel: "मुख्य भाग", - pmBotJobs: "42-JobsBOT", - pmBotProjects: "42-ProjectsBOT", - pmBotMarket: "42-MarketBOT", + pmBotJobs: "JobsBot", + pmBotProjects: "ProjectsBot", + pmBotMarket: "MarketBot", + pmBotShops: "ShopsBot", + pmBotPolitical: "PoliticalBot", + politicalBotLarpTitle: "L.A.R.P का शासक घराना बदल गया है।", + politicalBotParliamentTitle: "संसद में एक नया शासन चक्र शुरू हो गया है।", + politicalBotTribeTitle: "आपकी किसी जनजाति में एक नया शासन चक्र शुरू हो गया है।", + politicalBotLarpIntro: "अब इस चक्र में {house} शासन करता है: {cycle}", + politicalBotParliamentIntro: "वर्तमान सरकार {gov} है", + politicalBotParliamentIntroLeader: "वर्तमान सरकार {gov} है, {leader} द्वारा संचालित", + politicalBotTribeIntro: "जनजाति {tribe} में वर्तमान सरकार {gov} है", + politicalBotTribeIntroLeader: "जनजाति {tribe} में वर्तमान सरकार {gov} है, {leader} द्वारा संचालित", + politicalBotLeaderLabel: "नेता", inboxJobSubscribedTitle: "आपके नौकरी प्रस्ताव के लिए नई सदस्यता", pmInhabitantWithId: "OASIS ID वाला निवासी:", pmHasSubscribedToYourJobOffer: "ने आपके नौकरी प्रस्ताव की सदस्यता ली है", inboxProjectCreatedTitle: "नई परियोजना बनाई गई", pmHasCreatedAProject: "ने एक परियोजना बनाई है", inboxMarketItemSoldTitle: "आइटम बिक गया", + inboxShopSoldTitle: "उत्पाद बिका", pmYourItem: "आपका आइटम", pmHasBeenSoldTo: "बेचा गया", pmFor: "के लिए", @@ -2398,7 +2492,7 @@ module.exports = { visitContent: "सामग्री देखें", banking: 'बैंकिंग', bankingTitle: 'बैंकिंग', - bankingDescription: 'ECOin का वर्तमान मूल्य और संबंधित UBI आवंटन खोजें, जो भागीदारी और विश्वास के आधार पर प्रति युग वितरित होता है।', + bankingDescription: "ECOin अर्थव्यवस्था: शेष, यूबीआई, कर और उद्योग मूल्य।", bankOverview: 'अवलोकन', bankEpochs: 'युग', bankRules: 'नियम', @@ -2432,6 +2526,10 @@ module.exports = { bankEpochId: 'युग ID', bankRuleHash: 'नियम स्नैपशॉट हैश', bankViewEpoch: 'युग देखें', + bankYourIndustryBalance: "आपका औद्योगिक हिस्सा", + bankYourUbiMonth: "आपकी UBI (इस माह)", + bankYourFundsMonth: "आपकी निधि (इस माह)", + bankIndustryBalance: "औद्योगिक उत्पादन (अनुमानित)", bankUserBalance: 'आपका शेष', ecoWalletNotConfigured: 'ECOin वॉलेट कॉन्फ़िगर नहीं है', editWallet: 'वॉलेट संपादित करें', @@ -2471,7 +2569,7 @@ module.exports = { pubIdPlaceholder: "@PUB_ID.ed25519", bankUbiAvailableNo: "कोई धन नहीं!", bankUbiAvailableOk: "उपलब्ध!", - bankUbiAvailability: "UBI उपलब्धता", + bankUbiAvailability: "UBI (नेटवर्क)", bankAlreadyClaimedThisMonth: "इस महीने पहले ही दावा किया गया", bankUbiThisMonth: "UBI (इस महीने)", bankUbiLastClaimed: "UBI (अंतिम दावा)", @@ -2901,6 +2999,208 @@ module.exports = { jobsTagsLabel: "टैग", jobsTagsPlaceholder: "tag1, tag2, tag3", noJobsMatch: "आपकी खोज से कोई नौकरी मेल नहीं खाती।", + industrySearchPlaceholder: "फैक्टरियाँ खोजें…", + industryAllSectors: "सभी क्षेत्र", + industryVisitButton: "उद्योग देखें", + industryAdvanceTo: "पर सेट करें", + industryAmount: "राशि", + industryApproveBuild: "मंज़ूर करें", + industryBackToFacility: "← फैक्टरी", + industryBlueprint: "ब्लूप्रिंट", + industryBlueprintLabel: "उद्योग ब्लूप्रिंट", + industryBlueprints: "ब्लूप्रिंट", + industryBuild: "बिल्ड", + industryBuildNotes: "नोट्स", + industryBuildStart: "आरंभ तिथि", + industryVoteUpdateButton: "अपडेट के लिए वोट", + industryVoteDeleteButton: "हटाने के लिए वोट", + industryRevokeVote: "वापस लें", + industryTimeLeft: "शेष समय", + industryBuildEnd: "समाप्ति तिथि", + industryBuilds: "बिल्ड्स", + industryBuildTitle: "शीर्षक", + industryContribute: "योगदान करें", + industryContributeEco: "ECO योगदान करें", + industryContributeLabor: "श्रम योगदान करें", + industryContributeButton: "योगदान करें", + industryContributeMaterial: "सामग्री योगदान करें", + industryContributions: "योगदान", + industryContributors: "योगदानकर्ता", + industryCreateBlueprintButton: "ब्लूप्रिंट बनाएं", + industryDistribute: "वितरण करें", + industryDistributeButton: "हिस्सों के अनुसार वितरण", + industryDistribution: "वितरण", + industryEcoAmount: "राशि (ECO)", + industryEcoContribHint: "ECO योगदान बिल्ड के कोष के संरक्षक के रूप में प्रबंधक को स्थानांतरित किए जाते हैं।", + industryEditBlueprint: "ब्लूप्रिंट संपादित करें", + industryFacility: "फैक्टरी", + industryKindLabel: "प्रकार", + industryKind_labor: "श्रम", + industryKind_material: "सामग्री", + industryKind_eco: "निधि", + industryLaborHours: "श्रम (घंटे)", + industryHours: "घंटे", + industryLicense: "लाइसेंस", + industryMaterialItem: "सामग्री", + industryMaterials: "सामग्री", + industryMaterialsHint: "प्रति पंक्ति एक सामग्री, प्रारूप नाम:मात्रा:मूल्य।", + industryEstMaterials: "कुल सामग्री", + industryEstLabor: "कुल श्रम", + industryEstTaxes: "कर", + industryEstTotal: "अनुमानित मूल्य", + industryBuildingPrice: "निर्माण मूल्य", + industryFinalPrice: "अंतिम मूल्य", + industryBlueprintDescPlaceholder: "विनिर्देश, आयाम, वज़न, दस्तावेज़…", + industryMaterialValue: "मूल्य (ECO)", + industryMember: "सदस्य", + industryNewBlueprint: "नया ब्लूप्रिंट", + industryNewBuild: "बिल्ड प्रस्तावित करें", + industryEditBuild: "उत्पादन संपादित करें", + industryNoBlueprint: "— कोई नहीं —", + industryNeedBlueprint: "पहले ब्लूप्रिंट बनाएँ: हर उत्पादन उसी को बनाता है।", + industryNoBlueprints: "अभी तक कोई ब्लूप्रिंट नहीं।", + industryNoBuilds: "अभी तक कोई बिल्ड नहीं।", + industryNote: "नोट", + industryOutput: "आउटपुट", + industryOutputItem: "उत्पाद का नाम", + industryOutputKind: "उत्पाद का प्रकार", + industryOutputQty: "प्रति उत्पादन इकाइयाँ", + industryOutputUnit: "माप की इकाई", + industryOutputValue: "आउटपुट मूल्य (ECO, जैसे बिक्री से)", + industryPayout: "भुगतान", + industryPoints: "Karma", + industryPostJob: "नौकरी बनाएँ", + industryPot: "कोष", + industryProposeBuildButton: "बिल्ड प्रस्तावित करें", + industryProposer: "प्रस्तावक", + industryAuthor: "लेखक", + industrySellOnMarket: "बाज़ार में भेजें", + industrySendToProjects: "प्रोजेक्ट बनाएँ", + industryShare: "हिस्सा", + industryShares: "हिस्से", + industrySkills: "कौशल", + industryTreasury: "कोष", + industryKind_physical: "भौतिक", + industryKind_digital: "डिजिटल", + industryBuildStatus_PROPOSED: "प्रस्तावित", + industryBuildStatus_REJECTED: "अस्वीकृत", + industryBuildStatus_APPROVED: "स्वीकृत", + industryBuildStatus_STOCKING: "भंडारण", + industryBuildStatus_IN_PRODUCTION: "उत्पादन में", + industryBuildStatus_COMPLETED: "पूर्ण", + industryBuildStatus_FAILED: "विफल", + pmBotIndustry: "IndustryBot", + industryBotAdmittedTitle: "आपको एक फैक्टरी में स्वीकार किया गया है।", + industryBotApplicationTitle: "आपकी फैक्टरी में नया सदस्यता अनुरोध।", + industryBotInvitedTitle: "आपको एक फैक्टरी में आमंत्रित किया गया है।", + industryBotDissolvedTitle: "आपकी एक फैक्टरी विघटित कर दी गई है।", + industryBotBuildApprovedTitle: "एक बिल्ड स्वीकृत हो गया है।", + industryBotDistributedTitle: "एक बिल्ड का आउटपुट वितरित किया गया है।", + industryFilterRules: "नियम", + industryRulesTitle: "नियम", + industryRulesIntro: "Industry निवासियों को उत्पादन के साधनों का सामूहिक स्वामित्व देता है: एक फैक्टरी नेटवर्क-स्वामित्व वाली कार्यशाला है जिसे कोई निजी रूप से नहीं रखता।", + industryRulesSteward: "संस्थापक प्रबंधक होता है — संरक्षक, मालिक नहीं: वह फैक्टरी को अकेले बेच, निजीकृत या विघटित नहीं कर सकता।", + industryRulesMembership: "सदस्यता नीति तय करती है कि कैसे जुड़ें: खुला (तुरंत), मतदान द्वारा (सदस्यों के वोट से स्वीकृत) या केवल आमंत्रण (पहले किसी सदस्य को आमंत्रित करना होगा)।", + industryRulesQuorum: "कोरम वह न्यूनतम सदस्य संख्या है जिन्हें किसी निर्णय के गिनने के लिए मतदान करना चाहिए, ताकि छोटी फैक्टरी एक व्यक्ति द्वारा तय न हो।", + industryRulesMajority: "बहुमत पारित होने के लिए आवश्यक मतों का अंश है (0.5 = आधा, 1 = सर्वसम्मति); निर्णय तब पारित होता है जब हाँ वोट कम से कम max(कोरम, सदस्य × बहुमत) तक पहुँचें।", + industryRulesDecisions: "सदस्य नए लोगों को स्वीकारने, बिल्ड स्वीकृत करने और फैक्टरी विघटित करने के लिए मतदान करते हैं; प्रबंधक इन सामूहिक निर्णयों को नहीं टाल सकता।", + industryRulesBlueprints: "ब्लूप्रिंट स्वतंत्र (COPYLEFT) व्यंजन हैं — इनपुट (सामग्री, श्रम घंटे, कौशल) और आउटपुट (एक भौतिक या डिजिटल वस्तु) — और कोई भी इन्हें फोर्क कर सकता है।", + industryRulesBuilds: "बिल्ड एक उत्पादन आदेश है: प्रस्तावित → स्वीकृत (मतदान से) → भंडारण → उत्पादन में → पूर्ण, और स्वीकृत होने के बाद ही योगदान स्वीकार करता है।", + industryRulesContributions: "सदस्य किसी बिल्ड में श्रम (घंटे), सामग्री (घोषित ECO मूल्य) या ECO का योगदान करते हैं; हर योगदान Karma अर्जित करता है।", + industryRulesLaborRate: "श्रम दर इस फैक्टरी में एक कार्य-घंटे का ECO मूल्य है; यह घंटों को Karma में बदलती है ताकि श्रम और पूँजी की निष्पक्ष तुलना हो: Karma = घंटे × दर + सामग्री मूल्य + ECO।", + industryRulesShares: "किसी बिल्ड में आपका हिस्सा आपका Karma ÷ कुल Karma है, और यह तय करता है कि आपको आउटपुट मूल्य का कितना भाग मिलेगा।", + industryRulesDistribution: "उत्पादन पूरा होने पर स्टीवर्ड पॉट (उत्पादन की निधि और बिक्री मूल्य) को योगदानकर्ताओं में उनके हिस्से के अनुपात में बाँटता है।", + industryRulesTreasury: "ECO योगदान वितरण तक प्रबंधक द्वारा बिल्ड कोष के संरक्षक के रूप में रखे जाते हैं; सभी लेन-देन नेटवर्क पर दर्ज होते हैं और विवाद Courts में जा सकते हैं।", + industryJobs: "नौकरियाँ", + industryNoJobs: "अभी तक कोई पद नहीं।", + industryCreatePosition: "पद बनाएं", + industryViewCV: "सीवी", + industryReportButton: "रिपोर्ट", + industryCreateTask: "कार्य बनाएँ", + industryOpenDispute: "विवाद खोलें", + industryMaterialsPlaceholder: "solar-panel:2:120\nbattery:1:80\nkit:1:15", + industryOutputUnitPlaceholder: "जैसे इकाई, kg, लाइसेंस", + industryOutputItemPlaceholder: "जैसे robot", + industryOutputQtyPlaceholder: "जैसे 5", + industrySkillsPlaceholder: "जैसे सोल्डरिंग, नेटवर्किंग, सौर-स्थापना", + industryCreateInvite: "आमंत्रण उत्पन्न करें", + industryPauseVotes: "स्थिति मत", + industryTitle: "उद्योग", + industryDescription: "उत्पादन के साधनों को सामूहिक रूप से प्रबंधित करने का मॉड्यूल।", + industryLabel: "उद्योग", + typeIndustryBuild: "बिल्ड", + typeIndustryBlueprint: "ब्लूप्रिंट", + typeIndustryAllocation: "वितरण", + typeIndustry: "उद्योग", + modulesIndustryLabel: "उद्योग", + modulesIndustryDescription: "उत्पादन के साधनों को सामूहिक रूप से प्रबंधित करने का मॉड्यूल।", + industryFilterAll: "सभी", + industryFilterMember: "सदस्य", + industryFilterBlueprints: "ब्लूप्रिंट", + industryFilterBuilds: "उत्पादन", + industryFilterMine: "मेरी", + industryFilterActive: "चालू", + industryFilterPaused: "रोकी गई", + industryFilterDissolved: "विघटित", + industryAllTitle: "उद्योग", + industryMemberTitle: "जिन फैक्टरियों में मैं हूँ", + industryMineTitle: "मेरी फैक्टरियाँ", + industryActiveTitle: "चालू", + industryPausedTitle: "रोकी गई फैक्टरियाँ", + industryDissolvedTitle: "विघटित फैक्टरियाँ", + industryNoFacilitiesFound: "कोई फैक्टरी नहीं मिली।", + industryCreateFacility: "फैक्टरी बनाएं", + industryMembers: "सदस्य", + industryMemberBadge: "सदस्य", + industryName: "नाम", + industryNamePlaceholder: "फैक्टरी का नाम", + industryDescriptionLabel: "विवरण", + industryDescriptionPlaceholder: "यह फैक्टरी क्या उत्पादित करती है?", + industrySector: "क्षेत्र", + industryMembershipPolicy: "सदस्यता नीति", + industryQuorum: "कोरम", + industryMajority: "बहुमत", + industryLaborRate: "श्रम दर", + industryCreateButton: "फैक्टरी बनाएं", + industryUpdateButton: "अपडेट", + industrySteward: "प्रबंधक", + industryStatusActive: "चालू", + industryStatusPaused: "रोका गया", + industryStatusDissolved: "विघटित", + industryStatusLabel: "स्थिति", + industryJoinButton: "शामिल हों", + industryApplyButton: "शामिल होने का अनुरोध", + industryLeaveButton: "छोड़ें", + industryInviteButton: "आमंत्रित करें", + industryInvitePlaceholder: "@...=.ed25519", + industryInviteNeeded: "शामिल होने के लिए आपको आमंत्रण चाहिए।", + industryPauseButton: "रोकने के लिए मतदान", + industryResumeButton: "फिर से शुरू करने के लिए मतदान", + industryEditButton: "संपादित करें", + industryDeleteButton: "हटाएं", + industryBackToList: "← उद्योग", + industryPendingApplicants: "लंबित आवेदक", + industryVotesYes: "हाँ", + industryVotesNo: "नहीं", + industryAlreadyVoted: "मतदान किया", + industryAdmit: "स्वीकार करें", + industryReject: "अस्वीकार करें", + industryDissolveTitle: "फैक्टरी विघटित करें", + industryDissolveHint: "विघटन के लिए सामूहिक मतदान आवश्यक है; प्रबंधक अकेले विघटित नहीं कर सकता।", + industryDissolveVote: "विघटन के लिए मतदान", + industrySector_software: "सॉफ्टवेयर", + industrySector_hardware: "हार्डवेयर", + industrySector_agriculture: "कृषि", + industrySector_textile: "वस्त्र", + industrySector_energy: "ऊर्जा", + industrySector_food: "खाद्य", + industrySector_construction: "निर्माण", + industrySector_media: "मीडिया", + industrySector_services: "सेवाएं", + industrySector_other: "अन्य", + industryPolicy_open: "खुला", + industryPolicy_vote: "मतदान से", + industryPolicy_invite: "केवल आमंत्रण", projectsTitle: "परियोजनाएँ", projectsDescription: "अपने नेटवर्क में समुदाय-संचालित परियोजनाएँ बनाएँ, वित्तपोषित करें और अनुसरण करें।", projectCreateProject: "परियोजना बनाएँ", @@ -3260,6 +3560,8 @@ module.exports = { chatOpenTitle: "Open Chats", chatClosedTitle: "Closed Chats", chatDescription: "विवरण", + chatMessageReply: "उत्तर", + chatMessageLabel: "संदेश", chatCategory: "श्रेणी", chatStatus: "स्थिति", chatFilterAll: "सभी", diff --git a/src/client/assets/translations/oasis_it.js b/src/client/assets/translations/oasis_it.js index a71a93f..3cfe5c4 100644 --- a/src/client/assets/translations/oasis_it.js +++ b/src/client/assets/translations/oasis_it.js @@ -1,26 +1,6 @@ const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); module.exports = { it: { - bbAdd: "Aggiungi", - bbQuickActions: "Azioni rapide", - bbPickTitle: "Fissa un modulo", - bbPickDesc: "Scegli un modulo per lo spazio + nella barra inferiore.", - bbPickNone: "Nessuno", - bbManage: "Modifica", - bbYourBar: "La tua barra", - bbRemove: "Rimuovi", - bbFull: "Massimo raggiunto", - bbDone: "Fatto", - activityChangeFilter: "Cambia filtro", - emptyConnectHint: "Connettiti a un pub per sincronizzarti con la rete.", - emptyConnectCta: "Connettiti a un pub", - menuCommunity: "Comunità", - activityGroupCommunity: "Comunità e governance", - activityGroupOrg: "Organizzazione", - activityGroupComm: "Comunicazione", - activityGroupEconomy: "Economia", - activityGroupMedia: "Contenuti e media", - activityGroupOther: "Altri", languageName: "Italiano", shopOrderStatusPaid: "Pagamento ricevuto", shopOrderStatusReceived: "Ricevuto", @@ -426,7 +406,8 @@ module.exports = { publish: "Pubblica", contentWarningPlaceholder: "Aggiungi un oggetto al post (opzionale)", privateWarningPlaceholder: "Aggiungi abitanti per inviare un post privato (opzionale)", - publishWarningPlaceholder: "...", + publishWarningPlaceholder: "Corpo limitato a 7000 caratteri.", + publishTooLong: "Il tuo post è troppo lungo. Accorcialo, per favore.", publishCustomDescription: [ "RICORDA: A causa della tecnologia blockchain, una volta pubblicato un post non può essere modificato né eliminato.", ], @@ -491,7 +472,89 @@ module.exports = { passwordLengthInfo: "La password deve avere almeno 32 caratteri.", passwordImport: "Scrivi la tua password per decifrare i dati che verranno salvati nella home del sistema (nome: secret)", exportPasswordPlaceholder: "Usa minuscole, maiuscole, numeri e simboli", - fileInfo: "La tua chiave segreta crittografata verrà salvata nella home del sistema (nome: oasis.enc)", + fileInfo: "La tua chiave segreta crittografata verrà scaricata sul tuo dispositivo (nome: oasis.enc)", + developer: "Sviluppo", + devTitle: "Sviluppo", + welcomeBannerText: "Benvenuta in OASIS. Segui questi passi rapidi per configurare il tuo avatar.", + welcomeBannerAction: "Inizia!", + welcomeTitle: "Benvenuta", + welcomeStepGreetingAction: "Invia", + welcomeJoinPubButton: "Unisciti al pub", + welcomeStepLarpAction: "Unisciti a \"L Accademia\"", + welcomeStepLarpText: "Unisciti al nostro gioco di ruolo dal vivo.", + welcomeStepLarpTitle: "Unisciti al L.A.R.P.", + welcomeStepGreetingTitle: "Invia un \"Ciao mondo!\"", + welcomeStepGreetingText: "Invia un messaggio perché gli altri abitanti possano scoprirti.", + welcomeJoinPub: "Unisciti a", + welcomeDescription: "Alcune cose da fare prima di iniziare.", + welcomeProgress: "Completato", + welcomeStepDone: "fatto", + welcomeStepPending: "in sospeso", + welcomeSkip: "Non ora", + welcomeStepLanguageTitle: "Scegli la tua lingua", + welcomeStepLanguageText: "Oasis parla diverse lingue. Puoi cambiarla quando vuoi.", + welcomeStepProfileTitle: "Modifica il tuo profilo", + welcomeStepProfileText: "Scegli un nome, aggiungi una descrizione e metti un immagine perché gli altri abitanti ti riconoscano.", + welcomeStepProfileAction: "Salva profilo", + welcomeStepFederationTitle: "Unisciti alla rete principale", + welcomeStepFederationText: "Collegati al nostro pub per incontrare altri abitanti e iniziare a replicare.", + welcomeStepFederationAction: "Vai agli inviti", + welcomeStepBackupTitle: "Fai il backup del tuo ID", + welcomeStepBackupText: "La tua identità è un file di chiavi su questo dispositivo.", + welcomeStepBackupWarning: "SE LO PERDI, NESSUNO POTRÀ RECUPERARLO PER TE.", + welcomeStepBackupAction: "Backup!", + welcomeCompleteTitle: "Tutto pronto.", + welcomeCompleteText: "Il tuo nodo è pronto. Da qui la rete cresce con le persone che segui.", + welcomeFindPeople: "Trova abitanti", + devFilterFiles: "FILE", + devFilterSearch: "CERCA", + devFilterModules: "MODULI", + devFilterTranslations: "TRADUZIONI", + devFilterStyles: "STILI", + devFilterTests: "TEST", + devVersion: "Versione", + devNodeVersion: "Node", + devStatsTests: "Suite di test", + devParentFolder: "Cartella superiore", + devOpenFolder: "apri", + devDescription: "Esplora il codice sorgente in esecuzione su questo nodo.", + devTreeTitle: "File", + devSearchTitle: "Cerca nel codice", + devMapTitle: "Moduli", + devRoot: "radice", + devRootButton: "RADICE", + devSearchPlaceholder: "Testo da cercare nel codice", + devAnyExtension: "Qualsiasi file", + devSearchButton: "Cerca", + devStatsFiles: "File", + devStatsLines: "Righe", + devStatsModules: "Moduli", + devStatsLanguages: "Lingue", + devNotViewable: "non visualizzabile", + devEmptyFolder: "Questa cartella è vuota.", + devSize: "Dimensione", + devShowing: "Mostrando", + devLine: "riga", + devReportBug: "Segnala Bug", + devCreateTask: "Crea Attività", + devRaw: "RAW", + devReportContext: "Trovato leggendo il codice sorgente di Oasis", + devTaskPrefix: "Rivedere", + devPrevPage: "Righe precedenti", + devNextPage: "Righe successive", + devMatches: "corrispondenze", + devFilesScanned: "file analizzati", + devNoResults: "Ancora nessuna corrispondenza.", + devColModule: "Modulo", + devColState: "Stato", + devColModel: "Modello", + devColView: "Vista", + devColRoutes: "Rotte", + devColTests: "Test", + devOn: "attivo", + devOff: "inattivo", + modulesDevLabel: "Sviluppo", + modulesDevDescription: "Modulo per gestire il codice sorgente di Oasis.", themeIntro: "Scegli un tema.", setTheme: "Imposta tema", @@ -693,7 +756,7 @@ module.exports = { spreadLabel: "Diffondi", spreadHint: "Diffondi questo a chi ti supporta (replica via il tuo feed).", welcomePmSubject: "Hello World!", - welcomePmBody: "Benvenuto su Oasis — un social network libero, peer-to-peer, federato e cifrato, con un'architettura distribuita solo su invito.\n\nAlcuni posti interessanti da cui partire:\n\n- /profile — Modifica il tuo avatar, nome, descrizione e quali moduli appaiono sul tuo lato pubblico.\n- /invites — Aggiungi un pub per federarti con il resto della rete.\n- /peers — Vedi chi è connesso, riscansiona la LAN, esporta o importa liste di peer.\n- /inhabitants — Scopri e visita gli avatar di altre persone.\n- /tribes — Crea o unisciti a gruppi privati cifrati end-to-end.\n- /inbox — Leggi e invia messaggi privati.\n- /activity — Vedi l'attività recente, tua e della tua rete.\n- /publish — Scrivi un post o condividi un'immagine, un audio, un video o un documento.\n\nAlcuni luoghi interessanti per connettersi:\n\n- /fediverse — Gestisci i tuoi altri account del fediverso, inclusi l'invio e la ricezione di contenuti.\n\nQuesto messaggio è stato inviato privatamente da te a te stesso, perciò non serviva alcuna connessione per riceverlo.", + welcomePmBody: "Benvenuto su Oasis — un social network libero, peer-to-peer, federato e cifrato, con un'architettura distribuita solo su invito.\n\nAlcuni posti interessanti da cui partire:\n\n- /profile — Modifica il tuo avatar, nome, descrizione e quali moduli appaiono sul tuo lato pubblico.\n- /invites — Aggiungi un pub per federarti con il resto della rete.\n- /peers — Vedi chi è connesso, riscansiona la LAN, esporta o importa liste di peer.\n- /inhabitants — Scopri e visita gli avatar di altre persone.\n- /tribes — Crea o unisciti a gruppi privati cifrati end-to-end.\n- /inbox — Leggi e invia messaggi privati.\n- /activity — Vedi l'attività recente, tua e della tua rete.\n- /publish — Scrivi un post o condividi un'immagine, un audio, un video o un documento.\n- /search — Cerca i contenuti della rete per tipo.\n\nAlcuni luoghi interessanti per connettersi:\n\n- /fediverse — Gestisci i tuoi altri account del fediverso, inclusi l'invio e la ricezione di contenuti.\n\nQuesto messaggio è stato inviato privatamente da te a te stesso, perciò non serviva alcuna connessione per riceverlo.", profileVisibilityTitle: "Visibilità del profilo pubblico", profileVisibilityHint: "Scegli quali campi mostrano gli altri abitanti nel tuo profilo. Per impostazione predefinita appare solo Karma.", profileSensorsSectionTitle: "Sensori", @@ -1224,6 +1287,9 @@ module.exports = { eventButton: "EVENTI", taskButton: "COMPITI", votesButton: "VOTAZIONI", + industryButton: "INDUSTRIA", + projectButton: "PROGETTI", + shopProductButton: "PRODOTTI", reportButton: "SEGNALAZIONI", feedButton: "FEED", marketButton: "MERCATO", @@ -1252,7 +1318,7 @@ module.exports = { trendingItemStatus: "Stato articolo", trendingTotalVotes: "Voti totali", trendingTotalOpinions: "Opinioni totali", - trendingNoContentMessage: "Nessun contenuto di tendenza disponibile.", + trendingNoContentMessage: "Ancora nessuna tendenza.", trendingAuthor: "Di", trendingCreatedAtLabel: "Creato il", trendingTotalCount: "Conteggio totale", @@ -1441,7 +1507,7 @@ module.exports = { transfersCreatedAt: "Creato il", transfersConfirmations: "CONFERME", transfersContainingBlock: "Blocco contenitore", - transfersExportContract: "Crea contratto", + transfersExportContract: "Contratto intelligente", transfersConfirmButton: "Conferma trasferimento", transfersNoItems: "Nessun trasferimento trovato.", transfersMineSectionTitle: "I tuoi trasferimenti", @@ -1567,7 +1633,7 @@ module.exports = { cvDeleteButton: "Elimina", cvNoCV: "Nessun CV trovato.", blogSubject: "Oggetto", - blogMessage: "Corpo limitato a 8096 caratteri.", + blogMessage: "Messaggio", blogImage: "Carica media (max: 50MB)", blogPublish: "Anteprima", noPopularMessages: "Nessun messaggio popolare pubblicato ancora", @@ -2202,6 +2268,7 @@ module.exports = { agendaFilterReports: "SEGNALAZIONI", agendaFilterTransfers: "TRASFERIMENTI", agendaFilterJobs: "LAVORI", + agendaFilterIndustry: "INDUSTRIA", agendaFilterProjects: "PROGETTI", agendaFilterCalendars: "CALENDARI", agendaNoItems: "Nessun incarico trovato.", @@ -2323,16 +2390,31 @@ module.exports = { privateMessage: "PM", pmSendTitle: "Messaggi privati", pmSend: "Invia!", - pmDescription: "Usa questo modulo per inviare un messaggio crittografato ad altri abitanti.", + pmCancel: "Annulla", + pmReset: "Reimposta", + pmSharedKeyHint: "Condividi questa chiave con il destinatario tramite un altro canale. È necessaria per decifrare.", + pmDescription: "Usa questo modulo per inviare un messaggio/file cifrato ad altri abitanti.", + pmComposeTitle: "Invia un messaggio", pmRecipients: "Destinatari", pmRecipientsHint: "Inserisci Oasis ID separati da virgole", pmLevelOfExposition: "Level of exposition", - pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmLimitsHint: "Fino a 7 destinatari per messaggio.", + pmTextPlaceholder: "Corpo limitato a 7000 caratteri.", pmSubject: "Oggetto", pmSubjectHint: "Inserisci l'oggetto del messaggio", pmText: "Messaggio", pmCrypterChip: "2xE2E", pmCrypterDecryptButton: "Decifra", + fileShareTitle: "Condividi un file", + fileShareRecipientPlaceholder: "@...=.ed25519", + fileShareFileLabel: "File", + fileShareDownload: "Scarica", + fileShareMutualError: "Puoi condividere file solo con abitanti con supporto reciproco.", + fileShareNoFile: "Nessun file selezionato.", + fileShareTooLarge: "Il file supera la dimensione consentita.", + fileShareFailed: "Impossibile preparare il file.", + fileShareSendError: "Impossibile inviare il file.", + fileShareUnavailable: "Questo file non è disponibile al momento. Riprova più tardi.", pmCrypterBadKey: "La chiave condivisa è errata!", pmCrypterTooLong: "Il messaggio è troppo lungo per essere cifrato con il Crypter. Accorcialo e riprova.", pmCrypterCipherLabel: "Messaggio ricifrato", @@ -2353,15 +2435,27 @@ module.exports = { pmNoSubject: "(senza oggetto)", pmSubjectLabel: "Oggetto:", pmBodyLabel: "Messaggio", - pmBotJobs: "42-LavoriBOT", - pmBotProjects: "42-ProgettiBOT", - pmBotMarket: "42-MercatoBOT", + pmBotJobs: "JobsBot", + pmBotProjects: "ProjectsBot", + pmBotMarket: "MarketBot", + pmBotShops: "ShopsBot", + pmBotPolitical: "PoliticalBot", + politicalBotLarpTitle: "La casa al governo del L.A.R.P è cambiata.", + politicalBotParliamentTitle: "È iniziato un nuovo ciclo di governo nel Parlamento.", + politicalBotTribeTitle: "È iniziato un nuovo ciclo di governo in una delle tue Tribù.", + politicalBotLarpIntro: "Ora governa {house} durante questo ciclo: {cycle}", + politicalBotParliamentIntro: "Il governo attuale è {gov}", + politicalBotParliamentIntroLeader: "Il governo attuale è {gov}, esercitato da {leader}", + politicalBotTribeIntro: "Il governo attuale nella Tribù {tribe} è {gov}", + politicalBotTribeIntroLeader: "Il governo attuale nella Tribù {tribe} è {gov}, esercitato da {leader}", + politicalBotLeaderLabel: "Leader", inboxJobSubscribedTitle: "Nuova iscrizione alla tua offerta di lavoro", pmInhabitantWithId: "Abitante con Oasis ID:", pmHasSubscribedToYourJobOffer: "si è iscritto alla tua offerta di lavoro", inboxProjectCreatedTitle: "Nuovo progetto creato", pmHasCreatedAProject: "ha creato un progetto", inboxMarketItemSoldTitle: "Articolo venduto", + inboxShopSoldTitle: "Prodotto venduto", pmYourItem: "Il tuo articolo", pmHasBeenSoldTo: "è stato venduto a", pmFor: "per", @@ -2396,7 +2490,7 @@ module.exports = { visitContent: "Visita contenuto", banking: 'Banking', bankingTitle: 'Banking', - bankingDescription: 'Esplora il valore attuale di ECOin e la corrispondente allocazione UBI, distribuita per epoca in base a partecipazione e fiducia.', + bankingDescription: "L'economia ECOin: saldo, reddito di base, tasse e valore dell'industria.", bankOverview: 'Panoramica', bankEpochs: 'Epoche', bankRules: 'Regole', @@ -2430,6 +2524,10 @@ module.exports = { bankEpochId: 'ID epoca', bankRuleHash: 'Hash snapshot regole', bankViewEpoch: 'Vedi epoca', + bankYourIndustryBalance: "La tua quota industriale", + bankYourUbiMonth: "Il tuo RBU (questo mese)", + bankYourFundsMonth: "I tuoi fondi (questo mese)", + bankIndustryBalance: "Produzione industriale (stimata)", bankUserBalance: 'Il tuo saldo', ecoWalletNotConfigured: 'Portafoglio ECOin non configurato', editWallet: 'Modifica portafoglio', @@ -2469,7 +2567,7 @@ module.exports = { pubIdPlaceholder: "@PUB_ID.ed25519", bankUbiAvailableNo: "NESSUN FONDO!", bankUbiAvailableOk: "DISPONIBILE!", - bankUbiAvailability: "Disponibilità UBI", + bankUbiAvailability: "RBU (rete)", bankAlreadyClaimedThisMonth: "Già richiesto questo mese", bankUbiThisMonth: "RBU (questo mese)", bankUbiLastClaimed: "RBU (ultima richiesta)", @@ -2899,6 +2997,208 @@ module.exports = { jobsTagsLabel: "Tag", jobsTagsPlaceholder: "tag1, tag2, tag3", noJobsMatch: "Nessun lavoro corrisponde alla ricerca.", + industrySearchPlaceholder: "Cerca fabbriche…", + industryAllSectors: "Tutti i settori", + industryVisitButton: "Visita industria", + industryAdvanceTo: "Imposta a", + industryAmount: "Importo", + industryApproveBuild: "Approva", + industryBackToFacility: "← Fabbrica", + industryBlueprint: "Blueprint", + industryBlueprintLabel: "Blueprint di Industria", + industryBlueprints: "Blueprint", + industryBuild: "Build", + industryBuildNotes: "Note", + industryBuildStart: "Data di inizio", + industryVoteUpdateButton: "Vota aggiornamento", + industryVoteDeleteButton: "Vota eliminazione", + industryRevokeVote: "Revoca", + industryTimeLeft: "Tempo rimanente", + industryBuildEnd: "Data di fine", + industryBuilds: "Build", + industryBuildTitle: "Titolo", + industryContribute: "Contribuisci", + industryContributeEco: "Contribuisci ECO", + industryContributeLabor: "Contribuisci lavoro", + industryContributeButton: "Contribuisci", + industryContributeMaterial: "Contribuisci materiale", + industryContributions: "Contributi", + industryContributors: "contributori", + industryCreateBlueprintButton: "Crea blueprint", + industryDistribute: "Distribuisci", + industryDistributeButton: "Distribuisci per quote", + industryDistribution: "Distribuzione", + industryEcoAmount: "Importo (ECO)", + industryEcoContribHint: "I contributi in ECO vengono trasferiti all'amministratore come custode della tesoreria del build.", + industryEditBlueprint: "Modifica blueprint", + industryFacility: "Fabbrica", + industryKindLabel: "Tipo", + industryKind_labor: "Lavoro", + industryKind_material: "Materiale", + industryKind_eco: "Fondi", + industryLaborHours: "Lavoro (ore)", + industryHours: "Ore", + industryLicense: "Licenza", + industryMaterialItem: "Materiale", + industryMaterials: "Materiali", + industryMaterialsHint: "Un materiale per riga, nel formato nome:quantità:prezzo.", + industryEstMaterials: "Totale materiali", + industryEstLabor: "Totale lavoro", + industryEstTaxes: "Tasse", + industryEstTotal: "Prezzo stimato", + industryBuildingPrice: "Prezzo di costruzione", + industryFinalPrice: "Prezzo finale", + industryBlueprintDescPlaceholder: "Specifiche, dimensioni, peso, documentazione…", + industryMaterialValue: "Valore (ECO)", + industryMember: "Membro", + industryNewBlueprint: "Nuovo blueprint", + industryNewBuild: "Proponi un build", + industryEditBuild: "Modifica produzione", + industryNoBlueprint: "— nessuno —", + industryNeedBlueprint: "Crea prima un progetto: ogni produzione ne fabbrica uno.", + industryNoBlueprints: "Ancora nessun blueprint.", + industryNoBuilds: "Ancora nessun build.", + industryNote: "Nota", + industryOutput: "Output", + industryOutputItem: "Nome del prodotto", + industryOutputKind: "Tipo di prodotto", + industryOutputQty: "Unità per produzione", + industryOutputUnit: "Unità di misura", + industryOutputValue: "Valore di output (ECO, es. dalla vendita)", + industryPayout: "Pagamento", + industryPoints: "Karma", + industryPostJob: "Crea lavoro", + industryPot: "Monte", + industryProposeBuildButton: "Proponi build", + industryProposer: "Proponente", + industryAuthor: "Autore", + industrySellOnMarket: "Invia al mercato", + industrySendToProjects: "Crea progetto", + industryShare: "Quota", + industryShares: "Quote", + industrySkills: "Competenze", + industryTreasury: "Tesoreria", + industryKind_physical: "Fisico", + industryKind_digital: "Digitale", + industryBuildStatus_PROPOSED: "PROPOSTO", + industryBuildStatus_REJECTED: "RESPINTO", + industryBuildStatus_APPROVED: "APPROVATO", + industryBuildStatus_STOCKING: "APPROVVIGIONAMENTO", + industryBuildStatus_IN_PRODUCTION: "IN PRODUZIONE", + industryBuildStatus_COMPLETED: "COMPLETATO", + industryBuildStatus_FAILED: "FALLITO", + pmBotIndustry: "IndustryBot", + industryBotAdmittedTitle: "Sei stato ammesso in una fabbrica.", + industryBotApplicationTitle: "Nuova richiesta di adesione nella tua fabbrica.", + industryBotInvitedTitle: "Sei stato invitato in una fabbrica.", + industryBotDissolvedTitle: "Una fabbrica a cui appartieni è stata sciolta.", + industryBotBuildApprovedTitle: "Un build è stato approvato.", + industryBotDistributedTitle: "L'output di un build è stato distribuito.", + industryFilterRules: "REGOLE", + industryRulesTitle: "Regole", + industryRulesIntro: "Industry permette agli abitanti di possedere collettivamente i mezzi di produzione: una Fabbrica è un'officina della rete che nessuno possiede privatamente.", + industryRulesSteward: "Il fondatore è l'amministratore — un custode, non un proprietario: non può vendere, privatizzare o sciogliere la Fabbrica da solo.", + industryRulesMembership: "La politica di adesione stabilisce come si entra: Aperta (subito), Per voto (ammessi dal voto dei membri) o Solo su invito (un membro deve invitare prima).", + industryRulesQuorum: "Il quorum è il numero minimo di membri che devono votare perché una decisione conti, così una piccola Fabbrica non è decisa da una sola persona.", + industryRulesMajority: "La maggioranza è la frazione di voti necessaria (0,5 = metà, 1 = unanimità); una decisione passa quando i SÌ raggiungono almeno max(quorum, membri × maggioranza).", + industryRulesDecisions: "I membri votano per ammettere nuovi arrivati, approvare i build e sciogliere la Fabbrica; l'amministratore non può scavalcare queste decisioni collettive.", + industryRulesBlueprints: "I Blueprint sono ricette libere (COPYLEFT) — gli input (materiali, ore di lavoro, competenze) e l'output (un bene fisico o digitale) — e chiunque può forkarle.", + industryRulesBuilds: "Un Build è un ordine di produzione: PROPOSTO → APPROVATO (per voto) → APPROVVIGIONAMENTO → IN PRODUZIONE → COMPLETATO, e accetta contributi solo una volta approvato.", + industryRulesContributions: "I membri contribuiscono con lavoro (ore), materiali (valore ECO dichiarato) o ECO a un build; ogni contributo guadagna karma.", + industryRulesLaborRate: "La tariffa di lavoro è il valore ECO di un'ora di lavoro in questa Fabbrica; converte le ore in karma per confrontare equamente lavoro e capitale: karma = ore × tariffa + valore materiali + ECO.", + industryRulesShares: "La tua quota di un build è il tuo karma ÷ karma totale, e stabilisce quanto del valore di output ricevi.", + industryRulesDistribution: "Quando una produzione è completata, lo steward distribuisce il monte (i fondi della produzione più il valore di vendita) tra i contributori, in proporzione alle loro quote.", + industryRulesTreasury: "I contributi in ECO sono custoditi dall'amministratore come tesoreria del build fino alla distribuzione; tutti i movimenti sono registrati sulla rete e le dispute possono andare alle Courts.", + industryJobs: "Lavori", + industryNoJobs: "Ancora nessuna posizione.", + industryCreatePosition: "Crea posizione", + industryViewCV: "CV", + industryReportButton: "Segnala", + industryCreateTask: "Crea attività", + industryOpenDispute: "Apri controversia", + industryMaterialsPlaceholder: "solar-panel:2:120\nbattery:1:80\nkit:1:15", + industryOutputUnitPlaceholder: "es. unità, kg, licenza", + industryOutputItemPlaceholder: "es. robot", + industryOutputQtyPlaceholder: "es. 5", + industrySkillsPlaceholder: "es. saldatura, reti, installazione-solare", + industryCreateInvite: "Genera invito", + industryPauseVotes: "Voti di stato", + industryTitle: "Industria", + industryDescription: "Modulo per gestire collettivamente i mezzi di produzione.", + industryLabel: "Industria", + typeIndustryBuild: "BUILD", + typeIndustryBlueprint: "BLUEPRINT", + typeIndustryAllocation: "DISTRIBUZIONE", + typeIndustry: "INDUSTRIA", + modulesIndustryLabel: "Industria", + modulesIndustryDescription: "Modulo per gestire collettivamente i mezzi di produzione.", + industryFilterAll: "TUTTE", + industryFilterMember: "MEMBRO", + industryFilterBlueprints: "PROGETTI", + industryFilterBuilds: "PRODUZIONI", + industryFilterMine: "MIE", + industryFilterActive: "IN FUNZIONE", + industryFilterPaused: "IN PAUSA", + industryFilterDissolved: "SCIOLTE", + industryAllTitle: "Industria", + industryMemberTitle: "Fabbriche di cui faccio parte", + industryMineTitle: "Le mie fabbriche", + industryActiveTitle: "In funzione", + industryPausedTitle: "Fabbriche in pausa", + industryDissolvedTitle: "Fabbriche sciolte", + industryNoFacilitiesFound: "Nessuna fabbrica trovata.", + industryCreateFacility: "Crea fabbrica", + industryMembers: "Membri", + industryMemberBadge: "MEMBRO", + industryName: "Nome", + industryNamePlaceholder: "Nome della fabbrica", + industryDescriptionLabel: "Descrizione", + industryDescriptionPlaceholder: "Cosa produce questa fabbrica?", + industrySector: "Settore", + industryMembershipPolicy: "Politica di adesione", + industryQuorum: "Quorum", + industryMajority: "Maggioranza", + industryLaborRate: "Tariffa di lavoro", + industryCreateButton: "Crea fabbrica", + industryUpdateButton: "Aggiorna", + industrySteward: "Amministratore", + industryStatusActive: "IN FUNZIONE", + industryStatusPaused: "IN PAUSA", + industryStatusDissolved: "SCIOLTA", + industryStatusLabel: "Stato", + industryJoinButton: "Unisciti", + industryApplyButton: "Richiedi di unirti", + industryLeaveButton: "Esci", + industryInviteButton: "Invita", + industryInvitePlaceholder: "@...=.ed25519", + industryInviteNeeded: "Serve un invito per unirti.", + industryPauseButton: "Vota la pausa", + industryResumeButton: "Vota la ripresa", + industryEditButton: "Modifica", + industryDeleteButton: "Elimina", + industryBackToList: "← Industria", + industryPendingApplicants: "Richieste in sospeso", + industryVotesYes: "sì", + industryVotesNo: "no", + industryAlreadyVoted: "votato", + industryAdmit: "Ammetti", + industryReject: "Rifiuta", + industryDissolveTitle: "Sciogli fabbrica", + industryDissolveHint: "Lo scioglimento richiede un voto collettivo; l'amministratore non può sciogliere da solo.", + industryDissolveVote: "Vota lo scioglimento", + industrySector_software: "Software", + industrySector_hardware: "Hardware", + industrySector_agriculture: "Agricoltura", + industrySector_textile: "Tessile", + industrySector_energy: "Energia", + industrySector_food: "Alimentare", + industrySector_construction: "Costruzioni", + industrySector_media: "Media", + industrySector_services: "Servizi", + industrySector_other: "Altro", + industryPolicy_open: "Aperta", + industryPolicy_vote: "Per voto", + industryPolicy_invite: "Solo su invito", projectsTitle: "Progetti", projectsDescription: "Crea, finanzia e segui progetti comunitari nella tua rete.", projectCreateProject: "Crea progetto", @@ -3259,6 +3559,8 @@ module.exports = { chatOpenTitle: "Open Chats", chatClosedTitle: "Closed Chats", chatDescription: "Descrizione", + chatMessageReply: "Risposta", + chatMessageLabel: "Messaggio", chatCategory: "Categoria", chatStatus: "STATO", chatFilterAll: "TUTTI", diff --git a/src/client/assets/translations/oasis_pt.js b/src/client/assets/translations/oasis_pt.js index 07a52ad..376820c 100644 --- a/src/client/assets/translations/oasis_pt.js +++ b/src/client/assets/translations/oasis_pt.js @@ -1,26 +1,6 @@ const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); module.exports = { pt: { - bbAdd: "Adicionar", - bbQuickActions: "Ações rápidas", - bbPickTitle: "Fixar um módulo", - bbPickDesc: "Escolhe um módulo para o espaço + da barra inferior.", - bbPickNone: "Nenhum", - bbManage: "Editar", - bbYourBar: "Sua barra", - bbRemove: "Remover", - bbFull: "Máximo atingido", - bbDone: "Concluído", - activityChangeFilter: "Mudar filtro", - emptyConnectHint: "Liga-te a um pub para sincronizar com a rede.", - emptyConnectCta: "Ligar a um pub", - menuCommunity: "Comunidade", - activityGroupCommunity: "Comunidade e governança", - activityGroupOrg: "Organização", - activityGroupComm: "Comunicação", - activityGroupEconomy: "Economia", - activityGroupMedia: "Conteúdo e média", - activityGroupOther: "Outros", languageName: "Português", shopOrderStatusPaid: "Pagamento recebido", shopOrderStatusReceived: "Recebido", @@ -426,7 +406,8 @@ module.exports = { publish: "Publicar", contentWarningPlaceholder: "Adiciona um assunto à publicação (opcional)", privateWarningPlaceholder: "Adiciona habitantes para enviar uma publicação privada (opcional)", - publishWarningPlaceholder: "...", + publishWarningPlaceholder: "Corpo limitado a 7000 caracteres.", + publishTooLong: "A tua publicação é demasiado longa. Encurta-a, por favor.", publishCustomDescription: [ "LEMBRA-TE: Devido à tecnologia blockchain, uma vez publicado, não pode ser editado nem eliminado.", ], @@ -491,7 +472,89 @@ module.exports = { passwordLengthInfo: "A senha deve ter pelo menos 32 caracteres.", passwordImport: "Escreve a tua senha para decifrar os dados que serão guardados no teu diretório pessoal (nome: secret)", exportPasswordPlaceholder: "Usa minúsculas, maiúsculas, números e símbolos", - fileInfo: "A tua chave secreta encriptada será guardada no teu diretório pessoal (nome: oasis.enc)", + fileInfo: "A tua chave secreta encriptada será descarregada para o teu dispositivo (nome: oasis.enc)", + developer: "Desenvolvimento", + devTitle: "Desenvolvimento", + welcomeBannerText: "Bem-vinda ao OASIS. Segue estes passos rápidos para configurar o teu avatar.", + welcomeBannerAction: "Começar!", + welcomeTitle: "Bem-vinda", + welcomeStepGreetingAction: "Enviar", + welcomeJoinPubButton: "Juntar-se ao pub", + welcomeStepLarpAction: "Juntar-se à \"Academia\"", + welcomeStepLarpText: "Junta-te ao nosso jogo de representação ao vivo.", + welcomeStepLarpTitle: "Junta-te ao L.A.R.P.", + welcomeStepGreetingTitle: "Envia um \"Olá mundo!\"", + welcomeStepGreetingText: "Envia uma mensagem para que outros habitantes te possam descobrir.", + welcomeJoinPub: "Juntar-se a", + welcomeDescription: "Algumas coisas que vale a pena fazer antes de começar.", + welcomeProgress: "Concluído", + welcomeStepDone: "feito", + welcomeStepPending: "pendente", + welcomeSkip: "Agora não", + welcomeStepLanguageTitle: "Escolhe o teu idioma", + welcomeStepLanguageText: "O Oasis fala vários idiomas. Podes mudar quando quiseres.", + welcomeStepProfileTitle: "Edita o teu perfil", + welcomeStepProfileText: "Escolhe um nome, acrescenta uma descrição e põe uma imagem para que os outros habitantes te reconheçam.", + welcomeStepProfileAction: "Guardar perfil", + welcomeStepFederationTitle: "Junta-te à rede principal", + welcomeStepFederationText: "Liga-te ao nosso pub para encontrar outros habitantes e começar a replicar.", + welcomeStepFederationAction: "Ir aos convites", + welcomeStepBackupTitle: "Faz cópia do teu ID", + welcomeStepBackupText: "A tua identidade é um ficheiro de chaves neste dispositivo.", + welcomeStepBackupWarning: "SE O PERDERES, NINGUÉM O PODE RECUPERAR POR TI.", + welcomeStepBackupAction: "Cópia de segurança!", + welcomeCompleteTitle: "Tudo pronto.", + welcomeCompleteText: "O teu nó está pronto. A partir daqui a rede cresce com as pessoas que segues.", + welcomeFindPeople: "Procurar habitantes", + devFilterFiles: "FICHEIROS", + devFilterSearch: "PROCURAR", + devFilterModules: "MÓDULOS", + devFilterTranslations: "TRADUÇÕES", + devFilterStyles: "ESTILOS", + devFilterTests: "TESTES", + devVersion: "Versão", + devNodeVersion: "Node", + devStatsTests: "Suites de testes", + devParentFolder: "Pasta superior", + devOpenFolder: "abrir", + devDescription: "Explora o código-fonte em execução neste nó.", + devTreeTitle: "Ficheiros", + devSearchTitle: "Procurar no código", + devMapTitle: "Módulos", + devRoot: "raiz", + devRootButton: "RAIZ", + devSearchPlaceholder: "Texto a procurar no código", + devAnyExtension: "Qualquer ficheiro", + devSearchButton: "Procurar", + devStatsFiles: "Ficheiros", + devStatsLines: "Linhas", + devStatsModules: "Módulos", + devStatsLanguages: "Idiomas", + devNotViewable: "não visualizável", + devEmptyFolder: "Esta pasta está vazia.", + devSize: "Tamanho", + devShowing: "A mostrar", + devLine: "linha", + devReportBug: "Reportar Bug", + devCreateTask: "Criar Tarefa", + devRaw: "RAW", + devReportContext: "Encontrado ao ler o código-fonte do Oasis", + devTaskPrefix: "Rever", + devPrevPage: "Linhas anteriores", + devNextPage: "Linhas seguintes", + devMatches: "correspondências", + devFilesScanned: "ficheiros analisados", + devNoResults: "Ainda sem correspondências.", + devColModule: "Módulo", + devColState: "Estado", + devColModel: "Modelo", + devColView: "Vista", + devColRoutes: "Rotas", + devColTests: "Testes", + devOn: "ativo", + devOff: "inativo", + modulesDevLabel: "Desenvolvimento", + modulesDevDescription: "Módulo para gerir o código-fonte do Oasis.", themeIntro: "Escolhe um tema.", setTheme: "Definir tema", @@ -693,7 +756,7 @@ module.exports = { spreadLabel: "Difundir", spreadHint: "Difunde isto aos teus apoiantes (replica pelo teu feed).", welcomePmSubject: "Hello World!", - welcomePmBody: "Bem-vindo ao Oasis — uma rede social livre, peer-to-peer, federada e cifrada, com uma arquitetura distribuída apenas por convite.\n\nAlguns locais interessantes por onde começar:\n\n- /profile — Edita o teu avatar, nome, descrição e quais módulos aparecem no teu lado público.\n- /invites — Adiciona um pub para te federares com o resto da rede.\n- /peers — Vê quem está ligado, faz nova varredura da LAN, exporta ou importa listas de peers.\n- /inhabitants — Descobre e visita os avatares de outras pessoas.\n- /tribes — Cria ou junta-te a grupos privados com cifragem ponta-a-ponta.\n- /inbox — Lê e envia mensagens privadas.\n- /activity — Vê a atividade recente, tua e da tua rede.\n- /publish — Escreve um post ou partilha uma imagem, áudio, vídeo ou documento.\n\nAlguns lugares interessantes para conectar:\n\n- /fediverse — Faça a gestão das suas outras contas do fediverso, incluindo enviar e receber conteúdo.\n\nEsta mensagem foi enviada de forma privada de ti para ti mesmo, por isso não foi necessária qualquer ligação para a receber.", + welcomePmBody: "Bem-vindo ao Oasis — uma rede social livre, peer-to-peer, federada e cifrada, com uma arquitetura distribuída apenas por convite.\n\nAlguns locais interessantes por onde começar:\n\n- /profile — Edita o teu avatar, nome, descrição e quais módulos aparecem no teu lado público.\n- /invites — Adiciona um pub para te federares com o resto da rede.\n- /peers — Vê quem está ligado, faz nova varredura da LAN, exporta ou importa listas de peers.\n- /inhabitants — Descobre e visita os avatares de outras pessoas.\n- /tribes — Cria ou junta-te a grupos privados com cifragem ponta-a-ponta.\n- /inbox — Lê e envia mensagens privadas.\n- /activity — Vê a atividade recente, tua e da tua rede.\n- /publish — Escreve um post ou partilha uma imagem, áudio, vídeo ou documento.\n- /search — Pesquise o conteúdo da rede por tipo.\n\nAlguns lugares interessantes para conectar:\n\n- /fediverse — Faça a gestão das suas outras contas do fediverso, incluindo enviar e receber conteúdo.\n\nEsta mensagem foi enviada de forma privada de ti para ti mesmo, por isso não foi necessária qualquer ligação para a receber.", profileVisibilityTitle: "Visibilidade do perfil público", profileVisibilityHint: "Escolhe que campos os outros habitantes veem no teu perfil. Por defeito apenas Karma é mostrado.", profileSensorsSectionTitle: "Sensores", @@ -1224,6 +1287,9 @@ module.exports = { eventButton: "EVENTOS", taskButton: "TAREFAS", votesButton: "VOTAÇÕES", + industryButton: "INDÚSTRIA", + projectButton: "PROJETOS", + shopProductButton: "PRODUTOS", reportButton: "RELATÓRIOS", feedButton: "FEED", marketButton: "MERCADO", @@ -1252,7 +1318,7 @@ module.exports = { trendingItemStatus: "Estado do item", trendingTotalVotes: "Total de votos", trendingTotalOpinions: "Total de opiniões", - trendingNoContentMessage: "Ainda sem conteúdo em tendência disponível.", + trendingNoContentMessage: "Ainda não há tendências.", trendingAuthor: "Por", trendingCreatedAtLabel: "Criado em", trendingTotalCount: "Contagem total", @@ -1441,7 +1507,7 @@ module.exports = { transfersCreatedAt: "Criado em", transfersConfirmations: "CONFIRMAÇÕES", transfersContainingBlock: "Bloco contendor", - transfersExportContract: "Criar contrato", + transfersExportContract: "Contrato inteligente", transfersConfirmButton: "Confirmar transferência", transfersNoItems: "Sem transferências encontradas.", transfersMineSectionTitle: "As tuas transferências", @@ -1567,7 +1633,7 @@ module.exports = { cvDeleteButton: "Eliminar", cvNoCV: "Sem CV encontrado.", blogSubject: "Assunto", - blogMessage: "Corpo limitado a 8096 caracteres.", + blogMessage: "Mensagem", blogImage: "Carregar mídia (máx: 50MB)", blogPublish: "Pré-visualizar", noPopularMessages: "Ainda sem mensagens populares publicadas", @@ -2202,6 +2268,7 @@ module.exports = { agendaFilterReports: "RELATÓRIOS", agendaFilterTransfers: "TRANSFERÊNCIAS", agendaFilterJobs: "EMPREGOS", + agendaFilterIndustry: "INDÚSTRIA", agendaFilterProjects: "PROJETOS", agendaFilterCalendars: "CALENDÁRIOS", agendaNoItems: "Sem atribuições encontradas.", @@ -2323,16 +2390,31 @@ module.exports = { privateMessage: "MP", pmSendTitle: "Mensagens privadas", pmSend: "Enviar!", - pmDescription: "Usa este formulário para enviar uma mensagem encriptada a outros habitantes.", + pmCancel: "Cancelar", + pmReset: "Repor", + pmSharedKeyHint: "Partilha esta chave com o destinatário por outro canal. É necessária para decifrar.", + pmDescription: "Use este formulário para enviar uma mensagem/ficheiro cifrado a outros habitantes.", + pmComposeTitle: "Enviar uma mensagem", pmRecipients: "Destinatários", pmRecipientsHint: "Introduz Oasis IDs separados por vírgulas", pmLevelOfExposition: "Level of exposition", - pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmLimitsHint: "Até 7 destinatários por mensagem.", + pmTextPlaceholder: "Corpo limitado a 7000 caracteres.", pmSubject: "Assunto", pmSubjectHint: "Introduz o assunto da mensagem", pmText: "Mensagem", pmCrypterChip: "2xE2E", pmCrypterDecryptButton: "Decifrar", + fileShareTitle: "Compartilhar um ficheiro", + fileShareRecipientPlaceholder: "@...=.ed25519", + fileShareFileLabel: "Ficheiro", + fileShareDownload: "Baixar", + fileShareMutualError: "Só pode compartilhar ficheiros com habitantes com apoio mútuo.", + fileShareNoFile: "Nenhum ficheiro selecionado.", + fileShareTooLarge: "O ficheiro excede o tamanho permitido.", + fileShareFailed: "Não foi possível preparar o ficheiro.", + fileShareSendError: "Não foi possível enviar o ficheiro.", + fileShareUnavailable: "Este ficheiro não está disponível agora. Tenta mais tarde.", pmCrypterBadKey: "A chave partilhada está incorreta!", pmCrypterTooLong: "A mensagem é demasiado longa para ser cifrada com o Crypter. Encurta-a e tenta de novo.", pmCrypterCipherLabel: "Mensagem recifrada", @@ -2353,15 +2435,27 @@ module.exports = { pmNoSubject: "(sem assunto)", pmSubjectLabel: "Assunto:", pmBodyLabel: "Corpo", - pmBotJobs: "42-EmpregoBOT", - pmBotProjects: "42-ProjetosBOT", - pmBotMarket: "42-MercadoBOT", + pmBotJobs: "JobsBot", + pmBotProjects: "ProjectsBot", + pmBotMarket: "MarketBot", + pmBotShops: "ShopsBot", + pmBotPolitical: "PoliticalBot", + politicalBotLarpTitle: "A casa governante do L.A.R.P mudou.", + politicalBotParliamentTitle: "Começou um novo ciclo de governo no Parlamento.", + politicalBotTribeTitle: "Começou um novo ciclo de governo numa das tuas Tribos.", + politicalBotLarpIntro: "Agora {house} governa durante este ciclo: {cycle}", + politicalBotParliamentIntro: "O governo atual é {gov}", + politicalBotParliamentIntroLeader: "O governo atual é {gov}, exercido por {leader}", + politicalBotTribeIntro: "O governo atual na Tribo {tribe} é {gov}", + politicalBotTribeIntroLeader: "O governo atual na Tribo {tribe} é {gov}, exercido por {leader}", + politicalBotLeaderLabel: "Líder", inboxJobSubscribedTitle: "Nova subscrição à tua oferta de emprego", pmInhabitantWithId: "Habitante com Oasis ID:", pmHasSubscribedToYourJobOffer: "subscreveu a tua oferta de emprego", inboxProjectCreatedTitle: "Novo projeto criado", pmHasCreatedAProject: "criou um projeto", inboxMarketItemSoldTitle: "Item vendido", + inboxShopSoldTitle: "Produto vendido", pmYourItem: "O teu item", pmHasBeenSoldTo: "foi vendido a", pmFor: "por", @@ -2396,7 +2490,7 @@ module.exports = { visitContent: "Visitar conteúdo", banking: 'Banking', bankingTitle: 'Banking', - bankingDescription: 'Explore the current value of ECOin and the corresponding UBI allocation, distributed per epoch based on participation and trust.', + bankingDescription: "A economia ECOin: saldo, RBU, taxas e valor da indústria.", bankOverview: 'Overview', bankEpochs: 'Epochs', bankRules: 'Rules', @@ -2430,6 +2524,10 @@ module.exports = { bankEpochId: 'Epoch ID', bankRuleHash: 'Rules Snapshot Hash', bankViewEpoch: 'View Epoch', + bankYourIndustryBalance: "A tua parte da indústria", + bankYourUbiMonth: "O teu RBU (este mês)", + bankYourFundsMonth: "Os teus fundos (este mês)", + bankIndustryBalance: "Produção industrial (estimada)", bankUserBalance: 'Your Balance', ecoWalletNotConfigured: 'ECOin Wallet not configured', editWallet: 'Edit wallet', @@ -2469,7 +2567,7 @@ module.exports = { pubIdPlaceholder: "@PUB_ID.ed25519", bankUbiAvailableNo: "SEM FUNDOS!", bankUbiAvailableOk: "DISPONÍVEL!", - bankUbiAvailability: "Disponibilidade UBI", + bankUbiAvailability: "RBU (rede)", bankAlreadyClaimedThisMonth: "Já reclamado este mês", bankUbiThisMonth: "RBU (este mês)", bankUbiLastClaimed: "RBU (última reclamada)", @@ -2899,6 +2997,208 @@ module.exports = { jobsTagsLabel: "Tags", jobsTagsPlaceholder: "tag1, tag2, tag3", noJobsMatch: "Nenhum emprego corresponde à tua pesquisa.", + industrySearchPlaceholder: "Procurar fábricas…", + industryAllSectors: "Todos os setores", + industryVisitButton: "Visitar indústria", + industryAdvanceTo: "Definir para", + industryAmount: "Quantia", + industryApproveBuild: "Aprovar", + industryBackToFacility: "← Fábrica", + industryBlueprint: "Blueprint", + industryBlueprintLabel: "Blueprint de Indústria", + industryBlueprints: "Blueprints", + industryBuild: "Build", + industryBuildNotes: "Notas", + industryBuildStart: "Data de início", + industryVoteUpdateButton: "Votar atualização", + industryVoteDeleteButton: "Votar exclusão", + industryRevokeVote: "Revogar", + industryTimeLeft: "Tempo restante", + industryBuildEnd: "Data de fim", + industryBuilds: "Builds", + industryBuildTitle: "Título", + industryContribute: "Contribuir", + industryContributeEco: "Contribuir ECO", + industryContributeLabor: "Contribuir trabalho", + industryContributeButton: "Contribuir", + industryContributeMaterial: "Contribuir material", + industryContributions: "Contribuições", + industryContributors: "colaboradores", + industryCreateBlueprintButton: "Criar blueprint", + industryDistribute: "Distribuir", + industryDistributeButton: "Distribuir por quotas", + industryDistribution: "Distribuição", + industryEcoAmount: "Quantia (ECO)", + industryEcoContribHint: "As contribuições em ECO são transferidas para o administrador como custodiante da tesouraria do build.", + industryEditBlueprint: "Editar blueprint", + industryFacility: "Fábrica", + industryKindLabel: "Tipo", + industryKind_labor: "Mão de obra", + industryKind_material: "Material", + industryKind_eco: "Fundos", + industryLaborHours: "Mão de obra (horas)", + industryHours: "Horas", + industryLicense: "Licença", + industryMaterialItem: "Material", + industryMaterials: "Materiais", + industryMaterialsHint: "Um material por linha, no formato nome:quantidade:preço.", + industryEstMaterials: "Total materiais", + industryEstLabor: "Total mão de obra", + industryEstTaxes: "Impostos", + industryEstTotal: "Preço estimado", + industryBuildingPrice: "Preço de construção", + industryFinalPrice: "Preço final", + industryBlueprintDescPlaceholder: "Especificações, dimensões, peso, documentação…", + industryMaterialValue: "Valor (ECO)", + industryMember: "Membro", + industryNewBlueprint: "Novo blueprint", + industryNewBuild: "Propor um build", + industryEditBuild: "Editar produção", + industryNoBlueprint: "— nenhum —", + industryNeedBlueprint: "Cria primeiro um plano: cada produção fabrica um.", + industryNoBlueprints: "Ainda não há blueprints.", + industryNoBuilds: "Ainda não há builds.", + industryNote: "Nota", + industryOutput: "Saída", + industryOutputItem: "Nome do produto", + industryOutputKind: "Tipo de produto", + industryOutputQty: "Unidades por produção", + industryOutputUnit: "Unidade de medida", + industryOutputValue: "Valor de saída (ECO, ex. da venda)", + industryPayout: "Pagamento", + industryPoints: "Karma", + industryPostJob: "Criar Emprego", + industryPot: "Bolo", + industryProposeBuildButton: "Propor build", + industryProposer: "Proponente", + industryAuthor: "Autor", + industrySellOnMarket: "Enviar ao Mercado", + industrySendToProjects: "Criar Projeto", + industryShare: "Quota", + industryShares: "Quotas", + industrySkills: "Competências", + industryTreasury: "Tesouraria", + industryKind_physical: "Físico", + industryKind_digital: "Digital", + industryBuildStatus_PROPOSED: "PROPOSTO", + industryBuildStatus_REJECTED: "REJEITADO", + industryBuildStatus_APPROVED: "APROVADO", + industryBuildStatus_STOCKING: "ABASTECENDO", + industryBuildStatus_IN_PRODUCTION: "EM PRODUÇÃO", + industryBuildStatus_COMPLETED: "CONCLUÍDO", + industryBuildStatus_FAILED: "FALHADO", + pmBotIndustry: "IndustryBot", + industryBotAdmittedTitle: "Você foi admitido numa fábrica.", + industryBotApplicationTitle: "Nova solicitação de adesão na sua fábrica.", + industryBotInvitedTitle: "Você foi convidado para uma fábrica.", + industryBotDissolvedTitle: "Uma fábrica a que pertence foi dissolvida.", + industryBotBuildApprovedTitle: "Um build foi aprovado.", + industryBotDistributedTitle: "A saída de um build foi distribuída.", + industryFilterRules: "REGRAS", + industryRulesTitle: "Regras", + industryRulesIntro: "Industry permite aos habitantes possuir coletivamente os meios de produção: uma Fábrica é uma oficina da rede que ninguém possui de forma privada.", + industryRulesSteward: "O fundador é o administrador — um guardião, não um dono: não pode vender, privatizar nem dissolver a Fábrica sozinho.", + industryRulesMembership: "A política de adesão define como se entra: Aberta (na hora), Por voto (admitido pelo voto dos membros) ou Apenas convite (um membro deve convidar primeiro).", + industryRulesQuorum: "O quórum é o número mínimo de membros que devem votar para uma decisão contar, para que uma Fábrica pequena não seja decidida por uma só pessoa.", + industryRulesMajority: "A maioria é a fração de votos necessária (0,5 = metade, 1 = unanimidade); uma decisão passa quando os SIM atingem pelo menos max(quórum, membros × maioria).", + industryRulesDecisions: "Os membros votam para admitir novos, aprovar builds e dissolver a Fábrica; o administrador não pode ignorar estas decisões coletivas.", + industryRulesBlueprints: "Os Blueprints são receitas livres (COPYLEFT) — as entradas (materiais, horas de trabalho, competências) e a saída (um bem físico ou digital) — e qualquer um pode forká-las.", + industryRulesBuilds: "Um Build é uma ordem de produção: PROPOSTO → APROVADO (por voto) → ABASTECENDO → EM PRODUÇÃO → CONCLUÍDO, e só aceita contribuições depois de aprovado.", + industryRulesContributions: "Os membros contribuem com trabalho (horas), materiais (valor ECO declarado) ou ECO a um build; cada contribuição ganha karma.", + industryRulesLaborRate: "A tarifa de trabalho é o valor ECO de uma hora de trabalho nesta Fábrica; converte horas em karma para comparar esforço e capital de forma justa: karma = horas × tarifa + valor material + ECO.", + industryRulesShares: "A tua quota de um build é o teu karma ÷ karma total, e define quanto do valor de saída recebes.", + industryRulesDistribution: "Quando uma produção é concluída, o steward distribui o fundo (os fundos da produção mais o valor de venda) entre os contribuidores, proporcionalmente às suas quotas.", + industryRulesTreasury: "As contribuições em ECO são guardadas pelo administrador como tesouraria do build até à distribuição; todos os movimentos ficam registados na rede e as disputas podem ir aos Courts.", + industryJobs: "Empregos", + industryNoJobs: "Ainda não há vagas.", + industryCreatePosition: "Criar vaga", + industryViewCV: "CV", + industryReportButton: "Denunciar", + industryCreateTask: "Criar Tarefa", + industryOpenDispute: "Abrir disputa", + industryMaterialsPlaceholder: "solar-panel:2:120\nbattery:1:80\nkit:1:15", + industryOutputUnitPlaceholder: "ex. unidade, kg, licença", + industryOutputItemPlaceholder: "ex. robô", + industryOutputQtyPlaceholder: "ex. 5", + industrySkillsPlaceholder: "ex. soldadura, redes, instalação-solar", + industryCreateInvite: "Gerar convite", + industryPauseVotes: "Votos de estado", + industryTitle: "Indústria", + industryDescription: "Módulo para gerir os meios de produção de forma coletiva.", + industryLabel: "Indústria", + typeIndustryBuild: "BUILD", + typeIndustryBlueprint: "BLUEPRINT", + typeIndustryAllocation: "DISTRIBUIÇÃO", + typeIndustry: "INDÚSTRIA", + modulesIndustryLabel: "Indústria", + modulesIndustryDescription: "Módulo para gerir os meios de produção de forma coletiva.", + industryFilterAll: "TODAS", + industryFilterMember: "MEMBRO", + industryFilterBlueprints: "PLANOS", + industryFilterBuilds: "PRODUÇÕES", + industryFilterMine: "MINHAS", + industryFilterActive: "EM FUNCIONAMENTO", + industryFilterPaused: "PAUSADAS", + industryFilterDissolved: "DISSOLVIDAS", + industryAllTitle: "Indústria", + industryMemberTitle: "Fábricas de que faço parte", + industryMineTitle: "Minhas fábricas", + industryActiveTitle: "Em funcionamento", + industryPausedTitle: "Fábricas pausadas", + industryDissolvedTitle: "Fábricas dissolvidas", + industryNoFacilitiesFound: "Nenhuma fábrica encontrada.", + industryCreateFacility: "Criar fábrica", + industryMembers: "Membros", + industryMemberBadge: "MEMBRO", + industryName: "Nome", + industryNamePlaceholder: "Nome da fábrica", + industryDescriptionLabel: "Descrição", + industryDescriptionPlaceholder: "O que esta fábrica produz?", + industrySector: "Setor", + industryMembershipPolicy: "Política de adesão", + industryQuorum: "Quórum", + industryMajority: "Maioria", + industryLaborRate: "Tarifa de trabalho", + industryCreateButton: "Criar fábrica", + industryUpdateButton: "Atualizar", + industrySteward: "Administrador", + industryStatusActive: "EM FUNCIONAMENTO", + industryStatusPaused: "PAUSADA", + industryStatusDissolved: "DISSOLVIDA", + industryStatusLabel: "Estado", + industryJoinButton: "Juntar-se", + industryApplyButton: "Pedir para entrar", + industryLeaveButton: "Sair", + industryInviteButton: "Convidar", + industryInvitePlaceholder: "@...=.ed25519", + industryInviteNeeded: "Precisas de um convite para entrar.", + industryPauseButton: "Votar pausar", + industryResumeButton: "Votar retomar", + industryEditButton: "Editar", + industryDeleteButton: "Eliminar", + industryBackToList: "← Indústria", + industryPendingApplicants: "Candidaturas pendentes", + industryVotesYes: "sim", + industryVotesNo: "não", + industryAlreadyVoted: "votado", + industryAdmit: "Admitir", + industryReject: "Rejeitar", + industryDissolveTitle: "Dissolver fábrica", + industryDissolveHint: "A dissolução requer um voto coletivo; o administrador não pode dissolver sozinho.", + industryDissolveVote: "Votar dissolução", + industrySector_software: "Software", + industrySector_hardware: "Hardware", + industrySector_agriculture: "Agricultura", + industrySector_textile: "Têxtil", + industrySector_energy: "Energia", + industrySector_food: "Alimentação", + industrySector_construction: "Construção", + industrySector_media: "Mídia", + industrySector_services: "Serviços", + industrySector_other: "Outro", + industryPolicy_open: "Aberta", + industryPolicy_vote: "Por voto", + industryPolicy_invite: "Apenas convite", projectsTitle: "Projetos", projectsDescription: "Cria, financia e acompanha projetos comunitários na tua rede.", projectCreateProject: "Criar projeto", @@ -3259,6 +3559,8 @@ module.exports = { chatOpenTitle: "Open Chats", chatClosedTitle: "Closed Chats", chatDescription: "Descrição", + chatMessageReply: "Resposta", + chatMessageLabel: "Mensagem", chatCategory: "Categoria", chatStatus: "ESTADO", chatFilterAll: "TODOS", diff --git a/src/client/assets/translations/oasis_ru.js b/src/client/assets/translations/oasis_ru.js index d422c1d..4f01c37 100644 --- a/src/client/assets/translations/oasis_ru.js +++ b/src/client/assets/translations/oasis_ru.js @@ -1,26 +1,6 @@ const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); module.exports = { ru: { - bbAdd: "Добавить", - bbQuickActions: "Быстрые действия", - bbPickTitle: "Закрепить модуль", - bbPickDesc: "Выберите модуль для слота + в нижней панели.", - bbPickNone: "Нет", - bbManage: "Изменить", - bbYourBar: "Ваша панель", - bbRemove: "Убрать", - bbFull: "Достигнут максимум", - bbDone: "Готово", - activityChangeFilter: "Сменить фильтр", - emptyConnectHint: "Подключитесь к пабу для синхронизации с сетью.", - emptyConnectCta: "Подключиться к пабу", - menuCommunity: "Сообщество", - activityGroupCommunity: "Сообщество и управление", - activityGroupOrg: "Организация", - activityGroupComm: "Коммуникация", - activityGroupEconomy: "Экономика", - activityGroupMedia: "Контент и медиа", - activityGroupOther: "Другое", languageName: "Русский", shopOrderStatusPaid: "Оплата получена", shopOrderStatusReceived: "Получено", @@ -433,7 +413,8 @@ module.exports = { publish: "Написать", contentWarningPlaceholder: "Добавьте тему к записи (необязательно)", privateWarningPlaceholder: "Добавьте жителей для отправки приватной записи (необязательно)", - publishWarningPlaceholder: "...", + publishWarningPlaceholder: "Текст ограничен 7000 символами.", + publishTooLong: "Ваш пост слишком длинный. Пожалуйста, сократите его.", publishCustomDescription: [ "ПОМНИТЕ: Благодаря технологии блокчейн, после публикации запись невозможно отредактировать или удалить.", ], @@ -498,7 +479,89 @@ module.exports = { passwordLengthInfo: "Пароль должен содержать минимум 32 символа.", passwordImport: "Введите пароль для расшифровки данных, которые будут сохранены в домашней директории системы (имя файла: secret)", exportPasswordPlaceholder: "Используйте строчные, заглавные буквы, цифры и символы", - fileInfo: "Ваш зашифрованный секретный ключ будет сохранён в домашней директории системы (имя файла: oasis.enc)", + fileInfo: "Ваш зашифрованный секретный ключ будет скачан на ваше устройство (имя файла: oasis.enc)", + developer: "Разработка", + devTitle: "Разработка", + welcomeBannerText: "Добро пожаловать в OASIS. Выполните эти быстрые шаги, чтобы настроить свой аватар.", + welcomeBannerAction: "Начать!", + welcomeTitle: "Добро пожаловать", + welcomeStepGreetingAction: "Отправить", + welcomeJoinPubButton: "Войти в паб", + welcomeStepLarpAction: "Вступить в «Академию»", + welcomeStepLarpText: "Присоединяйтесь к нашей ролевой игре живого действия.", + welcomeStepLarpTitle: "Присоединиться к L.A.R.P.", + welcomeStepGreetingTitle: "Отправьте «Привет, мир!»", + welcomeStepGreetingText: "Отправьте сообщение, чтобы другие жители могли вас найти.", + welcomeJoinPub: "Присоединиться:", + welcomeDescription: "Несколько вещей, которые стоит сделать перед началом.", + welcomeProgress: "Выполнено", + welcomeStepDone: "готово", + welcomeStepPending: "не сделано", + welcomeSkip: "Не сейчас", + welcomeStepLanguageTitle: "Выберите язык", + welcomeStepLanguageText: "Oasis говорит на нескольких языках. Изменить можно в любой момент.", + welcomeStepProfileTitle: "Заполните профиль", + welcomeStepProfileText: "Выберите имя, добавьте описание и поставьте изображение, чтобы другие жители вас узнавали.", + welcomeStepProfileAction: "Сохранить профиль", + welcomeStepFederationTitle: "Присоединитесь к основной сети", + welcomeStepFederationText: "Подключитесь к нашему пабу, чтобы встретить других жителей и начать репликацию.", + welcomeStepFederationAction: "К приглашениям", + welcomeStepBackupTitle: "Сохраните свой ID", + welcomeStepBackupText: "Ваша личность — это файл ключа на этом устройстве.", + welcomeStepBackupWarning: "ЕСЛИ ВЫ ЕГО ПОТЕРЯЕТЕ, НИКТО НЕ СМОЖЕТ ЕГО ВОССТАНОВИТЬ.", + welcomeStepBackupAction: "Резервная копия!", + welcomeCompleteTitle: "Всё готово.", + welcomeCompleteText: "Ваш узел готов. Дальше сеть растёт вместе с теми, на кого вы подписаны.", + welcomeFindPeople: "Найти жителей", + devFilterFiles: "ФАЙЛЫ", + devFilterSearch: "ПОИСК", + devFilterModules: "МОДУЛИ", + devFilterTranslations: "ПЕРЕВОДЫ", + devFilterStyles: "СТИЛИ", + devFilterTests: "ТЕСТЫ", + devVersion: "Версия", + devNodeVersion: "Node", + devStatsTests: "Наборы тестов", + devParentFolder: "Родительская папка", + devOpenFolder: "открыть", + devDescription: "Изучайте исходный код, работающий на этом узле.", + devTreeTitle: "Файлы", + devSearchTitle: "Поиск по коду", + devMapTitle: "Модули", + devRoot: "корень", + devRootButton: "КОРЕНЬ", + devSearchPlaceholder: "Текст для поиска в коде", + devAnyExtension: "Любой файл", + devSearchButton: "Искать", + devStatsFiles: "Файлы", + devStatsLines: "Строки", + devStatsModules: "Модули", + devStatsLanguages: "Языки", + devNotViewable: "нельзя открыть", + devEmptyFolder: "Эта папка пуста.", + devSize: "Размер", + devShowing: "Показано", + devLine: "строка", + devReportBug: "Сообщить об Ошибке", + devCreateTask: "Создать Задачу", + devRaw: "RAW", + devReportContext: "Найдено при чтении исходного кода Oasis", + devTaskPrefix: "Проверить", + devPrevPage: "Предыдущие строки", + devNextPage: "Следующие строки", + devMatches: "совпадений", + devFilesScanned: "файлов проверено", + devNoResults: "Совпадений пока нет.", + devColModule: "Модуль", + devColState: "Состояние", + devColModel: "Модель", + devColView: "Представление", + devColRoutes: "Маршруты", + devColTests: "Тесты", + devOn: "вкл", + devOff: "выкл", + modulesDevLabel: "Разработка", + modulesDevDescription: "Модуль для управления исходным кодом Oasis.", themeIntro: "Выберите тему.", setTheme: "Установить тему", @@ -700,7 +763,7 @@ module.exports = { spreadLabel: "Распространить", spreadHint: "Распространить вашим сторонникам (через ваш feed).", welcomePmSubject: "Hello World!", - welcomePmBody: "Добро пожаловать в Oasis — свободная, одноранговая, федеративная и зашифрованная социальная сеть с распределённой архитектурой только по приглашениям.\n\nНесколько интересных мест, с которых можно начать:\n\n- /profile — Отредактируй аватар, имя, описание и какие модули видны на твоей публичной стороне.\n- /invites — Добавь pub, чтобы федерироваться с остальной сетью.\n- /peers — Посмотри, кто на связи, пересканируй LAN, экспортируй или импортируй списки пиров.\n- /inhabitants — Открывай и заходи к аватарам других людей.\n- /tribes — Создавай или вступай в приватные группы со сквозным шифрованием.\n- /inbox — Читай и отправляй приватные сообщения.\n- /activity — Смотри свежую активность — свою и твоей сети.\n- /publish — Напиши пост или поделись изображением, аудио, видео или документом.\n\nНесколько интересных мест для подключения:\n\n- /fediverse — Управляйте своими другими аккаунтами федиверса, включая отправку и получение контента.\n\nЭто сообщение было отправлено приватно от тебя самому себе, поэтому для его получения не требовалось соединение.", + welcomePmBody: "Добро пожаловать в Oasis — свободная, одноранговая, федеративная и зашифрованная социальная сеть с распределённой архитектурой только по приглашениям.\n\nНесколько интересных мест, с которых можно начать:\n\n- /profile — Отредактируй аватар, имя, описание и какие модули видны на твоей публичной стороне.\n- /invites — Добавь pub, чтобы федерироваться с остальной сетью.\n- /peers — Посмотри, кто на связи, пересканируй LAN, экспортируй или импортируй списки пиров.\n- /inhabitants — Открывай и заходи к аватарам других людей.\n- /tribes — Создавай или вступай в приватные группы со сквозным шифрованием.\n- /inbox — Читай и отправляй приватные сообщения.\n- /activity — Смотри свежую активность — свою и твоей сети.\n- /publish — Напиши пост или поделись изображением, аудио, видео или документом.\n- /search — Ищите содержимое сети по типу.\n\nНесколько интересных мест для подключения:\n\n- /fediverse — Управляйте своими другими аккаунтами федиверса, включая отправку и получение контента.\n\nЭто сообщение было отправлено приватно от тебя самому себе, поэтому для его получения не требовалось соединение.", profileVisibilityTitle: "Видимость публичного профиля", profileVisibilityHint: "Выберите поля, видимые другим жителям. По умолчанию показана только Карма.", profileSensorsSectionTitle: "Сенсоры", @@ -1231,6 +1294,9 @@ module.exports = { eventButton: "СОБЫТИЯ", taskButton: "ЗАДАЧИ", votesButton: "ГОЛОСОВАНИЯ", + industryButton: "ПРОМЫШЛЕННОСТЬ", + projectButton: "ПРОЕКТЫ", + shopProductButton: "ТОВАРЫ", reportButton: "ОТЧЁТЫ", feedButton: "ЛЕНТА", marketButton: "РЫНОК", @@ -1259,7 +1325,7 @@ module.exports = { trendingItemStatus: "Статус элемента", trendingTotalVotes: "Всего голосов", trendingTotalOpinions: "Всего мнений", - trendingNoContentMessage: "Трендового контента пока нет.", + trendingNoContentMessage: "Трендов пока нет.", trendingAuthor: "Автор", trendingCreatedAtLabel: "Создано", trendingTotalCount: "Всего", @@ -1448,7 +1514,7 @@ module.exports = { transfersCreatedAt: "Создано", transfersConfirmations: "ПОДТВЕРЖДЕНИЯ", transfersContainingBlock: "Содержащий блок", - transfersExportContract: "Создать контракт", + transfersExportContract: "Смарт-контракт", transfersConfirmButton: "Подтвердить перевод", transfersNoItems: "Переводы не найдены.", transfersMineSectionTitle: "Ваши переводы", @@ -1574,7 +1640,7 @@ module.exports = { cvDeleteButton: "Удалить", cvNoCV: "Резюме не найдено.", blogSubject: "Тема", - blogMessage: "Текст ограничен 8096 символами.", + blogMessage: "Сообщение", blogImage: "Загрузить медиа (макс: 50MB)", blogPublish: "Предварительный просмотр", noPopularMessages: "Популярных сообщений пока нет", @@ -2214,6 +2280,7 @@ module.exports = { agendaFilterReports: "ОТЧЁТЫ", agendaFilterTransfers: "ПЕРЕВОДЫ", agendaFilterJobs: "ВАКАНСИИ", + agendaFilterIndustry: "ПРОМЫШЛЕННОСТЬ", agendaFilterProjects: "ПРОЕКТЫ", agendaFilterCalendars: "КАЛЕНДАРИ", agendaNoItems: "Назначений не найдено.", @@ -2331,16 +2398,31 @@ module.exports = { privateMessage: "ЛС", pmSendTitle: "Личные сообщения", pmSend: "Отправить!", - pmDescription: "Используйте эту форму для отправки зашифрованного сообщения другим жителям.", + pmCancel: "Отмена", + pmReset: "Сбросить", + pmSharedKeyHint: "Передайте этот ключ получателю по другому каналу. Он нужен для расшифровки.", + pmDescription: "Используйте эту форму, чтобы отправить зашифрованное сообщение/файл другим жителям.", + pmComposeTitle: "Отправить сообщение", pmRecipients: "Получатели", pmRecipientsHint: "Введите Oasis ID через запятую", pmLevelOfExposition: "Level of exposition", - pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmLimitsHint: "До 7 получателей на сообщение.", + pmTextPlaceholder: "Текст ограничен 7000 символами.", pmSubject: "Тема", pmSubjectHint: "Введите тему сообщения", pmText: "Сообщение", pmCrypterChip: "2xE2E", pmCrypterDecryptButton: "Расшифровать", + fileShareTitle: "Поделиться файлом", + fileShareRecipientPlaceholder: "@...=.ed25519", + fileShareFileLabel: "Файл", + fileShareDownload: "Скачать", + fileShareMutualError: "Вы можете делиться файлами только с жителями при взаимной поддержке.", + fileShareNoFile: "Файл не выбран.", + fileShareTooLarge: "Файл превышает допустимый размер.", + fileShareFailed: "Не удалось подготовить файл.", + fileShareSendError: "Не удалось отправить файл.", + fileShareUnavailable: "Этот файл сейчас недоступен. Попробуйте позже.", pmCrypterBadKey: "Общий ключ неверный!", pmCrypterTooLong: "Сообщение слишком длинное для шифрования с помощью Crypter. Сократите его и попробуйте снова.", pmCrypterCipherLabel: "Повторно зашифрованное сообщение", @@ -2352,15 +2434,27 @@ module.exports = { pmNoSubject: "(без темы)", pmSubjectLabel: "Тема:", pmBodyLabel: "Тело", - pmBotJobs: "42-JobsBOT", - pmBotProjects: "42-ProjectsBOT", - pmBotMarket: "42-MarketBOT", + pmBotJobs: "JobsBot", + pmBotProjects: "ProjectsBot", + pmBotMarket: "MarketBot", + pmBotShops: "ShopsBot", + pmBotPolitical: "PoliticalBot", + politicalBotLarpTitle: "Правящий дом L.A.R.P сменился.", + politicalBotParliamentTitle: "В Парламенте начался новый цикл правления.", + politicalBotTribeTitle: "В одном из ваших племён начался новый цикл правления.", + politicalBotLarpIntro: "Теперь в этом цикле правит {house}: {cycle}", + politicalBotParliamentIntro: "Текущее правление — {gov}", + politicalBotParliamentIntroLeader: "Текущее правление — {gov}, осуществляет {leader}", + politicalBotTribeIntro: "Текущее правление в племени {tribe} — {gov}", + politicalBotTribeIntroLeader: "Текущее правление в племени {tribe} — {gov}, осуществляет {leader}", + politicalBotLeaderLabel: "Лидер", inboxJobSubscribedTitle: "Новая подписка на вашу вакансию", pmInhabitantWithId: "Житель с OASIS ID:", pmHasSubscribedToYourJobOffer: "подписался на вашу вакансию", inboxProjectCreatedTitle: "Создан новый проект", pmHasCreatedAProject: "создал проект", inboxMarketItemSoldTitle: "Товар продан", + inboxShopSoldTitle: "Товар продан", pmYourItem: "Ваш товар", pmHasBeenSoldTo: "был продан", pmFor: "за", @@ -2395,7 +2489,7 @@ module.exports = { visitContent: "Посетить контент", banking: "Банкинг", bankingTitle: "Банкинг", - bankingDescription: "Исследуйте текущую стоимость ECOin и соответствующее распределение UBI, распределяемое за эпоху на основе участия и доверия.", + bankingDescription: "Экономика ECOin: баланс, базовый доход, налоги и ценность промышленности.", bankOverview: "Обзор", bankEpochs: "Эпохи", bankRules: "Правила", @@ -2428,6 +2522,10 @@ module.exports = { bankEpochId: "ID эпохи", bankRuleHash: "Хэш снимка правил", bankViewEpoch: "Просмотр эпохи", + bankYourIndustryBalance: "Ваша доля в промышленности", + bankYourUbiMonth: "Ваш БОД (в этом месяце)", + bankYourFundsMonth: "Ваши средства (в этом месяце)", + bankIndustryBalance: "Промышленное производство (оценка)", bankUserBalance: "Ваш баланс", ecoWalletNotConfigured: "Кошелёк ECOin не настроен", editWallet: "Редактировать кошелёк", @@ -2466,7 +2564,7 @@ module.exports = { pubIdPlaceholder: "@PUB_ID.ed25519", bankUbiAvailableNo: "НЕТ СРЕДСТВ!", bankUbiAvailableOk: "ДОСТУПНО!", - bankUbiAvailability: "Доступность UBI", + bankUbiAvailability: "БОД (сеть)", bankAlreadyClaimedThisMonth: "Уже получено в этом месяце", bankUbiThisMonth: "БОД (этот месяц)", bankUbiLastClaimed: "БОД (последнее получение)", @@ -2892,6 +2990,208 @@ module.exports = { jobsTagsLabel: "Теги", jobsTagsPlaceholder: "тег1, тег2, тег3", noJobsMatch: "Вакансий не найдено.", + industrySearchPlaceholder: "Поиск фабрик…", + industryAllSectors: "Все секторы", + industryVisitButton: "Открыть промышленность", + industryAdvanceTo: "Перевести в", + industryAmount: "Сумма", + industryApproveBuild: "Одобрить", + industryBackToFacility: "← Фабрика", + industryBlueprint: "Чертёж", + industryBlueprintLabel: "Промышленный чертёж", + industryBlueprints: "Чертежи", + industryBuild: "Сборка", + industryBuildNotes: "Заметки", + industryBuildStart: "Дата начала", + industryVoteUpdateButton: "Голос за обновление", + industryVoteDeleteButton: "Голос за удаление", + industryRevokeVote: "Отозвать", + industryTimeLeft: "Осталось времени", + industryBuildEnd: "Дата окончания", + industryBuilds: "Сборки", + industryBuildTitle: "Название", + industryContribute: "Внести вклад", + industryContributeEco: "Внести ECO", + industryContributeLabor: "Внести труд", + industryContributeButton: "Внести вклад", + industryContributeMaterial: "Внести материал", + industryContributions: "Вклады", + industryContributors: "участники", + industryCreateBlueprintButton: "Создать чертёж", + industryDistribute: "Распределить", + industryDistributeButton: "Распределить по долям", + industryDistribution: "Распределение", + industryEcoAmount: "Сумма (ECO)", + industryEcoContribHint: "Вклады в ECO передаются управляющему как хранителю казны сборки.", + industryEditBlueprint: "Редактировать чертёж", + industryFacility: "Фабрика", + industryKindLabel: "Тип", + industryKind_labor: "Труд", + industryKind_material: "Материал", + industryKind_eco: "Средства", + industryLaborHours: "Труд (часы)", + industryHours: "Часы", + industryLicense: "Лицензия", + industryMaterialItem: "Материал", + industryMaterials: "Материалы", + industryMaterialsHint: "Один материал в строке в формате название:количество:цена.", + industryEstMaterials: "Итого материалы", + industryEstLabor: "Итого труд", + industryEstTaxes: "Налоги", + industryEstTotal: "Оценочная цена", + industryBuildingPrice: "Цена изготовления", + industryFinalPrice: "Итоговая цена", + industryBlueprintDescPlaceholder: "Характеристики, размеры, вес, документация…", + industryMaterialValue: "Стоимость (ECO)", + industryMember: "Участник", + industryNewBlueprint: "Новый чертёж", + industryNewBuild: "Предложить сборку", + industryEditBuild: "Редактировать производство", + industryNoBlueprint: "— нет —", + industryNeedBlueprint: "Сначала создайте чертёж: каждое производство изготавливает его.", + industryNoBlueprints: "Пока нет чертежей.", + industryNoBuilds: "Пока нет сборок.", + industryNote: "Заметка", + industryOutput: "Выход", + industryOutputItem: "Название продукта", + industryOutputKind: "Тип продукта", + industryOutputQty: "Единиц за производство", + industryOutputUnit: "Единица измерения", + industryOutputValue: "Стоимость выхода (ECO, напр. от продажи)", + industryPayout: "Выплата", + industryPoints: "Karma", + industryPostJob: "Создать вакансию", + industryPot: "Банк", + industryProposeBuildButton: "Предложить сборку", + industryProposer: "Инициатор", + industryAuthor: "Автор", + industrySellOnMarket: "Отправить на рынок", + industrySendToProjects: "Создать проект", + industryShare: "Доля", + industryShares: "Доли", + industrySkills: "Навыки", + industryTreasury: "Казна", + industryKind_physical: "Физический", + industryKind_digital: "Цифровой", + industryBuildStatus_PROPOSED: "ПРЕДЛОЖЕНО", + industryBuildStatus_REJECTED: "ОТКЛОНЁН", + industryBuildStatus_APPROVED: "ОДОБРЕНО", + industryBuildStatus_STOCKING: "СНАБЖЕНИЕ", + industryBuildStatus_IN_PRODUCTION: "В ПРОИЗВОДСТВЕ", + industryBuildStatus_COMPLETED: "ЗАВЕРШЕНО", + industryBuildStatus_FAILED: "ПРОВАЛЕНО", + pmBotIndustry: "IndustryBot", + industryBotAdmittedTitle: "Вас приняли на фабрику.", + industryBotApplicationTitle: "Новая заявка на вступление в вашу фабрику.", + industryBotInvitedTitle: "Вас пригласили на фабрику.", + industryBotDissolvedTitle: "Фабрика, к которой вы принадлежите, была распущена.", + industryBotBuildApprovedTitle: "Сборка одобрена.", + industryBotDistributedTitle: "Выход сборки распределён.", + industryFilterRules: "ПРАВИЛА", + industryRulesTitle: "Правила", + industryRulesIntro: "Industry позволяет жителям коллективно владеть средствами производства: Фабрика — это принадлежащая сети мастерская, которой никто не владеет частно.", + industryRulesSteward: "Основатель — управляющий, хранитель, а не владелец: он не может в одиночку продать, приватизировать или распустить Фабрику.", + industryRulesMembership: "Политика членства задаёт способ вступления: Открытая (сразу), Голосованием (принимают голосом участников) или Только по приглашению (сначала должен пригласить участник).", + industryRulesQuorum: "Кворум — минимальное число участников, которые должны проголосовать, чтобы решение засчиталось, чтобы маленькую Фабрику не решал один человек.", + industryRulesMajority: "Большинство — доля голосов для принятия (0,5 = половина, 1 = единогласие); решение проходит, когда голоса ЗА достигают хотя бы max(кворум, участники × большинство).", + industryRulesDecisions: "Участники голосуют за приём новых, одобрение сборок и роспуск Фабрики; управляющий не может обойти эти коллективные решения.", + industryRulesBlueprints: "Чертежи — свободные (COPYLEFT) рецепты: входы (материалы, часы труда, навыки) и выход (физический или цифровой продукт), и любой может их форкнуть.", + industryRulesBuilds: "Сборка — производственный заказ: ПРЕДЛОЖЕНО → ОДОБРЕНО (голосованием) → СНАБЖЕНИЕ → В ПРОИЗВОДСТВЕ → ЗАВЕРШЕНО, и принимает вклады только после одобрения.", + industryRulesContributions: "Участники вносят труд (часы), материалы (заявленная стоимость в ECO) или ECO в сборку; каждый вклад даёт карму.", + industryRulesLaborRate: "Ставка труда — стоимость в ECO одного рабочего часа на этой Фабрике; она переводит часы в карму, чтобы справедливо сравнивать труд и капитал: карма = часы × ставка + стоимость материалов + ECO.", + industryRulesShares: "Ваша доля в сборке — это ваша карма ÷ общая карма, и она определяет, сколько стоимости выхода вы получите.", + industryRulesDistribution: "После завершения производства стюард распределяет банк (средства производства плюс стоимость продажи) между участниками пропорционально их долям.", + industryRulesTreasury: "Вклады в ECO хранит управляющий как хранитель казны сборки до распределения; все движения записываются в сети, а споры могут идти в Courts.", + industryJobs: "Вакансии", + industryNoJobs: "Пока нет вакансий.", + industryCreatePosition: "Создать вакансию", + industryViewCV: "Резюме", + industryReportButton: "Пожаловаться", + industryCreateTask: "Создать задачу", + industryOpenDispute: "Открыть спор", + industryMaterialsPlaceholder: "solar-panel:2:120\nbattery:1:80\nkit:1:15", + industryOutputUnitPlaceholder: "напр. шт., кг, лицензия", + industryOutputItemPlaceholder: "напр. робот", + industryOutputQtyPlaceholder: "напр. 5", + industrySkillsPlaceholder: "напр. пайка, сети, солнечная-установка", + industryCreateInvite: "Создать приглашение", + industryPauseVotes: "Голоса за статус", + industryTitle: "Промышленность", + industryDescription: "Модуль для коллективного управления средствами производства.", + industryLabel: "Промышленность", + typeIndustryBuild: "СБОРКА", + typeIndustryBlueprint: "ЧЕРТЁЖ", + typeIndustryAllocation: "РАСПРЕДЕЛЕНИЕ", + typeIndustry: "ПРОМЫШЛЕННОСТЬ", + modulesIndustryLabel: "Промышленность", + modulesIndustryDescription: "Модуль для коллективного управления средствами производства.", + industryFilterAll: "ВСЕ", + industryFilterMember: "УЧАСТНИК", + industryFilterBlueprints: "ЧЕРТЕЖИ", + industryFilterBuilds: "ПРОИЗВОДСТВА", + industryFilterMine: "МОИ", + industryFilterActive: "РАБОТАЕТ", + industryFilterPaused: "НА ПАУЗЕ", + industryFilterDissolved: "РАСПУЩЕННЫЕ", + industryAllTitle: "Промышленность", + industryMemberTitle: "Фабрики, где я состою", + industryMineTitle: "Мои фабрики", + industryActiveTitle: "Работает", + industryPausedTitle: "Фабрики на паузе", + industryDissolvedTitle: "Распущенные фабрики", + industryNoFacilitiesFound: "Фабрики не найдены.", + industryCreateFacility: "Создать фабрику", + industryMembers: "Участники", + industryMemberBadge: "УЧАСТНИК", + industryName: "Название", + industryNamePlaceholder: "Название фабрики", + industryDescriptionLabel: "Описание", + industryDescriptionPlaceholder: "Что производит эта фабрика?", + industrySector: "Сектор", + industryMembershipPolicy: "Политика членства", + industryQuorum: "Кворум", + industryMajority: "Большинство", + industryLaborRate: "Ставка труда", + industryCreateButton: "Создать фабрику", + industryUpdateButton: "Обновить", + industrySteward: "Управляющий", + industryStatusActive: "РАБОТАЕТ", + industryStatusPaused: "НА ПАУЗЕ", + industryStatusDissolved: "РАСПУЩЕНА", + industryStatusLabel: "Статус", + industryJoinButton: "Присоединиться", + industryApplyButton: "Подать заявку", + industryLeaveButton: "Покинуть", + industryInviteButton: "Пригласить", + industryInvitePlaceholder: "@...=.ed25519", + industryInviteNeeded: "Для вступления нужно приглашение.", + industryPauseButton: "Голосовать за паузу", + industryResumeButton: "Голосовать за возобновление", + industryEditButton: "Редактировать", + industryDeleteButton: "Удалить", + industryBackToList: "← Промышленность", + industryPendingApplicants: "Ожидающие заявки", + industryVotesYes: "да", + industryVotesNo: "нет", + industryAlreadyVoted: "проголосовал", + industryAdmit: "Принять", + industryReject: "Отклонить", + industryDissolveTitle: "Распустить фабрику", + industryDissolveHint: "Роспуск требует коллективного голосования; управляющий не может распустить её в одиночку.", + industryDissolveVote: "Голосовать за роспуск", + industrySector_software: "ПО", + industrySector_hardware: "Оборудование", + industrySector_agriculture: "Сельское хозяйство", + industrySector_textile: "Текстиль", + industrySector_energy: "Энергетика", + industrySector_food: "Продукты", + industrySector_construction: "Строительство", + industrySector_media: "Медиа", + industrySector_services: "Услуги", + industrySector_other: "Другое", + industryPolicy_open: "Открытая", + industryPolicy_vote: "Голосованием", + industryPolicy_invite: "Только по приглашению", projectsTitle: "Проекты", projectsDescription: "Создавайте, финансируйте и следите за проектами сообщества в вашей сети.", projectCreateProject: "Создать проект", @@ -3260,6 +3560,8 @@ module.exports = { chatOpenTitle: "Open Chats", chatClosedTitle: "Closed Chats", chatDescription: "Описание", + chatMessageReply: "Ответ", + chatMessageLabel: "Сообщение", chatCategory: "Категория", chatStatus: "СТАТУС", chatFilterAll: "ВСЕ", diff --git a/src/client/assets/translations/oasis_zh.js b/src/client/assets/translations/oasis_zh.js index 18367fd..edc5c4c 100644 --- a/src/client/assets/translations/oasis_zh.js +++ b/src/client/assets/translations/oasis_zh.js @@ -1,26 +1,6 @@ const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); module.exports = { zh: { - bbAdd: "添加", - bbQuickActions: "快捷操作", - bbPickTitle: "固定一个模块", - bbPickDesc: "为底部栏的 + 位置选择一个模块。", - bbPickNone: "无", - bbManage: "编辑", - bbYourBar: "你的工具栏", - bbRemove: "移除", - bbFull: "已达上限", - bbDone: "完成", - activityChangeFilter: "更改筛选", - emptyConnectHint: "连接到一个 pub 以与网络同步。", - emptyConnectCta: "连接到 pub", - menuCommunity: "社区", - activityGroupCommunity: "社区与治理", - activityGroupOrg: "组织", - activityGroupComm: "沟通", - activityGroupEconomy: "经济", - activityGroupMedia: "内容与媒体", - activityGroupOther: "其他", languageName: "中文", shopOrderStatusPaid: "已收款", shopOrderStatusReceived: "已收到", @@ -428,7 +408,8 @@ module.exports = { publish: "写作", contentWarningPlaceholder: "为帖子添加主题(可选)", privateWarningPlaceholder: "添加居民以发送私密帖子(可选)", - publishWarningPlaceholder: "...", + publishWarningPlaceholder: "正文上限 7000 个字符。", + publishTooLong: "你的帖子太长了,请缩短。", publishCustomDescription: [ "提醒:由于区块链技术,帖子一旦发布就无法编辑或删除。", ], @@ -493,7 +474,89 @@ module.exports = { passwordLengthInfo: "密码至少需要32个字符。", passwordImport: "输入密码以解密将保存在系统主目录的数据(文件名:secret)", exportPasswordPlaceholder: "使用小写字母、大写字母、数字和符号", - fileInfo: "你的加密私钥将保存在系统主目录(文件名:oasis.enc)", + fileInfo: "你的加密私钥将下载到你的设备(文件名:oasis.enc)", + developer: "开发", + devTitle: "开发", + welcomeBannerText: "欢迎来到 OASIS。按这几个步骤设置你的化身。", + welcomeBannerAction: "开始设置!", + welcomeTitle: "欢迎", + welcomeStepGreetingAction: "发送", + welcomeJoinPubButton: "加入 pub", + welcomeStepLarpAction: "加入「学院」", + welcomeStepLarpText: "加入我们的实境角色扮演游戏。", + welcomeStepLarpTitle: "加入 L.A.R.P.", + welcomeStepGreetingTitle: "发送一句「Hello world!」", + welcomeStepGreetingText: "发一条消息,让其他居民能发现你。", + welcomeJoinPub: "加入", + welcomeDescription: "开始之前值得做的几件事。", + welcomeProgress: "已完成", + welcomeStepDone: "完成", + welcomeStepPending: "待办", + welcomeSkip: "暂不设置", + welcomeStepLanguageTitle: "选择你的语言", + welcomeStepLanguageText: "Oasis 支持多种语言,随时都可以更改。", + welcomeStepProfileTitle: "编辑你的资料", + welcomeStepProfileText: "取个名字、写段介绍并设置头像,让其他居民认得你。", + welcomeStepProfileAction: "保存资料", + welcomeStepFederationTitle: "加入主网络", + welcomeStepFederationText: "连接到我们的 pub,认识其他居民并开始同步。", + welcomeStepFederationAction: "前往邀请", + welcomeStepBackupTitle: "备份你的 ID", + welcomeStepBackupText: "你的身份就是本机上的一个密钥文件。", + welcomeStepBackupWarning: "一旦丢失,没有任何人能帮你找回。", + welcomeStepBackupAction: "立即备份!", + welcomeCompleteTitle: "全部就绪。", + welcomeCompleteText: "你的节点已经就绪。从这里开始,网络随着你关注的人一起成长。", + welcomeFindPeople: "寻找居民", + devFilterFiles: "文件", + devFilterSearch: "搜索", + devFilterModules: "模块", + devFilterTranslations: "翻译", + devFilterStyles: "样式", + devFilterTests: "测试", + devVersion: "版本", + devNodeVersion: "Node", + devStatsTests: "测试套件", + devParentFolder: "上级文件夹", + devOpenFolder: "打开", + devDescription: "浏览本节点上运行的源代码。", + devTreeTitle: "文件", + devSearchTitle: "搜索代码", + devMapTitle: "模块", + devRoot: "根目录", + devRootButton: "根目录", + devSearchPlaceholder: "要在代码中查找的文本", + devAnyExtension: "任意文件", + devSearchButton: "搜索", + devStatsFiles: "文件", + devStatsLines: "行数", + devStatsModules: "模块", + devStatsLanguages: "语言", + devNotViewable: "无法查看", + devEmptyFolder: "此文件夹为空。", + devSize: "大小", + devShowing: "显示", + devLine: "行", + devReportBug: "报告缺陷", + devCreateTask: "创建任务", + devRaw: "RAW", + devReportContext: "在阅读 Oasis 源代码时发现", + devTaskPrefix: "复查", + devPrevPage: "上一段", + devNextPage: "下一段", + devMatches: "个匹配", + devFilesScanned: "个文件已扫描", + devNoResults: "暂无匹配。", + devColModule: "模块", + devColState: "状态", + devColModel: "模型", + devColView: "视图", + devColRoutes: "路由", + devColTests: "测试", + devOn: "开", + devOff: "关", + modulesDevLabel: "开发", + modulesDevDescription: "用于管理 Oasis 源代码的模块。", themeIntro: "选择一个主题。", setTheme: "设置主题", @@ -696,7 +759,7 @@ module.exports = { spreadLabel: "转发", spreadHint: "转发给你的支持者(通过你的 feed 复制)。", welcomePmSubject: "Hello World!", - welcomePmBody: "欢迎来到 Oasis — 一个自由、点对点、联邦化且加密的社交网络,采用仅限邀请的分布式架构。\n\n一些值得开始探索的地方:\n\n- /profile —— 编辑你的头像、名字、简介以及在公开侧显示哪些模块。\n- /invites —— 添加一个 pub,与更广泛的网络联邦。\n- /peers —— 查看谁已连接,重新扫描局域网,导出或导入对等节点列表。\n- /inhabitants —— 发现并访问其他人的头像。\n- /tribes —— 创建或加入端到端加密的私人小组。\n- /inbox —— 阅读和发送私信。\n- /activity —— 查看最近的活动,你的和你网络中的。\n- /publish —— 写一篇帖子,或分享图片、音频、视频、文档。\n\n一些值得连接的地方:\n\n- /fediverse — 管理你的其他联邦宇宙账户,包括发送和接收内容。\n\n这条消息是私下从你发给你自己的,因此无需任何连接即可接收。", + welcomePmBody: "欢迎来到 Oasis — 一个自由、点对点、联邦化且加密的社交网络,采用仅限邀请的分布式架构。\n\n一些值得开始探索的地方:\n\n- /profile —— 编辑你的头像、名字、简介以及在公开侧显示哪些模块。\n- /invites —— 添加一个 pub,与更广泛的网络联邦。\n- /peers —— 查看谁已连接,重新扫描局域网,导出或导入对等节点列表。\n- /inhabitants —— 发现并访问其他人的头像。\n- /tribes —— 创建或加入端到端加密的私人小组。\n- /inbox —— 阅读和发送私信。\n- /activity —— 查看最近的活动,你的和你网络中的。\n- /publish —— 写一篇帖子,或分享图片、音频、视频、文档。\n- /search — 按类型搜索网络内容。\n\n一些值得连接的地方:\n\n- /fediverse — 管理你的其他联邦宇宙账户,包括发送和接收内容。\n\n这条消息是私下从你发给你自己的,因此无需任何连接即可接收。", profileVisibilityTitle: "公开资料可见性", profileVisibilityHint: "选择其他居民在你的资料中可见的字段。默认仅显示 Karma。", profileSensorsSectionTitle: "传感器", @@ -1227,6 +1290,9 @@ module.exports = { eventButton: "活动", taskButton: "任务", votesButton: "投票", + industryButton: "工业", + projectButton: "项目", + shopProductButton: "产品", reportButton: "报告", feedButton: "动态", marketButton: "市场", @@ -1255,7 +1321,7 @@ module.exports = { trendingItemStatus: "项目状态", trendingTotalVotes: "总票数", trendingTotalOpinions: "总观点数", - trendingNoContentMessage: "暂无热门内容。", + trendingNoContentMessage: "暂时还没有热门内容。", trendingAuthor: "作者", trendingCreatedAtLabel: "创建于", trendingTotalCount: "总计", @@ -1444,7 +1510,7 @@ module.exports = { transfersCreatedAt: "创建于", transfersConfirmations: "确认", transfersContainingBlock: "所在区块", - transfersExportContract: "创建合约", + transfersExportContract: "智能合约", transfersConfirmButton: "确认转账", transfersNoItems: "未找到转账。", transfersMineSectionTitle: "你的转账", @@ -1570,7 +1636,7 @@ module.exports = { cvDeleteButton: "删除", cvNoCV: "未找到简历。", blogSubject: "主题", - blogMessage: "正文限制为 8096 个字符。", + blogMessage: "消息", blogImage: "上传媒体(最大:50MB)", blogPublish: "预览", noPopularMessages: "还没有发布热门消息", @@ -2205,6 +2271,7 @@ module.exports = { agendaFilterReports: "报告", agendaFilterTransfers: "转账", agendaFilterJobs: "工作", + agendaFilterIndustry: "工业", agendaFilterProjects: "项目", agendaFilterCalendars: "日历", agendaNoItems: "未找到分配项目。", @@ -2326,16 +2393,31 @@ module.exports = { privateMessage: "私信", pmSendTitle: "私信", pmSend: "发送!", - pmDescription: "使用此表单向其他居民发送加密消息。", + pmCancel: "取消", + pmReset: "重置", + pmSharedKeyHint: "请通过其他渠道将此密钥分享给收件人。解密时需要用到。", + pmDescription: "使用此表单向其他居民发送加密的消息/文件。", + pmComposeTitle: "发送消息", pmRecipients: "收件人", pmRecipientsHint: "输入 Oasis ID,用逗号分隔", pmLevelOfExposition: "Level of exposition", - pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmLimitsHint: "每条消息最多 7 个收件人。", + pmTextPlaceholder: "正文上限 7000 个字符。", pmSubject: "主题", pmSubjectHint: "输入消息主题", pmText: "消息", pmCrypterChip: "2xE2E", pmCrypterDecryptButton: "解密", + fileShareTitle: "分享文件", + fileShareRecipientPlaceholder: "@...=.ed25519", + fileShareFileLabel: "文件", + fileShareDownload: "下载", + fileShareMutualError: "你只能与相互支持的居民分享文件。", + fileShareNoFile: "未选择文件。", + fileShareTooLarge: "文件超过允许的大小。", + fileShareFailed: "无法准备文件。", + fileShareSendError: "无法发送文件。", + fileShareUnavailable: "该文件当前不可用。请稍后再试。", pmCrypterBadKey: "共享密钥不正确!", pmCrypterTooLong: "消息过长,无法用 Crypter 加密。请缩短后重试。", pmCrypterCipherLabel: "重新加密的消息", @@ -2356,15 +2438,27 @@ module.exports = { pmNoSubject: "(无主题)", pmSubjectLabel: "主题:", pmBodyLabel: "正文", - pmBotJobs: "42-JobsBOT", - pmBotProjects: "42-ProjectsBOT", - pmBotMarket: "42-MarketBOT", + pmBotJobs: "JobsBot", + pmBotProjects: "ProjectsBot", + pmBotMarket: "MarketBot", + pmBotShops: "ShopsBot", + pmBotPolitical: "PoliticalBot", + politicalBotLarpTitle: "L.A.R.P 的执政家族已更替。", + politicalBotParliamentTitle: "议会已开始新的执政周期。", + politicalBotTribeTitle: "你所在的某个部落已开始新的执政周期。", + politicalBotLarpIntro: "现在由 {house} 在本周期执政:{cycle}", + politicalBotParliamentIntro: "当前政府为 {gov}", + politicalBotParliamentIntroLeader: "当前政府为 {gov},由 {leader} 行使", + politicalBotTribeIntro: "部落 {tribe} 当前政府为 {gov}", + politicalBotTribeIntroLeader: "部落 {tribe} 当前政府为 {gov},由 {leader} 行使", + politicalBotLeaderLabel: "领袖", inboxJobSubscribedTitle: "你的工作机会有新订阅", pmInhabitantWithId: "Oasis ID 为以下的居民:", pmHasSubscribedToYourJobOffer: "已订阅你的工作机会", inboxProjectCreatedTitle: "新项目已创建", pmHasCreatedAProject: "已创建一个项目", inboxMarketItemSoldTitle: "商品已售出", + inboxShopSoldTitle: "商品已售出", pmYourItem: "你的商品", pmHasBeenSoldTo: "已售出给", pmFor: "售价", @@ -2399,7 +2493,7 @@ module.exports = { visitContent: "访问内容", banking: '银行', bankingTitle: '银行', - bankingDescription: '探索 ECOin 的当前价值和相应的 UBI 分配,根据参与度和信任度按纪元分配。', + bankingDescription: "ECOin 经济:余额、全民基本收入、税收与工业价值。", bankOverview: '概览', bankEpochs: '纪元', bankRules: '规则', @@ -2433,6 +2527,10 @@ module.exports = { bankEpochId: '纪元 ID', bankRuleHash: '规则快照哈希', bankViewEpoch: '查看纪元', + bankYourIndustryBalance: "你的工业份额", + bankYourUbiMonth: "你本月的全民基本收入", + bankYourFundsMonth: "你本月的资金", + bankIndustryBalance: "工业产值(估算)", bankUserBalance: '你的余额', ecoWalletNotConfigured: '未配置 ECOin 钱包', editWallet: '编辑钱包', @@ -2472,7 +2570,7 @@ module.exports = { pubIdPlaceholder: "@PUB_ID.ed25519", bankUbiAvailableNo: "资金不足!", bankUbiAvailableOk: "可用!", - bankUbiAvailability: "UBI 可用性", + bankUbiAvailability: "全民基本收入(网络)", bankAlreadyClaimedThisMonth: "本月已领取", bankUbiThisMonth: "UBI(本月)", bankUbiLastClaimed: "UBI(上次领取)", @@ -2902,6 +3000,208 @@ module.exports = { jobsTagsLabel: "标签", jobsTagsPlaceholder: "标签1, 标签2, 标签3", noJobsMatch: "没有匹配的工作。", + industrySearchPlaceholder: "搜索工厂…", + industryAllSectors: "所有部门", + industryVisitButton: "访问工业", + industryAdvanceTo: "设为", + industryAmount: "金额", + industryApproveBuild: "批准", + industryBackToFacility: "← 工厂", + industryBlueprint: "蓝图", + industryBlueprintLabel: "工业蓝图", + industryBlueprints: "蓝图", + industryBuild: "构建", + industryBuildNotes: "备注", + industryBuildStart: "开始日期", + industryVoteUpdateButton: "投票更新", + industryVoteDeleteButton: "投票删除", + industryRevokeVote: "撤销", + industryTimeLeft: "剩余时间", + industryBuildEnd: "结束日期", + industryBuilds: "构建", + industryBuildTitle: "标题", + industryContribute: "贡献", + industryContributeEco: "贡献 ECO", + industryContributeLabor: "贡献劳动", + industryContributeButton: "贡献", + industryContributeMaterial: "贡献材料", + industryContributions: "贡献", + industryContributors: "贡献者", + industryCreateBlueprintButton: "创建蓝图", + industryDistribute: "分配", + industryDistributeButton: "按份额分配", + industryDistribution: "分配", + industryEcoAmount: "金额 (ECO)", + industryEcoContribHint: "ECO 贡献将转给管理人,作为构建资金的托管人。", + industryEditBlueprint: "编辑蓝图", + industryFacility: "工厂", + industryKindLabel: "类型", + industryKind_labor: "人工", + industryKind_material: "材料", + industryKind_eco: "资金", + industryLaborHours: "人工(小时)", + industryHours: "小时", + industryLicense: "许可", + industryMaterialItem: "材料", + industryMaterials: "材料", + industryMaterialsHint: "每行一种材料,格式为 名称:数量:价格。", + industryEstMaterials: "材料总计", + industryEstLabor: "人工总计", + industryEstTaxes: "税", + industryEstTotal: "预计价格", + industryBuildingPrice: "制造价格", + industryFinalPrice: "最终价格", + industryBlueprintDescPlaceholder: "规格、尺寸、重量、文档……", + industryMaterialValue: "价值(ECO)", + industryMember: "成员", + industryNewBlueprint: "新建蓝图", + industryNewBuild: "提议构建", + industryEditBuild: "编辑生产", + industryNoBlueprint: "— 无 —", + industryNeedBlueprint: "请先创建蓝图:每次生产都基于蓝图。", + industryNoBlueprints: "还没有蓝图。", + industryNoBuilds: "还没有构建。", + industryNote: "备注", + industryOutput: "产出", + industryOutputItem: "产品名称", + industryOutputKind: "产品类型", + industryOutputQty: "每次生产的单位数", + industryOutputUnit: "计量单位", + industryOutputValue: "产出价值(ECO,例如来自销售)", + industryPayout: "支付", + industryPoints: "Karma", + industryPostJob: "创建工作", + industryPot: "资金池", + industryProposeBuildButton: "提议构建", + industryProposer: "提议者", + industryAuthor: "作者", + industrySellOnMarket: "发送到市场", + industrySendToProjects: "创建项目", + industryShare: "份额", + industryShares: "份额", + industrySkills: "技能", + industryTreasury: "资金", + industryKind_physical: "实体", + industryKind_digital: "数字", + industryBuildStatus_PROPOSED: "已提议", + industryBuildStatus_REJECTED: "已拒绝", + industryBuildStatus_APPROVED: "已批准", + industryBuildStatus_STOCKING: "备料中", + industryBuildStatus_IN_PRODUCTION: "生产中", + industryBuildStatus_COMPLETED: "已完成", + industryBuildStatus_FAILED: "已失败", + pmBotIndustry: "IndustryBot", + industryBotAdmittedTitle: "你已被接纳进入一家工厂。", + industryBotApplicationTitle: "你的工厂有新的加入申请。", + industryBotInvitedTitle: "你被邀请加入一家工厂。", + industryBotDissolvedTitle: "你所属的一家工厂已被解散。", + industryBotBuildApprovedTitle: "一个构建已获批准。", + industryBotDistributedTitle: "一个构建的产出已被分配。", + industryFilterRules: "规则", + industryRulesTitle: "规则", + industryRulesIntro: "Industry 让居民集体拥有生产资料:一家工厂是网络所有的作坊,无人私有。", + industryRulesSteward: "创始人是管理人——托管者,而非所有者:不能独自出售、私有化或解散工厂。", + industryRulesMembership: "成员政策决定如何加入:开放(立即加入)、投票(由成员投票接纳)或仅邀请(需成员先邀请)。", + industryRulesQuorum: "法定人数是一项决定生效所需投票的最少成员数,以免小工厂由一人决定。", + industryRulesMajority: "多数是通过所需的投票比例(0.5=一半,1=全体一致);当赞成票达到至少 max(法定人数, 成员×多数) 时决定通过。", + industryRulesDecisions: "成员投票接纳新人、批准构建和解散工厂;管理人不能凌驾于这些集体决定之上。", + industryRulesBlueprints: "蓝图是自由(COPYLEFT)配方——投入(材料、工时、技能)与产出(实体或数字商品)——任何人都可以复刻。", + industryRulesBuilds: "构建是一份生产订单:已提议→已批准(投票)→备料→生产中→已完成,且仅在批准后接受贡献。", + industryRulesContributions: "成员向构建贡献劳动(工时)、材料(申报的 ECO 价值)或 ECO;每次贡献获得 Karma。", + industryRulesLaborRate: "劳动费率是本工厂一工时的 ECO 价值;它把工时转为 Karma,以公平比较劳动与资本:Karma = 工时×费率 + 材料价值 + ECO。", + industryRulesShares: "你在构建中的份额是你的 Karma÷总 Karma,决定你能获得多少产出价值。", + industryRulesDistribution: "生产完成后,管理者将资金池(生产资金加销售价值)按贡献份额分配给贡献者。", + industryRulesTreasury: "ECO 贡献由管理人作为构建资金托管人保管,直到分配;所有流动都记录在网络上,纠纷可提交 Courts。", + industryJobs: "职位", + industryNoJobs: "还没有职位。", + industryCreatePosition: "创建职位", + industryViewCV: "简历", + industryReportButton: "举报", + industryCreateTask: "创建任务", + industryOpenDispute: "发起争议", + industryMaterialsPlaceholder: "solar-panel:2:120\nbattery:1:80\nkit:1:15", + industryOutputUnitPlaceholder: "例如 个、kg、许可证", + industryOutputItemPlaceholder: "例如 机器人", + industryOutputQtyPlaceholder: "例如 5", + industrySkillsPlaceholder: "例如 焊接、网络、太阳能安装", + industryCreateInvite: "生成邀请", + industryPauseVotes: "状态票数", + industryTitle: "工业", + industryDescription: "用于集体管理生产资料的模块。", + industryLabel: "工业", + typeIndustryBuild: "构建", + typeIndustryBlueprint: "蓝图", + typeIndustryAllocation: "分配", + typeIndustry: "工业", + modulesIndustryLabel: "工业", + modulesIndustryDescription: "用于集体管理生产资料的模块。", + industryFilterAll: "全部", + industryFilterMember: "成员", + industryFilterBlueprints: "蓝图", + industryFilterBuilds: "生产", + industryFilterMine: "我的", + industryFilterActive: "运行中", + industryFilterPaused: "已暂停", + industryFilterDissolved: "已解散", + industryAllTitle: "工业", + industryMemberTitle: "我参与的工厂", + industryMineTitle: "我的工厂", + industryActiveTitle: "运行中", + industryPausedTitle: "已暂停的工厂", + industryDissolvedTitle: "已解散的工厂", + industryNoFacilitiesFound: "未找到工厂。", + industryCreateFacility: "创建工厂", + industryMembers: "成员", + industryMemberBadge: "成员", + industryName: "名称", + industryNamePlaceholder: "工厂名称", + industryDescriptionLabel: "描述", + industryDescriptionPlaceholder: "这家工厂生产什么?", + industrySector: "部门", + industryMembershipPolicy: "成员政策", + industryQuorum: "法定人数", + industryMajority: "多数", + industryLaborRate: "劳动费率", + industryCreateButton: "创建工厂", + industryUpdateButton: "更新", + industrySteward: "管理人", + industryStatusActive: "运行中", + industryStatusPaused: "已暂停", + industryStatusDissolved: "已解散", + industryStatusLabel: "状态", + industryJoinButton: "加入", + industryApplyButton: "申请加入", + industryLeaveButton: "退出", + industryInviteButton: "邀请", + industryInvitePlaceholder: "@...=.ed25519", + industryInviteNeeded: "你需要邀请才能加入。", + industryPauseButton: "投票暂停", + industryResumeButton: "投票恢复", + industryEditButton: "编辑", + industryDeleteButton: "删除", + industryBackToList: "← 工业", + industryPendingApplicants: "待处理申请", + industryVotesYes: "赞成", + industryVotesNo: "反对", + industryAlreadyVoted: "已投票", + industryAdmit: "接纳", + industryReject: "拒绝", + industryDissolveTitle: "解散工厂", + industryDissolveHint: "解散需要集体投票;管理人不能单独解散。", + industryDissolveVote: "投票解散", + industrySector_software: "软件", + industrySector_hardware: "硬件", + industrySector_agriculture: "农业", + industrySector_textile: "纺织", + industrySector_energy: "能源", + industrySector_food: "食品", + industrySector_construction: "建筑", + industrySector_media: "媒体", + industrySector_services: "服务", + industrySector_other: "其他", + industryPolicy_open: "开放", + industryPolicy_vote: "投票", + industryPolicy_invite: "仅邀请", projectsTitle: "项目", projectsDescription: "创建、资助和关注你网络中的社区驱动项目。", projectCreateProject: "创建项目", @@ -3261,6 +3561,8 @@ module.exports = { chatOpenTitle: "Open Chats", chatClosedTitle: "Closed Chats", chatDescription: "描述", + chatMessageReply: "回复", + chatMessageLabel: "消息", chatCategory: "类别", chatStatus: "状态", chatFilterAll: "全部", diff --git a/src/configs/config-manager.js b/src/configs/config-manager.js index c2aa18e..5fb9cdc 100644 --- a/src/configs/config-manager.js +++ b/src/configs/config-manager.js @@ -22,6 +22,7 @@ if (!fs.existsSync(configFilePath)) { "invitesMod": "on", "walletMod": "on", "legacyMod": "on", + "devMod": "on", "cipherMod": "on", "bookmarksMod": "on", "videosMod": "on", @@ -51,6 +52,7 @@ if (!fs.existsSync(configFilePath)) { "jobsMod": "on", "shopsMod": "on", "projectsMod": "on", + "industryMod": "on", "bankingMod": "on", "parliamentMod": "on", "courtsMod": "on", @@ -78,7 +80,6 @@ if (!fs.existsSync(configFilePath)) { "limit": 2000 }, "homePage": "activity", - "bottomBarPins": ["search", "inbox", "publish"], "language": "en", "wish": "whole", "pmVisibility": "whole", diff --git a/src/configs/oasis-config.json b/src/configs/oasis-config.json index 146e017..6474298 100644 --- a/src/configs/oasis-config.json +++ b/src/configs/oasis-config.json @@ -15,7 +15,9 @@ "fediverseMod": "on", "invitesMod": "on", "walletMod": "off", - "legacyMod": "off", + "legacyMod": "on", + "devMod": "off", + "industryMod": "on", "cipherMod": "on", "bookmarksMod": "on", "videosMod": "on", @@ -75,5 +77,11 @@ "language": "en", "wish": "whole", "pmVisibility": "whole", - "lanBroadcasting": true + "lanBroadcasting": true, + "bottomBarPins": [ + "search", + "inbox", + "publish", + "activity" + ] } \ No newline at end of file diff --git a/src/models/activity_model.js b/src/models/activity_model.js index 6b51862..83d8a22 100644 --- a/src/models/activity_model.js +++ b/src/models/activity_model.js @@ -82,7 +82,7 @@ const HIDDEN_ENVELOPE_TYPES = new Set([ 'courts-key' ]); -module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel }) => { +module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }) => { let ssb; const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }; @@ -143,6 +143,72 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel }) => { return t.length > max ? t.slice(0, max - 1) + '…' : t; }; + // Builds self-contained chatThread items (title + recent messages embedded) for + // general (non-tribe) OPEN chats that have activity. Emitted as one item per chat + // with ts = latest message, so it survives the recent/all/mine filters intact. + const CHAT_THREAD_LIMIT = 6; + const buildChatThreads = async (ssbClient, idToAction, rootOf) => { + const replacedChatIds = new Set(); + for (const a of idToAction.values()) { + if (a.type !== 'chat') continue; + const rep = a.content && a.content.replaces; + if (rep) replacedChatIds.add(rep); + } + const stateByRoot = new Map(); + for (const a of idToAction.values()) { + if (a.type !== 'chat') continue; + const root = rootOf(a.id); + const isTip = !replacedChatIds.has(a.id); + const prev = stateByRoot.get(root); + // Resolve the chain tip by replaces (a non-replaced action wins), breaking ts + // ties correctly — the mock and real SSB can stamp equal timestamps. + if (prev && !((isTip && !prev.isTip) || (isTip === prev.isTip && (a.ts || 0) >= prev.ts))) continue; + const cc = a.content || {}; + stateByRoot.set(root, { ts: a.ts || 0, isTip, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || '') }); + } + const msgsByRoot = new Map(); + for (const a of idToAction.values()) { + if (a.type !== 'chatMessage') continue; + const c = a.content || {}; + if (c.tribeId || c.encryptedText) continue; + const root = c.chatId; + if (!root || typeof c.text !== 'string' || !c.text) continue; + if (!msgsByRoot.has(root)) msgsByRoot.set(root, []); + msgsByRoot.get(root).push(a); + } + for (const root of msgsByRoot.keys()) { + if (stateByRoot.has(root)) continue; + const val = await getMsg(ssbClient, root); + const cc = val && val.content; + if (cc && typeof cc === 'object' && cc.type === 'chat') { + stateByRoot.set(root, { ts: 0, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || '') }); + } else { + stateByRoot.set(root, null); + } + } + const items = []; + for (const [root, msgs] of msgsByRoot.entries()) { + const info = stateByRoot.get(root); + if (!info || info.tribeId || info.status !== 'OPEN') continue; + const asc = msgs.slice().sort((x, y) => (x.ts || 0) - (y.ts || 0)); + const last = asc[asc.length - 1]; + items.push({ + id: `chatthread:${root}`, + type: 'chatThread', + author: last.author, + ts: last.ts || 0, + content: { + type: 'chatThread', + chatRoot: root, + title: info.title || '', + description: info.description || '', + replies: asc.slice(-CHAT_THREAD_LIMIT).map(m => ({ id: m.id, author: m.author, ts: m.ts || 0, text: (m.content && m.content.text) || '' })) + } + }); + } + return items; + }; + return { invalidateCache() { _feedCache = null; @@ -256,7 +322,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel }) => { const target = String(c.target || ''); const author = v.author; if (!target || !author) continue; - const key = `${target}${author}`; + const key = `${target}\u0000${author}`; const prev = aiHelpfulSeen.get(key); const curTs = v.timestamp || 0; if (!prev || curTs > prev.ts) aiHelpfulSeen.set(key, { target, ts: curTs, helpful: c.helpful !== false }); @@ -491,7 +557,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel }) => { if (tombstoned.has(a.id)) continue; if (a.type === 'tribe' && parentOf.has(a.id)) continue; const c = a.content || {}; - if (c.root && tombstoned.has(c.root)) continue; + if (c.root && tombstoned.has(c.root) && !c.replaces) continue; if (a.type === 'vote' && tombstoned.has(c.vote?.link)) continue; if (a.type === 'spread' && (c.spreadTargetId || c.vote?.link) && tombstoned.has(c.spreadTargetId || c.vote?.link)) continue; if (c.key && tombstoned.has(c.key)) continue; @@ -598,8 +664,33 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel }) => { deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x }); + if (industryModel && deduped.some(a => a.type === 'industryBuild')) { + const builds = await industryModel.listAllBuilds().catch(() => []); + const buildStatusById = new Map(); + for (const b of (builds || [])) buildStatusById.set(b.id || b.key, String(b.status || 'PROPOSED').toUpperCase()); + deduped = deduped.filter(a => { + if (a.type !== 'industryBuild') return true; + const st = buildStatusById.get(a.id); + if (st === undefined || st === 'PROPOSED' || st === 'REJECTED') return false; + a.content = { ...a.content, buildStatus: st }; + return true; + }); + } + + if (industryModel && deduped.some(a => a.type === 'industryBlueprint')) { + const bps = await industryModel.listAllBlueprints().catch(() => []); + const bpById = new Map((bps || []).map(b => [b.id || b.key, b])); + for (const a of deduped) { + if (a.type !== 'industryBlueprint') continue; + const b = bpById.get(a.id); + if (b && b.estTotal != null) a.content = { ...a.content, estTotal: b.estTotal }; + } + } + const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']); const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'taskReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log', 'gameScore']); + const chatThreadItems = await buildChatThreads(ssbClient, idToAction, rootOf, deduped); + deduped = deduped.concat(chatThreadItems); const isAllowedTribeActivity = (a) => { if (tribeInternalTypes.has(a.type)) return false; const c = a.content || {}; @@ -628,6 +719,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel }) => { if (a.type === 'shop' && String(c.visibility || '').toUpperCase() === 'CLOSED' && a.author !== userId) return false; if (a.type === 'curriculum' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId) return false; if (a.type === 'shopProduct' && c.shopVisibility && String(c.shopVisibility).toUpperCase() === 'CLOSED' && a.author !== userId) return false; + if (a.type === 'transfer' && String(c.status || '').toUpperCase() === 'UNCONFIRMED' && a.author !== userId && c.from !== userId && c.to !== userId) return false; return true; }; const isVisible = (a) => { @@ -664,9 +756,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel }) => { }); else if (filter === 'task') out = deduped.filter(a => a.type === 'task' || a.type === 'taskAssignment'); + else if (filter === 'industry') + out = deduped.filter(a => ['industry', 'industryBuild', 'industryBlueprint', 'industryAllocation'].includes(a.type) && isVisible(a)); else if (filter === 'pad') out = deduped.filter(a => a.type === 'pad' && (a.content || {}).status === 'OPEN'); - else if (filter === 'chat') out = deduped.filter(a => a.type === 'chat' && (a.content || {}).status === 'OPEN'); + else if (filter === 'chat') out = deduped.filter(a => (a.type === 'chat' || a.type === 'chatThread') && isAllowedTribeActivity(a) && isVisible(a)); else if (filter === 'calendar') out = deduped.filter(a => a.type === 'calendar' && (a.content || {}).status === 'OPEN'); + else if (filter === 'transfer') out = deduped.filter(a => a.type === 'transfer' && isVisible(a)); else out = deduped.filter(a => a.type === filter); out.sort((a, b) => (b.ts || 0) - (a.ts || 0)); diff --git a/src/models/agenda_model.js b/src/models/agenda_model.js index 863b8a5..1f4b014 100644 --- a/src/models/agenda_model.js +++ b/src/models/agenda_model.js @@ -19,7 +19,7 @@ function writeAgendaConfig(cfg) { fs.writeFileSync(agendaConfigPath, JSON.stringify(cfg, null, 2)); } -module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel }) => { +module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel, industryModel }) => { let ssb; const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; }; @@ -184,7 +184,11 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel ? projectsModel.listProjects('all').then(normalize).catch(() => []) : fetchItems('project'); - const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll, calendarsAll] = await Promise.all([ + const industryViaModel = industryModel && typeof industryModel.listMyBuilds === 'function' + ? industryModel.listMyBuilds().then(normalize).catch(() => []) + : []; + + const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll, calendarsAll, industryAll] = await Promise.all([ tasksViaModel, eventsViaModel, fetchItems('transfer'), @@ -193,7 +197,8 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel fetchItems('report'), jobsViaModel, projectsViaModel, - calendarsViaModel + calendarsViaModel, + industryViaModel ]); const tasks = tasksAll.filter(c => Array.isArray(c.assignees) && c.assignees.includes(userId)).map(t => ({ ...t, type: 'task' })); @@ -206,6 +211,7 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel const reports = reportsAll.filter(c => c.author === userId || (Array.isArray(c.confirmations) && c.confirmations.includes(userId))).map(r => ({ ...r, type: 'report' })); const jobs = jobsAll.filter(c => c.author === userId || (Array.isArray(c.subscribers) && c.subscribers.includes(userId))).map(j => ({ ...j, type: 'job', title: j.title })); const projects = projectsAll.map(p => ({ ...p, type: 'project' })); + const industryBuilds = industryAll.map(b => ({ ...b, type: 'industry', title: b.title })); const myCalendars = calendarsAll .filter(c => c.author === userId || (Array.isArray(c.participants) && c.participants.includes(userId))); const calendars = myCalendars.map(c => ({ ...c, type: 'calendar' })); @@ -241,6 +247,7 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel ...reports, ...jobs, ...projects, + ...industryBuilds, ...calendars, ...calendarDates ]; @@ -260,6 +267,7 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel else if (filter === 'closed') filtered = filtered.filter(i => String(i.status).toUpperCase() === 'CLOSED'); else if (filter === 'jobs') filtered = filtered.filter(i => i.type === 'job'); else if (filter === 'projects') filtered = filtered.filter(i => i.type === 'project'); + else if (filter === 'industry') filtered = filtered.filter(i => i.type === 'industry'); else if (filter === 'calendars') filtered = filtered.filter(i => i.type === 'calendar' || i.type === 'calendarDate'); else if (filter === 'today') { const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0); @@ -313,6 +321,7 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel reports: mainItems.filter(i => i.type === 'report').length, jobs: mainItems.filter(i => i.type === 'job').length, projects: mainItems.filter(i => i.type === 'project').length, + industry: mainItems.filter(i => i.type === 'industry').length, calendars: mainItems.filter(i => i.type === 'calendar' || i.type === 'calendarDate').length, today: mainItems.filter(i => { const d = itemTs(i); return d >= startOfDay.getTime() && d <= endOfDay.getTime(); }).length, upcoming: mainItems.filter(i => itemTs(i) > now).length, diff --git a/src/models/banking_model.js b/src/models/banking_model.js index 4837168..46d93ff 100644 --- a/src/models/banking_model.js +++ b/src/models/banking_model.js @@ -589,6 +589,14 @@ function scoreFromActions(actions) { else if (t === "chat") score += 1 * decay; else if (t === "gamescore") score += 2 * decay; else if (t === "pixelia") score += 2 * decay; + else if (rawType === "industry") score += 8 * decay; + else if (rawType === "industryblueprint") score += 6 * decay; + else if (rawType === "industrybuild") score += 8 * decay; + else if (rawType === "industrycontribution") score += 4 * decay; + else if (rawType === "industryallocation") score += 10 * decay; + else if (rawType === "industryvote") score += 2 * decay; + else if (rawType === "industrymember") score += 2 * decay; + else if (rawType === "industryopinion") score += 1 * decay; } return Math.max(0, Math.round(score)); } @@ -754,6 +762,35 @@ async function getUserArchTax(userId) { return archTaxFromDays(ageDays); } +let _industryModel = null; +function industryModel() { + if (_industryModel) return _industryModel; + if (!services || !services.cooler) return null; + try { _industryModel = require("./industry_model")({ cooler: services.cooler }); } catch (_) { _industryModel = null; } + return _industryModel; +} + +async function getIndustryBalance(userId) { + const uid = resolveUserId(userId); + const industry = industryModel(); + if (!industry) return { received: 0, sent: 0, net: 0, networkTotal: 0 }; + const builds = await industry.listAllBuilds().catch(() => []); + let production = 0; + let earned = 0; + for (const b of (builds || [])) { + const est = parseFloat(b && b.estTotal); + if (Number.isFinite(est) && est > 0) production += est; + const mine = parseFloat(b && b.points && b.points[uid]); + if (Number.isFinite(mine) && mine > 0) earned += mine; + } + return { + received: Number(earned.toFixed(6)), + sent: 0, + net: Number(earned.toFixed(6)), + networkTotal: Number(production.toFixed(6)) + }; +} + async function getUserEngagementScore(userId) { const ssb = await openSsb(); const uid = resolveUserId(userId); @@ -1062,6 +1099,7 @@ async function getLastPublishedTimestamp(userId) { allocations = await getUbiAllocationsFromSSB(); } const userBalance = await safeGetBalance("user"); + const industryBal = await getIndustryBalance(uid).catch(() => ({ received: 0, sent: 0, net: 0, networkTotal: 0 })); const epochs = await epochsRepo.list(); let computed = null; try { computed = await computeEpoch({ epochId, userId: uid, rules: DEFAULT_RULES }); } catch {} @@ -1080,6 +1118,10 @@ async function getLastPublishedTimestamp(userId) { const hasValidWallet = !!(userAddress && isValidEcoinAddress(userAddress) && userWalletCfg.url); const summary = { userBalance, + industryBalance: industryBal.net, + industryNetworkTotal: industryBal.networkTotal, + industryReceived: industryBal.received, + industrySent: industryBal.sent, epochId, pool: poolForEpoch, weightsSum: computed?.epoch?.weightsSum || 0, diff --git a/src/models/calendars_model.js b/src/models/calendars_model.js index 5eb49ce..cccc854 100644 --- a/src/models/calendars_model.js +++ b/src/models/calendars_model.js @@ -305,17 +305,11 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel }) try { const ssbClient = await openSsb() const messages = await readAll(ssbClient) - const live = new Set() const tomb = buildValidatedTombstoneSet(messages) - for (const m of messages) { - const c = m.value && m.value.content - if (!c) continue - if (c.type === "calendar") live.add(m.key) - } const all = ownCrypto.getAllRootIds() let removed = 0 for (const rid of all) { - if (!live.has(rid) || tomb.has(rid)) { + if (tomb.has(rid)) { try { ownCrypto.dropKey(rid); removed += 1 } catch (_) {} } } diff --git a/src/models/chats_model.js b/src/models/chats_model.js index f4abc89..7806bfa 100644 --- a/src/models/chats_model.js +++ b/src/models/chats_model.js @@ -779,17 +779,11 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => { try { const ssbClient = await openSsb() const messages = await readAll(ssbClient) - const live = new Set() - for (const m of messages) { - const c = m.value && m.value.content - if (!c) continue - if (c.type === "chat") live.add(m.key) - } const tomb = buildValidatedTombstoneSet(messages) const all = ownCrypto.getAllRootIds() let removed = 0 for (const rid of all) { - if (!live.has(rid) || tomb.has(rid)) { + if (tomb.has(rid)) { try { ownCrypto.dropKey(rid); removed += 1 } catch (_) {} } } diff --git a/src/models/dev_model.js b/src/models/dev_model.js new file mode 100644 index 0000000..bdfe054 --- /dev/null +++ b/src/models/dev_model.js @@ -0,0 +1,343 @@ +const fs = require('fs'); +const path = require('path'); +const { getConfig } = require('../configs/config-manager.js'); + +const ROOT = path.resolve(__dirname, '..', '..'); + +const ALLOWED_ROOTS = ['src', 'docs', 'scripts', 'test']; +const ALLOWED_ROOT_FILES = ['README.md', 'LICENSE', 'install.sh', 'oasis.sh']; +const DENIED_SEGMENTS = new Set(['node_modules', '.git', '.ssb', 'packages', 'configs', 'embeddings', 'tiles', 'cache', 'results']); +const DENIED_NAMES = new Set(['secret', '.env', '.npmrc', 'package-lock.json']); +const DENIED_EXTENSIONS = new Set(['.gguf', '.jks', '.keystore', '.enc', '.pem', '.key', '.p12', '.crt']); +const VIEWABLE_EXTENSIONS = new Set(['.js', '.mjs', '.json', '.css', '.md', '.sh', '.html', '.txt', '.yml', '.yaml']); + +const STATS_TTL = 60 * 1000; +let statsCache = null; +let statsCacheAt = 0; + +const MAX_VIEW_BYTES = 2 * 1024 * 1024; +const PAGE_LINES = 500; +const SEARCH_MAX = 200; +const SEARCH_SNIPPET = 200; + +const extOf = (name) => { + const i = String(name || '').lastIndexOf('.'); + return i > 0 ? String(name).slice(i).toLowerCase() : ''; +}; + +const isDeniedEntry = (name) => { + const n = String(name || ''); + if (!n || n === '.' || n === '..') return true; + if (n.toLowerCase().startsWith('.git')) return true; + if (DENIED_SEGMENTS.has(n)) return true; + if (DENIED_NAMES.has(n)) return true; + if (DENIED_EXTENSIONS.has(extOf(n))) return true; + return false; +}; + +const PLAIN_TEXT_NAME = /^[A-Z][A-Z0-9_-]*$/; + +const isViewable = (name) => (VIEWABLE_EXTENSIONS.has(extOf(name)) || PLAIN_TEXT_NAME.test(String(name || ''))) && !isDeniedEntry(name); + +function realRoot() { + try { return fs.realpathSync(ROOT); } catch (_) { return ROOT; } +} + +function splitPath(rel) { + const raw = String(rel || ''); + for (let i = 0; i < raw.length; i++) { if (raw.charCodeAt(i) < 32) throw new Error('Invalid path'); } + return raw.replace(/^[/\\]+/, '').split(/[/\\]+/).filter(s => s && s !== '.'); +} + +function resolveSafe(rel) { + const segments = splitPath(rel); + if (segments.some(s => s === '..')) throw new Error('Invalid path'); + if (segments.some(isDeniedEntry)) throw new Error('Path not available'); + if (segments.length) { + const head = segments[0]; + const isRootFile = segments.length === 1 && ALLOWED_ROOT_FILES.includes(head); + if (!ALLOWED_ROOTS.includes(head) && !isRootFile) throw new Error('Path not available'); + } + const target = path.resolve(ROOT, segments.join(path.sep)); + let real; + try { real = fs.realpathSync(target); } catch (_) { throw new Error('Not found'); } + const base = realRoot(); + if (real !== base && !real.startsWith(base + path.sep)) throw new Error('Path not available'); + return { abs: real, rel: segments.join('/'), segments }; +} + +function parentsOf(segments) { + const out = []; + let acc = ''; + for (const s of segments) { + acc = acc ? `${acc}/${s}` : s; + out.push({ name: s, path: acc }); + } + return out; +} + +function rootListing() { + const dirs = []; + const files = []; + for (const name of ALLOWED_ROOTS) { + const abs = path.join(ROOT, name); + if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) dirs.push({ name, path: name }); + } + for (const name of ALLOWED_ROOT_FILES) { + const abs = path.join(ROOT, name); + if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue; + files.push({ name, path: name, size: fs.statSync(abs).size, ext: extOf(name), viewable: isViewable(name) }); + } + return { path: '', parents: [], dirs, files, hidden: 0 }; +} + +function walkFiles(relDir, onFile) { + let entries; + try { entries = fs.readdirSync(path.resolve(ROOT, relDir), { withFileTypes: true }); } catch (_) { return true; } + for (const e of entries) { + if (isDeniedEntry(e.name)) continue; + const rel = relDir ? `${relDir}/${e.name}` : e.name; + if (e.isSymbolicLink()) continue; + if (e.isDirectory()) { if (walkFiles(rel, onFile) === false) return false; continue; } + if (!e.isFile()) continue; + if (onFile(rel, e.name) === false) return false; + } + return true; +} + +const MODULE_ALIASES = { + ai: { model: 'src/AI/ai_service.mjs', view: 'src/views/AI_view.js' }, + aiNav: { model: 'src/AI/routes_index.js', view: 'src/views/AI_view.js', paths: ['/ai/ask'] }, + docs: { paths: ['/documents'] }, + latest: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest'] }, + popular: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/popular'] }, + topics: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/topics'] }, + summaries: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/summaries'] }, + threads: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/threads'] }, + multiverse: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/extended'] }, + invites: { model: 'src/models/main_models.js', view: 'src/views/invites_view.js' }, + graphos: { model: 'src/models/main_models.js', view: 'src/views/graphos_view.js' } +}; + +const GROUPS = { + translations: ['src/client/assets/translations'], + styles: ['src/client/assets/styles', 'src/client/assets/themes'], + tests: ['test'] +}; + +module.exports = { + ROOT, + PAGE_LINES, + GROUPS, + + listGroup(key) { + const roots = GROUPS[String(key || '')]; + if (!roots) throw new Error('Not found'); + const dirs = []; + const files = []; + for (const rel of roots) { + let listing; + try { listing = module.exports.listTree(rel); } catch (_) { continue; } + dirs.push(...listing.dirs); + files.push(...listing.files); + } + dirs.sort((a, b) => a.name.localeCompare(b.name)); + files.sort((a, b) => a.name.localeCompare(b.name)); + return { group: String(key), path: '', parents: [], dirs, files, hidden: 0 }; + }, + + listTree(relPath) { + const segments = splitPath(relPath); + if (!segments.length) return rootListing(); + const target = resolveSafe(relPath); + const stat = fs.statSync(target.abs); + if (!stat.isDirectory()) throw new Error('Not a directory'); + const entries = fs.readdirSync(target.abs, { withFileTypes: true }); + const dirs = []; + const files = []; + let hidden = 0; + for (const e of entries) { + if (isDeniedEntry(e.name) || e.isSymbolicLink()) { hidden++; continue; } + const rel = `${target.rel}/${e.name}`; + if (e.isDirectory()) { dirs.push({ name: e.name, path: rel }); continue; } + if (!e.isFile()) { hidden++; continue; } + let size = 0; + try { size = fs.statSync(path.join(target.abs, e.name)).size; } catch (_) { size = 0; } + files.push({ name: e.name, path: rel, size, ext: extOf(e.name), viewable: isViewable(e.name) }); + } + dirs.sort((a, b) => a.name.localeCompare(b.name)); + files.sort((a, b) => a.name.localeCompare(b.name)); + return { path: target.rel, parents: parentsOf(target.segments), dirs, files, hidden }; + }, + + readFile(relPath, opts = {}) { + const target = resolveSafe(relPath); + const stat = fs.statSync(target.abs); + if (!stat.isFile()) throw new Error('Not a file'); + if (!isViewable(path.basename(target.abs))) throw new Error('This file type cannot be displayed'); + if (stat.size > MAX_VIEW_BYTES) throw new Error('This file is too large to be displayed'); + const all = fs.readFileSync(target.abs, 'utf8').split('\n'); + const totalLines = all.length; + const requested = parseInt(opts.from, 10); + const from = Math.min(Math.max(Number.isFinite(requested) ? requested : 1, 1), Math.max(1, totalLines)); + const to = Math.min(from + PAGE_LINES - 1, totalLines); + const lines = []; + for (let n = from; n <= to; n++) lines.push({ n, text: all[n - 1] }); + return { + path: target.rel, + parents: parentsOf(target.segments), + name: path.basename(target.abs), + size: stat.size, + totalLines, + from, + to, + pageLines: PAGE_LINES, + hasPrev: from > 1, + hasNext: to < totalLines, + lines + }; + }, + + rawFile(relPath) { + const target = resolveSafe(relPath); + const stat = fs.statSync(target.abs); + if (!stat.isFile()) throw new Error('Not a file'); + if (!isViewable(path.basename(target.abs))) throw new Error('This file type cannot be displayed'); + if (stat.size > MAX_VIEW_BYTES) throw new Error('This file is too large to be displayed'); + return { name: path.basename(target.abs), content: fs.readFileSync(target.abs, 'utf8') }; + }, + + searchCode(query, opts = {}) { + const q = String(query || '').trim(); + if (q.length < 2) return { query: q, results: [], truncated: false, scanned: 0 }; + const wanted = String(opts.ext || '').trim().toLowerCase(); + const max = Math.min(Math.max(parseInt(opts.max, 10) || SEARCH_MAX, 1), SEARCH_MAX); + const needle = q.toLowerCase(); + const results = []; + let scanned = 0; + let truncated = false; + for (const root of ALLOWED_ROOTS) { + if (truncated) break; + walkFiles(root, (rel, name) => { + if (!isViewable(name)) return true; + if (wanted && extOf(name) !== wanted) return true; + let stat; + try { stat = fs.statSync(path.resolve(ROOT, rel)); } catch (_) { return true; } + if (stat.size > MAX_VIEW_BYTES) return true; + scanned++; + let content; + try { content = fs.readFileSync(path.resolve(ROOT, rel), 'utf8'); } catch (_) { return true; } + if (content.toLowerCase().indexOf(needle) < 0) return true; + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (lines[i].toLowerCase().indexOf(needle) < 0) continue; + results.push({ path: rel, line: i + 1, text: lines[i].trim().slice(0, SEARCH_SNIPPET) }); + if (results.length >= max) { truncated = true; return false; } + } + return true; + }); + } + return { query: q, results, truncated, scanned }; + }, + + moduleMap() { + const cfg = getConfig(); + const mods = Object.keys((cfg && cfg.modules) || {}).map(k => k.replace(/Mod$/, '')).sort(); + let backend = ''; + try { backend = fs.readFileSync(path.join(ROOT, 'src', 'backend', 'backend.js'), 'utf8'); } catch (_) { backend = ''; } + const modelFiles = fs.existsSync(path.join(ROOT, 'src', 'models')) ? fs.readdirSync(path.join(ROOT, 'src', 'models')) : []; + const viewFiles = fs.existsSync(path.join(ROOT, 'src', 'views')) ? fs.readdirSync(path.join(ROOT, 'src', 'views')) : []; + const stem = (s) => String(s || '').toLowerCase().replace(/s$/, ''); + const pick = (files, name, suffixes) => { + for (const suf of suffixes) { + const exact = files.find(f => f.toLowerCase() === `${name.toLowerCase()}${suf}`); + if (exact) return exact; + } + const singular = name.replace(/s$/, ''); + for (const suf of suffixes) { + const short = files.find(f => f.toLowerCase() === `${singular.toLowerCase()}${suf}`); + if (short) return short; + } + const target = stem(name); + return files.find(f => { + const suf = suffixes.find(x => f.toLowerCase().endsWith(x)); + if (!suf) return false; + const base = stem(f.slice(0, f.length - suf.length)); + return base.startsWith(target) || target.startsWith(base); + }) || null; + }; + const testsIndex = new Map(); + const modsDir = path.join(ROOT, 'test', 'mods'); + const indexTests = (dir, prefix) => { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; } + for (const e of entries) { + if (!e.isDirectory()) continue; + const rel = `${prefix}/${e.name}`; + if (!testsIndex.has(e.name)) testsIndex.set(e.name, rel); + indexTests(path.join(dir, e.name), rel); + } + }; + indexTests(modsDir, 'test/mods'); + return mods.map(name => { + const alias = MODULE_ALIASES[name] || {}; + const routePaths = Array.isArray(alias.paths) && alias.paths.length ? alias.paths : [`/${name}`]; + let routes = 0; + for (const routePath of routePaths) { + const escaped = routePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(`\\.(get|post)\\(\\s*["'\`]${escaped}(?=["'/?])`, 'g'); + routes += backend ? (backend.match(re) || []).length : 0; + } + const model = alias.model !== undefined ? alias.model : (pick(modelFiles, name, ['_model.js', '_models.js']) ? `src/models/${pick(modelFiles, name, ['_model.js', '_models.js'])}` : null); + const view = alias.view !== undefined ? alias.view : (pick(viewFiles, name, ['_view.js', '_views.js']) ? `src/views/${pick(viewFiles, name, ['_view.js', '_views.js'])}` : null); + return { + name, + enabled: (cfg.modules || {})[`${name}Mod`] !== 'off', + model, + view, + routes, + tests: testsIndex.get(name) || testsIndex.get(name.replace(/s$/, '')) || (() => { + const target = stem(name); + for (const [dir, rel] of testsIndex) { + const base = stem(dir); + if (base.startsWith(target) || target.startsWith(base)) return rel; + } + return null; + })() + }; + }); + }, + + projectStats() { + const now = Date.now(); + if (statsCache && now - statsCacheAt < STATS_TTL) return statsCache; + let files = 0; + let lines = 0; + let bytes = 0; + for (const root of ALLOWED_ROOTS) { + walkFiles(root, (rel, name) => { + if (!isViewable(name)) return true; + try { + const abs = path.resolve(ROOT, rel); + const stat = fs.statSync(abs); + if (stat.size > MAX_VIEW_BYTES) return true; + files++; + bytes += stat.size; + lines += fs.readFileSync(abs, 'utf8').split('\n').length; + } catch (_) {} + return true; + }); + } + const cfg = getConfig(); + const modules = Object.keys((cfg && cfg.modules) || {}).length; + const translations = path.join(ROOT, 'src', 'client', 'assets', 'translations'); + const languages = fs.existsSync(translations) ? fs.readdirSync(translations).filter(f => /^oasis_[a-z]{2}\.js$/.test(f)).length : 0; + const testsDir = path.join(ROOT, 'test', 'mods'); + const testMods = fs.existsSync(testsDir) ? fs.readdirSync(testsDir).length : 0; + let version = ''; + try { version = String(JSON.parse(fs.readFileSync(path.join(ROOT, 'src', 'server', 'package.json'), 'utf8')).version || ''); } catch (_) { version = ''; } + statsCache = { files, lines, bytes, modules, languages, testMods, version, node: process.version }; + statsCacheAt = now; + return statsCache; + } +}; diff --git a/src/models/fileshare_model.js b/src/models/fileshare_model.js new file mode 100644 index 0000000..a275fa6 --- /dev/null +++ b/src/models/fileshare_model.js @@ -0,0 +1,200 @@ +const pull = require('../server/node_modules/pull-stream'); +const fs = require('fs'); +const crypto = require('crypto'); +const { Readable } = require('stream'); +const fsc = require('../backend/fileshare_crypto'); + +const MAX_CHUNK_SIZE = 5 * 1024 * 1024; + +module.exports = ({ cooler }) => { + let ssb; + const openSsb = async () => { + if (!ssb) ssb = await cooler.open(); + return ssb; + }; + + const addBlob = (client, buffer) => + new Promise((resolve, reject) => { + pull(pull.values([buffer]), client.blobs.add((err, ref) => err ? reject(err) : resolve(ref))); + }); + + const getBlob = (client, ref) => + new Promise((resolve, reject) => { + pull(client.blobs.get(ref), pull.collect((err, chunks) => err ? reject(err) : resolve(Buffer.concat(chunks)))); + }); + + const hasBlob = (client, ref) => + new Promise((resolve) => { + if (!client.blobs || typeof client.blobs.has !== 'function') return resolve(false); + client.blobs.has(ref, (err, has) => resolve(!err && !!has)); + }); + + const wantBlob = (client, ref, timeoutMs) => + new Promise((resolve) => { + if (typeof client.blobs.want !== 'function') return resolve(true); + let done = false; + const finish = (ok) => { if (done) return; done = true; if (timer) clearTimeout(timer); resolve(ok); }; + const timer = timeoutMs ? setTimeout(() => finish(false), timeoutMs) : null; + client.blobs.want(ref, (err) => finish(!err)); + }); + + const rmBlob = (client, ref) => + new Promise((resolve) => { + if (typeof client.blobs.rm !== 'function') return resolve(false); + client.blobs.rm(ref, (err) => resolve(!err)); + }); + + const createShareFromBuffer = async ({ buffer, filename, mime, chunkSize } = {}) => { + if (!Buffer.isBuffer(buffer)) throw new Error('buffer required'); + const client = await openSsb(); + const key = fsc.generateFileKey(); + const size = fsc.DEFAULT_CHUNK_SIZE; + const useChunk = Math.min(Number(chunkSize) || fsc.DEFAULT_CHUNK_SIZE, MAX_CHUNK_SIZE); + + const chunkHashes = []; + for (const part of fsc.splitBuffer(buffer, useChunk)) { + const payload = fsc.encryptChunk(part, key); + chunkHashes.push(await addBlob(client, payload)); + } + + const manifest = fsc.buildManifest({ + filename, mime, size: buffer.length, chunkSize: useChunk, + chunkHashes, plainSha256: fsc.sha256Hex(buffer) + }); + const manifestBlobId = await addBlob(client, fsc.encryptManifest(manifest, key)); + + return { + type: 'fileShare', + v: 1, + key: fsc.keyToHex(key), + manifestBlobId, + filename: manifest.filename, + mime: manifest.mime, + size: manifest.size, + chunkCount: chunkHashes.length + }; + }; + + const createShareFromFile = async ({ filepath, filename, mime, chunkSize } = {}) => { + if (!filepath) throw new Error('filepath required'); + const client = await openSsb(); + const key = fsc.generateFileKey(); + const useChunk = Math.min(Number(chunkSize) || fsc.DEFAULT_CHUNK_SIZE, MAX_CHUNK_SIZE); + const chunkHashes = []; + const hashAll = crypto.createHash('sha256'); + let total = 0; + let carry = Buffer.alloc(0); + const flush = async (part) => { + hashAll.update(part); + total += part.length; + chunkHashes.push(await addBlob(client, fsc.encryptChunk(part, key))); + }; + for await (const data of fs.createReadStream(filepath, { highWaterMark: useChunk })) { + carry = carry.length ? Buffer.concat([carry, data]) : data; + while (carry.length >= useChunk) { + await flush(carry.subarray(0, useChunk)); + carry = carry.subarray(useChunk); + } + } + if (carry.length || !chunkHashes.length) await flush(carry); + + const manifest = fsc.buildManifest({ + filename, mime, size: total, chunkSize: useChunk, + chunkHashes, plainSha256: hashAll.digest('hex') + }); + const manifestBlobId = await addBlob(client, fsc.encryptManifest(manifest, key)); + + return { + type: 'fileShare', v: 1, + key: fsc.keyToHex(key), manifestBlobId, + filename: manifest.filename, mime: manifest.mime, size: manifest.size, + chunkCount: chunkHashes.length + }; + }; + + const openManifest = async (pointer) => { + const client = await openSsb(); + const key = fsc.keyFromHex(pointer.key); + await wantBlob(client, pointer.manifestBlobId); + const payload = await getBlob(client, pointer.manifestBlobId); + return fsc.decryptManifest(payload, key); + }; + + const CHUNK_WANT_TIMEOUT = 30000; + const PREFLIGHT_TIMEOUT = 12000; + + const readShareStream = (pointer) => { + const key = fsc.keyFromHex(pointer.key); + async function* gen() { + const client = await openSsb(); + if (!(await wantBlob(client, pointer.manifestBlobId, CHUNK_WANT_TIMEOUT))) throw new Error('fileshare blob unavailable'); + const manifest = fsc.decryptManifest(await getBlob(client, pointer.manifestBlobId), key); + for (const ref of manifest.chunks) { + if (!(await wantBlob(client, ref, CHUNK_WANT_TIMEOUT))) throw new Error('fileshare blob unavailable'); + yield fsc.decryptChunk(await getBlob(client, ref), key); + } + } + return Readable.from(gen()); + }; + + const ensureAvailable = async (pointer, timeoutMs = PREFLIGHT_TIMEOUT) => { + try { + const client = await openSsb(); + const key = fsc.keyFromHex(pointer.key); + if (!(await wantBlob(client, pointer.manifestBlobId, timeoutMs))) return false; + const manifest = fsc.decryptManifest(await getBlob(client, pointer.manifestBlobId), key); + if (manifest.chunks.length && !(await wantBlob(client, manifest.chunks[0], timeoutMs))) return false; + return true; + } catch (_) { + return false; + } + }; + + const reassembleToBuffer = async (pointer) => { + const parts = []; + for await (const chunk of readShareStream(pointer)) parts.push(chunk); + return Buffer.concat(parts); + }; + + const isAvailable = async (pointer) => { + const client = await openSsb(); + if (!(await hasBlob(client, pointer.manifestBlobId))) return false; + try { + const key = fsc.keyFromHex(pointer.key); + const manifest = fsc.decryptManifest(await getBlob(client, pointer.manifestBlobId), key); + for (const ref of manifest.chunks) if (!(await hasBlob(client, ref))) return false; + return true; + } catch (_) { + return false; + } + }; + + const removeLocalBlobs = async (pointer) => { + const client = await openSsb(); + let removed = 0; + try { + const key = fsc.keyFromHex(pointer.key); + if (await hasBlob(client, pointer.manifestBlobId)) { + const manifest = fsc.decryptManifest(await getBlob(client, pointer.manifestBlobId), key); + for (const ref of manifest.chunks) if (await rmBlob(client, ref)) removed++; + } + } catch (_) {} + if (await rmBlob(client, pointer.manifestBlobId)) removed++; + return removed; + }; + + const pruneExpired = async (sentFileShares, ttlMs, now) => { + const cutoff = (Number.isFinite(now) ? now : Date.now()) - (Number(ttlMs) || 0); + let pruned = 0; + for (const item of (Array.isArray(sentFileShares) ? sentFileShares : [])) { + if (!item || !item.pointer) continue; + const sentAt = new Date(item.sentAt || 0).getTime(); + if (!(sentAt <= cutoff)) continue; + const n = await removeLocalBlobs(item.pointer); + if (n > 0) pruned++; + } + return pruned; + }; + + return { createShareFromBuffer, createShareFromFile, openManifest, readShareStream, reassembleToBuffer, isAvailable, ensureAvailable, removeLocalBlobs, pruneExpired }; +}; diff --git a/src/models/industry_model.js b/src/models/industry_model.js new file mode 100644 index 0000000..5afb0fa --- /dev/null +++ b/src/models/industry_model.js @@ -0,0 +1,993 @@ +const pull = require("../server/node_modules/pull-stream") +const { getConfig } = require("../configs/config-manager.js") +const { buildValidatedTombstoneSet } = require('./tombstone_validator') +const logLimit = (getConfig().ssbLogStream && getConfig().ssbLogStream.limit) || 1000 + +module.exports = ({ cooler }) => { + let ssb + const openSsb = async () => { + if (!ssb) ssb = await cooler.open() + return ssb + } + + const TYPE = "industry" + const SECTORS = ["software", "hardware", "agriculture", "textile", "energy", "food", "construction", "media", "services", "other"] + const POLICIES = ["open", "vote", "invite"] + + const clampPercent = (n) => { + const x = parseInt(n, 10) + if (!Number.isFinite(x)) return 0 + return Math.max(0, Math.min(100, x)) + } + const clampMajority = (n) => { + const x = parseFloat(n) + if (!Number.isFinite(x)) return 0.5 + return Math.max(0.5, Math.min(1, x)) + } + const clampQuorum = (n) => { + const x = parseInt(n, 10) + if (!Number.isFinite(x) || x < 1) return 1 + return Math.min(100000, x) + } + const safeSector = (s) => { + const v = String(s || "").toLowerCase().trim() + return SECTORS.includes(v) ? v : "other" + } + const safePolicy = (p) => { + const v = String(p || "").toLowerCase().trim() + return POLICIES.includes(v) ? v : "vote" + } + const nonNegNum = (v) => { + const x = parseFloat(v) + return Number.isFinite(x) && x >= 0 ? x : 0 + } + + async function getAllMsgs(ssbClient) { + return new Promise((r, j) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((e, m) => (e ? j(e) : r(m))) + ) + }) + } + + function chainMaps(nodeMap) { + const next = new Map(), prev = new Map(), strictNext = new Map() + for (const [key, n] of nodeMap) { + const t = n.c.replaces + if (!t) continue + next.set(t, key); prev.set(key, t) + const orig = nodeMap.get(t) + if (orig && orig.author === n.author) strictNext.set(t, key) + } + return { next, prev, strictNext } + } + + function buildIndex(messages) { + const tomb = buildValidatedTombstoneSet(messages) + const nodes = new Map() + const blueprints = new Map() + const builds = new Map() + const collab = { member: [], invite: [], apply: [], vote: [], opinion: [], buildStatus: [], buildDates: [], contribution: [], allocation: [] } + for (const m of messages) { + const c = m && m.value && m.value.content + if (!c) continue + if (c.type === "tombstone") continue + const ts = (m.value && m.value.timestamp) || 0 + const author = m.value && m.value.author + if (c.type === "industryMember") { collab.member.push({ target: c.target, author, action: c.action === "leave" ? "leave" : "join", ts }); continue } + if (c.type === "industryInvite") { collab.invite.push({ target: c.target, author, invitee: c.invitee, ts }); continue } + if (c.type === "industryApply") { collab.apply.push({ target: c.target, author, ts }); continue } + if (c.type === "industryVote") { collab.vote.push({ target: c.target, author, subject: String(c.subject || ""), ref: String(c.ref || ""), choice: c.choice === "no" ? "no" : "yes", ts }); continue } + if (c.type === "industryOpinion") { collab.opinion.push({ target: c.target, author, category: c.category, ts }); continue } + if (c.type === "industryBlueprint") { blueprints.set(m.key, { key: m.key, ts, c, author }); continue } + if (c.type === "industryBuild") { builds.set(m.key, { key: m.key, ts, c, author }); continue } + if (c.type === "industryBuildStatus") { collab.buildStatus.push({ target: c.target, author, status: String(c.status || "").toUpperCase(), ts }); continue } + if (c.type === "industryBuildDates" || c.type === "industryBuildUpdate") { collab.buildDates.push({ target: c.target, author, title: String(c.title || ""), notes: String(c.notes || ""), startDate: String(c.startDate || ""), endDate: String(c.endDate || ""), ts }); continue } + if (c.type === "industryContribution") { collab.contribution.push({ target: c.target, author, kind: c.kind, hours: nonNegNum(c.hours), item: String(c.item || ""), value: nonNegNum(c.value), eco: nonNegNum(c.eco), transferId: c.transferId || null, note: String(c.note || ""), ts }); continue } + if (c.type === "industryAllocation") { collab.allocation.push({ target: c.target, author, shares: (c.shares && typeof c.shares === "object") ? c.shares : {}, amounts: (c.amounts && typeof c.amounts === "object") ? c.amounts : {}, pot: nonNegNum(c.pot), outputValue: nonNegNum(c.outputValue), disposition: c.disposition || null, ts }); continue } + if (c.type !== TYPE) continue + nodes.set(m.key, { key: m.key, ts, c, author }) + } + const fc = chainMaps(nodes) + const bp = chainMaps(blueprints) + return { tomb, nodes, blueprints, builds, collab, naiveNext: fc.next, naivePrev: fc.prev, strictNext: fc.strictNext, bpNext: bp.next, bpPrev: bp.prev, bpStrictNext: bp.strictNext } + } + + const rootOfIdx = (idx, key) => { let x = key, g = 0; while (idx.naivePrev.has(x) && idx.nodes.has(idx.naivePrev.get(x)) && g++ < 100000) x = idx.naivePrev.get(x); return x } + const rootOfBlueprint = (idx, key) => { let x = key, g = 0; while (idx.bpPrev.has(x) && idx.blueprints.has(idx.bpPrev.get(x)) && g++ < 100000) x = idx.bpPrev.get(x); return x } + + function passesThreshold(yesCount, memberCount, quorum, majority) { + const size = Math.max(1, memberCount) + const need = Math.max(Math.min(clampQuorum(quorum), size), Math.ceil(size * clampMajority(majority))) + return yesCount >= need + } + + function actionVoteState(idx, groupRoot, members, subject, ref) { + const pref = new Map() + for (const v of idx.collab.vote.filter(x => x.target === groupRoot && x.subject === subject && x.ref === ref).sort((a, b) => a.ts - b.ts)) { + if (!members.has(v.author)) continue + pref.set(v.author, v.choice) + } + const voters = [...pref.entries()].filter(([, c]) => c === "yes").map(([m]) => m) + return { yes: voters.length, voters } + } + + function voteNeedOf(g) { + const size = Math.max(1, new Set(g.facility.members).size) + return Math.max(Math.min(clampQuorum(g.facility.quorum), size), Math.ceil(size * clampMajority(g.facility.majority))) + } + + function actionApproved(idx, g, subject, ref) { + const members = new Set(g.facility.members) + if (members.size <= 1) return true + const st = actionVoteState(idx, g.root, members, subject, ref) + return passesThreshold(st.yes, members.size, g.facility.quorum, g.facility.majority) + } + + function blueprintGovernance(idx, g, bpRoot) { + const members = new Set(g.facility.members) + const up = actionVoteState(idx, g.root, members, "bpUpdate", bpRoot) + const del = actionVoteState(idx, g.root, members, "bpDelete", bpRoot) + return { + root: bpRoot, + facilityMembers: members.size, + voteNeed: voteNeedOf(g), + updateYes: up.yes, + updateVoters: up.voters, + deleteYes: del.yes, + deleteVoters: del.voters, + updateApproved: actionApproved(idx, g, "bpUpdate", bpRoot), + deleteApproved: actionApproved(idx, g, "bpDelete", bpRoot) + } + } + + function buildGovernance(idx, g, buildId) { + const members = new Set(g.facility.members) + const up = actionVoteState(idx, g.root, members, "buildUpdate", buildId) + const del = actionVoteState(idx, g.root, members, "buildDelete", buildId) + return { + facilityMembers: members.size, + voteNeed: voteNeedOf(g), + updateYes: up.yes, + updateVoters: up.voters, + deleteYes: del.yes, + deleteVoters: del.voters, + updateApproved: actionApproved(idx, g, "buildUpdate", buildId), + deleteApproved: actionApproved(idx, g, "buildDelete", buildId) + } + } + + function resolveGroup(idx, root) { + const { nodes, collab, naiveNext, strictNext } = idx + const followStrict = (key) => { let x = key, g = 0; while (strictNext.has(x) && g++ < 100000) x = strictNext.get(x); return x } + const rootNode = nodes.get(root) + if (!rootNode) return null + const steward = rootNode.author + let tip = followStrict(root) + const tn = nodes.get(tip) + if (!tn || tn.author !== steward) tip = root + const best = nodes.get(tip) + if (!best) return null + + const groupKeys = [] + { let x = root, g = 0; groupKeys.push(x); while (naiveNext.has(x) && g++ < 100000) { x = naiveNext.get(x); groupKeys.push(x) } } + + const facility = { ...best.c, id: tip } + const policy = safePolicy(facility.membershipPolicy) + const quorum = clampQuorum(facility.quorum) + const majority = clampMajority(facility.majority) + + const KIND_ORDER = { invite: 0, apply: 1, member: 2, vote: 3 } + const events = [] + for (const e of collab.member.filter(x => x.target === root)) events.push({ kind: "member", ...e }) + for (const e of collab.invite.filter(x => x.target === root)) events.push({ kind: "invite", ...e }) + for (const e of collab.apply.filter(x => x.target === root)) events.push({ kind: "apply", ...e }) + for (const e of collab.vote.filter(x => x.target === root)) events.push({ kind: "vote", ...e }) + events.sort((a, b) => (a.ts - b.ts) || (KIND_ORDER[a.kind] - KIND_ORDER[b.kind])) + + const members = new Set([steward]) + const invites = new Set() + const applicants = new Set() + const admitVotes = new Map() + const dissolveVoters = new Set() + let dissolved = false + + const admitKey = (uid) => "admit:" + uid + for (const ev of events) { + if (ev.kind === "invite") { + if (members.has(ev.author) && ev.invitee) invites.add(ev.invitee) + continue + } + if (ev.kind === "apply") { + if (!members.has(ev.author)) applicants.add(ev.author) + continue + } + if (ev.kind === "member") { + if (ev.action === "leave") { if (ev.author !== steward) members.delete(ev.author); continue } + if (members.has(ev.author)) continue + if (policy === "open") { members.add(ev.author); applicants.delete(ev.author) } + else if (policy === "invite" && invites.has(ev.author)) { members.add(ev.author); applicants.delete(ev.author) } + continue + } + if (ev.kind === "vote") { + if (!members.has(ev.author)) continue + if (ev.subject === "admit" && ev.ref) { + const k = admitKey(ev.ref) + if (!admitVotes.has(k)) admitVotes.set(k, new Map()) + admitVotes.get(k).set(ev.author, ev.choice) + const yes = [...admitVotes.get(k).values()].filter(v => v === "yes").length + if (!members.has(ev.ref) && passesThreshold(yes, members.size, quorum, majority)) { + members.add(ev.ref); applicants.delete(ev.ref) + } + } else if (ev.subject === "dissolve") { + if (ev.choice === "yes") dissolveVoters.add(ev.author); else dissolveVoters.delete(ev.author) + if (passesThreshold(dissolveVoters.size, members.size, quorum, majority)) dissolved = true + } + continue + } + } + + const pendingApplicants = [...applicants].map(uid => { + const k = admitKey(uid) + const vm = admitVotes.get(k) || new Map() + let yes = 0, no = 0 + for (const v of vm.values()) { if (v === "yes") yes++; else no++ } + return { id: uid, yes, no, voters: [...vm.keys()] } + }) + + const opinions = {} + const opinionSet = new Set() + for (const k of groupKeys) { + const n = nodes.get(k); if (!n || n.author !== steward) continue + const c = n.c + if (c.opinions && typeof c.opinions === "object") { for (const kk of Object.keys(c.opinions)) opinions[kk] = Math.max(opinions[kk] || 0, Number(c.opinions[kk]) || 0) } + for (const v of (Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : [])) opinionSet.add(v) + } + for (const op of collab.opinion.filter(x => x.target === root)) { if (!opinionSet.has(op.author)) { opinionSet.add(op.author); opinions[op.category] = (opinions[op.category] || 0) + 1 } } + + const pausePref = new Map() + for (const ev of events) { + if (ev.kind !== "vote" || ev.subject !== "pause") continue + if (!members.has(ev.author)) continue + pausePref.set(ev.author, ev.choice) + } + const pauseVoters = [...pausePref.entries()].filter(([, c]) => c === "yes").map(([m]) => m) + const paused = passesThreshold(pauseVoters.length, members.size, quorum, majority) + + facility.steward = steward + facility.root = root + facility.membershipPolicy = policy + facility.quorum = quorum + facility.majority = majority + facility.members = [...members] + facility.memberCount = members.size + facility.pendingApplicants = pendingApplicants + facility.invites = [...invites] + facility.opinions = opinions + facility.opinions_inhabitants = [...opinionSet] + facility.pauseVoters = pauseVoters + facility.pauseYes = pauseVoters.length + facility.status = dissolved ? "DISSOLVED" : (paused ? "PAUSED" : "ACTIVE") + + return { tip, root, best, facility, tombstoned: idx.tomb.has(tip) } + } + + const BUILD_STATUSES = ["PROPOSED", "REJECTED", "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 = new Set(["APPROVED", "STOCKING", "IN_PRODUCTION"]) + + const normalizeMaterials = (raw, text) => { + if (Array.isArray(raw)) return raw.map(m => ({ item: String((m && m.item) || "").trim(), qty: nonNegNum(m && m.qty), price: nonNegNum(m && m.price) })).filter(m => m.item) + const t = String(text || "") + if (!t.trim()) return [] + return t.split("\n").map(line => { const parts = line.split(/[:,]/).map(x => String(x || "").trim()); return { item: parts[0] || "", qty: nonNegNum(parts[1]), price: nonNegNum(parts[2]) } }).filter(m => m.item) + } + + const safeMaterials = (v) => (Array.isArray(v) ? v : []) + const estimateBlueprint = (bp, laborRate) => { + const materialsCost = safeMaterials(bp.materials).reduce((s, m) => s + nonNegNum(m.qty) * nonNegNum(m.price), 0) + const laborCost = nonNegNum(bp.laborHours) * nonNegNum(laborRate) + return { + estMaterialsCost: Number(materialsCost.toFixed(6)), + estLaborCost: Number(laborCost.toFixed(6)), + estTotal: Number((materialsCost + laborCost).toFixed(6)) + } + } + const estimateForBlueprintRef = (idx, bpRef, laborRate) => { + if (!bpRef) return null + const b = resolveBlueprint(idx, rootOfBlueprint(idx, bpRef)) + if (!b || b.tombstoned) return null + return { blueprintName: b.blueprint.name, blueprintImage: b.blueprint.image || null, blueprintKind: b.blueprint.outKind || null, blueprintLicense: b.blueprint.license || "copyleft", ...estimateBlueprint(b.blueprint, laborRate) } + } + const normalizeSkills = (raw) => { + if (Array.isArray(raw)) return raw.map(x => String(x || "").trim()).filter(Boolean) + return String(raw || "").split(",").map(x => x.trim()).filter(Boolean) + } + + function computeShares(contributions, laborRate) { + const rate = nonNegNum(laborRate) + const points = new Map() + for (const c of contributions) { + let p = 0 + if (c.kind === "labor") p = (c.hours || 0) * rate + else if (c.kind === "material") p = (c.value || 0) + else if (c.kind === "eco") p = (c.eco || 0) + if (p <= 0) continue + points.set(c.author, (points.get(c.author) || 0) + p) + } + let total = 0 + for (const v of points.values()) total += v + const shares = new Map() + if (total > 0) for (const [k, v] of points) shares.set(k, v / total) + return { points, total, shares } + } + + function resolveBlueprint(idx, root) { + const { blueprints, bpStrictNext } = idx + const followStrict = (key) => { let x = key, g = 0; while (bpStrictNext.has(x) && g++ < 100000) x = bpStrictNext.get(x); return x } + const rootNode = blueprints.get(root) + if (!rootNode) return null + const author = rootNode.author + let tip = followStrict(root) + const tn = blueprints.get(tip) + if (!tn || tn.author !== author) tip = root + const best = blueprints.get(tip) + if (!best) return null + return { tip, root, blueprint: { ...best.c, id: tip, author }, tombstoned: idx.tomb.has(tip) } + } + + function resolveBuild(idx, root, facility) { + const node = idx.builds.get(root) + if (!node) return null + const build = { ...node.c, id: root, proposer: node.author } + const steward = facility.steward + const members = new Set(facility.members) + const approveVotes = new Map() + for (const v of idx.collab.vote.filter(x => x.subject === "build" && x.ref === root).sort((a, b) => a.ts - b.ts)) { + if (!members.has(v.author)) continue + approveVotes.set(v.author, v.choice) + } + const approveYes = [...approveVotes.values()].filter(x => x === "yes").length + const approveNo = [...approveVotes.values()].filter(x => x === "no").length + let status = "PROPOSED" + if (passesThreshold(approveYes, members.size, facility.quorum, facility.majority)) status = "APPROVED" + else if (passesThreshold(approveNo, members.size, facility.quorum, facility.majority)) status = "REJECTED" + for (const ev of idx.collab.buildStatus.filter(x => x.target === root).sort((a, b) => a.ts - b.ts)) { + const allowed = BUILD_TRANSITIONS[status] || [] + if (!allowed.includes(ev.status)) continue + const isSteward = ev.author === steward + const isProposer = ev.author === build.proposer + const stewardOnly = ev.status === "COMPLETED" || ev.status === "FAILED" + if (stewardOnly && !isSteward) continue + if (!isSteward && !isProposer) continue + status = ev.status + } + for (const ev of idx.collab.buildDates.filter(x => x.target === root).sort((a, b) => a.ts - b.ts)) { + if (ev.author !== steward && ev.author !== build.proposer) continue + if (ev.title) build.title = ev.title + if (ev.notes) build.notes = ev.notes + if (ev.startDate) build.startDate = ev.startDate + if (ev.endDate) build.endDate = ev.endDate + } + build.status = status + const contributions = idx.collab.contribution.filter(x => x.target === root && members.has(x.author)).sort((a, b) => a.ts - b.ts) + build.contributions = contributions.map(c => ({ author: c.author, kind: c.kind, hours: c.hours, item: c.item, value: c.value, eco: c.eco, note: c.note, at: new Date(c.ts || 0).toISOString() })) + const cs = computeShares(contributions, facility.laborRate) + build.points = Object.fromEntries(cs.points) + build.totalPoints = cs.total + build.shares = Object.fromEntries(cs.shares) + build.treasury = contributions.reduce((s, c) => s + (c.eco || 0), 0) + const allocs = idx.collab.allocation.filter(x => x.target === root && x.author === steward).sort((a, b) => a.ts - b.ts) + const latest = allocs[allocs.length - 1] || null + build.distributed = !!latest + build.allocation = latest ? { shares: latest.shares, amounts: latest.amounts, pot: latest.pot, outputValue: latest.outputValue, disposition: latest.disposition, at: new Date(latest.ts || 0).toISOString() } : null + build.tombstoned = idx.tomb.has(root) + return build + } + + async function getById(id) { + const ssbClient = await openSsb() + const idx = buildIndex(await getAllMsgs(ssbClient)) + const g = resolveGroup(idx, rootOfIdx(idx, id)) + if (!g || g.tombstoned) throw new Error("Facility not found") + return g.facility + } + + async function resolveTipId(id) { + const ssbClient = await openSsb() + const idx = buildIndex(await getAllMsgs(ssbClient)) + const g = resolveGroup(idx, rootOfIdx(idx, id)) + if (!g || g.tombstoned) throw new Error("Facility not found") + return g.tip + } + + const CONTENT_COLLAB_FIELDS = ["id", "root", "steward", "members", "memberCount", "pendingApplicants", "invites", "opinions", "opinions_inhabitants", "pauseVoters", "pauseYes"] + function stripCollab(content) { + const out = { ...content } + for (const f of CONTENT_COLLAB_FIELDS) delete out[f] + out.opinions = {} + out.opinions_inhabitants = [] + return out + } + + async function publishReplace(ssbClient, currentId, content) { + const tomb = { type: "tombstone", target: currentId, deletedAt: new Date().toISOString(), author: ssbClient.id } + const updated = { ...stripCollab(content), type: TYPE, replaces: currentId, updatedAt: new Date().toISOString() } + await new Promise((res, rej) => ssbClient.publish(tomb, (e) => (e ? rej(e) : res()))) + return new Promise((res, rej) => ssbClient.publish(updated, (e, m) => (e ? rej(e) : res(m)))) + } + + const publish = (ssbClient, content) => new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m)))) + + return { + type: TYPE, + SECTORS, + POLICIES, + + async createFacility(data) { + const ssbClient = await openSsb() + const content = { + type: TYPE, + name: String(data.name || "").trim(), + sector: safeSector(data.sector), + description: String(data.description || "").trim(), + image: data.image || null, + mapUrl: String(data.mapUrl || "").trim(), + membershipPolicy: safePolicy(data.membershipPolicy), + quorum: clampQuorum(data.quorum), + majority: clampMajority(data.majority), + laborRate: nonNegNum(data.laborRate), + license: String(data.license || "copyleft").trim() || "copyleft", + tags: normalizeSkills(data.tags), + status: "ACTIVE", + author: ssbClient.id, + createdAt: new Date().toISOString(), + updatedAt: null, + opinions: {}, + opinions_inhabitants: [] + } + if (!content.name) throw new Error("Facility name is required") + return publish(ssbClient, content) + }, + + async updateFacility(id, patch) { + const ssbClient = await openSsb() + const idx = buildIndex(await getAllMsgs(ssbClient)) + const g = resolveGroup(idx, rootOfIdx(idx, id)) + if (!g || g.tombstoned) throw new Error("Facility not found") + if (g.facility.steward !== ssbClient.id) throw new Error("Unauthorized") + const current = g.facility + const nextStatus = patch.status === undefined ? current.status : String(patch.status || "").toUpperCase() + if (nextStatus === "DISSOLVED" && current.status !== "DISSOLVED") throw new Error("Dissolution requires a governance vote") + const updated = { + ...current, + ...patch, + name: patch.name === undefined ? current.name : String(patch.name || "").trim(), + sector: patch.sector === undefined ? current.sector : safeSector(patch.sector), + description: patch.description === undefined ? current.description : String(patch.description || "").trim(), + image: patch.image === undefined ? current.image : (patch.image || null), + mapUrl: patch.mapUrl === undefined ? current.mapUrl : String(patch.mapUrl || "").trim(), + membershipPolicy: patch.membershipPolicy === undefined ? current.membershipPolicy : safePolicy(patch.membershipPolicy), + quorum: patch.quorum === undefined ? current.quorum : clampQuorum(patch.quorum), + majority: patch.majority === undefined ? current.majority : clampMajority(patch.majority), + laborRate: patch.laborRate === undefined ? current.laborRate : nonNegNum(patch.laborRate), + tags: patch.tags === undefined ? current.tags : normalizeSkills(patch.tags), + status: nextStatus + } + return publishReplace(ssbClient, current.id, updated) + }, + + async deleteFacility(id) { + const ssbClient = await openSsb() + const idx = buildIndex(await getAllMsgs(ssbClient)) + const g = resolveGroup(idx, rootOfIdx(idx, id)) + if (!g || g.tombstoned) throw new Error("Facility not found") + if (g.facility.steward !== ssbClient.id) throw new Error("Unauthorized") + if (g.facility.memberCount > 1) throw new Error("A facility with members cannot be deleted; it must be dissolved by vote") + const tomb = { type: "tombstone", target: g.tip, deletedAt: new Date().toISOString(), author: ssbClient.id } + return publish(ssbClient, tomb) + }, + + async joinFacility(id) { + const ssbClient = await openSsb() + const idx = buildIndex(await getAllMsgs(ssbClient)) + const g = resolveGroup(idx, rootOfIdx(idx, id)) + if (!g || g.tombstoned) throw new Error("Facility not found") + if (g.facility.status === "DISSOLVED") throw new Error("Facility is dissolved") + const uid = ssbClient.id + if (g.facility.members.includes(uid)) throw new Error("Already a member") + const policy = g.facility.membershipPolicy + if (policy === "vote") return publish(ssbClient, { type: "industryApply", target: g.root, createdAt: new Date().toISOString() }) + if (policy === "invite" && !g.facility.invites.includes(uid)) throw new Error("You need an invitation to join") + return publish(ssbClient, { type: "industryMember", target: g.root, action: "join", createdAt: new Date().toISOString() }) + }, + + async leaveFacility(id) { + const ssbClient = await openSsb() + const idx = buildIndex(await getAllMsgs(ssbClient)) + const g = resolveGroup(idx, rootOfIdx(idx, id)) + if (!g || g.tombstoned) throw new Error("Facility not found") + const uid = ssbClient.id + if (uid === g.facility.steward) throw new Error("The steward cannot leave; dissolve the facility instead") + if (!g.facility.members.includes(uid)) throw new Error("Not a member") + return publish(ssbClient, { type: "industryMember", target: g.root, action: "leave", createdAt: new Date().toISOString() }) + }, + + async inviteToFacility(id, invitee) { + const ssbClient = await openSsb() + const idx = buildIndex(await getAllMsgs(ssbClient)) + const g = resolveGroup(idx, rootOfIdx(idx, id)) + if (!g || g.tombstoned) throw new Error("Facility not found") + if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members can invite") + if (!/^@[A-Za-z0-9+/]+={0,2}\.ed25519$/.test(String(invitee || ""))) throw new Error("Invalid invitee id") + return publish(ssbClient, { type: "industryInvite", target: g.root, invitee: String(invitee), createdAt: new Date().toISOString() }) + }, + + async voteGovernance(id, subject, ref, choice) { + const ssbClient = await openSsb() + const idx = buildIndex(await getAllMsgs(ssbClient)) + const g = resolveGroup(idx, rootOfIdx(idx, id)) + if (!g || g.tombstoned) throw new Error("Facility not found") + if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members can vote") + const subj = String(subject || "") + const SUBJECTS = ["admit", "dissolve", "pause", "bpUpdate", "bpDelete", "buildUpdate", "buildDelete"] + if (!SUBJECTS.includes(subj)) throw new Error("Invalid governance subject") + if (subj !== "admit" && subj !== "dissolve" && subj !== "pause" && !String(ref || "").trim()) throw new Error("Governance vote requires a target") + return publish(ssbClient, { type: "industryVote", target: g.root, subject: subj, ref: String(ref || ""), choice: choice === "no" ? "no" : "yes", createdAt: new Date().toISOString() }) + }, + + async createOpinion(id, category) { + const categories = require('../backend/opinion_categories') + if (!categories.includes(category)) throw new Error('Invalid opinion category') + const ssbClient = await openSsb() + const idx = buildIndex(await getAllMsgs(ssbClient)) + const g = resolveGroup(idx, rootOfIdx(idx, id)) + if (!g || g.tombstoned) throw new Error('Facility not found') + if ((g.facility.opinions_inhabitants || []).includes(ssbClient.id)) throw new Error('Already opined') + return publish(ssbClient, { type: 'industryOpinion', target: g.root, category, createdAt: new Date().toISOString() }) + }, + + async getFacilityById(id) { + return getById(id) + }, + + async listFacilities(filter = "ALL") { + const ssbClient = await openSsb() + const uid = ssbClient.id + const idx = buildIndex(await getAllMsgs(ssbClient)) + const roots = new Set() + for (const key of idx.nodes.keys()) roots.add(rootOfIdx(idx, key)) + let list = [] + for (const root of roots) { + const g = resolveGroup(idx, root) + if (!g || g.tombstoned) continue + list.push(g.facility) + } + const f = String(filter || "ALL").toUpperCase() + if (f === "MINE") list = list.filter(x => x.steward === uid) + else if (f === "MEMBER") list = list.filter(x => Array.isArray(x.members) && x.members.includes(uid)) + else if (f === "ACTIVE") list = list.filter(x => x.status === "ACTIVE") + else if (f === "PAUSED") list = list.filter(x => x.status === "PAUSED") + else if (f === "DISSOLVED") list = list.filter(x => x.status === "DISSOLVED") + else if (SECTORS.includes(f.toLowerCase())) list = list.filter(x => x.sector === f.toLowerCase()) + list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + return list + }, + + async facilitiesOf(feedId) { + const ssbClient = await openSsb() + const uid = feedId || ssbClient.id + const idx = buildIndex(await getAllMsgs(ssbClient)) + const roots = new Set() + for (const key of idx.nodes.keys()) roots.add(rootOfIdx(idx, key)) + const list = [] + for (const root of roots) { + const g = resolveGroup(idx, root) + if (!g || g.tombstoned) continue + if (Array.isArray(g.facility.members) && g.facility.members.includes(uid)) list.push(g.facility) + } + list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + return list + }, + + async listMyBuilds() { + const ssbClient = await openSsb() + const uid = ssbClient.id + const idx = buildIndex(await getAllMsgs(ssbClient)) + const facRoots = new Set() + for (const key of idx.nodes.keys()) facRoots.add(rootOfIdx(idx, key)) + const myFacilities = new Map() + for (const root of facRoots) { + const g = resolveGroup(idx, root) + if (!g || g.tombstoned) continue + if (Array.isArray(g.facility.members) && g.facility.members.includes(uid)) myFacilities.set(g.root, g.facility) + } + const out = [] + for (const [key, node] of idx.builds) { + if (idx.tomb.has(key)) continue + const fac = myFacilities.get(node.c.facility) + if (!fac) continue + const build = resolveBuild(idx, key, fac) + build.facilityId = fac.root + build.facilityName = fac.name + out.push(build) + } + out.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + return out + }, + + async createBlueprint(facilityId, data) { + const ssbClient = await openSsb() + const idx = buildIndex(await getAllMsgs(ssbClient)) + const g = resolveGroup(idx, rootOfIdx(idx, facilityId)) + if (!g || g.tombstoned) throw new Error("Facility not found") + if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members can add blueprints") + const content = { + type: "industryBlueprint", + facility: g.root, + name: String(data.name || "").trim(), + description: String(data.description || "").trim(), + image: data.image || null, + sector: safeSector(data.sector || g.facility.sector), + materials: normalizeMaterials(data.materials, data.materialsText), + laborHours: nonNegNum(data.laborHours), + skills: normalizeSkills(data.skills), + outItem: String(data.outItem || "").trim(), + outQty: nonNegNum(data.outQty), + outUnit: String(data.outUnit || "unit").trim() || "unit", + outKind: String(data.outKind || "physical").toLowerCase() === "digital" ? "digital" : "physical", + license: String(data.license || "copyleft").trim() || "copyleft", + createdAt: new Date().toISOString() + } + if (!content.name) throw new Error("Blueprint name is required") + return publish(ssbClient, content) + }, + + async updateBlueprint(id, patch) { + const ssbClient = await openSsb() + const idx = buildIndex(await getAllMsgs(ssbClient)) + const b = resolveBlueprint(idx, rootOfBlueprint(idx, id)) + if (!b || b.tombstoned) throw new Error("Blueprint not found") + if (b.blueprint.author !== ssbClient.id) throw new Error("Unauthorized") + const gb = resolveGroup(idx, rootOfIdx(idx, b.blueprint.facility)) + if (!gb || gb.tombstoned) throw new Error("Facility not found") + if (!gb.facility.members.includes(ssbClient.id)) throw new Error("Only members of the facility can do this") + if (!actionApproved(idx, gb, "bpUpdate", b.root)) throw new Error("Updating this blueprint requires a governance vote") + const cur = b.blueprint + const updated = { + type: "industryBlueprint", + facility: cur.facility, + name: patch.name === undefined ? cur.name : String(patch.name || "").trim(), + description: patch.description === undefined ? (cur.description || "") : String(patch.description || "").trim(), + image: patch.image === undefined ? (cur.image || null) : (patch.image || null), + sector: patch.sector === undefined ? cur.sector : safeSector(patch.sector), + materials: patch.materials === undefined && patch.materialsText === undefined ? cur.materials : normalizeMaterials(patch.materials, patch.materialsText), + laborHours: patch.laborHours === undefined ? cur.laborHours : nonNegNum(patch.laborHours), + skills: patch.skills === undefined ? cur.skills : normalizeSkills(patch.skills), + outItem: patch.outItem === undefined ? cur.outItem : String(patch.outItem || "").trim(), + outQty: patch.outQty === undefined ? cur.outQty : nonNegNum(patch.outQty), + outUnit: patch.outUnit === undefined ? cur.outUnit : String(patch.outUnit || "unit").trim() || "unit", + outKind: patch.outKind === undefined ? cur.outKind : (String(patch.outKind).toLowerCase() === "digital" ? "digital" : "physical"), + license: patch.license === undefined ? cur.license : String(patch.license || "copyleft").trim() || "copyleft", + replaces: b.tip, + createdAt: cur.createdAt, + updatedAt: new Date().toISOString() + } + const tomb = { type: "tombstone", target: b.tip, deletedAt: new Date().toISOString(), author: ssbClient.id } + await publish(ssbClient, tomb) + return publish(ssbClient, updated) + }, + + async deleteBlueprint(id) { + const ssbClient = await openSsb() + const idx = buildIndex(await getAllMsgs(ssbClient)) + const b = resolveBlueprint(idx, rootOfBlueprint(idx, id)) + if (!b || b.tombstoned) throw new Error("Blueprint not found") + const g = resolveGroup(idx, rootOfIdx(idx, b.blueprint.facility)) + if (!g || g.tombstoned) throw new Error("Facility not found") + if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members of the facility can do this") + if (b.blueprint.author !== ssbClient.id) throw new Error("Only the author can delete a blueprint") + if (!actionApproved(idx, g, "bpDelete", b.root)) throw new Error("Deleting this blueprint requires a governance vote") + const chain = [] + { let x = b.root, guard = 0; chain.push(x); while (idx.bpNext.has(x) && guard++ < 100000) { x = idx.bpNext.get(x); chain.push(x) } } + const chainSet = new Set(chain) + let usedBy = 0 + for (const [key, node] of idx.builds) { + if (idx.tomb.has(key)) continue + if (chainSet.has(node.c.blueprint)) usedBy++ + } + if (usedBy > 0) throw new Error(`This blueprint is used by ${usedBy} build${usedBy > 1 ? "s" : ""}; delete them first`) + let last = null + for (const key of chain) { + const node = idx.blueprints.get(key) + if (!node || node.author !== ssbClient.id) continue + if (idx.tomb.has(key)) continue + last = await publish(ssbClient, { type: "tombstone", target: key, deletedAt: new Date().toISOString(), author: ssbClient.id }) + } + return last + }, + + async getBlueprint(id) { + const ssbClient = await openSsb() + const idx = buildIndex(await getAllMsgs(ssbClient)) + const b = resolveBlueprint(idx, rootOfBlueprint(idx, id)) + if (!b || b.tombstoned) throw new Error("Blueprint not found") + const g = resolveGroup(idx, rootOfIdx(idx, b.blueprint.facility)) + if (!g || g.tombstoned) throw new Error("Facility not found") + const builds = [] + for (const [key, node] of idx.builds) { + if (idx.tomb.has(key)) continue + if (node.c.blueprint !== b.tip && node.c.blueprint !== b.root) continue + const rb = resolveBuild(idx, key, g.facility) + if (rb) builds.push({ id: key, title: rb.title, status: rb.status, startDate: rb.startDate, endDate: rb.endDate }) + } + builds.sort((x, y) => new Date(y.startDate || 0) - new Date(x.startDate || 0)) + return { + ...b.blueprint, + facilityId: g.root, + facilityName: g.facility.name, + members: g.facility.members, + builds, + ...estimateBlueprint(b.blueprint, g.facility.laborRate), + ...blueprintGovernance(idx, g, b.root) + } + }, + + async listBlueprints(facilityId) { + const ssbClient = await openSsb() + const msgs = await getAllMsgs(ssbClient) + const idx = buildIndex(msgs) + const root = rootOfIdx(idx, facilityId) + const g = resolveGroup(idx, root) + const laborRate = g && !g.tombstoned ? g.facility.laborRate : 0 + const roots = new Set() + for (const key of idx.blueprints.keys()) roots.add(rootOfBlueprint(idx, key)) + const list = [] + for (const r of roots) { + const b = resolveBlueprint(idx, r) + if (!b || b.tombstoned) continue + if (b.blueprint.facility !== root) continue + list.push({ ...b.blueprint, ...estimateBlueprint(b.blueprint, laborRate), ...(g && !g.tombstoned ? blueprintGovernance(idx, g, b.root) : {}) }) + } + list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + return list + }, + + async createBuild(facilityId, data) { + const ssbClient = await openSsb() + const idx = buildIndex(await getAllMsgs(ssbClient)) + const g = resolveGroup(idx, rootOfIdx(idx, facilityId)) + if (!g || g.tombstoned) throw new Error("Facility not found") + if (g.facility.status !== "ACTIVE") throw new Error("Facility is not active") + if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members can propose builds") + let blueprintId = String(data.blueprintId || "").trim() || null + if (!blueprintId) throw new Error("A build needs a blueprint") + { + const b = resolveBlueprint(idx, rootOfBlueprint(idx, blueprintId)) + if (!b || b.tombstoned || b.blueprint.facility !== g.root) throw new Error("Blueprint not found in this facility") + blueprintId = b.tip + } + const content = { type: "industryBuild", facility: g.root, blueprint: blueprintId, title: String(data.title || "").trim(), notes: String(data.notes || "").trim(), image: data.image || null, startDate: String(data.startDate || "").trim() || null, endDate: String(data.endDate || "").trim() || null, createdAt: new Date().toISOString() } + if (!content.title) throw new Error("Build title is required") + const today = new Date().toISOString().slice(0, 10) + if (!content.startDate) throw new Error("Build start date is required") + if (!content.endDate) throw new Error("Build end date is required") + if (content.startDate < today) throw new Error("Start date cannot be in the past") + if (content.startDate && content.endDate && new Date(content.endDate) < new Date(content.startDate)) throw new Error("End date must be after start date") + return publish(ssbClient, content) + }, + + async voteBuild(buildId, choice) { + const ssbClient = await openSsb() + const idx = buildIndex(await getAllMsgs(ssbClient)) + const bnode = idx.builds.get(buildId) + if (!bnode || idx.tomb.has(buildId)) throw new Error("Build not found") + const g = resolveGroup(idx, rootOfIdx(idx, bnode.c.facility)) + if (!g || g.tombstoned) throw new Error("Facility not found") + if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members can vote") + return publish(ssbClient, { type: "industryVote", target: g.root, subject: "build", ref: buildId, choice: choice === "no" ? "no" : "yes", createdAt: new Date().toISOString() }) + }, + + async setBuildStatus(buildId, status) { + const ssbClient = await openSsb() + const idx = buildIndex(await getAllMsgs(ssbClient)) + const bnode = idx.builds.get(buildId) + if (!bnode || idx.tomb.has(buildId)) throw new Error("Build not found") + const g = resolveGroup(idx, rootOfIdx(idx, bnode.c.facility)) + if (!g || g.tombstoned) throw new Error("Facility not found") + const build = resolveBuild(idx, buildId, g.facility) + const next = String(status || "").toUpperCase() + if (!BUILD_STATUSES.includes(next)) throw new Error("Invalid status") + const allowed = BUILD_TRANSITIONS[build.status] || [] + if (!allowed.includes(next)) throw new Error(`Cannot move build from ${build.status} to ${next}`) + if (g.facility.status !== "ACTIVE") throw new Error("Facility is not active") + if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members of the facility can do this") + const isSteward = g.facility.steward === ssbClient.id + const isProposer = build.proposer === ssbClient.id + const isReopen = build.status === "COMPLETED" || build.status === "FAILED" + if (build.status === "COMPLETED" && build.distributed) throw new Error("A distributed build cannot be reopened") + if ((next === "COMPLETED" || next === "FAILED" || isReopen) && !isSteward) throw new Error("Only the steward can complete, fail or reopen a build") + if (!isSteward && !isProposer) throw new Error("Only the steward or the proposer can advance the build") + return publish(ssbClient, { type: "industryBuildStatus", target: buildId, status: next, createdAt: new Date().toISOString() }) + }, + + async contribute(buildId, data) { + const ssbClient = await openSsb() + const idx = buildIndex(await getAllMsgs(ssbClient)) + const bnode = idx.builds.get(buildId) + if (!bnode || idx.tomb.has(buildId)) throw new Error("Build not found") + const g = resolveGroup(idx, rootOfIdx(idx, bnode.c.facility)) + if (!g || g.tombstoned) throw new Error("Facility not found") + if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members can contribute") + if (g.facility.status !== "ACTIVE") throw new Error("Facility is not active") + const build = resolveBuild(idx, buildId, g.facility) + if (!CONTRIBUTABLE.has(build.status)) throw new Error("This build is not accepting contributions") + const kind = ["labor", "material", "eco"].includes(String(data.kind)) ? String(data.kind) : "labor" + const content = { type: "industryContribution", target: buildId, kind, hours: nonNegNum(data.hours), item: String(data.item || "").trim(), value: nonNegNum(data.value), eco: nonNegNum(data.eco), transferId: data.transferId || null, note: String(data.note || "").trim(), createdAt: new Date().toISOString() } + if (kind === "labor") { + if (content.hours <= 0) throw new Error("Labor contributions require hours above zero") + content.item = ""; content.value = 0; content.eco = 0 + } else if (kind === "material") { + if (!content.item) throw new Error("Material contributions require an item") + if (content.value <= 0) throw new Error("Material contributions require a value above zero") + content.hours = 0; content.eco = 0 + } else { + if (content.eco <= 0) throw new Error("ECO contributions require an amount above zero") + content.hours = 0; content.item = ""; content.value = 0 + } + return publish(ssbClient, content) + }, + + async getBuild(buildId) { + const ssbClient = await openSsb() + const msgs = await getAllMsgs(ssbClient) + const idx = buildIndex(msgs) + const bnode = idx.builds.get(buildId) + if (!bnode || idx.tomb.has(buildId)) throw new Error("Build not found") + const g = resolveGroup(idx, rootOfIdx(idx, bnode.c.facility)) + if (!g) throw new Error("Facility not found") + const build = resolveBuild(idx, buildId, g.facility) + build.facilityId = g.root + build.facilityName = g.facility.name + build.steward = g.facility.steward + build.members = g.facility.members + const est = estimateForBlueprintRef(idx, build.blueprint, g.facility.laborRate) + if (est) Object.assign(build, est) + Object.assign(build, buildGovernance(idx, g, buildId)) + return build + }, + + async updateBuild(buildId, data) { + const ssbClient = await openSsb() + const idx = buildIndex(await getAllMsgs(ssbClient)) + const bnode = idx.builds.get(buildId) + if (!bnode || idx.tomb.has(buildId)) throw new Error("Build not found") + const g = resolveGroup(idx, rootOfIdx(idx, bnode.c.facility)) + if (!g || g.tombstoned) throw new Error("Facility not found") + if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members of the facility can do this") + const build = resolveBuild(idx, buildId, g.facility) + const isSteward = g.facility.steward === ssbClient.id + if (build.proposer !== ssbClient.id && !isSteward) throw new Error("Only the steward or the proposer can update a build") + if (build.distributed) throw new Error("A distributed build cannot be updated") + if (!actionApproved(idx, g, "buildUpdate", buildId)) throw new Error("Updating this build requires a governance vote") + const startDate = String(data.startDate || "").trim() + const endDate = String(data.endDate || "").trim() + if (!startDate) throw new Error("Build start date is required") + if (!endDate) throw new Error("Build end date is required") + if (new Date(endDate) < new Date(startDate)) throw new Error("End date must be after start date") + const today = new Date().toISOString().slice(0, 10) + if (endDate < today) throw new Error("End date cannot be in the past") + const title = data.title === undefined ? String(build.title || "") : String(data.title || "").trim() + if (!title) throw new Error("Build title is required") + const notes = data.notes === undefined ? String(build.notes || "") : String(data.notes || "").trim() + return publish(ssbClient, { type: "industryBuildUpdate", target: buildId, title, notes, startDate, endDate, createdAt: new Date().toISOString() }) + }, + + async deleteBuild(buildId) { + const ssbClient = await openSsb() + const idx = buildIndex(await getAllMsgs(ssbClient)) + const bnode = idx.builds.get(buildId) + if (!bnode || idx.tomb.has(buildId)) throw new Error("Build not found") + const g = resolveGroup(idx, rootOfIdx(idx, bnode.c.facility)) + if (!g || g.tombstoned) throw new Error("Facility not found") + if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members of the facility can do this") + if (bnode.author !== ssbClient.id) throw new Error("Only the proposer can delete a build") + if (!actionApproved(idx, g, "buildDelete", buildId)) throw new Error("Deleting this build requires a governance vote") + return publish(ssbClient, { type: "tombstone", target: buildId, deletedAt: new Date().toISOString(), author: ssbClient.id }) + }, + + async listBuilds(facilityId, filter = "ALL") { + const ssbClient = await openSsb() + const idx = buildIndex(await getAllMsgs(ssbClient)) + const g = resolveGroup(idx, rootOfIdx(idx, facilityId)) + if (!g || g.tombstoned) throw new Error("Facility not found") + let list = [] + for (const [key, node] of idx.builds) { + if (idx.tomb.has(key)) continue + if (node.c.facility !== g.root) continue + const b = resolveBuild(idx, key, g.facility) + if (!b) continue + const est = estimateForBlueprintRef(idx, b.blueprint, g.facility.laborRate) + list.push({ ...b, ...(est || {}), ...buildGovernance(idx, g, key) }) + } + const f = String(filter || "ALL").toUpperCase() + if (BUILD_STATUSES.includes(f)) list = list.filter(x => x.status === f) + list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + return list + }, + + async listAllBlueprints() { + const ssbClient = await openSsb() + const msgs = await getAllMsgs(ssbClient) + const idx = buildIndex(msgs) + const roots = new Set() + for (const key of idx.blueprints.keys()) roots.add(rootOfBlueprint(idx, key)) + const facCache = new Map() + const list = [] + for (const root of roots) { + const b = resolveBlueprint(idx, root) + if (!b || b.tombstoned) continue + const facRoot = rootOfIdx(idx, b.blueprint.facility) + let g = facCache.get(facRoot) + if (g === undefined) { g = resolveGroup(idx, facRoot); facCache.set(facRoot, g) } + if (!g || g.tombstoned) continue + list.push({ ...b.blueprint, facilityId: g.root, facilityName: g.facility.name, members: g.facility.members, ...estimateBlueprint(b.blueprint, g.facility.laborRate), ...blueprintGovernance(idx, g, b.root) }) + } + list.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0)) + return list + }, + + async listAllBuilds() { + const ssbClient = await openSsb() + const msgs = await getAllMsgs(ssbClient) + const idx = buildIndex(msgs) + const facCache = new Map() + const list = [] + for (const [key, node] of idx.builds) { + if (idx.tomb.has(key)) continue + const facRoot = rootOfIdx(idx, node.c.facility) + let g = facCache.get(facRoot) + if (g === undefined) { g = resolveGroup(idx, facRoot); facCache.set(facRoot, g) } + if (!g || g.tombstoned) continue + const b = resolveBuild(idx, key, g.facility) + if (!b) continue + const est = estimateForBlueprintRef(idx, b.blueprint, g.facility.laborRate) + list.push({ ...b, facilityId: g.root, facilityName: g.facility.name, members: g.facility.members, ...(est || {}), ...buildGovernance(idx, g, key) }) + } + list.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0)) + return list + }, + + computeDistributionPlan(build, outputValue) { + const pot = nonNegNum(build.treasury) + nonNegNum(outputValue) + const distributable = pot + const allocations = [] + const amounts = {} + for (const [uid, share] of Object.entries(build.shares || {})) { + const amount = distributable * share + amounts[uid] = amount + if (amount > 0) allocations.push({ to: uid, amount, share }) + } + return { pot, distributable, allocations, amounts } + }, + + async distributeBuild(buildId, data) { + const ssbClient = await openSsb() + const idx = buildIndex(await getAllMsgs(ssbClient)) + const bnode = idx.builds.get(buildId) + if (!bnode || idx.tomb.has(buildId)) throw new Error("Build not found") + const g = resolveGroup(idx, rootOfIdx(idx, bnode.c.facility)) + if (!g || g.tombstoned) throw new Error("Facility not found") + if (g.facility.steward !== ssbClient.id) throw new Error("Only the steward can distribute") + const build = resolveBuild(idx, buildId, g.facility) + if (build.status !== "COMPLETED") throw new Error("Only a completed build can be distributed") + if (build.distributed) throw new Error("This build has already been distributed") + if (build.totalPoints <= 0) throw new Error("There are no contributions to distribute") + const plan = this.computeDistributionPlan(build, data && data.outputValue) + const content = { type: "industryAllocation", target: buildId, shares: build.shares, amounts: plan.amounts, pot: plan.pot, outputValue: nonNegNum(data && data.outputValue), disposition: (data && data.disposition) || null, createdAt: new Date().toISOString() } + await publish(ssbClient, content) + return { plan, facility: g.facility, build } + } + } +} diff --git a/src/models/inhabitants_model.js b/src/models/inhabitants_model.js index 2b3c0dd..87b1a06 100644 --- a/src/models/inhabitants_model.js +++ b/src/models/inhabitants_model.js @@ -407,7 +407,7 @@ module.exports = ({ cooler }) => { const isOwner = viewer === target; const arr = (v) => Array.isArray(v) ? v : []; const up = (v) => String(v || '').toUpperCase(); - const COUNTED = new Set(['post','event','task','forum','tribe','market','job','project','shop','image','video','audio','document','bookmark','transfer','map']); + const COUNTED = new Set(['post','event','task','forum','tribe','market','job','project','industry','shop','image','video','audio','document','bookmark','transfer','map']); const accessible = (type, c) => { if (c.encryptedPayload) return false; switch (type) { diff --git a/src/models/jobs_model.js b/src/models/jobs_model.js index 3cf3ff6..cf046b6 100644 --- a/src/models/jobs_model.js +++ b/src/models/jobs_model.js @@ -177,6 +177,7 @@ module.exports = ({ cooler, tribeCrypto }) => { status: c.status || "OPEN", tags: Array.isArray(c.tags) ? c.tags : normalizeTags(c.tags), subscribers: Array.isArray(visibleSubs) ? visibleSubs : [], + industry: c.industry || "", mapUrl: c.mapUrl || "", visibility: String(c.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC", clearnetPublic: !!c.clearnetPublic @@ -235,6 +236,7 @@ module.exports = ({ cooler, tribeCrypto }) => { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), status: "OPEN", + industry: String(jobData.industry || "").trim(), mapUrl: String(jobData.mapUrl || "").trim(), visibility: String(jobData.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC", clearnetPublic: jobData.clearnetPublic === true || jobData.clearnetPublic === 'true' || jobData.clearnetPublic === 'on' diff --git a/src/models/legacy_model.js b/src/models/legacy_model.js index 3499811..c2f44dc 100644 --- a/src/models/legacy_model.js +++ b/src/models/legacy_model.js @@ -58,11 +58,9 @@ module.exports = { const encrypted = encryptBuffer(original, pw); const roundtrip = decryptBuffer(encrypted, pw); if (!roundtrip.equals(original)) { - throw new Error('Backup verification failed; nothing was written.'); + throw new Error('Backup verification failed; nothing was exported.'); } - const encryptedFilePath = path.join(homeDir, 'oasis.enc'); - fs.writeFileSync(encryptedFilePath, encrypted, { mode: 0o600 }); - return encryptedFilePath; + return { filename: 'oasis.enc', data: encrypted }; } catch (error) { throw new Error("Error exporting data: " + error.message); } diff --git a/src/models/logs_model.js b/src/models/logs_model.js index 88bade7..031fc91 100644 --- a/src/models/logs_model.js +++ b/src/models/logs_model.js @@ -23,7 +23,7 @@ const ACTION_TYPES = new Set([ 'post', 'about', 'contact', 'feed', 'bookmark', 'image', 'audio', 'video', 'document', 'torrent', 'event', 'task', 'taskAssignment', 'votes', 'vote', 'report', 'tribe', 'chat', 'chatMessage', 'pad', 'padEntry', - 'forum', 'market', 'job', 'project', 'pixelia', 'map', 'mapMarker', + 'forum', 'market', 'job', 'project', 'industry', 'industryBuild', 'pixelia', 'map', 'mapMarker', 'shop', 'shopProduct', 'curriculum', 'gameScore', 'calendar', 'calendarDate', 'calendarNote', 'transfer', 'bankClaim', 'ubiClaim', @@ -61,6 +61,8 @@ const ACTION_PHRASES = { forum: 'posted in the forum', job: 'posted a job opportunity', project: 'advanced a project', + industry: 'founded a network-owned facility', + industryBuild: 'proposed a production build', pixelia: 'placed a pixel in pixelia', map: 'contributed to a map', mapMarker: 'placed a marker on a map', @@ -291,7 +293,10 @@ module.exports = ({ cooler }) => { const c = v?.content; if (!c) continue; if (v.author !== userId) continue; - if (c.type === 'tombstone') continue; + if (c.type === 'tombstone') { + if (typeof c.target === 'string' && c.target) tombstoned.add(c.target); + continue; + } if (c.type !== 'log') continue; if (c.replaces) replaced.set(c.replaces, dec.key || keyIn); items.push({ diff --git a/src/models/maps_model.js b/src/models/maps_model.js index b1bd77c..5f9faf2 100644 --- a/src/models/maps_model.js +++ b/src/models/maps_model.js @@ -412,17 +412,11 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => { try { const ssbClient = await openSsb(); const messages = await new Promise((resolve, reject) => pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((e, m) => e ? reject(e) : resolve(m)))); - const live = new Set(); const tomb = buildValidatedTombstoneSet(messages); - for (const m of messages) { - const c = m.value && m.value.content; - if (!c) continue; - if (c.type === "map") live.add(m.key); - } const all = ownCrypto.getAllRootIds(); let removed = 0; for (const rid of all) { - if (!live.has(rid) || tomb.has(rid)) { + if (tomb.has(rid)) { try { ownCrypto.dropKey(rid); removed += 1; } catch (_) {} } } diff --git a/src/models/market_model.js b/src/models/market_model.js index f4143a7..cb2bcd1 100644 --- a/src/models/market_model.js +++ b/src/models/market_model.js @@ -180,6 +180,7 @@ module.exports = ({ cooler, tribeCrypto }) => { shopProductId: shopOpts.shopProductId || "", shopId: shopOpts.shopId || "", shopTitle: shopOpts.shopTitle || "", + industry: shopOpts.industry || "", visibility: String(visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC" } @@ -344,7 +345,8 @@ module.exports = ({ cooler, tribeCrypto }) => { mapUrl: c.mapUrl || "", shopProductId: c.shopProductId || "", shopId: c.shopId || "", - shopTitle: c.shopTitle || "" + shopTitle: c.shopTitle || "", + industry: c.industry || "" }) } @@ -445,7 +447,8 @@ module.exports = ({ cooler, tribeCrypto }) => { mapUrl: c.mapUrl || "", shopProductId: c.shopProductId || "", shopId: c.shopId || "", - shopTitle: c.shopTitle || "" + shopTitle: c.shopTitle || "", + industry: c.industry || "" } }, diff --git a/src/models/onboarding_model.js b/src/models/onboarding_model.js new file mode 100644 index 0000000..3f8eb50 --- /dev/null +++ b/src/models/onboarding_model.js @@ -0,0 +1,181 @@ +const fs = require('fs'); +const path = require('path'); +const pull = require('../server/node_modules/pull-stream'); + +const STEPS = ['language', 'profile', 'federation', 'larp', 'greeting', 'backup']; +const FLAG = 'oasis-first-contact'; +const PENDING = 'welcome=pending'; +const DISMISSED = 'welcome=dismissed'; +const DONE = 'welcome=done'; + +const ssbPathOf = (given) => { + if (given) return given; + try { return require('../server/ssb_config').path; } catch (_) { return null; } +}; + +const flagPath = (dir) => path.join(dir, FLAG); + +function readFlag(dir) { + if (!dir) return { exists: false, id: '', markers: new Set() }; + let raw = ''; + try { raw = fs.readFileSync(flagPath(dir), 'utf8'); } catch (_) { return { exists: false, id: '', markers: new Set() }; } + const lines = raw.split('\n').map(l => l.trim()).filter(Boolean); + const isMarker = (l) => /^(welcome|step)=/.test(l); + const markers = new Set(lines.filter(isMarker)); + return { exists: true, id: lines.find(l => !isMarker(l)) || '', markers }; +} + +function appendMarker(dir, marker, createIfMissing) { + if (!dir) return false; + const flag = readFlag(dir); + if (flag.markers.has(marker)) return true; + if (!flag.exists && !createIfMissing) return false; + try { + fs.mkdirSync(dir, { recursive: true }); + fs.appendFileSync(flagPath(dir), `${marker}\n`); + return true; + } catch (_) { + return false; + } +} + +const isPending = (dir) => { + if (!dir) return false; + const flag = readFlag(dir); + if (!flag.exists) return true; + if (flag.markers.has(DISMISSED) || flag.markers.has(DONE)) return false; + return flag.markers.has(PENDING); +}; + +const bannerVisible = (ssbPath) => isPending(ssbPathOf(ssbPath)); + +module.exports = ({ cooler, ssbPath } = {}) => { + const dir = () => ssbPathOf(ssbPath); + + const ownMessages = async () => { + const ssb = await cooler.open(); + const mine = ssb.id; + const all = await new Promise((resolve, reject) => + pull(ssb.createLogStream({ reverse: true, limit: 2000 }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs || []))) + ); + return { mine, messages: all.filter(m => m && m.value && m.value.author === mine) }; + }; + + const hasProfile = (mine, messages) => messages.some(m => { + const c = m.value.content; + if (!c || typeof c !== 'object' || c.type !== 'about' || c.about !== mine) return false; + return String(c.name || '').trim().length > 0 || String(c.image || '').trim().length > 0; + }); + + const hasGreeted = (messages) => messages.some(m => { + const c = m.value.content; + return c && typeof c === 'object' && c.type === 'feed' && String(c.text || '').trim().length > 0; + }); + + const hasJoinedLarp = (messages) => { + for (const m of messages) { + const c = m.value.content; + if (!c || typeof c !== 'object') continue; + if (c.type === 'larpJoinHouse' && c.house) return true; + if (c.type === 'larpLeaveLarp') return false; + } + return false; + }; + + const hasFederated = (mine, messages) => { + try { + const gossip = JSON.parse(fs.readFileSync(path.join(dir(), 'gossip.json'), 'utf8')); + if (Array.isArray(gossip) && gossip.length > 0) return true; + } catch (_) {} + return messages.some(m => { + const c = m.value.content; + return c && typeof c === 'object' && c.type === 'contact' && c.following === true && c.contact && c.contact !== mine; + }); + }; + + return { + STEPS, + + flagPath() { + return flagPath(dir()); + }, + + firstContactSeen(feedId) { + const flag = readFlag(dir()); + return flag.exists && (!feedId || flag.id === String(feedId)); + }, + + begin(feedId) { + const target = dir(); + if (!target) return false; + const flag = readFlag(target); + const closed = flag.markers.has(DISMISSED) || flag.markers.has(DONE); + const markers = closed ? [...flag.markers] : [...new Set([PENDING, ...flag.markers])]; + try { + fs.mkdirSync(target, { recursive: true }); + fs.writeFileSync(flagPath(target), `${String(feedId || '')}\n${new Date().toISOString()}\n${markers.join('\n')}\n`); + return true; + } catch (_) { + return false; + } + }, + + markStep(step) { + if (!STEPS.includes(step)) return false; + if (step !== 'language' && step !== 'backup') return true; + if (!isPending(dir())) return true; + return appendMarker(dir(), `step=${step}`); + }, + + dismiss() { + return appendMarker(dir(), DISMISSED, true); + }, + + isVisible() { + return isPending(dir()); + }, + + async status(available) { + const target = dir(); + const flag = readFlag(target); + const usable = STEPS.filter(s => !available || available[s] !== false); + let mine = ''; + let messages = []; + try { ({ mine, messages } = await ownMessages()); } catch (_) { mine = ''; messages = []; } + const steps = { + language: flag.markers.has('step=language'), + profile: hasProfile(mine, messages), + federation: hasFederated(mine, messages), + larp: hasJoinedLarp(messages), + backup: flag.markers.has('step=backup'), + greeting: hasGreeted(messages) + }; + const profile = { id: mine, name: '', description: '', image: '' }; + for (const m of messages) { + const c = m.value.content; + if (!c || typeof c !== 'object' || c.type !== 'about' || c.about !== mine) continue; + if (!profile.name && String(c.name || '').trim()) profile.name = String(c.name).trim(); + if (!profile.description && String(c.description || '').trim()) profile.description = String(c.description).trim(); + if (!profile.image && String(c.image || '').trim()) profile.image = String(c.image).trim(); + if (profile.name && profile.description && profile.image) break; + } + const done = usable.filter(s => steps[s]).length; + const complete = done === usable.length; + if (complete && isPending(target)) appendMarker(target, DONE); + return { + id: flag.id, + pending: isPending(target), + dismissed: flag.markers.has(DISMISSED), + steps, + profile, + usable, + done, + total: usable.length, + complete + }; + } + }; +}; + +module.exports.bannerVisible = bannerVisible; +module.exports.STEPS = STEPS; diff --git a/src/models/opinions_model.js b/src/models/opinions_model.js index 4ffb636..ac6e5df 100644 --- a/src/models/opinions_model.js +++ b/src/models/opinions_model.js @@ -22,12 +22,13 @@ module.exports = ({ cooler }) => { const validTypes = [ 'bookmark', 'votes', 'transfer', - 'feed', 'image', 'audio', 'video', 'document', 'torrent' + 'feed', 'image', 'audio', 'video', 'document', 'torrent', + 'industry', 'project', 'report', 'task', 'event', 'shopProduct' ]; const getPreview = c => { if (c.type === 'bookmark' && c.bookmark) return `🔖 ${c.bookmark}`; - return c.text || c.description || c.title || ''; + return c.text || c.description || c.title || c.name || ''; }; const OPINION_MSG_TYPE = { @@ -39,7 +40,13 @@ module.exports = ({ cooler }) => { audio: 'audioOpinion', video: 'videoOpinion', document: 'documentOpinion', - torrent: 'torrentOpinion' + torrent: 'torrentOpinion', + industry: 'industryOpinion', + project: 'projectOpinion', + report: 'reportOpinion', + task: 'taskOpinion', + event: 'eventOpinion', + shopProduct: 'shopOpinion' }; const createVote = async (contentId, category) => { @@ -72,6 +79,7 @@ module.exports = ({ cooler }) => { const tombstoned = buildValidatedTombstoneSet(messages); const replaces = new Map(); const byId = new Map(); + const authorOf = new Map(); const opinionMsgs = []; for (const msg of messages) { @@ -82,6 +90,7 @@ module.exports = ({ cooler }) => { if (tombstoned.has(c.target)) byId.delete(c.target); continue; } + if (key) authorOf.set(key, msg.value.author); if (typeof c.type === 'string' && c.type.endsWith('Opinion') && c.target) { opinionMsgs.push({ target: c.target, author: msg.value.author, category: c.category }); continue; @@ -104,10 +113,10 @@ module.exports = ({ cooler }) => { } for (const [oldId, newId] of Array.from(replaces.entries())) { - const oldM = byId.get(oldId); + const oldAuthor = authorOf.get(oldId); const newM = byId.get(newId); - if (!oldM) { replaces.delete(oldId); continue; } - if (!newM || String(newM.value.author) !== String(oldM.value.author)) { + if (oldAuthor === undefined) { replaces.delete(oldId); continue; } + if (!newM || String(newM.value.author) !== String(oldAuthor)) { replaces.delete(oldId); byId.delete(newId); } @@ -226,6 +235,11 @@ module.exports = ({ cooler }) => { } filtered = Array.from(bySig.values()); + filtered = filtered.filter(m => { + const ops = m.value?.content?.opinions || {}; + return Object.values(ops).reduce((acc, x) => acc + (Number(x) || 0), 0) > 0; + }); + if (filter === 'MINE') { filtered = filtered.filter(m => m.value.author === userId); } else if (filter === 'RECENT') { diff --git a/src/models/pads_model.js b/src/models/pads_model.js index 6390e46..f6a42a8 100644 --- a/src/models/pads_model.js +++ b/src/models/pads_model.js @@ -615,17 +615,11 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel }) try { const ssbClient = await openSsb() const messages = await readAll(ssbClient) - const live = new Set() const tomb = buildValidatedTombstoneSet(messages) - for (const m of messages) { - const c = m.value && m.value.content - if (!c) continue - if (c.type === "pad") live.add(m.key) - } const all = ownCrypto.getAllRootIds() let removed = 0 for (const rid of all) { - if (!live.has(rid) || tomb.has(rid)) { + if (tomb.has(rid)) { try { ownCrypto.dropKey(rid); removed += 1 } catch (_) {} } } diff --git a/src/models/parliament_model.js b/src/models/parliament_model.js index 83dc837..be9f392 100644 --- a/src/models/parliament_model.js +++ b/src/models/parliament_model.js @@ -474,7 +474,12 @@ if (c.type === type) { const pol = await summarizePoliciesForTerm({ ...term }); const eff = pol.proposed > 0 ? Math.round((pol.approved / pol.proposed) * 100) : 0; const cands = await listByType('parliamentCandidature'); - const presented = cands.length; + const cycleStartMs = term.startAt ? new Date(term.startAt).getTime() : 0; + const cycleEndMs = term.endAt ? new Date(term.endAt).getTime() : Number.MAX_SAFE_INTEGER; + const presented = cands.filter(c => { + const t = c.createdAt ? new Date(c.createdAt).getTime() : 0; + return t >= cycleStartMs && t <= cycleEndMs; + }).length; return { method, powerType: term.powerType, @@ -1210,6 +1215,50 @@ if (c.type === type) { return await computeGovernmentCard({ ...term, id: term.id || term.startAt }); } + async function getLatestTerm() { + return await getLatestTermAny(); + } + + async function getLatestGovernmentCard() { + const term = await getLatestTermAny(); + if (!term) return null; + const card = await computeGovernmentCard({ ...term, id: term.id || term.startAt }); + const now = moment(); + let start = moment(term.startAt); + let end = term.endAt ? moment(term.endAt) : start.clone().add(TERM_DAYS, 'days'); + let rolled = false; + while (start.isValid() && end.isValid() && end.isSameOrBefore(now)) { + start = end.clone(); + end = end.clone().add(TERM_DAYS, 'days'); + rolled = true; + } + if (rolled) { + return { + ...card, + id: term.id || term.startAt, + since: start.toISOString(), + end: end.toISOString(), + method: 'ANARCHY', + powerType: 'none', + powerId: null, + powerTitle: 'ANARCHY', + winnerVotes: 0, + totalVotes: 0, + votesReceived: 0, + presented: 0, + proposed: 0, + approved: 0, + declined: 0, + discarded: 0, + revocated: 0, + efficiency: 0, + expired: false, + rolled: true + }; + } + return { ...card, id: term.id || term.startAt, since: term.startAt, end: term.endAt, expired: false, rolled: false }; + } + async function listLaws() { const items = await listByType('parliamentLaw'); return items.sort((a, b) => new Date(b.enactedAt) - new Date(a.enactedAt)); @@ -1464,6 +1513,8 @@ if (c.type === type) { listCandidatures, listTerms, getCurrentTerm, + getLatestTerm, + getLatestGovernmentCard, listLeaders, sweepProposals, getActorMeta, diff --git a/src/models/pm_model.js b/src/models/pm_model.js index 03be1d3..e0c3484 100644 --- a/src/models/pm_model.js +++ b/src/models/pm_model.js @@ -28,7 +28,7 @@ module.exports = ({ cooler }) => { return { type: 'post', - async sendMessage(recipients = [], subject = '', text = '', crypter = false) { + async sendMessage(recipients = [], subject = '', text = '', crypter = false, ref = '') { const ssbClient = await openSsb(); const recps = uniqueRecps([userId, ...recipients]); const content = { @@ -39,6 +39,39 @@ module.exports = ({ cooler }) => { text, sentAt: new Date().toISOString(), private: true, + ...(ref ? { ref: String(ref) } : {}), + ...(crypter ? { crypter: true } : {}) + }; + const publishAsync = util.promisify(ssbClient.private.publish); + return publishAsync(content, recps); + }, + + async sentRefs() { + const out = new Set(); + let messages = []; + try { messages = await this.listAllPrivate(); } catch (_) { return out; } + for (const m of (messages || [])) { + const c = m && m.value && m.value.content; + if (!c || !c.ref) continue; + if (m.value.author !== userId) continue; + out.add(`${String(c.subject || '')}|${String(c.ref)}`); + } + return out; + }, + + async sendFileShare(recipients = [], subject = '', fileShare = null, crypter = false) { + const ssbClient = await openSsb(); + const recps = uniqueRecps([userId, ...recipients]); + if (!fileShare || typeof fileShare !== 'object') throw new Error('Invalid file share'); + const content = { + type: 'post', + from: userId, + to: recps, + subject, + text: '', + sentAt: new Date().toISOString(), + private: true, + fileShare, ...(crypter ? { crypter: true } : {}) }; const publishAsync = util.promisify(ssbClient.private.publish); diff --git a/src/models/search_model.js b/src/models/search_model.js index 650b736..aed753a 100644 --- a/src/models/search_model.js +++ b/src/models/search_model.js @@ -40,7 +40,7 @@ module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => { 'post', 'about', 'curriculum', 'tribe', 'transfer', 'feed', 'votes', 'report', 'task', 'event', 'bookmark', 'document', 'image', 'audio', 'video', 'torrent', 'market', 'bankWallet', 'bankClaim', - 'project', 'job', 'forum', 'vote', 'contact', 'pub', 'map', 'shop', 'shopProduct', 'chat', 'pad' + 'project', 'job', 'industry', 'industryBlueprint', 'forum', 'vote', 'contact', 'pub', 'map', 'shop', 'shopProduct', 'chat', 'pad' ]; const getRelevantFields = (type, content) => { @@ -87,6 +87,10 @@ module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => { return [content?.title, content?.status, content?.progress, content?.goal, content?.pledged, content?.deadline, (content?.followers || []).length, (content?.backers || []).length, (content?.milestones || []).length, content?.bounty, content?.bountyAmount, content?.bounty_currency, content?.activity?.kind, content?.activityActor]; case 'job': return [content?.title, content?.job_type, ...(content?.tasks || []), content?.location, content?.vacants, content?.salary, content?.status, (content?.subscribers || []).length]; + case 'industry': + return [content?.name, content?.sector, content?.description, content?.membershipPolicy, content?.status, content?.license, ...(content?.tags || [])]; + case 'industryBlueprint': + return [content?.name, content?.description, content?.sector, content?.outItem, content?.outKind, content?.license, ...(content?.skills || []), ...((content?.materials || []).map(m => m && m.item))]; case 'forum': return [content?.root, content?.category, content?.title, content?.text, content?.key]; case 'vote': @@ -239,6 +243,14 @@ module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => { ].join('|'); } + if (t === 'industry') { + return ['industry', author, norm(c.name), norm(c.sector)].join('|'); + } + + if (t === 'industryBlueprint') { + return ['industryBlueprint', author, norm(c.facility), norm(c.name)].join('|'); + } + if (t === 'forum') { return `forum:${c.key || c.root || `${author}|${norm(c.title)}` || msg.key}`; } diff --git a/src/models/stats_model.js b/src/models/stats_model.js index bc14f72..0af631c 100644 --- a/src/models/stats_model.js +++ b/src/models/stats_model.js @@ -58,7 +58,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => { }; 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','log', @@ -281,6 +281,14 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => { else if (t === 'chat') score += 1; else if (t === 'gamescore') score += 2; else if (t === 'pixelia') score += 2; + else if (rawType === 'industry') score += 8; + else if (rawType === 'industryblueprint') score += 6; + else if (rawType === 'industrybuild') score += 8; + else if (rawType === 'industrycontribution') score += 4; + else if (rawType === 'industryallocation') score += 10; + else if (rawType === 'industryvote') score += 2; + else if (rawType === 'industrymember') score += 2; + else if (rawType === 'industryopinion') score += 1; } return Math.max(0, Math.round(score)); }; diff --git a/src/models/tags_model.js b/src/models/tags_model.js index f074453..d7c4fea 100644 --- a/src/models/tags_model.js +++ b/src/models/tags_model.js @@ -78,6 +78,10 @@ module.exports = ({ cooler, padsModel, tribesModel }) => { return ['job', author, norm(c.title), norm(c.location), norm(c.salary), norm(c.job_type)].join('|'); } + if (t === 'industry') { + return ['industry', author, norm(c.name), norm(c.sector)].join('|'); + } + if (t === 'forum') { return `forum:${c.key || c.root || `${author}|${norm(c.title)}` || msg.key}`; } diff --git a/src/models/trending_model.js b/src/models/trending_model.js index 0528d61..e0ab387 100644 --- a/src/models/trending_model.js +++ b/src/models/trending_model.js @@ -20,7 +20,8 @@ module.exports = ({ cooler }) => { const types = [ 'bookmark', 'votes', 'feed', - 'image', 'audio', 'video', 'document', 'torrent', 'transfer' + 'image', 'audio', 'video', 'document', 'torrent', 'transfer', + 'industry', 'project', 'report', 'task', 'event', 'shopProduct' ]; const categories = opinionCategories; @@ -39,6 +40,8 @@ module.exports = ({ cooler }) => { const tombstoned = buildValidatedTombstoneSet(messages); const replaces = new Map(); const itemsById = new Map(); + const authorOf = new Map(); + const opinionMsgs = []; for (const m of messages) { const k = m.key; @@ -51,6 +54,13 @@ module.exports = ({ cooler }) => { continue; } + if (k) authorOf.set(k, m.value?.author); + + if (typeof c.type === 'string' && c.type.endsWith('Opinion') && c.target) { + opinionMsgs.push({ target: c.target, author: m.value?.author, category: c.category }); + continue; + } + if (c.opinions && !tombstoned.has(k) && !['task', 'event', 'report'].includes(c.type)) { if (c.replaces) replaces.set(c.replaces, k); itemsById.set(k, m); @@ -62,17 +72,47 @@ module.exports = ({ cooler }) => { } } - for (const [replacedId, newId] of replaces.entries()) { - const oldMsg = itemsById.get(replacedId); + for (const [replacedId, newId] of Array.from(replaces.entries())) { + const oldAuthor = authorOf.get(replacedId); const newMsg = itemsById.get(newId); - if (!oldMsg) continue; - if (!newMsg || String(newMsg.value?.author) !== String(oldMsg.value?.author)) { + if (oldAuthor === undefined) { replaces.delete(replacedId); continue; } + if (!newMsg || String(newMsg.value?.author) !== String(oldAuthor)) { + replaces.delete(replacedId); itemsById.delete(newId); continue; } itemsById.delete(replacedId); } + const tipToChain = new Map(); + for (const [oldId, newId] of replaces.entries()) { + let tip = newId; let g = 0; + while (replaces.has(tip) && g++ < 100000) tip = replaces.get(tip); + if (!tipToChain.has(tip)) tipToChain.set(tip, new Set([tip])); + tipToChain.get(tip).add(oldId); tipToChain.get(tip).add(newId); + } + const idToTip = new Map(); + for (const [tip, chain] of tipToChain.entries()) for (const id of chain) idToTip.set(id, tip); + const opinionByTip = new Map(); + for (const op of opinionMsgs) { + const tip = idToTip.get(op.target) || op.target; + if (!opinionByTip.has(tip)) opinionByTip.set(tip, { opinions: {}, voters: new Set() }); + const agg = opinionByTip.get(tip); + if (agg.voters.has(op.author)) continue; + agg.voters.add(op.author); + if (op.category) agg.opinions[op.category] = (agg.opinions[op.category] || 0) + 1; + } + for (const [k, m] of Array.from(itemsById.entries())) { + const agg = opinionByTip.get(k); + if (!agg) continue; + const c = m.value?.content || {}; + const mergedVoters = new Set([...(Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : [])]); + const mergedOpinions = { ...(c.opinions || {}) }; + for (const [cat, n] of Object.entries(agg.opinions)) mergedOpinions[cat] = (mergedOpinions[cat] || 0) + n; + for (const v of agg.voters) mergedVoters.add(v); + itemsById.set(k, { ...m, value: { ...m.value, content: { ...c, opinions: mergedOpinions, opinions_inhabitants: [...mergedVoters] } } }); + } + let rawItems = Array.from(itemsById.values()).filter(m => types.includes(m.value?.content?.type)); const blobTypes = ['document', 'image', 'audio', 'video']; @@ -148,9 +188,7 @@ module.exports = ({ cooler }) => { items = items.filter(m => m.value.content.type === filter); } - if (filter !== 'ALL' && !types.includes(filter)) { - items = items.filter(m => (m.value.content.opinions_inhabitants || []).length > 0); - } + items = items.filter(m => (m.value.content.opinions_inhabitants || []).length > 0); if (filter === 'TOP') { items.sort((a, b) => { diff --git a/src/models/tribes_model.js b/src/models/tribes_model.js index 7d2a799..64e657c 100644 --- a/src/models/tribes_model.js +++ b/src/models/tribes_model.js @@ -929,7 +929,7 @@ module.exports = ({ cooler, tribeCrypto }) => { const all = tribeCrypto.getAllRootIds(); let removed = 0; for (const rid of all) { - if (!idx.tribes.has(rid) || idx.effectivelyTombstoned.has(rid)) { + if (idx.effectivelyTombstoned.has(rid)) { try { tribeCrypto.dropKey(rid); removed++; } catch (_) {} } } diff --git a/src/server/package-lock.json b/src/server/package-lock.json index f8ea18b..099f6b7 100644 --- a/src/server/package-lock.json +++ b/src/server/package-lock.json @@ -1,6 +1,6 @@ { "name": "@krakenslab/oasis", - "version": "0.8.7", + "version": "0.8.9", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/src/server/package.json b/src/server/package.json index 00bb761..d3ab71a 100644 --- a/src/server/package.json +++ b/src/server/package.json @@ -1,6 +1,6 @@ { "name": "@krakenslab/oasis", - "version": "0.8.7", + "version": "0.9.0", "description": "Oasis - Social Networking Utopia", "repository": { "type": "git", diff --git a/src/views/activity_view.js b/src/views/activity_view.js index f594a71..5be2861 100644 --- a/src/views/activity_view.js +++ b/src/views/activity_view.js @@ -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())) diff --git a/src/views/agenda_view.js b/src/views/agenda_view.js index c13b8e2..f0613a4 100644 --- a/src/views/agenda_view.js +++ b/src/views/agenda_view.js @@ -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' }, diff --git a/src/views/banking_views.js b/src/views/banking_views.js index 5928d3e..4a1e976 100644 --- a/src/views/banking_views.js +++ b/src/views/banking_views.js @@ -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`) ) ) ); diff --git a/src/views/blockchain_view.js b/src/views/blockchain_view.js index a70d752..aa5adf5 100644 --- a/src/views/blockchain_view.js +++ b/src/views/blockchain_view.js @@ -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`; diff --git a/src/views/courts_view.js b/src/views/courts_view.js index ef8d681..52e85c2 100644 --- a/src/views/courts_view.js +++ b/src/views/courts_view.js @@ -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 ) ); }; diff --git a/src/views/dev_view.js b/src/views/dev_view.js new file mode 100644 index 0000000..5699a74 --- /dev/null +++ b/src/views/dev_view.js @@ -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" }, "-")) + )) + ) + ) + ) + ) + ) +} diff --git a/src/views/industry_view.js b/src/views/industry_view.js new file mode 100644 index 0000000..c91dcbc --- /dev/null +++ b/src/views/industry_view.js @@ -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 + ) + ) + ) +} diff --git a/src/views/jobs_view.js b/src/views/jobs_view.js index 8c6694b..5b5b76b 100644 --- a/src/views/jobs_view.js +++ b/src/views/jobs_view.js @@ -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) diff --git a/src/views/legacy_view.js b/src/views/legacy_view.js index 2a8964c..3238e13 100644 --- a/src/views/legacy_view.js +++ b/src/views/legacy_view.js @@ -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", diff --git a/src/views/main_views.js b/src/views/main_views.js index dab3c0d..71090a1 100644 --- a/src/views/main_views.js +++ b/src/views/main_views.js @@ -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('
') + const mdLinks = [] + const masked = parts.join('
') + .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(`${label}`) + return `\u0000MD${idx}\u0000` + }) + return masked .replace(/(@[a-zA-Z0-9/+._=-]+\.ed25519)/g, (match, id) => `${userLinkLabel(id)}`) .replace(/\/jobs\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `${match}`) .replace(/\/projects\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `${match}`) .replace(/\/market\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `${match}`) .replace(/\/calendars\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `${match}`) .replace(/\/ai\/ask\?[^\s<"]+/g, (match) => `${match}`) - .replace(/(? `${match}`) + .replace(/\/tribe\/([%A-Za-z0-9/+._=-]+\.sha256)(\?section=[a-zA-Z]+)?/g, (match) => `${match}`) + .replace(/\/larp\/([a-zA-Z]+)/g, (match) => `${match}`) + .replace(/(? `${match}`) .replace(/(https?:\/\/[^\s<"]+)/g, (match) => `${match}`) + .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 || "" ), diff --git a/src/views/market_view.js b/src/views/market_view.js index 019d4f3..83ae5de 100644 --- a/src/views/market_view.js +++ b/src/views/market_view.js @@ -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) diff --git a/src/views/modules_view.js b/src/views/modules_view.js index 03be08b..70cb158 100644 --- a/src/views/modules_view.js +++ b/src/views/modules_view.js @@ -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) }; diff --git a/src/views/opinions_view.js b/src/views/opinions_view.js index c742fd0..3e0d57a 100644 --- a/src/views/opinions_view.js +++ b/src/views/opinions_view.js @@ -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]') }) ) ) ); diff --git a/src/views/parliament_view.js b/src/views/parliament_view.js index 7a9a9b1..9ebad8c 100644 --- a/src/views/parliament_view.js +++ b/src/views/parliament_view.js @@ -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 }; diff --git a/src/views/pm_view.js b/src/views/pm_view.js index 3612627..8f37aef 100644 --- a/src/views/pm_view.js +++ b/src/views/pm_view.js @@ -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 + ) ) ) ); diff --git a/src/views/projects_view.js b/src/views/projects_view.js index 8c17d80..b99820f 100644 --- a/src/views/projects_view.js +++ b/src/views/projects_view.js @@ -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" diff --git a/src/views/report_view.js b/src/views/report_view.js index c3f261c..f86b55a 100644 --- a/src/views/report_view.js +++ b/src/views/report_view.js @@ -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( diff --git a/src/views/search_view.js b/src/views/search_view.js index a8a583c..52ba670 100644 --- a/src/views/search_view.js +++ b/src/views/search_view.js @@ -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, diff --git a/src/views/settings_view.js b/src/views/settings_view.js index bebe8ae..0969d08 100644 --- a/src/views/settings_view.js +++ b/src/views/settings_view.js @@ -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) => { diff --git a/src/views/stats_view.js b/src/views/stats_view.js index a2cd700..972388d 100644 --- a/src/views/stats_view.js +++ b/src/views/stats_view.js @@ -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, diff --git a/src/views/task_view.js b/src/views/task_view.js index 40a2624..38d18c8 100644 --- a/src/views/task_view.js +++ b/src/views/task_view.js @@ -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(), diff --git a/src/views/trending_view.js b/src/views/trending_view.js index cbdb142..b24ab81 100644 --- a/src/views/trending_view.js +++ b/src/views/trending_view.js @@ -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 => { diff --git a/src/views/tribes_view.js b/src/views/tribes_view.js index 13cf41e..9203217 100644 --- a/src/views/tribes_view.js +++ b/src/views/tribes_view.js @@ -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})`; diff --git a/src/views/welcome_view.js b/src/views/welcome_view.js new file mode 100644 index 0000000..618e357 --- /dev/null +++ b/src/views/welcome_view.js @@ -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 + ) + }) + ) + ) + ) +}