upstream: integrar 0.9.5 en lo que no diverge (57 ficheros) + dependencias nuevas
Primera tanda de la integracion 0.9.1 -> 0.9.5. Se actualizan los 57 ficheros que upstream cambio y la rama no tocaba, entre ellos los 11 de traduccion: eso ya solo es posible gracias al overlay de i18n, antes eran 11 conflictos garantizados. Se portan ademas las dependencias que 0.9.5 introduce y de las que dependen los ficheros actualizados: pdfDocument, content_favorites, recurrence, media_gallery, comments_view, gallery_view y polls (modelo, limites y vista). Quedan 48 ficheros con divergencia real por resolver a mano (backend.js, main_views.js, OasisMobile.css y 45 vistas y modelos). Verificado: 0 errores de sintaxis en src/, 0 requires relativos rotos, la app arranca declarando 0.9.5 y las 47 rutas principales responden 200/302.
This commit is contained in:
parent
b3986b12a0
commit
f964e61a1c
67 changed files with 5740 additions and 10907 deletions
153
src/backend/content_favorites.js
Normal file
153
src/backend/content_favorites.js
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const TEMPLATE = path.join(__dirname, "../configs/content_favorites.json");
|
||||
|
||||
const storePath = () => {
|
||||
try {
|
||||
return require("../server/ssb_config").statePath("content_favorites.json") || TEMPLATE;
|
||||
} catch (_) { return TEMPLATE; }
|
||||
};
|
||||
|
||||
const DEFAULT = {
|
||||
audios: [],
|
||||
blogs: [],
|
||||
bookmarks: [],
|
||||
calendars: [],
|
||||
chats: [],
|
||||
documents: [],
|
||||
events: [],
|
||||
forum: [],
|
||||
housing: [],
|
||||
images: [],
|
||||
jobs: [],
|
||||
logs: [],
|
||||
maps: [],
|
||||
pads: [],
|
||||
polls: [],
|
||||
projects: [],
|
||||
reports: [],
|
||||
shops: [],
|
||||
market: [],
|
||||
shopProducts: [],
|
||||
tasks: [],
|
||||
torrents: [],
|
||||
transfers: [],
|
||||
videos: [],
|
||||
votes: []
|
||||
};
|
||||
|
||||
const safeArr = (v) => (Array.isArray(v) ? v : []);
|
||||
|
||||
let queue = Promise.resolve();
|
||||
|
||||
const withLock = (fn) => {
|
||||
queue = queue.then(fn, fn);
|
||||
return queue;
|
||||
};
|
||||
|
||||
const normalize = (raw) => {
|
||||
const out = {};
|
||||
for (const k of Object.keys(DEFAULT)) {
|
||||
const list = safeArr(raw?.[k]).map((x) => String(x || "").trim()).filter(Boolean);
|
||||
out[k] = Array.from(new Set(list));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const ensureFile = async () => {
|
||||
try {
|
||||
await fs.promises.access(storePath());
|
||||
} catch (e) {
|
||||
const dir = path.dirname(storePath());
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await fs.promises.writeFile(storePath(), JSON.stringify(DEFAULT, null, 2), "utf8");
|
||||
}
|
||||
};
|
||||
|
||||
const readAll = async () => {
|
||||
await ensureFile();
|
||||
try {
|
||||
const txt = await fs.promises.readFile(storePath(), "utf8");
|
||||
return normalize(JSON.parse(txt || "{}"));
|
||||
} catch (e) {
|
||||
const fixed = normalize(DEFAULT);
|
||||
await fs.promises.writeFile(storePath(), JSON.stringify(fixed, null, 2), "utf8");
|
||||
return fixed;
|
||||
}
|
||||
};
|
||||
|
||||
const writeAll = async (data) => {
|
||||
const dir = path.dirname(storePath());
|
||||
const tmp = path.join(dir, `.content_favorites.${process.pid}.${Date.now()}.tmp`);
|
||||
const txt = JSON.stringify(normalize(data), null, 2);
|
||||
await fs.promises.writeFile(tmp, txt, "utf8");
|
||||
await fs.promises.rename(tmp, storePath());
|
||||
};
|
||||
|
||||
const assertKind = (kind) => {
|
||||
const k = String(kind || "").trim();
|
||||
if (!Object.prototype.hasOwnProperty.call(DEFAULT, k)) throw new Error("Invalid favorites kind");
|
||||
return k;
|
||||
};
|
||||
|
||||
const lastArg = (args, n) => args[args.length - n];
|
||||
|
||||
const kindFromArgs = (args) => {
|
||||
const k = String(lastArg(args, 1) || "").trim();
|
||||
return assertKind(k);
|
||||
};
|
||||
|
||||
const idFromArgs = (args) => String(lastArg(args, 1) || "").trim();
|
||||
|
||||
exports.getFavoriteSet = async (kind) => {
|
||||
const k = assertKind(kind);
|
||||
const data = await readAll();
|
||||
return new Set(safeArr(data[k]).map(String));
|
||||
};
|
||||
|
||||
exports.getFavoriteIndex = async () => {
|
||||
const data = await readAll();
|
||||
const index = new Map();
|
||||
for (const kind of Object.keys(DEFAULT)) {
|
||||
for (const id of safeArr(data[kind])) index.set(String(id), kind);
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
exports.addFavorite = async (kind, id) =>
|
||||
withLock(async () => {
|
||||
const k = assertKind(kind);
|
||||
const favId = String(id || "").trim();
|
||||
if (!favId) return;
|
||||
const data = await readAll();
|
||||
const set = new Set(safeArr(data[k]).map(String));
|
||||
set.add(favId);
|
||||
data[k] = Array.from(set);
|
||||
await writeAll(data);
|
||||
});
|
||||
|
||||
exports.removeFavorite = async (kind, id) =>
|
||||
withLock(async () => {
|
||||
const k = assertKind(kind);
|
||||
const favId = String(id || "").trim();
|
||||
if (!favId) return;
|
||||
const data = await readAll();
|
||||
const set = new Set(safeArr(data[k]).map(String));
|
||||
set.delete(favId);
|
||||
data[k] = Array.from(set);
|
||||
await writeAll(data);
|
||||
});
|
||||
|
||||
exports.getFavoritesSet = async (...args) => exports.getFavoriteSet(kindFromArgs(args));
|
||||
exports.addToFavorites = async (...args) => {
|
||||
const kind = kindFromArgs(args.slice(0, -1));
|
||||
const id = idFromArgs(args);
|
||||
return exports.addFavorite(kind, id);
|
||||
};
|
||||
exports.removeFromFavorites = async (...args) => {
|
||||
const kind = kindFromArgs(args.slice(0, -1));
|
||||
const id = idFromArgs(args);
|
||||
return exports.removeFavorite(kind, id);
|
||||
};
|
||||
exports.storePath = storePath;
|
||||
|
|
@ -3,7 +3,7 @@ const path = require('path');
|
|||
|
||||
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
|
||||
|
||||
const escapePdf = s => String(s || '').replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
const { escapePdf } = require('./pdfDocument');
|
||||
|
||||
const linkPattern = /(?:https?:\/\/[^\s]+|www\.[^\s]+|@[A-Za-z0-9+/=.\-]+\.ed25519|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g;
|
||||
|
||||
|
|
@ -94,8 +94,8 @@ function buildLogsPdf(entries, oasisId, opts = {}) {
|
|||
|
||||
const catalogId = addObj(null);
|
||||
const pagesId = addObj(null);
|
||||
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>');
|
||||
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>');
|
||||
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
|
||||
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
|
||||
|
||||
let logoXObjId = null;
|
||||
if (logoBuf && logoDims) {
|
||||
|
|
@ -156,7 +156,7 @@ function buildLogsPdf(entries, oasisId, opts = {}) {
|
|||
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
|
||||
|
||||
const content = parts.join('\n');
|
||||
const stream = `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`;
|
||||
const stream = `<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`;
|
||||
const cid = addObj(stream);
|
||||
contentIds.push(cid);
|
||||
const pid = addObj(null);
|
||||
|
|
|
|||
199
src/backend/pdfDocument.js
Normal file
199
src/backend/pdfDocument.js
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
|
||||
|
||||
const WIN_ANSI_HIGH = {
|
||||
'\u20AC': '\x80', '\u201A': '\x82', '\u0192': '\x83', '\u201E': '\x84',
|
||||
'\u2026': '\x85', '\u2020': '\x86', '\u2021': '\x87', '\u02C6': '\x88',
|
||||
'\u2030': '\x89', '\u0160': '\x8A', '\u2039': '\x8B', '\u0152': '\x8C',
|
||||
'\u017D': '\x8E', '\u2018': '\x91', '\u2019': '\x92', '\u201C': '\x93',
|
||||
'\u201D': '\x94', '\u2022': '\x95', '\u2013': '\x96', '\u2014': '\x97',
|
||||
'\u02DC': '\x98', '\u2122': '\x99', '\u0161': '\x9A', '\u203A': '\x9B',
|
||||
'\u0153': '\x9C', '\u017E': '\x9E', '\u0178': '\x9F'
|
||||
};
|
||||
|
||||
const escapePdf = s => String(s == null ? '' : s)
|
||||
.replace(/[\r\n\t]+/g, ' ')
|
||||
.replace(/[\u0152\u0153\u0160\u0161\u0178\u017D\u017E\u0192\u02C6\u02DC\u2013\u2014\u2018\u2019\u201A\u201C\u201D\u201E\u2020\u2021\u2022\u2026\u2030\u2039\u203A\u20AC\u2122]/g,
|
||||
(c) => WIN_ANSI_HIGH[c] || '?')
|
||||
.replace(/[^\x20-\xFF\x80-\x9F]/g, '?')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\(/g, '\\(')
|
||||
.replace(/\)/g, '\\)');
|
||||
|
||||
const wrap = (txt, max = 82) => {
|
||||
const out = [];
|
||||
for (const raw of String(txt == null ? '' : txt).split('\n')) {
|
||||
let line = raw;
|
||||
while (line.length > max) {
|
||||
let cut = line.lastIndexOf(' ', max);
|
||||
if (cut <= 0) cut = max;
|
||||
out.push(line.slice(0, cut));
|
||||
line = line.slice(cut).replace(/^\s+/, '');
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const readJpegDims = (buf) => {
|
||||
let i = 2;
|
||||
while (i < buf.length) {
|
||||
if (buf[i] !== 0xFF) return null;
|
||||
const marker = buf[i + 1];
|
||||
if (marker === 0xD8 || marker === 0xD9) { i += 2; continue; }
|
||||
const len = buf.readUInt16BE(i + 2);
|
||||
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
|
||||
const h = buf.readUInt16BE(i + 5);
|
||||
const w = buf.readUInt16BE(i + 7);
|
||||
const c = buf[i + 9];
|
||||
return { w, h, c };
|
||||
}
|
||||
i += 2 + len;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const flattenSections = (sections) => {
|
||||
const lines = [];
|
||||
for (const s of Array.isArray(sections) ? sections : []) {
|
||||
if (!s) continue;
|
||||
if (s.kind === 'kv') {
|
||||
const txt = `${s.label}: ${s.value == null ? '' : s.value}`;
|
||||
for (const w of wrap(txt, 82)) lines.push({ kind: 'kv', text: w });
|
||||
} else if (s.kind === 'text') {
|
||||
for (const w of wrap(s.text, 82)) lines.push({ kind: 'kv', text: w });
|
||||
} else {
|
||||
lines.push({ kind: s.kind, text: s.text });
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
function buildDocumentPdf({ title, issuedToLabel, issuedTo, sections } = {}) {
|
||||
const pageW = 612;
|
||||
const pageH = 792;
|
||||
const marginX = 50;
|
||||
const headerH = 90;
|
||||
const footerH = 40;
|
||||
const bodyTop = pageH - headerH - 24;
|
||||
const bodyBottom = footerH + 10;
|
||||
const lineH = 14;
|
||||
|
||||
let logoBuf = null;
|
||||
let logoDims = null;
|
||||
try {
|
||||
logoBuf = fs.readFileSync(LOGO_PATH);
|
||||
logoDims = readJpegDims(logoBuf);
|
||||
} catch (_) {}
|
||||
|
||||
const lines = flattenSections(sections);
|
||||
|
||||
const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH);
|
||||
const pages = [];
|
||||
for (let i = 0; i < lines.length; i += maxBodyLines) pages.push(lines.slice(i, i + maxBodyLines));
|
||||
if (!pages.length) pages.push([]);
|
||||
|
||||
const objects = [];
|
||||
const addObj = body => { objects.push(body); return objects.length; };
|
||||
|
||||
const catalogId = addObj(null);
|
||||
const pagesId = addObj(null);
|
||||
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
|
||||
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
|
||||
let logoXObjId = null;
|
||||
if (logoBuf && logoDims) {
|
||||
const cs = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
|
||||
const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${cs} /BitsPerComponent 8 /Filter /DCTDecode /Length ${logoBuf.length} >>`;
|
||||
logoXObjId = addObj({ dict, stream: logoBuf });
|
||||
}
|
||||
|
||||
const exportDate = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
|
||||
const footerLeft = `Generated: ${exportDate}`;
|
||||
|
||||
const pageIds = [];
|
||||
const contentIds = [];
|
||||
|
||||
pages.forEach((pg, pgIdx) => {
|
||||
const parts = [];
|
||||
if (logoXObjId) {
|
||||
const logoH = 60;
|
||||
const logoW = Math.round((logoDims.w / logoDims.h) * logoH);
|
||||
parts.push(`q\n${logoW} 0 0 ${logoH} ${marginX} ${pageH - headerH + 15} cm\n/Logo Do\nQ`);
|
||||
}
|
||||
const titleX = (logoXObjId ? marginX + 80 : marginX);
|
||||
const titleY = pageH - 45;
|
||||
parts.push(`BT\n/F2 16 Tf\n${titleX} ${titleY} Td\n(${escapePdf(title || 'OASIS')}) Tj\nET`);
|
||||
if (issuedTo) {
|
||||
const prefix = `${issuedToLabel || 'Issued to'}: `;
|
||||
const prefixW = prefix.length * 5.4;
|
||||
parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(prefix)}) Tj\nET`);
|
||||
parts.push(`BT\n/F2 9 Tf\n${titleX + prefixW} ${titleY - 16} Td\n(${escapePdf(String(issuedTo))}) Tj\nET`);
|
||||
}
|
||||
|
||||
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${pageH - headerH} m\n${pageW - marginX} ${pageH - headerH} l\nS\nQ`);
|
||||
|
||||
let y = bodyTop;
|
||||
for (const ln of pg) {
|
||||
if (ln.kind === 'title') {
|
||||
parts.push(`BT\n/F2 14 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
|
||||
} else if (ln.kind === 'subtitle') {
|
||||
parts.push(`BT\n/F1 11 Tf\n0.2 0.2 0.2 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
|
||||
} else if (ln.kind === 'section') {
|
||||
parts.push(`BT\n/F2 11 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
|
||||
parts.push(`q\n0 0 0 RG\n0.5 w\n${marginX} ${y - 3} m\n${pageW - marginX} ${y - 3} l\nS\nQ`);
|
||||
} else if (ln.kind === 'kv') {
|
||||
parts.push(`BT\n/F1 10 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
|
||||
}
|
||||
y -= lineH;
|
||||
}
|
||||
|
||||
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${footerH + 5} m\n${pageW - marginX} ${footerH + 5} l\nS\nQ`);
|
||||
parts.push(`BT\n/F1 8 Tf\n${marginX} ${footerH - 10} Td\n(${escapePdf(footerLeft)}) Tj\nET`);
|
||||
const pageLabel = `Page ${pgIdx + 1} of ${pages.length}`;
|
||||
const pageLabelW = pageLabel.length * 4.8;
|
||||
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
|
||||
|
||||
const content = parts.join('\n');
|
||||
const stream = `<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`;
|
||||
const cid = addObj(stream);
|
||||
contentIds.push(cid);
|
||||
const pid = addObj(null);
|
||||
pageIds.push(pid);
|
||||
});
|
||||
|
||||
for (let i = 0; i < pageIds.length; i++) {
|
||||
const resources = logoXObjId
|
||||
? `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> /XObject << /Logo ${logoXObjId} 0 R >> >>`
|
||||
: `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> >>`;
|
||||
objects[pageIds[i] - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentIds[i]} 0 R /Resources ${resources} >>`;
|
||||
}
|
||||
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
|
||||
objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
|
||||
|
||||
const chunks = [];
|
||||
const offsets = [0];
|
||||
let byteLen = 0;
|
||||
const push = (buf) => { chunks.push(buf); byteLen += buf.length; };
|
||||
push(Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'));
|
||||
for (let i = 0; i < objects.length; i++) {
|
||||
offsets.push(byteLen);
|
||||
const obj = objects[i];
|
||||
if (obj && typeof obj === 'object' && obj.dict && obj.stream) {
|
||||
push(Buffer.from(`${i + 1} 0 obj\n${obj.dict}\nstream\n`, 'binary'));
|
||||
push(obj.stream);
|
||||
push(Buffer.from('\nendstream\nendobj\n', 'binary'));
|
||||
} else {
|
||||
push(Buffer.from(`${i + 1} 0 obj\n${obj}\nendobj\n`, 'binary'));
|
||||
}
|
||||
}
|
||||
const xrefStart = byteLen;
|
||||
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
||||
for (let i = 1; i <= objects.length; i++) xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
|
||||
xref += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
|
||||
push(Buffer.from(xref, 'binary'));
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
module.exports = { buildDocumentPdf, escapePdf, wrap };
|
||||
|
|
@ -1,3 +1,7 @@
|
|||
const fs = require('fs');
|
||||
|
||||
const ANNOUNCE_SEEN_FILE = 'oasis-political-seen';
|
||||
|
||||
const isMutual = (relationship) => !!(relationship && relationship.following && relationship.followsMe);
|
||||
|
||||
const isRecipientAllowed = ({ pmVisibility, viewerId, recipientId, relationship } = {}) => {
|
||||
|
|
@ -6,4 +10,41 @@ const isRecipientAllowed = ({ pmVisibility, viewerId, recipientId, relationship
|
|||
return isMutual(relationship);
|
||||
};
|
||||
|
||||
module.exports = { isRecipientAllowed, isMutual };
|
||||
const announceSeenPath = () => {
|
||||
try { return require('../server/ssb_config').statePath(ANNOUNCE_SEEN_FILE); } catch (_) { return null; }
|
||||
};
|
||||
|
||||
const readAnnounceSeen = () => {
|
||||
const file = announceSeenPath();
|
||||
if (!file) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch (_) { return {}; }
|
||||
};
|
||||
|
||||
const writeAnnounceSeen = (state) => {
|
||||
const file = announceSeenPath();
|
||||
if (!file) return false;
|
||||
try { fs.writeFileSync(file, JSON.stringify(state || {})); return true; } catch (_) { return false; }
|
||||
};
|
||||
|
||||
const everAnnounced = (announced, subject) => {
|
||||
for (const entry of (announced || [])) {
|
||||
if (String(entry).startsWith(`${subject}|`)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const decideAnnouncement = ({ subject, ref, announced = new Set(), seen = {} } = {}) => {
|
||||
if (!subject || !ref) return { send: false, remember: false };
|
||||
const key = String(ref);
|
||||
if (announced.has(`${subject}|${key}`)) return { send: false, remember: false };
|
||||
if (seen[subject] === key) return { send: false, remember: false };
|
||||
if (!everAnnounced(announced, subject) && seen[subject] === undefined) {
|
||||
return { send: false, remember: true };
|
||||
}
|
||||
return { send: true, remember: true };
|
||||
};
|
||||
|
||||
module.exports = { isRecipientAllowed, isMutual, announceSeenPath, readAnnounceSeen, writeAnnounceSeen, decideAnnouncement };
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ const path = require('path');
|
|||
|
||||
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
|
||||
|
||||
const escapePdf = s => String(s || '').replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
const { escapePdf } = require('./pdfDocument');
|
||||
|
||||
const wrap = (txt, max = 82) => {
|
||||
const out = [];
|
||||
|
|
@ -131,8 +131,8 @@ function buildSmartContractPdf({ transfer, block, viewerId }) {
|
|||
|
||||
const catalogId = addObj(null);
|
||||
const pagesId = addObj(null);
|
||||
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>');
|
||||
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>');
|
||||
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
|
||||
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
|
||||
let logoXObjId = null;
|
||||
if (logoBuf && logoDims) {
|
||||
const cs = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
|
||||
|
|
@ -185,7 +185,7 @@ function buildSmartContractPdf({ transfer, block, viewerId }) {
|
|||
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
|
||||
|
||||
const content = parts.join('\n');
|
||||
const stream = `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`;
|
||||
const stream = `<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`;
|
||||
const cid = addObj(stream);
|
||||
contentIds.push(cid);
|
||||
const pid = addObj(null);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"discardedItems": []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
src/configs/content_favorites.json
Normal file
27
src/configs/content_favorites.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"audios": [],
|
||||
"blogs": [],
|
||||
"bookmarks": [],
|
||||
"calendars": [],
|
||||
"chats": [],
|
||||
"documents": [],
|
||||
"events": [],
|
||||
"forum": [],
|
||||
"housing": [],
|
||||
"images": [],
|
||||
"jobs": [],
|
||||
"logs": [],
|
||||
"maps": [],
|
||||
"market": [],
|
||||
"pads": [],
|
||||
"polls": [],
|
||||
"projects": [],
|
||||
"reports": [],
|
||||
"shopProducts": [],
|
||||
"shops": [],
|
||||
"tasks": [],
|
||||
"torrents": [],
|
||||
"transfers": [],
|
||||
"videos": [],
|
||||
"votes": []
|
||||
}
|
||||
|
|
@ -9,6 +9,9 @@ let _ecoValue = null;
|
|||
let _lastActivity = null;
|
||||
let _maxBlockBytes = 0;
|
||||
let _inhabitantCount = 0;
|
||||
let _mentionsCount = 0;
|
||||
let _bestMatch = null;
|
||||
let _dismissedSuggestion = null;
|
||||
module.exports = {
|
||||
getInboxCount: () => _inboxCount,
|
||||
setInboxCount: (n) => { _inboxCount = n; },
|
||||
|
|
@ -30,6 +33,12 @@ module.exports = {
|
|||
setLastActivity: (a) => { _lastActivity = a; },
|
||||
getMaxBlockBytes: () => _maxBlockBytes,
|
||||
setMaxBlockBytes: (n) => { if (Number(n) > _maxBlockBytes) _maxBlockBytes = Number(n); },
|
||||
getMentionsCount: () => _mentionsCount,
|
||||
setMentionsCount: (n) => { _mentionsCount = Math.max(0, Number(n) || 0); },
|
||||
getInhabitantCount: () => _inhabitantCount,
|
||||
setInhabitantCount: (n) => { _inhabitantCount = Math.max(0, Number(n) || 0); }
|
||||
setInhabitantCount: (n) => { _inhabitantCount = Math.max(0, Number(n) || 0); },
|
||||
getBestMatch: () => _bestMatch,
|
||||
setBestMatch: (m) => { _bestMatch = m || null; },
|
||||
getDismissedSuggestion: () => _dismissedSuggestion,
|
||||
setDismissedSuggestion: (href) => { _dismissedSuggestion = href || null; }
|
||||
};
|
||||
|
|
|
|||
|
|
@ -81,14 +81,21 @@ pins = json.loads(sys.argv[1])
|
|||
im = Image.open(sys.argv[2]).copy()
|
||||
draw = ImageDraw.Draw(im)
|
||||
|
||||
for p in pins:
|
||||
x, y, main = p['x'], p['y'], p.get('main', False)
|
||||
sw = 3 if main else 2
|
||||
sh = 18 if main else 13
|
||||
def draw_pin(draw, x, y, main):
|
||||
sw = 7 if main else 5
|
||||
sh = 34 if main else 26
|
||||
head = 11 if main else 8
|
||||
clr = '#e74c3c' if main else '#3498db'
|
||||
dark = '#c0392b' if main else '#2980b9'
|
||||
draw.polygon([(x, y + 2), (x - sw, y - sh + sw * 2), (x + sw, y - sh + sw * 2)], fill=clr)
|
||||
draw.ellipse([x - sw - 1, y - sh - sw, x + sw + 1, y - sh + sw], fill=dark, outline='white', width=1)
|
||||
cy = y - sh
|
||||
draw.polygon([(x, y + 2), (x - sw - 1, cy + sw), (x + sw + 1, cy + sw)], fill='white')
|
||||
draw.polygon([(x, y), (x - sw, cy + sw), (x + sw, cy + sw)], fill=clr)
|
||||
draw.ellipse([x - head - 2, cy - head - 2, x + head + 2, cy + head + 2], fill='white')
|
||||
draw.ellipse([x - head, cy - head, x + head, cy + head], fill=clr, outline=dark, width=2)
|
||||
draw.ellipse([x - head // 3, cy - head // 3, x + head // 3, cy + head // 3], fill='white')
|
||||
|
||||
for p in pins:
|
||||
draw_pin(draw, p['x'], p['y'], p.get('main', False))
|
||||
|
||||
im.save(sys.argv[3], optimize=True)
|
||||
`;
|
||||
|
|
@ -193,15 +200,23 @@ cropped = canvas.crop((int(crop_x), int(crop_y), int(crop_x + vw), int(crop_y +
|
|||
result = cropped.resize((1024, 1024), Image.LANCZOS)
|
||||
|
||||
draw = ImageDraw.Draw(result)
|
||||
def draw_pin(draw, x, y, main):
|
||||
sw = 7 if main else 5
|
||||
sh = 34 if main else 26
|
||||
head = 11 if main else 8
|
||||
clr = '#e74c3c' if main else '#3498db'
|
||||
dark = '#c0392b' if main else '#2980b9'
|
||||
cy = y - sh
|
||||
draw.polygon([(x, y + 2), (x - sw - 1, cy + sw), (x + sw + 1, cy + sw)], fill='white')
|
||||
draw.polygon([(x, y), (x - sw, cy + sw), (x + sw, cy + sw)], fill=clr)
|
||||
draw.ellipse([x - head - 2, cy - head - 2, x + head + 2, cy + head + 2], fill='white')
|
||||
draw.ellipse([x - head, cy - head, x + head, cy + head], fill=clr, outline=dark, width=2)
|
||||
draw.ellipse([x - head // 3, cy - head // 3, x + head // 3, cy + head // 3], fill='white')
|
||||
|
||||
for p in pins:
|
||||
px, py, main = p['px'], p['py'], p.get('main', False)
|
||||
if -20 <= px <= 1044 and -20 <= py <= 1044:
|
||||
sw = 3 if main else 2
|
||||
sh = 18 if main else 13
|
||||
clr = '#e74c3c' if main else '#3498db'
|
||||
dark = '#c0392b' if main else '#2980b9'
|
||||
draw.polygon([(px, py + 2), (px - sw, py - sh + sw * 2), (px + sw, py - sh + sw * 2)], fill=clr)
|
||||
draw.ellipse([px - sw - 1, py - sh - sw, px + sw + 1, py - sh + sw], fill=dark, outline='white', width=1)
|
||||
if -40 <= px <= 1064 and -40 <= py <= 1064:
|
||||
draw_pin(draw, px, py, main)
|
||||
|
||||
result.save(out_file, optimize=True)
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -145,7 +145,6 @@ module.exports = ({ cooler }) => {
|
|||
updatedAt: c.updatedAt || null,
|
||||
lastVisit: c.lastVisit || null,
|
||||
tags: safeArr(c.tags),
|
||||
category: c.category || "",
|
||||
description: c.description || "",
|
||||
opinions,
|
||||
opinions_inhabitants: voters,
|
||||
|
|
@ -182,7 +181,7 @@ module.exports = ({ cooler }) => {
|
|||
return root;
|
||||
},
|
||||
|
||||
async createBookmark(url, tagsRaw, description, category, lastVisit) {
|
||||
async createBookmark(url, tagsRaw, description, lastVisit) {
|
||||
const ssbClient = await openSsb();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
|
|
@ -195,7 +194,6 @@ module.exports = ({ cooler }) => {
|
|||
url: u,
|
||||
tags: normalizeTags(tagsRaw),
|
||||
description: description || "",
|
||||
category: category || "",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastVisit: coerceLastVisit(lastVisit),
|
||||
|
|
@ -231,7 +229,6 @@ module.exports = ({ cooler }) => {
|
|||
url,
|
||||
tags: updatedData.tags !== undefined ? normalizeTags(updatedData.tags) : safeArr(oldMsg.content.tags),
|
||||
description: updatedData.description !== undefined ? updatedData.description || "" : oldMsg.content.description || "",
|
||||
category: updatedData.category !== undefined ? updatedData.category || "" : oldMsg.content.category || "",
|
||||
lastVisit: coerceLastVisit(updatedData.lastVisit || oldMsg.content.lastVisit),
|
||||
createdAt: oldMsg.content.createdAt,
|
||||
updatedAt: now
|
||||
|
|
@ -296,11 +293,10 @@ module.exports = ({ cooler }) => {
|
|||
if (q) {
|
||||
list = list.filter((b) => {
|
||||
const url = String(b.url || "").toLowerCase();
|
||||
const cat = String(b.category || "").toLowerCase();
|
||||
const desc = String(b.description || "").toLowerCase();
|
||||
const tags = safeArr(b.tags).join(" ").toLowerCase();
|
||||
const author = String(b.author || "").toLowerCase();
|
||||
return url.includes(q) || cat.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q);
|
||||
return url.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,25 +14,26 @@ const normalizeTags = (raw) => {
|
|||
return String(raw).split(",").map(t => t.trim()).filter(Boolean)
|
||||
}
|
||||
const hasAnyInterval = (w, m, y) => !!(w || m || y)
|
||||
const expandRecurrence = (firstDate, deadline, weekly, monthly, yearly) => {
|
||||
const start = new Date(firstDate)
|
||||
const out = [start]
|
||||
if (!deadline || !hasAnyInterval(weekly, monthly, yearly)) return out
|
||||
const end = new Date(deadline).getTime()
|
||||
const seen = new Set([start.getTime()])
|
||||
const walk = (mutate) => {
|
||||
const n = new Date(start)
|
||||
mutate(n)
|
||||
while (n.getTime() <= end) {
|
||||
const t = n.getTime()
|
||||
if (!seen.has(t)) { seen.add(t); out.push(new Date(n)) }
|
||||
mutate(n)
|
||||
}
|
||||
}
|
||||
if (weekly) walk((d) => d.setDate(d.getDate() + 7))
|
||||
if (monthly) walk((d) => d.setMonth(d.getMonth() + 1))
|
||||
if (yearly) walk((d) => d.setFullYear(d.getFullYear() + 1))
|
||||
return out.sort((a, b) => a.getTime() - b.getTime())
|
||||
const { expandRecurrence } = require('./recurrence')
|
||||
|
||||
const ts = (v) => {
|
||||
const t = new Date(v).getTime()
|
||||
return Number.isFinite(t) ? t : null
|
||||
}
|
||||
|
||||
const assertCalendarDates = ({ deadline, date, until }) => {
|
||||
const now = Date.now()
|
||||
const dl = deadline ? ts(deadline) : null
|
||||
const dt = date ? ts(date) : null
|
||||
const un = until ? ts(until) : null
|
||||
if (deadline && dl === null) throw new Error("Deadline is not a valid date")
|
||||
if (date && dt === null) throw new Error("Date is not a valid date")
|
||||
if (until && un === null) throw new Error("The recurrence end is not a valid date")
|
||||
if (dl !== null && dl <= now) throw new Error("Deadline must be in the future")
|
||||
if (dt !== null && dt <= now) throw new Error("Date must be in the future")
|
||||
if (dt !== null && dl !== null && dt > dl) throw new Error("Date cannot be later than the deadline")
|
||||
if (un !== null && dt !== null && un < dt) throw new Error("The recurrence cannot end before it starts")
|
||||
if (un !== null && dl !== null && un > dl) throw new Error("The recurrence cannot end after the deadline")
|
||||
}
|
||||
|
||||
module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel }) => {
|
||||
|
|
@ -108,6 +109,12 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
return tribeCrypto.encryptContent(content, [key], true)
|
||||
}
|
||||
|
||||
const decryptScoped = async (content, rootId) => {
|
||||
if (!content) return content
|
||||
if (content.tribeId) return await decryptIfTribe(content)
|
||||
return decryptCalendarRoot(content, rootId)
|
||||
}
|
||||
|
||||
const decryptCalendarRoot = (content, rootId) => {
|
||||
if (!content || !content.encryptedPayload) return content
|
||||
if (!tribeCrypto) return content
|
||||
|
|
@ -259,6 +266,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
status: c.status || "OPEN",
|
||||
deadline: undec ? "" : (c.deadline || ""),
|
||||
tags: Array.isArray(c.tags) ? c.tags : [],
|
||||
mapUrl: typeof c.mapUrl === "string" ? c.mapUrl : "",
|
||||
author: c.author || node.author,
|
||||
participants: Array.isArray(participants) ? participants : (Array.isArray(c.participants) ? c.participants : []),
|
||||
invites: Array.isArray(c.invites) ? c.invites : [],
|
||||
|
|
@ -339,14 +347,18 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
return tip
|
||||
},
|
||||
|
||||
async createCalendar({ title, status, deadline, tags, firstDate, firstDateLabel, firstNote, intervalWeekly, intervalMonthly, intervalYearly, tribeId }) {
|
||||
async createCalendar({ title, status, deadline, tags, firstDate, firstDateLabel, firstNote, intervalWeekly, intervalMonthly, intervalYearly, intervalDeadline, mapUrl, tribeId }) {
|
||||
const ssbClient = await openSsb()
|
||||
const userId = ssbClient.id
|
||||
const now = new Date().toISOString()
|
||||
const validStatus = ["OPEN", "CLOSED"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
|
||||
|
||||
if (deadline && new Date(deadline).getTime() <= Date.now()) throw new Error("Deadline must be in the future")
|
||||
if (!firstDate || new Date(firstDate).getTime() <= Date.now()) throw new Error("First date must be in the future")
|
||||
if (!firstDate) throw new Error("First date must be in the future")
|
||||
assertCalendarDates({
|
||||
deadline,
|
||||
date: firstDate,
|
||||
until: hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) ? intervalDeadline : ""
|
||||
})
|
||||
|
||||
let plainContent = {
|
||||
type: "calendar",
|
||||
|
|
@ -354,6 +366,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
status: validStatus,
|
||||
deadline: deadline || "",
|
||||
tags: normalizeTags(tags),
|
||||
mapUrl: safeText(mapUrl),
|
||||
author: userId,
|
||||
participants: [userId],
|
||||
invites: [],
|
||||
|
|
@ -400,6 +413,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
status: validStatus,
|
||||
deadline: dec.deadline || "",
|
||||
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
||||
mapUrl: dec.mapUrl || "",
|
||||
author: userId,
|
||||
participants: [userId],
|
||||
invites: [{ code: pubCode, ek, salt: inviteSalt, gen: 1, public: true }],
|
||||
|
|
@ -424,7 +438,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
...(intervalWeekly ? { intervalWeekly: true } : {}),
|
||||
...(intervalMonthly ? { intervalMonthly: true } : {}),
|
||||
...(intervalYearly ? { intervalYearly: true } : {}),
|
||||
...(deadline && hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) ? { intervalDeadline: deadline } : {}),
|
||||
...(hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) && (intervalDeadline || deadline) ? { intervalDeadline: intervalDeadline || deadline } : {}),
|
||||
...(tribeId ? { tribeId } : {})
|
||||
}
|
||||
if (tribeId) dateContent = await encryptIfTribe(dateContent)
|
||||
|
|
@ -467,12 +481,15 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
: decryptCalendarRoot(item.content, rootId)
|
||||
assertReadable(oldDec, "Calendar")
|
||||
if ((oldDec.author || item.content.author) !== userId) throw new Error("Not the author")
|
||||
const nextDeadline = data.deadline !== undefined ? data.deadline : (oldDec.deadline || "")
|
||||
if (nextDeadline !== (oldDec.deadline || "")) assertCalendarDates({ deadline: nextDeadline })
|
||||
let updated = {
|
||||
type: "calendar",
|
||||
title: data.title !== undefined ? safeText(data.title) : (oldDec.title || ""),
|
||||
status: data.status !== undefined ? (["OPEN","CLOSED"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : oldDec.status) : (oldDec.status || "OPEN"),
|
||||
deadline: data.deadline !== undefined ? data.deadline : (oldDec.deadline || ""),
|
||||
deadline: nextDeadline,
|
||||
tags: data.tags !== undefined ? normalizeTags(data.tags) : (Array.isArray(oldDec.tags) ? oldDec.tags : []),
|
||||
mapUrl: data.mapUrl !== undefined ? safeText(data.mapUrl) : (oldDec.mapUrl || ""),
|
||||
author: oldDec.author || userId,
|
||||
participants: oldDec.participants || [userId],
|
||||
invites: Array.isArray(oldDec.invites) ? oldDec.invites : [],
|
||||
|
|
@ -495,7 +512,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
const userId = ssbClient.id
|
||||
const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
|
||||
if (!item || !item.content) throw new Error("Calendar not found")
|
||||
const dec = await decryptIfTribe(item.content)
|
||||
const rootId = await this.resolveRootId(id)
|
||||
const dec = await decryptScoped(item.content, rootId)
|
||||
assertReadable(dec, "Calendar")
|
||||
const contentAuthor = (dec && dec.author) || (typeof item.content === 'object' && item.content.author)
|
||||
if (contentAuthor !== userId) throw new Error("Not the author")
|
||||
|
|
@ -588,9 +606,14 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
const cal = await this.getCalendarById(rootId)
|
||||
if (!cal) throw new Error("Calendar not found")
|
||||
if (cal.status === "CLOSED" && userId !== cal.author) throw new Error("Only the author can add dates to a CLOSED calendar")
|
||||
if (!date || new Date(date).getTime() <= Date.now()) throw new Error("Date must be in the future")
|
||||
|
||||
if (!date) throw new Error("Date must be in the future")
|
||||
const hasInterval = hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly)
|
||||
assertCalendarDates({
|
||||
deadline: cal.deadline || "",
|
||||
date,
|
||||
until: hasInterval ? intervalDeadline : ""
|
||||
})
|
||||
|
||||
const ruleDeadline = hasInterval ? (intervalDeadline || cal.deadline || "") : ""
|
||||
let dateContent = {
|
||||
type: "calendarDate",
|
||||
|
|
@ -748,8 +771,9 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
const userId = ssbClient.id
|
||||
const item = await new Promise((resolve, reject) => ssbClient.get(noteId, (e, it) => e ? reject(e) : resolve(it)))
|
||||
if (!item || !item.content) throw new Error("Note not found")
|
||||
const dec = await decryptIfTribe(item.content)
|
||||
if ((dec.author || item.content.author) !== userId) throw new Error("Not the author")
|
||||
const noteRoot = item.content.calendarId || null
|
||||
const dec = await decryptScoped(item.content, noteRoot)
|
||||
if (((dec && dec.author) || item.content.author) !== userId) throw new Error("Not the author")
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish({ type: "tombstone", target: noteId, deletedAt: new Date().toISOString(), author: userId }, (e, msg) => e ? reject(e) : resolve(msg))
|
||||
})
|
||||
|
|
@ -777,8 +801,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
if (tombstoned.has(m.key)) continue
|
||||
if (c.calendarId !== rootId || c.dateId !== dateId) continue
|
||||
let dec = c
|
||||
if (c.encryptedPayload && tribeCrypto && tribesModel) {
|
||||
const r = await tribeCrypto.decryptFromTribe(c, tribesModel)
|
||||
if (c.encryptedPayload) {
|
||||
const r = await decryptScoped(c, rootId)
|
||||
if (r && !r._undecryptable) dec = r
|
||||
else continue
|
||||
}
|
||||
|
|
@ -929,6 +953,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
status: dec.status || "OPEN",
|
||||
deadline: dec.deadline || "",
|
||||
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
||||
mapUrl: dec.mapUrl || "",
|
||||
author: dec.author,
|
||||
participants: Array.isArray(dec.participants) ? dec.participants : [userId],
|
||||
invites,
|
||||
|
|
@ -975,6 +1000,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
status: dec.status || "OPEN",
|
||||
deadline: dec.deadline || "",
|
||||
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
||||
mapUrl: dec.mapUrl || "",
|
||||
author: dec.author,
|
||||
participants: Array.isArray(dec.participants) ? dec.participants : [userId],
|
||||
invites,
|
||||
|
|
@ -1009,7 +1035,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
let calKey = null
|
||||
if (tribeCrypto && typeof matchedInvite === "object") {
|
||||
if (matchedInvite.ekChain) {
|
||||
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt)
|
||||
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt, 3)
|
||||
if (Array.isArray(chain) && chain.length) {
|
||||
for (const entry of chain) {
|
||||
if (Array.isArray(entry.keys) && entry.keys.length) {
|
||||
|
|
@ -1043,6 +1069,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
status: dec.status || "OPEN",
|
||||
deadline: dec.deadline || "",
|
||||
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
||||
mapUrl: dec.mapUrl || "",
|
||||
author: dec.author,
|
||||
participants: [...(Array.isArray(dec.participants) ? dec.participants : []), userId],
|
||||
invites,
|
||||
|
|
|
|||
|
|
@ -223,14 +223,27 @@ module.exports = (configPath, namespace = 'tribes') => {
|
|||
return decryptWithKey(encryptedKey, derived.toString('hex'), inviteAad(inviteCode, salt));
|
||||
};
|
||||
|
||||
const INVITE_ENCRYPT_ATTEMPTS = 3;
|
||||
|
||||
const encryptChainForInvite = (ancestryRootIds, code, salt) => {
|
||||
const chain = ancestryRootIds.map(rid => ({ rootId: rid, keys: getKeys(rid), gen: getGen(rid) }));
|
||||
if (chain.some(e => !Array.isArray(e.keys) || !e.keys.length)) return null;
|
||||
const k = deriveInviteKey(code, salt);
|
||||
return encryptWithKey(JSON.stringify(chain), k.toString('hex'), inviteAad(code, salt));
|
||||
const plain = JSON.stringify(chain);
|
||||
const aad = inviteAad(code, salt);
|
||||
for (let attempt = 0; attempt < INVITE_ENCRYPT_ATTEMPTS; attempt++) {
|
||||
const k = deriveInviteKey(code, salt);
|
||||
const payload = encryptWithKey(plain, k.toString('hex'), aad);
|
||||
if (!payload) continue;
|
||||
const readBack = decryptChainFromInvite(payload, code, salt);
|
||||
if (readBack && JSON.stringify(readBack.map(e => ({ rootId: e.rootId, keys: e.keys, gen: e.gen })))
|
||||
=== JSON.stringify(chain.map(e => ({ rootId: e.rootId, keys: e.keys, gen: e.gen })))) {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
throw new Error('Could not produce a verifiable invite on this machine');
|
||||
};
|
||||
|
||||
const decryptChainFromInvite = (encryptedPayload, code, salt) => {
|
||||
const decryptChainOnce = (encryptedPayload, code, salt) => {
|
||||
const k = deriveInviteKey(code, salt);
|
||||
try {
|
||||
const json = decryptWithKey(encryptedPayload, k.toString('hex'), inviteAad(code, salt));
|
||||
|
|
@ -247,6 +260,15 @@ module.exports = (configPath, namespace = 'tribes') => {
|
|||
return null;
|
||||
};
|
||||
|
||||
const decryptChainFromInvite = (encryptedPayload, code, salt, attempts = 1) => {
|
||||
const tries = Math.max(1, Number(attempts) || 1);
|
||||
for (let i = 0; i < tries; i++) {
|
||||
const chain = decryptChainOnce(encryptedPayload, code, salt);
|
||||
if (chain) return chain;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const inviteMatchesCode = (inv, code) => {
|
||||
if (!inv || typeof inv !== 'object' || !inv.codeHash) return false;
|
||||
return inv.codeHash === hashInviteCode(code, inv.salt);
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ module.exports = ({ cooler }) => {
|
|||
status: data.status || 'LOOKING FOR WORK',
|
||||
preferences: data.preferences || 'REMOTE WORKING',
|
||||
visibility: String(data.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC',
|
||||
aiManaged: data.aiManaged === undefined ? true : (data.aiManaged === true || data.aiManaged === '1' || data.aiManaged === 'true'),
|
||||
matchThreshold: Math.min(100, Math.max(0, parseInt(data.matchThreshold, 10) || 80)),
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -85,7 +87,7 @@ module.exports = ({ cooler }) => {
|
|||
author: userId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
photo: extractBlobId(photoBlobId) || null,
|
||||
photo: extractBlobId(photoBlobId) || old.content.photo || null,
|
||||
contact: userId,
|
||||
personalSkills: parseCSV(data.personalSkills),
|
||||
personalExperiences: data.personalExperiences || '',
|
||||
|
|
@ -102,6 +104,12 @@ module.exports = ({ cooler }) => {
|
|||
visibility: data.visibility !== undefined
|
||||
? (String(data.visibility).toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC')
|
||||
: (old.content.visibility || 'PUBLIC'),
|
||||
aiManaged: data.aiManaged !== undefined
|
||||
? (data.aiManaged === true || data.aiManaged === '1' || data.aiManaged === 'true')
|
||||
: (old.content.aiManaged !== false),
|
||||
matchThreshold: data.matchThreshold !== undefined
|
||||
? Math.min(100, Math.max(0, parseInt(data.matchThreshold, 10) || 80))
|
||||
: (Number(old.content.matchThreshold) || 80),
|
||||
createdAt: old.content.createdAt,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -109,12 +109,9 @@ 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'] },
|
||||
blogs: { model: 'src/models/blog_model.js', view: 'src/views/blog_view.js', paths: ['/blogs'] },
|
||||
polls: { model: 'src/models/polls_model.js', view: 'src/views/polls_view.js', paths: ['/polls'] },
|
||||
data: { model: 'src/models/data_model.js', view: 'src/views/data_view.js', paths: ['/data'] },
|
||||
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' }
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ const { getConfig } = require("../configs/config-manager.js");
|
|||
const categories = require("../backend/opinion_categories");
|
||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
const { dedupeBy, norm } = require('../backend/dedupe');
|
||||
const mediaFavorites = require("../backend/media-favorites");
|
||||
const contentFavorites = require("../backend/content_favorites");
|
||||
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||
|
||||
|
|
@ -154,7 +154,7 @@ module.exports = ({ cooler }) => {
|
|||
|
||||
const favoritesSetForDocuments = async () => {
|
||||
try {
|
||||
return await mediaFavorites.getFavoriteSet("documents");
|
||||
return await contentFavorites.getFavoriteSet("documents");
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
const pull = require('../server/node_modules/pull-stream');
|
||||
const moment = require('../server/node_modules/moment');
|
||||
const { normalizeImages, normalizeVideo } = require('./media_gallery');
|
||||
const { truthy, hasAnyInterval, nextOccurrence, upcomingOccurrences } = require('./recurrence');
|
||||
const crypto = require('crypto');
|
||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
|
|
@ -72,8 +74,22 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
|||
return m.toISOString();
|
||||
};
|
||||
|
||||
const recurrenceOf = (c) => ({
|
||||
weekly: truthy(c.intervalWeekly),
|
||||
monthly: truthy(c.intervalMonthly),
|
||||
yearly: truthy(c.intervalYearly),
|
||||
until: c.recurrenceUntil || ''
|
||||
});
|
||||
|
||||
const effectiveDate = (c) => {
|
||||
const r = recurrenceOf(c);
|
||||
if (!hasAnyInterval(r.weekly, r.monthly, r.yearly) || !r.until) return c.date;
|
||||
const next = nextOccurrence(c.date, r.until, r.weekly, r.monthly, r.yearly);
|
||||
return next ? next.toISOString() : c.date;
|
||||
};
|
||||
|
||||
const deriveStatus = (c) => {
|
||||
const dateM = moment(c.date);
|
||||
const dateM = moment(effectiveDate(c));
|
||||
let status = String(c.status || 'OPEN').toUpperCase();
|
||||
if (dateM.isValid() && dateM.isBefore(moment())) status = 'CLOSED';
|
||||
if (status !== 'OPEN' && status !== 'CLOSED') status = 'OPEN';
|
||||
|
|
@ -171,7 +187,7 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
|||
return idx.rootOf(id);
|
||||
},
|
||||
|
||||
async createEvent(title, description, date, location, price = 0, url = "", attendees = [], tagsRaw = [], isPublic, mapUrl = "", clearnetPublic = false) {
|
||||
async createEvent(title, description, date, location, price = 0, url = "", attendees = [], tagsRaw = [], isPublic, mapUrl = "", clearnetPublic = false, media = {}, recurrence = {}) {
|
||||
const ssbClient = await openSsb();
|
||||
const userId = await me();
|
||||
|
||||
|
|
@ -203,6 +219,12 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
|||
isPublic: visibility,
|
||||
mapUrl: String(mapUrl || "").trim(),
|
||||
clearnetPublic: clearnetPublic === true || clearnetPublic === 'true' || clearnetPublic === 'on',
|
||||
images: normalizeImages(media && media.images),
|
||||
video: normalizeVideo(media && media.video),
|
||||
intervalWeekly: truthy(recurrence && recurrence.weekly),
|
||||
intervalMonthly: truthy(recurrence && recurrence.monthly),
|
||||
intervalYearly: truthy(recurrence && recurrence.yearly),
|
||||
recurrenceUntil: String((recurrence && recurrence.until) || '').trim(),
|
||||
opinions: {},
|
||||
opinions_inhabitants: []
|
||||
};
|
||||
|
|
@ -461,6 +483,15 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
|||
status,
|
||||
isPublic: normalizePrivacy(c.isPublic),
|
||||
mapUrl: c.mapUrl || "",
|
||||
images: normalizeImages(c.images),
|
||||
video: normalizeVideo(c.video),
|
||||
intervalWeekly: truthy(c.intervalWeekly),
|
||||
intervalMonthly: truthy(c.intervalMonthly),
|
||||
intervalYearly: truthy(c.intervalYearly),
|
||||
recurrenceUntil: c.recurrenceUntil || '',
|
||||
recurring: hasAnyInterval(truthy(c.intervalWeekly), truthy(c.intervalMonthly), truthy(c.intervalYearly)) && !!c.recurrenceUntil,
|
||||
nextDate: effectiveDate(c),
|
||||
occurrences: upcomingOccurrences(c.date, c.recurrenceUntil, truthy(c.intervalWeekly), truthy(c.intervalMonthly), truthy(c.intervalYearly)).map(d => d.toISOString()),
|
||||
clearnetPublic: !!c.clearnetPublic,
|
||||
encrypted: normalizePrivacy(c.isPublic) === 'private',
|
||||
opinions: agg.opinions,
|
||||
|
|
@ -503,6 +534,12 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
|||
isPublic: updatedData.isPublic !== undefined ? normalizePrivacy(updatedData.isPublic) : normalizePrivacy(c.isPublic),
|
||||
clearnetPublic: updatedData.clearnetPublic !== undefined ? (updatedData.clearnetPublic === true || updatedData.clearnetPublic === 'true' || updatedData.clearnetPublic === 'on') : !!c.clearnetPublic,
|
||||
attendees: uniq(Array.isArray(c.attendees) ? c.attendees : []),
|
||||
images: normalizeImages(updatedData.images !== undefined ? updatedData.images : c.images),
|
||||
video: updatedData.video !== undefined ? normalizeVideo(updatedData.video) : normalizeVideo(c.video),
|
||||
intervalWeekly: updatedData.intervalWeekly !== undefined ? truthy(updatedData.intervalWeekly) : truthy(c.intervalWeekly),
|
||||
intervalMonthly: updatedData.intervalMonthly !== undefined ? truthy(updatedData.intervalMonthly) : truthy(c.intervalMonthly),
|
||||
intervalYearly: updatedData.intervalYearly !== undefined ? truthy(updatedData.intervalYearly) : truthy(c.intervalYearly),
|
||||
recurrenceUntil: updatedData.recurrenceUntil !== undefined ? String(updatedData.recurrenceUntil || '').trim() : (c.recurrenceUntil || ''),
|
||||
updatedAt: new Date().toISOString(),
|
||||
replaces: eventId
|
||||
};
|
||||
|
|
@ -610,6 +647,14 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
|||
status,
|
||||
isPublic: normalizePrivacy(c.isPublic),
|
||||
mapUrl: c.mapUrl || "",
|
||||
images: normalizeImages(c.images),
|
||||
video: normalizeVideo(c.video),
|
||||
intervalWeekly: truthy(c.intervalWeekly),
|
||||
intervalMonthly: truthy(c.intervalMonthly),
|
||||
intervalYearly: truthy(c.intervalYearly),
|
||||
recurrenceUntil: c.recurrenceUntil || '',
|
||||
recurring: hasAnyInterval(truthy(c.intervalWeekly), truthy(c.intervalMonthly), truthy(c.intervalYearly)) && !!c.recurrenceUntil,
|
||||
nextDate: effectiveDate(c),
|
||||
encrypted: normalizePrivacy(c.isPublic) === 'private',
|
||||
opinions: agg.opinions,
|
||||
opinions_inhabitants: agg.opinions_inhabitants
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const mediaFavorites = require("../backend/media-favorites");
|
||||
const contentFavorites = require("../backend/content_favorites");
|
||||
|
||||
const safeArr = (v) => (Array.isArray(v) ? v : []);
|
||||
const safeText = (v) => String(v || "").trim();
|
||||
|
|
@ -15,7 +15,7 @@ const toTs = (d) => {
|
|||
return Number.isFinite(t) ? t : 0;
|
||||
};
|
||||
|
||||
module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel, torrentsModel, marketModel, shopsModel }) => {
|
||||
module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel, torrentsModel, marketModel, shopsModel, eventsModel, tasksModel, reportsModel, votesModel, jobsModel, housingModel, projectsModel, transfersModel, forumModel, blogsModel, pollsModel }) => {
|
||||
const kindConfig = {
|
||||
audios: {
|
||||
base: "/audios/",
|
||||
|
|
@ -64,10 +64,58 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
|
|||
shopProducts: {
|
||||
base: "/shops/product/",
|
||||
getById: getFn(shopsModel, ["getProductById"])
|
||||
},
|
||||
events: {
|
||||
base: "/events/",
|
||||
getById: getFn(eventsModel, ["getEventById", "getById"])
|
||||
},
|
||||
tasks: {
|
||||
base: "/tasks/",
|
||||
getById: getFn(tasksModel, ["getTaskById", "getById"])
|
||||
},
|
||||
reports: {
|
||||
base: "/reports/",
|
||||
getById: getFn(reportsModel, ["getReportById", "getById"])
|
||||
},
|
||||
votes: {
|
||||
base: "/votes/",
|
||||
getById: getFn(votesModel, ["getVoteById", "getById"])
|
||||
},
|
||||
jobs: {
|
||||
base: "/jobs/",
|
||||
getById: getFn(jobsModel, ["getJobById", "getById"])
|
||||
},
|
||||
housing: {
|
||||
base: "/housing/",
|
||||
getById: getFn(housingModel, ["getHousingById", "getById"])
|
||||
},
|
||||
projects: {
|
||||
base: "/projects/",
|
||||
getById: getFn(projectsModel, ["getProjectById", "getById"])
|
||||
},
|
||||
transfers: {
|
||||
base: "/transfers/",
|
||||
getById: getFn(transfersModel, ["getTransferById", "getById"])
|
||||
},
|
||||
forum: {
|
||||
base: "/forum/",
|
||||
getById: getFn(forumModel, ["getForumById", "getById"])
|
||||
},
|
||||
blogs: {
|
||||
base: "/blogs/",
|
||||
getById: getFn(blogsModel, ["getBlogById", "getById"])
|
||||
},
|
||||
polls: {
|
||||
base: "/polls/",
|
||||
getById: getFn(pollsModel, ["getPollById", "getById"])
|
||||
},
|
||||
shops: {
|
||||
base: "/shops/",
|
||||
getById: getFn(shopsModel, ["getShopById", "getById"])
|
||||
}
|
||||
};
|
||||
|
||||
const kindOrder = ["audios", "bookmarks", "calendars", "chats", "documents", "images", "maps", "pads", "torrents", "videos", "market", "shopProducts"];
|
||||
const kindOrder = ["audios", "blogs", "bookmarks", "calendars", "chats", "documents", "events", "forum", "housing", "images", "jobs", "maps", "market", "pads", "polls", "projects", "reports", "shopProducts", "shops", "tasks", "torrents", "transfers", "videos", "votes"];
|
||||
|
||||
const hydrateKind = async (kind, ids) => {
|
||||
const cfg = kindConfig[kind];
|
||||
|
|
@ -79,9 +127,11 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
|
|||
if (!id) return null;
|
||||
try {
|
||||
const obj = await cfg.getById(id);
|
||||
if (!obj || typeof obj !== "object") return null;
|
||||
const viewId = safeText(obj?.key || obj?.id || id);
|
||||
|
||||
return {
|
||||
content: obj,
|
||||
kind,
|
||||
favId: id,
|
||||
viewHref: `${cfg.base}${encodeURIComponent(viewId)}`,
|
||||
|
|
@ -104,7 +154,7 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
|
|||
};
|
||||
|
||||
const loadAll = async () => {
|
||||
const sets = await Promise.all(kindOrder.map((k) => mediaFavorites.getFavoriteSet(k)));
|
||||
const sets = await Promise.all(kindOrder.map((k) => contentFavorites.getFavoriteSet(k)));
|
||||
const idsByKind = {};
|
||||
kindOrder.forEach((k, i) => {
|
||||
idsByKind[k] = Array.from(sets[i] || []);
|
||||
|
|
@ -118,21 +168,8 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
|
|||
|
||||
const flat = kindOrder.flatMap((k) => byKind[k]);
|
||||
|
||||
const counts = {
|
||||
audios: byKind.audios.length,
|
||||
bookmarks: byKind.bookmarks.length,
|
||||
calendars: byKind.calendars.length,
|
||||
chats: byKind.chats.length,
|
||||
documents: byKind.documents.length,
|
||||
images: byKind.images.length,
|
||||
maps: byKind.maps.length,
|
||||
pads: byKind.pads.length,
|
||||
torrents: byKind.torrents.length,
|
||||
videos: byKind.videos.length,
|
||||
market: byKind.market.length,
|
||||
shopProducts: byKind.shopProducts.length,
|
||||
all: flat.length
|
||||
};
|
||||
const counts = { all: flat.length };
|
||||
for (const k of kindOrder) counts[k] = (byKind[k] || []).length;
|
||||
|
||||
const recentFlat = flat
|
||||
.slice()
|
||||
|
|
@ -167,11 +204,13 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
|
|||
return { items: grouped, counts };
|
||||
},
|
||||
|
||||
kinds: kindOrder.slice(),
|
||||
|
||||
async removeFavorite(kind, id) {
|
||||
const k = safeText(kind);
|
||||
const favId = safeText(id);
|
||||
if (!k || !favId) return;
|
||||
await mediaFavorites.removeFavorite(k, favId);
|
||||
await contentFavorites.removeFavorite(k, favId);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -142,7 +142,6 @@ module.exports = ({ cooler }) => {
|
|||
title: c.title || "",
|
||||
description: c.description || "",
|
||||
mapUrl: c.mapUrl || "",
|
||||
meme: !!c.meme,
|
||||
opinions: agg ? agg.opinions : (c.opinions || {}),
|
||||
opinions_inhabitants: voters,
|
||||
hasVoted: viewerId ? voters.includes(viewerId) : false,
|
||||
|
|
@ -177,7 +176,7 @@ module.exports = ({ cooler }) => {
|
|||
return root;
|
||||
},
|
||||
|
||||
async createImage(blobMarkdown, tagsRaw, title, description, memeBool, mapUrl) {
|
||||
async createImage(blobMarkdown, tagsRaw, title, description, mapUrl) {
|
||||
const ssbClient = await openSsb();
|
||||
const blobId = parseBlobId(blobMarkdown);
|
||||
const tags = normalizeTags(tagsRaw) || [];
|
||||
|
|
@ -193,7 +192,6 @@ module.exports = ({ cooler }) => {
|
|||
title: title || "",
|
||||
description: description || "",
|
||||
mapUrl: mapUrl || "",
|
||||
meme: !!memeBool,
|
||||
opinions: {},
|
||||
opinions_inhabitants: []
|
||||
};
|
||||
|
|
@ -203,7 +201,7 @@ module.exports = ({ cooler }) => {
|
|||
});
|
||||
},
|
||||
|
||||
async updateImageById(id, blobMarkdown, tagsRaw, title, description, memeBool, mapUrl) {
|
||||
async updateImageById(id, blobMarkdown, tagsRaw, title, description, mapUrl) {
|
||||
const ssbClient = await openSsb();
|
||||
const userId = ssbClient.id;
|
||||
const tipId = await this.resolveCurrentId(id);
|
||||
|
|
@ -226,7 +224,6 @@ module.exports = ({ cooler }) => {
|
|||
title: title !== undefined ? title || "" : oldMsg.content.title || "",
|
||||
description: description !== undefined ? description || "" : oldMsg.content.description || "",
|
||||
mapUrl: mapUrl !== undefined ? mapUrl || "" : oldMsg.content.mapUrl || "",
|
||||
meme: typeof memeBool === "boolean" ? memeBool : !!oldMsg.content.meme,
|
||||
createdAt: oldMsg.content.createdAt,
|
||||
updatedAt: now
|
||||
};
|
||||
|
|
@ -280,7 +277,6 @@ module.exports = ({ cooler }) => {
|
|||
|
||||
if (filter === "mine") list = list.filter((im) => String(im.author) === String(viewerId));
|
||||
else if (filter === "recent") list = list.filter((im) => new Date(im.createdAt).getTime() >= now - 86400000);
|
||||
else if (filter === "meme") list = list.filter((im) => im.meme === true);
|
||||
else if (filter === "top") {
|
||||
list = list
|
||||
.slice()
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
|||
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const WEEK_MS = 7 * DAY_MS;
|
||||
const MONTH_MS = 30 * DAY_MS;
|
||||
|
|
@ -311,7 +312,16 @@ module.exports = ({ cooler }) => {
|
|||
ref: c.ref || null
|
||||
});
|
||||
}
|
||||
const parentOf = new Map();
|
||||
for (const i of items) if (i.replaces) parentOf.set(i.key, i.replaces);
|
||||
const rootOf = (key) => {
|
||||
let cur = key;
|
||||
const seen = new Set();
|
||||
while (parentOf.has(cur) && !seen.has(cur)) { seen.add(cur); cur = parentOf.get(cur); }
|
||||
return cur;
|
||||
};
|
||||
const survivors = items.filter(i => !tombstoned.has(i.key) && !replaced.has(i.key));
|
||||
for (const i of survivors) i.rootId = rootOf(i.key);
|
||||
survivors.sort((a, b) => b.ts - a.ts);
|
||||
return survivors;
|
||||
}
|
||||
|
|
@ -332,11 +342,14 @@ module.exports = ({ cooler }) => {
|
|||
async function updateLog(id, { text, label, mode }) {
|
||||
const current = await getLogById(id);
|
||||
if (!current) return { status: 'not_found' };
|
||||
await republishLog({
|
||||
replaces: current.key,
|
||||
const next = {
|
||||
text: text !== undefined ? text : current.text,
|
||||
label: label !== undefined ? label : current.label,
|
||||
mode: mode || current.mode,
|
||||
mode: mode || current.mode
|
||||
};
|
||||
await republishLog({
|
||||
replaces: current.key,
|
||||
...next,
|
||||
createdAt: current.createdAt
|
||||
});
|
||||
return { status: 'ok' };
|
||||
|
|
|
|||
|
|
@ -501,6 +501,31 @@ async function checkLocalBlob(blobId) {
|
|||
}
|
||||
|
||||
models.blob = {
|
||||
getCached: async ({ blobId }) => {
|
||||
const local = await checkLocalBlob(blobId);
|
||||
if (local) return local;
|
||||
const ssb = await cooler.open();
|
||||
const has = await new Promise((resolve) => ssb.blobs.has(blobId, (err, v) => resolve(!err && !!v)));
|
||||
if (!has) {
|
||||
try { ssb.blobs.want(blobId, () => {}); } catch (_) {}
|
||||
return null;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
pull(
|
||||
ssb.blobs.get(blobId),
|
||||
pull.collect(async (err, bufs) => {
|
||||
if (err || !bufs || !bufs.length) return resolve(null);
|
||||
const buffer = Buffer.concat(bufs);
|
||||
try {
|
||||
const filePath = blobIdToHexPath(blobId);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, buffer);
|
||||
} catch (e) { /* ignore */ }
|
||||
resolve(buffer);
|
||||
})
|
||||
);
|
||||
});
|
||||
},
|
||||
getResolved: async ({ blobId, timeout = 30000 }) => {
|
||||
let buf = await checkLocalBlob(blobId);
|
||||
if (buf) return buf;
|
||||
|
|
|
|||
|
|
@ -864,7 +864,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
let mapKey = null;
|
||||
if (tribeCrypto && typeof matchedInvite === "object") {
|
||||
if (matchedInvite.ekChain) {
|
||||
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt);
|
||||
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt, 3);
|
||||
if (Array.isArray(chain) && chain.length) {
|
||||
for (const entry of chain) {
|
||||
if (Array.isArray(entry.keys) && entry.keys.length) {
|
||||
|
|
|
|||
42
src/models/media_gallery.js
Normal file
42
src/models/media_gallery.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
const MAX_IMAGES = 8
|
||||
|
||||
const MEDIA_MARKDOWN = /\[(image|video|audio):[^\]]*\]\(/
|
||||
|
||||
const blobOf = (value) => {
|
||||
let blobId = value || null
|
||||
if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
|
||||
return blobId || null
|
||||
}
|
||||
|
||||
const keepMarkdown = (value, id) =>
|
||||
MEDIA_MARKDOWN.test(value) || value.startsWith("![") ? value : id
|
||||
|
||||
const normalizeImages = (raw, max = MAX_IMAGES) => {
|
||||
const list = Array.isArray(raw) ? raw : (raw ? [raw] : [])
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
for (const entry of list) {
|
||||
const value = String(entry || "").trim()
|
||||
const id = blobOf(value)
|
||||
if (!id || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
out.push(keepMarkdown(value, id))
|
||||
if (out.length >= max) break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const normalizeVideo = (raw) => {
|
||||
const value = String(Array.isArray(raw) ? raw[0] || "" : raw || "").trim()
|
||||
const id = blobOf(value)
|
||||
if (!id) return ""
|
||||
return keepMarkdown(value, id)
|
||||
}
|
||||
|
||||
const mergeGallery = (current, uploaded, removeIndex = -1, max = MAX_IMAGES) => {
|
||||
let list = normalizeImages(current, max)
|
||||
if (removeIndex >= 0) list = list.filter((_, i) => i !== removeIndex)
|
||||
return normalizeImages([...list, ...normalizeImages(uploaded, max)], max)
|
||||
}
|
||||
|
||||
module.exports = { MAX_IMAGES, blobOf, normalizeImages, normalizeVideo, mergeGallery }
|
||||
|
|
@ -2,7 +2,7 @@ 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 STEPS = ['language', 'ux', 'profile', 'federation', 'larp', 'greeting', 'backup'];
|
||||
const FLAG = 'oasis-first-contact';
|
||||
const PENDING = 'welcome=pending';
|
||||
const DISMISSED = 'welcome=dismissed';
|
||||
|
|
@ -122,7 +122,7 @@ module.exports = ({ cooler, ssbPath } = {}) => {
|
|||
|
||||
markStep(step) {
|
||||
if (!STEPS.includes(step)) return false;
|
||||
if (step !== 'language' && step !== 'backup') return true;
|
||||
if (step !== 'language' && step !== 'backup' && step !== 'ux') return true;
|
||||
if (!isPending(dir())) return true;
|
||||
return appendMarker(dir(), `step=${step}`);
|
||||
},
|
||||
|
|
@ -147,6 +147,7 @@ module.exports = ({ cooler, ssbPath } = {}) => {
|
|||
profile: hasProfile(mine, messages),
|
||||
federation: hasFederated(mine, messages),
|
||||
larp: hasJoinedLarp(messages),
|
||||
ux: flag.markers.has('step=ux'),
|
||||
backup: flag.markers.has('step=backup'),
|
||||
greeting: hasGreeted(messages)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,6 +12,20 @@ const QUORUM_RATIO = 0.25;
|
|||
const VOTE_METHODS = new Set(['DEMOCRACY', 'ANARCHY', 'MAJORITY', 'MINORITY']);
|
||||
const METHODS = ['DEMOCRACY', 'MAJORITY', 'MINORITY', 'DICTATORSHIP', 'KARMATOCRACY'];
|
||||
const FEED_ID_RE = /^@.+\.ed25519$/;
|
||||
const TERM_EPOCH = Date.UTC(2020, 0, 1);
|
||||
const TERM_SPAN_MS = TERM_DAYS * 86400000;
|
||||
|
||||
const termWindowFor = (at = Date.now()) => {
|
||||
const ms = at instanceof Date ? at.getTime() : Number(at);
|
||||
const base = Number.isFinite(ms) ? ms : Date.now();
|
||||
const index = Math.floor((base - TERM_EPOCH) / TERM_SPAN_MS);
|
||||
const startMs = TERM_EPOCH + index * TERM_SPAN_MS;
|
||||
return {
|
||||
cycle: index,
|
||||
startAt: new Date(startMs).toISOString(),
|
||||
endAt: new Date(startMs + TERM_SPAN_MS).toISOString()
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = ({ cooler, services = {} }) => {
|
||||
let ssb;
|
||||
|
|
@ -251,7 +265,13 @@ if (c.type === type) {
|
|||
|
||||
async function listCandidaturesOpenRaw() {
|
||||
const all = await listByType('parliamentCandidature');
|
||||
const filtered = all.filter(c => (c.status || 'OPEN') === 'OPEN');
|
||||
const window = termWindowFor(Date.now());
|
||||
const from = new Date(window.startAt).getTime();
|
||||
const filtered = all.filter(c => {
|
||||
if ((c.status || 'OPEN') !== 'OPEN') return false;
|
||||
const created = new Date(c.createdAt).getTime();
|
||||
return Number.isFinite(created) ? created >= from : true;
|
||||
});
|
||||
return filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
}
|
||||
|
||||
|
|
@ -345,6 +365,28 @@ if (c.type === type) {
|
|||
return isExpiredTerm(t) ? null : t;
|
||||
}
|
||||
|
||||
function virtualAnarchyTerm(window = termWindowFor(Date.now())) {
|
||||
return {
|
||||
id: `anarchy:${window.cycle}`,
|
||||
virtual: true,
|
||||
type: 'parliamentTerm',
|
||||
cycle: window.cycle,
|
||||
method: 'ANARCHY',
|
||||
powerType: 'none',
|
||||
powerId: null,
|
||||
powerTitle: 'ANARCHY',
|
||||
winnerTribeId: null,
|
||||
winnerInhabitantId: null,
|
||||
winnerVotes: 0,
|
||||
totalVotes: 0,
|
||||
population: 0,
|
||||
startAt: window.startAt,
|
||||
endAt: window.endAt,
|
||||
createdBy: null,
|
||||
createdAt: window.startAt
|
||||
};
|
||||
}
|
||||
|
||||
function currentCycleStart(term) {
|
||||
return term ? term.startAt : moment().subtract(TERM_DAYS, 'days').toISOString();
|
||||
}
|
||||
|
|
@ -352,6 +394,7 @@ if (c.type === type) {
|
|||
async function archiveAllCandidatures() {
|
||||
const all = await listCandidaturesOpenRaw();
|
||||
for (const c of all) {
|
||||
if (String(c.proposer || '') !== String(userId)) continue;
|
||||
const updated = { ...stripId(c), replaces: c.id, status: 'DISCARDED', updatedAt: nowISO() };
|
||||
await publishMsg(updated);
|
||||
}
|
||||
|
|
@ -713,10 +756,7 @@ if (c.type === type) {
|
|||
|
||||
async function createProposal({ title, description }) {
|
||||
let term = await getCurrentTermBase();
|
||||
if (!term) {
|
||||
await resolveElection();
|
||||
term = await getCurrentTermBase();
|
||||
}
|
||||
if (!term) term = await resolveElection();
|
||||
if (!term) throw new Error('No active government');
|
||||
const allowed = await canPropose();
|
||||
if (!allowed) throw new Error('You are not in the goverment, yet.');
|
||||
|
|
@ -833,6 +873,11 @@ if (c.type === type) {
|
|||
}
|
||||
|
||||
async function getCurrentTerm() {
|
||||
const published = await getCurrentTermBase();
|
||||
return published || virtualAnarchyTerm();
|
||||
}
|
||||
|
||||
async function getPublishedTerm() {
|
||||
return await getCurrentTermBase();
|
||||
}
|
||||
|
||||
|
|
@ -1135,44 +1180,20 @@ if (c.type === type) {
|
|||
}
|
||||
}
|
||||
|
||||
const startAt = now.toISOString();
|
||||
const endAt = moment(startAt).add(TERM_DAYS, 'days').toISOString();
|
||||
const window = termWindowFor(now.valueOf());
|
||||
const startAt = window.startAt;
|
||||
const endAt = window.endAt;
|
||||
const population = await inhabitantsCount();
|
||||
|
||||
if (!chosen) {
|
||||
const termAnarchy = {
|
||||
type: 'parliamentTerm',
|
||||
method: 'ANARCHY',
|
||||
powerType: 'none',
|
||||
powerId: null,
|
||||
powerTitle: 'ANARCHY',
|
||||
winnerTribeId: null,
|
||||
winnerInhabitantId: null,
|
||||
winnerVotes: 0,
|
||||
totalVotes: 0,
|
||||
startAt,
|
||||
endAt,
|
||||
createdBy: userId,
|
||||
createdAt: nowISO()
|
||||
};
|
||||
|
||||
const resAnarchy = await publishMsg(termAnarchy);
|
||||
try {
|
||||
await sleep(250);
|
||||
const canonical = await getCurrentTermBase();
|
||||
const myId = resAnarchy.key || resAnarchy.id;
|
||||
if (canonical && canonical.id && myId && canonical.id !== myId) {
|
||||
const tomb = { type: 'tombstone', target: myId, deletedAt: nowISO(), author: userId };
|
||||
await publishMsg(tomb);
|
||||
await archiveAllCandidatures();
|
||||
return canonical;
|
||||
}
|
||||
} catch {}
|
||||
await archiveAllCandidatures();
|
||||
return resAnarchy;
|
||||
return virtualAnarchyTerm(window);
|
||||
}
|
||||
|
||||
const term = {
|
||||
type: 'parliamentTerm',
|
||||
cycle: window.cycle,
|
||||
population,
|
||||
method: chosen.method,
|
||||
powerType: chosen.targetType,
|
||||
powerId: chosen.targetId,
|
||||
|
|
@ -1223,21 +1244,16 @@ if (c.type === type) {
|
|||
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;
|
||||
}
|
||||
const current = termWindowFor(Date.now());
|
||||
const termEnd = term.endAt ? new Date(term.endAt).getTime() : NaN;
|
||||
const rolled = Number.isFinite(termEnd) && termEnd <= Date.now();
|
||||
if (rolled) {
|
||||
return {
|
||||
...card,
|
||||
id: term.id || term.startAt,
|
||||
since: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
cycle: current.cycle,
|
||||
since: current.startAt,
|
||||
end: current.endAt,
|
||||
method: 'ANARCHY',
|
||||
powerType: 'none',
|
||||
powerId: null,
|
||||
|
|
@ -1513,6 +1529,7 @@ if (c.type === type) {
|
|||
listCandidatures,
|
||||
listTerms,
|
||||
getCurrentTerm,
|
||||
getPublishedTerm,
|
||||
getLatestTerm,
|
||||
getLatestGovernmentCard,
|
||||
listLeaders,
|
||||
|
|
@ -1543,6 +1560,29 @@ if (c.type === type) {
|
|||
|
||||
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
function compareTermsForWindow(a, b) {
|
||||
const num = (t, field) => Number(t && t[field]) || 0;
|
||||
const anarchy = (t) => (String(t && t.method || '').toUpperCase() === 'ANARCHY' ? 1 : 0);
|
||||
|
||||
const populationA = num(a, 'population');
|
||||
const populationB = num(b, 'population');
|
||||
if (populationA !== populationB) return populationB - populationA;
|
||||
|
||||
const turnoutA = num(a, 'totalVotes');
|
||||
const turnoutB = num(b, 'totalVotes');
|
||||
if (turnoutA !== turnoutB) return turnoutB - turnoutA;
|
||||
|
||||
const votesA = num(a, 'winnerVotes');
|
||||
const votesB = num(b, 'winnerVotes');
|
||||
if (votesA !== votesB) return votesB - votesA;
|
||||
|
||||
const anarchyA = anarchy(a);
|
||||
const anarchyB = anarchy(b);
|
||||
if (anarchyA !== anarchyB) return anarchyA - anarchyB;
|
||||
|
||||
return String(a && a.id || '').localeCompare(String(b && b.id || ''));
|
||||
}
|
||||
|
||||
function collapseOverlappingTerms(terms = []) {
|
||||
if (!terms.length) return [];
|
||||
const sorted = [...terms].sort((a, b) => new Date(a.startAt) - new Date(b.startAt));
|
||||
|
|
@ -1563,20 +1603,13 @@ function collapseOverlappingTerms(terms = []) {
|
|||
if (!placed) groups.push({ items: [t], minStart: tStart, maxEnd: tEnd });
|
||||
}
|
||||
const winners = groups.map(g => {
|
||||
g.items.sort((a, b) => {
|
||||
const aAn = String(a.method || '').toUpperCase() === 'ANARCHY' ? 1 : 0;
|
||||
const bAn = String(b.method || '').toUpperCase() === 'ANARCHY' ? 1 : 0;
|
||||
if (aAn !== bAn) return aAn - bAn;
|
||||
const aC = new Date(a.createdAt || a.startAt).getTime();
|
||||
const bC = new Date(b.createdAt || b.startAt).getTime();
|
||||
if (aC !== bC) return aC - bC;
|
||||
const aS = new Date(a.startAt).getTime();
|
||||
const bS = new Date(b.startAt).getTime();
|
||||
if (aS !== bS) return aS - bS;
|
||||
return String(a.id || '').localeCompare(String(b.id || ''));
|
||||
});
|
||||
g.items.sort(compareTermsForWindow);
|
||||
return g.items[0];
|
||||
});
|
||||
return winners.sort((a, b) => new Date(b.startAt) - new Date(a.startAt));
|
||||
}
|
||||
|
||||
module.exports.termWindowFor = termWindowFor;
|
||||
module.exports.compareTermsForWindow = compareTermsForWindow;
|
||||
module.exports.collapseOverlappingTerms = collapseOverlappingTerms;
|
||||
module.exports.TERM_DAYS = TERM_DAYS;
|
||||
|
|
|
|||
443
src/models/polls_model.js
Normal file
443
src/models/polls_model.js
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
const pull = require('../server/node_modules/pull-stream');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||
|
||||
const POLL_TYPE = 'poll';
|
||||
const VOTE_TYPE = 'pollVote';
|
||||
const OPINION_TYPE = 'pollOpinion';
|
||||
|
||||
const { MAX_OPTIONS, MIN_OPTIONS, MAX_OPTION_LENGTH } = require('./polls_model_limits');
|
||||
|
||||
const clean = (v) => String(v == null ? '' : v).trim();
|
||||
|
||||
const normalizeOptions = (raw) => {
|
||||
const list = Array.isArray(raw) ? raw : String(raw || '').split('\n');
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const entry of list) {
|
||||
const value = clean(entry).slice(0, MAX_OPTION_LENGTH);
|
||||
if (!value) continue;
|
||||
const key = value.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(value);
|
||||
if (out.length >= MAX_OPTIONS) break;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const normalizeTags = (raw) => {
|
||||
const list = Array.isArray(raw) ? raw : String(raw || '').split(',');
|
||||
return list.map(t => clean(t)).filter(Boolean).slice(0, 20);
|
||||
};
|
||||
|
||||
module.exports = ({ cooler, isPublic = false, tribeCrypto = null, chatsModel = null }) => {
|
||||
let ssb;
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
||||
|
||||
const getAllMessages = async (ssbClient) =>
|
||||
new Promise((resolve, reject) => {
|
||||
pull(
|
||||
ssbClient.createLogStream({ limit: logLimit }),
|
||||
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
|
||||
);
|
||||
});
|
||||
|
||||
const decryptField = (value, keys) => {
|
||||
if (!tribeCrypto || typeof value !== 'string' || !value) return null;
|
||||
for (const k of keys) {
|
||||
try {
|
||||
const out = tribeCrypto.decryptWithKey(value, k);
|
||||
if (typeof out === 'string' && out) return out;
|
||||
} catch (_) {}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const scopeKey = async (scope) => {
|
||||
if (!scope || !chatsModel) return null;
|
||||
if (!scope.chatId && !scope.tribeId) return null;
|
||||
try { return await chatsModel.encryptionKeyFor(scope.chatId || null, scope.tribeId || null); } catch (_) { return null; }
|
||||
};
|
||||
|
||||
const buildIndex = (messages) => {
|
||||
const tomb = buildValidatedTombstoneSet(messages);
|
||||
const nodes = new Map();
|
||||
const replaced = new Map();
|
||||
const votesByRoot = new Map();
|
||||
const opinionsByRoot = new Map();
|
||||
const closedRoots = new Set();
|
||||
const commentsByRoot = new Map();
|
||||
const authorByKey = new Map();
|
||||
|
||||
for (const m of messages) {
|
||||
const v = m && m.value;
|
||||
const c = v && v.content;
|
||||
if (!c || typeof c !== 'object') continue;
|
||||
authorByKey.set(m.key, v.author);
|
||||
|
||||
if (c.type === POLL_TYPE) {
|
||||
nodes.set(m.key, { key: m.key, author: v.author, ts: v.timestamp || m.timestamp || 0, c });
|
||||
if (typeof c.replaces === 'string') replaced.set(c.replaces, m.key);
|
||||
continue;
|
||||
}
|
||||
if (c.type === VOTE_TYPE && typeof c.target === 'string') {
|
||||
const entry = votesByRoot.get(c.target) || new Map();
|
||||
const prev = entry.get(v.author);
|
||||
const ts = v.timestamp || m.timestamp || 0;
|
||||
if (!prev || ts >= prev.ts) {
|
||||
entry.set(v.author, {
|
||||
ts,
|
||||
choices: Array.isArray(c.choices) ? c.choices : [],
|
||||
encryptedChoices: typeof c.encryptedChoices === 'string' ? c.encryptedChoices : null
|
||||
});
|
||||
}
|
||||
votesByRoot.set(c.target, entry);
|
||||
continue;
|
||||
}
|
||||
if (c.type === OPINION_TYPE && typeof c.target === 'string') {
|
||||
const entry = opinionsByRoot.get(c.target) || { counts: {}, voters: [] };
|
||||
if (!entry.voters.includes(v.author)) {
|
||||
entry.voters.push(v.author);
|
||||
entry.counts[c.category] = (entry.counts[c.category] || 0) + 1;
|
||||
}
|
||||
opinionsByRoot.set(c.target, entry);
|
||||
continue;
|
||||
}
|
||||
if (c.type === 'pollClose' && typeof c.target === 'string') {
|
||||
closedRoots.add(`${v.author}|${c.target}`);
|
||||
continue;
|
||||
}
|
||||
if (c.type === 'post' && typeof c.root === 'string') {
|
||||
commentsByRoot.set(c.root, (commentsByRoot.get(c.root) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const rootOf = (key) => {
|
||||
let cur = key;
|
||||
const seen = new Set();
|
||||
while (nodes.has(cur) && nodes.get(cur).c.replaces && !seen.has(cur)) {
|
||||
seen.add(cur);
|
||||
const next = nodes.get(cur).c.replaces;
|
||||
if (!nodes.has(next)) break;
|
||||
cur = next;
|
||||
}
|
||||
return cur;
|
||||
};
|
||||
|
||||
const tipOf = (root) => {
|
||||
let cur = root;
|
||||
const seen = new Set();
|
||||
while (replaced.has(cur) && !seen.has(cur)) {
|
||||
seen.add(cur);
|
||||
const next = replaced.get(cur);
|
||||
const node = nodes.get(next);
|
||||
if (!node || node.author !== nodes.get(cur).author) break;
|
||||
cur = next;
|
||||
}
|
||||
return cur;
|
||||
};
|
||||
|
||||
return { tomb, nodes, replaced, votesByRoot, opinionsByRoot, closedRoots, commentsByRoot, rootOf, tipOf };
|
||||
};
|
||||
|
||||
const isExpired = (c) => {
|
||||
if (!c.deadline) return false;
|
||||
const t = Date.parse(c.deadline);
|
||||
return Number.isFinite(t) && t <= Date.now();
|
||||
};
|
||||
|
||||
const buildPoll = (idx, rootId, viewerId, keys = []) => {
|
||||
const tipId = idx.tipOf(rootId);
|
||||
const node = idx.nodes.get(tipId);
|
||||
if (!node) return null;
|
||||
const c = node.c || {};
|
||||
let question = clean(c.question);
|
||||
let rawOptions = c.options;
|
||||
let undecryptable = false;
|
||||
if (c.encryptedQuestion) {
|
||||
const q = decryptField(c.encryptedQuestion, keys);
|
||||
const o = decryptField(c.encryptedOptions, keys);
|
||||
if (q === null) undecryptable = true;
|
||||
question = q || '';
|
||||
try { rawOptions = o ? JSON.parse(o) : []; } catch (_) { rawOptions = []; }
|
||||
}
|
||||
const options = normalizeOptions(rawOptions);
|
||||
const voteMap = idx.votesByRoot.get(rootId) || new Map();
|
||||
|
||||
const counts = {};
|
||||
for (const o of options) counts[o] = 0;
|
||||
let totalVoters = 0;
|
||||
const voters = [];
|
||||
let myChoices = [];
|
||||
for (const [author, entry] of voteMap.entries()) {
|
||||
let choices = Array.isArray(entry.choices) ? entry.choices : [];
|
||||
if (entry.encryptedChoices) {
|
||||
const plain = decryptField(entry.encryptedChoices, keys);
|
||||
try { choices = plain ? JSON.parse(plain) : []; } catch (_) { choices = []; }
|
||||
}
|
||||
const picked = (Array.isArray(choices) ? choices : []).filter(o => options.includes(o));
|
||||
if (!picked.length) continue;
|
||||
totalVoters += 1;
|
||||
voters.push(author);
|
||||
for (const o of picked) counts[o] += 1;
|
||||
if (viewerId && author === viewerId) myChoices = picked;
|
||||
}
|
||||
|
||||
const closed = idx.closedRoots.has(`${node.author}|${rootId}`) || isExpired(c);
|
||||
const op = idx.opinionsByRoot.get(rootId) || { counts: {}, voters: [] };
|
||||
|
||||
return {
|
||||
id: rootId,
|
||||
rootId,
|
||||
tipId,
|
||||
author: node.author,
|
||||
question,
|
||||
options,
|
||||
encrypted: !!c.encryptedQuestion,
|
||||
undecryptable,
|
||||
chatId: c.chatId || null,
|
||||
anonymous: c.anonymous === true,
|
||||
multiple: c.multiple === true,
|
||||
deadline: c.deadline || '',
|
||||
tags: normalizeTags(c.tags),
|
||||
tribeId: c.tribeId || null,
|
||||
houseKey: c.houseKey || null,
|
||||
createdAt: c.createdAt || new Date(node.ts).toISOString(),
|
||||
updatedAt: c.updatedAt || null,
|
||||
status: closed ? 'CLOSED' : 'OPEN',
|
||||
counts,
|
||||
totalVoters,
|
||||
voters: c.anonymous === true ? [] : voters,
|
||||
votersHidden: c.anonymous === true,
|
||||
myChoices,
|
||||
hasVoted: myChoices.length > 0,
|
||||
commentCount: idx.commentsByRoot.get(rootId) || 0,
|
||||
opinions: op.counts,
|
||||
opinions_inhabitants: op.voters
|
||||
};
|
||||
};
|
||||
|
||||
const collect = async (viewerId) => {
|
||||
const ssbClient = await openSsb();
|
||||
const messages = await getAllMessages(ssbClient);
|
||||
const idx = buildIndex(messages);
|
||||
const roots = new Set();
|
||||
for (const key of idx.nodes.keys()) roots.add(idx.rootOf(key));
|
||||
|
||||
const keyCache = new Map();
|
||||
const keysFor = async (c) => {
|
||||
if (!c || (!c.chatId && !c.tribeId)) return [];
|
||||
const cacheKey = `${c.chatId || ''}|${c.tribeId || ''}`;
|
||||
if (keyCache.has(cacheKey)) return keyCache.get(cacheKey);
|
||||
const k = await scopeKey({ chatId: c.chatId || null, tribeId: c.tribeId || null });
|
||||
const keys = k ? [k] : [];
|
||||
keyCache.set(cacheKey, keys);
|
||||
return keys;
|
||||
};
|
||||
|
||||
const list = [];
|
||||
for (const rootId of roots) {
|
||||
const tipId = idx.tipOf(rootId);
|
||||
if (idx.tomb.has(tipId) || idx.tomb.has(rootId)) continue;
|
||||
const node = idx.nodes.get(tipId);
|
||||
const keys = await keysFor(node && node.c);
|
||||
const poll = buildPoll(idx, rootId, viewerId || ssbClient.id, keys);
|
||||
if (!poll) continue;
|
||||
if (poll.undecryptable) { list.push(poll); continue; }
|
||||
if (!poll.question || poll.options.length < MIN_OPTIONS) continue;
|
||||
list.push(poll);
|
||||
}
|
||||
return { list, idx, viewerId: viewerId || ssbClient.id };
|
||||
};
|
||||
|
||||
return {
|
||||
POLL_TYPE,
|
||||
VOTE_TYPE,
|
||||
MAX_OPTIONS,
|
||||
MIN_OPTIONS,
|
||||
|
||||
async listAll(filter = 'ALL', opts = {}) {
|
||||
const { list, viewerId } = await collect();
|
||||
const f = String(filter || 'ALL').toUpperCase();
|
||||
const scopeChat = opts.chatId || null;
|
||||
const scopeTribe = opts.tribeId || null;
|
||||
const scopeHouse = opts.houseKey || null;
|
||||
let out = list.filter(p =>
|
||||
scopeChat ? p.chatId === scopeChat
|
||||
: scopeTribe ? (p.tribeId === scopeTribe && !p.chatId)
|
||||
: scopeHouse ? p.houseKey === scopeHouse
|
||||
: (!p.tribeId && !p.chatId && !p.houseKey));
|
||||
|
||||
if (f === 'MINE') out = out.filter(p => String(p.author) === String(viewerId));
|
||||
else if (f === 'OPEN') out = out.filter(p => p.status === 'OPEN');
|
||||
else if (f === 'CLOSED') out = out.filter(p => p.status === 'CLOSED');
|
||||
else if (f === 'VOTED') out = out.filter(p => p.hasVoted);
|
||||
else if (f === 'FAVORITES') {
|
||||
const fav = new Set((opts.favorites || []).map(String));
|
||||
out = out.filter(p => fav.has(String(p.id)));
|
||||
}
|
||||
|
||||
const q = clean(opts.q).toLowerCase();
|
||||
if (q) out = out.filter(p =>
|
||||
p.question.toLowerCase().includes(q) ||
|
||||
p.options.some(o => o.toLowerCase().includes(q)) ||
|
||||
p.tags.some(t => t.toLowerCase().includes(q)));
|
||||
|
||||
if (f === 'TOP') out.sort((a, b) => b.totalVoters - a.totalVoters || new Date(b.createdAt) - new Date(a.createdAt));
|
||||
else out.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
async getPollById(id, viewerId = null) {
|
||||
const { list, idx } = await collect(viewerId);
|
||||
const rootId = idx.rootOf(id);
|
||||
const poll = list.find(p => p.id === rootId || p.id === id || p.tipId === id);
|
||||
if (!poll) throw new Error('Poll not found');
|
||||
return poll;
|
||||
},
|
||||
|
||||
async resolveRootId(id) {
|
||||
const { idx } = await collect();
|
||||
return idx.rootOf(id);
|
||||
},
|
||||
|
||||
async createPoll(data = {}) {
|
||||
if (isPublic) throw new Error('Not available in public mode');
|
||||
const question = clean(data.question);
|
||||
if (!question) throw new Error('Question is required');
|
||||
const options = normalizeOptions(data.options);
|
||||
if (options.length < MIN_OPTIONS) throw new Error(`A poll needs at least ${MIN_OPTIONS} options`);
|
||||
const deadline = clean(data.deadline);
|
||||
if (deadline) {
|
||||
const t = Date.parse(deadline);
|
||||
if (!Number.isFinite(t)) throw new Error('Invalid deadline');
|
||||
if (t <= Date.now()) throw new Error('Deadline must be in the future');
|
||||
}
|
||||
const ssbClient = await openSsb();
|
||||
const content = {
|
||||
type: POLL_TYPE,
|
||||
anonymous: data.anonymous === true || data.anonymous === 'true' || data.anonymous === '1',
|
||||
multiple: data.multiple === true || data.multiple === 'true' || data.multiple === '1',
|
||||
deadline,
|
||||
tags: normalizeTags(data.tags),
|
||||
createdAt: new Date().toISOString(),
|
||||
...(data.tribeId ? { tribeId: String(data.tribeId) } : {}),
|
||||
...(data.chatId ? { chatId: String(data.chatId) } : {}),
|
||||
...(data.houseKey ? { houseKey: String(data.houseKey) } : {})
|
||||
};
|
||||
const key = await scopeKey({ chatId: data.chatId, tribeId: data.tribeId });
|
||||
if (key && tribeCrypto) {
|
||||
content.encryptedQuestion = tribeCrypto.encryptWithKey(question, key);
|
||||
content.encryptedOptions = tribeCrypto.encryptWithKey(JSON.stringify(options), key);
|
||||
} else {
|
||||
content.question = question;
|
||||
content.options = options;
|
||||
}
|
||||
return new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
|
||||
},
|
||||
|
||||
async updatePoll(id, data = {}) {
|
||||
const ssbClient = await openSsb();
|
||||
const userId = ssbClient.id;
|
||||
const poll = await this.getPollById(id, userId);
|
||||
if (poll.author !== userId) throw new Error('Not the author');
|
||||
if (poll.status === 'CLOSED') throw new Error('Cannot edit a closed poll');
|
||||
if (poll.totalVoters > 0) throw new Error('Cannot edit a poll that already has votes');
|
||||
|
||||
const options = data.options !== undefined ? normalizeOptions(data.options) : poll.options;
|
||||
if (options.length < MIN_OPTIONS) throw new Error(`A poll needs at least ${MIN_OPTIONS} options`);
|
||||
const deadline = data.deadline !== undefined ? clean(data.deadline) : poll.deadline;
|
||||
if (deadline) {
|
||||
const t = Date.parse(deadline);
|
||||
if (!Number.isFinite(t)) throw new Error('Invalid deadline');
|
||||
}
|
||||
const question = data.question !== undefined ? clean(data.question) : poll.question;
|
||||
const content = {
|
||||
type: POLL_TYPE,
|
||||
replaces: poll.tipId,
|
||||
anonymous: data.anonymous !== undefined
|
||||
? (data.anonymous === true || data.anonymous === 'true' || data.anonymous === '1')
|
||||
: poll.anonymous,
|
||||
multiple: data.multiple !== undefined
|
||||
? (data.multiple === true || data.multiple === 'true' || data.multiple === '1')
|
||||
: poll.multiple,
|
||||
deadline,
|
||||
tags: data.tags !== undefined ? normalizeTags(data.tags) : poll.tags,
|
||||
createdAt: poll.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
...(poll.tribeId ? { tribeId: poll.tribeId } : {}),
|
||||
...(poll.chatId ? { chatId: poll.chatId } : {}),
|
||||
...(poll.houseKey ? { houseKey: poll.houseKey } : {})
|
||||
};
|
||||
const key = await scopeKey({ chatId: poll.chatId, tribeId: poll.tribeId });
|
||||
if (key && tribeCrypto) {
|
||||
content.encryptedQuestion = tribeCrypto.encryptWithKey(question, key);
|
||||
content.encryptedOptions = tribeCrypto.encryptWithKey(JSON.stringify(options), key);
|
||||
} else {
|
||||
content.question = question;
|
||||
content.options = options;
|
||||
}
|
||||
return new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
|
||||
},
|
||||
|
||||
async vote(id, choicesRaw) {
|
||||
const ssbClient = await openSsb();
|
||||
const userId = ssbClient.id;
|
||||
const poll = await this.getPollById(id, userId);
|
||||
if (poll.status === 'CLOSED') throw new Error('This poll is closed');
|
||||
|
||||
const wanted = (Array.isArray(choicesRaw) ? choicesRaw : [choicesRaw])
|
||||
.map(c => clean(c))
|
||||
.filter(c => poll.options.includes(c));
|
||||
const unique = [...new Set(wanted)];
|
||||
if (!unique.length) throw new Error('Pick at least one option');
|
||||
if (!poll.multiple && unique.length > 1) throw new Error('This poll only accepts one option');
|
||||
|
||||
const content = {
|
||||
type: VOTE_TYPE,
|
||||
target: poll.rootId,
|
||||
createdAt: new Date().toISOString(),
|
||||
...(poll.chatId ? { chatId: poll.chatId } : {})
|
||||
};
|
||||
const key = await scopeKey({ chatId: poll.chatId, tribeId: poll.tribeId });
|
||||
if (key && tribeCrypto) content.encryptedChoices = tribeCrypto.encryptWithKey(JSON.stringify(unique), key);
|
||||
else content.choices = unique;
|
||||
return new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
|
||||
},
|
||||
|
||||
async closePoll(id) {
|
||||
const ssbClient = await openSsb();
|
||||
const userId = ssbClient.id;
|
||||
const poll = await this.getPollById(id, userId);
|
||||
if (poll.author !== userId) throw new Error('Not the author');
|
||||
if (poll.status === 'CLOSED') return { status: 'already_closed' };
|
||||
const content = { type: 'pollClose', target: poll.rootId, createdAt: new Date().toISOString() };
|
||||
await new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
|
||||
return { status: 'ok' };
|
||||
},
|
||||
|
||||
async deletePoll(id) {
|
||||
const ssbClient = await openSsb();
|
||||
const userId = ssbClient.id;
|
||||
const poll = await this.getPollById(id, userId);
|
||||
if (poll.author !== userId) throw new Error('Not the author');
|
||||
const content = { type: 'tombstone', target: poll.tipId, deletedAt: new Date().toISOString(), author: userId };
|
||||
return new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
|
||||
},
|
||||
|
||||
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 userId = ssbClient.id;
|
||||
const poll = await this.getPollById(id, userId);
|
||||
if (poll.opinions_inhabitants.includes(userId)) throw new Error('Already opined');
|
||||
const content = { type: OPINION_TYPE, target: poll.rootId, category, createdAt: new Date().toISOString() };
|
||||
return new Promise((res, rej) => ssbClient.publish(content, (err, result) => err ? rej(err) : res(result)));
|
||||
}
|
||||
};
|
||||
};
|
||||
5
src/models/polls_model_limits.js
Normal file
5
src/models/polls_model_limits.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const MAX_OPTIONS = 10;
|
||||
const MIN_OPTIONS = 2;
|
||||
const MAX_OPTION_LENGTH = 120;
|
||||
|
||||
module.exports = { MAX_OPTIONS, MIN_OPTIONS, MAX_OPTION_LENGTH };
|
||||
40
src/models/recurrence.js
Normal file
40
src/models/recurrence.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
const truthy = (v) => v === true || v === 1 || v === '1' || v === 'on' || v === 'true';
|
||||
|
||||
const hasAnyInterval = (weekly, monthly, yearly) => truthy(weekly) || truthy(monthly) || truthy(yearly);
|
||||
|
||||
const expandRecurrence = (firstDate, until, weekly, monthly, yearly) => {
|
||||
const start = new Date(firstDate);
|
||||
if (isNaN(start.getTime())) return [];
|
||||
const out = [start];
|
||||
if (!until || !hasAnyInterval(weekly, monthly, yearly)) return out;
|
||||
const end = new Date(until).getTime();
|
||||
if (!Number.isFinite(end)) return out;
|
||||
const seen = new Set([start.getTime()]);
|
||||
const walk = (mutate) => {
|
||||
const n = new Date(start);
|
||||
mutate(n);
|
||||
let guard = 0;
|
||||
while (n.getTime() <= end && guard++ < 5000) {
|
||||
const t = n.getTime();
|
||||
if (!seen.has(t)) { seen.add(t); out.push(new Date(n)); }
|
||||
mutate(n);
|
||||
}
|
||||
};
|
||||
if (truthy(weekly)) walk((d) => d.setUTCDate(d.getUTCDate() + 7));
|
||||
if (truthy(monthly)) walk((d) => d.setUTCMonth(d.getUTCMonth() + 1));
|
||||
if (truthy(yearly)) walk((d) => d.setUTCFullYear(d.getUTCFullYear() + 1));
|
||||
return out.sort((a, b) => a.getTime() - b.getTime());
|
||||
};
|
||||
|
||||
const nextOccurrence = (firstDate, until, weekly, monthly, yearly, from = Date.now()) => {
|
||||
const list = expandRecurrence(firstDate, until, weekly, monthly, yearly);
|
||||
for (const d of list) if (d.getTime() >= from) return d;
|
||||
return list.length ? list[list.length - 1] : null;
|
||||
};
|
||||
|
||||
const upcomingOccurrences = (firstDate, until, weekly, monthly, yearly, limit = 12, from = Date.now()) =>
|
||||
expandRecurrence(firstDate, until, weekly, monthly, yearly)
|
||||
.filter(d => d.getTime() >= from)
|
||||
.slice(0, limit);
|
||||
|
||||
module.exports = { truthy, hasAnyInterval, expandRecurrence, nextOccurrence, upcomingOccurrences };
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
const pull = require('../server/node_modules/pull-stream');
|
||||
const { normalizeImages, normalizeVideo } = require('./media_gallery');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
const { dedupeBy, norm } = require('../backend/dedupe');
|
||||
|
|
@ -39,7 +40,7 @@ const normalizeTemplate = (category, tpl) => {
|
|||
}
|
||||
|
||||
if (cat === 'CONTENT') {
|
||||
return pick(['contentLocation', 'whyInappropriate', 'requestedAction', 'evidenceLinks']);
|
||||
return pick(['contentLocation', 'whyInappropriate', 'evidenceLinks']);
|
||||
}
|
||||
|
||||
return {};
|
||||
|
|
@ -164,6 +165,8 @@ module.exports = ({ cooler }) => {
|
|||
...c,
|
||||
category: cat,
|
||||
status: normalizeStatus(c.status || 'OPEN'),
|
||||
images: normalizeImages(c.images && c.images.length ? c.images : c.image),
|
||||
video: normalizeVideo(c.video),
|
||||
severity: normalizeSeverity(c.severity) || 'low',
|
||||
confirmations,
|
||||
tags: ensureArray(c.tags),
|
||||
|
|
@ -174,14 +177,13 @@ module.exports = ({ cooler }) => {
|
|||
};
|
||||
|
||||
return {
|
||||
async createReport(title, description, category, image, tagsRaw = [], severity = 'low', template = {}) {
|
||||
async createReport(title, description, category, image, tagsRaw = [], severity = 'low', template = {}, media = {}) {
|
||||
const ssb = await openSsb();
|
||||
const userId = ssb.id;
|
||||
|
||||
let blobId = null;
|
||||
if (image) {
|
||||
blobId = String(image).trim() || null;
|
||||
}
|
||||
const images = normalizeImages((media && media.images && media.images.length) ? media.images : image);
|
||||
const blobId = images[0] || null;
|
||||
const clip = normalizeVideo(media && media.video);
|
||||
|
||||
const tags = Array.isArray(tagsRaw)
|
||||
? tagsRaw.filter(Boolean)
|
||||
|
|
@ -196,6 +198,8 @@ module.exports = ({ cooler }) => {
|
|||
createdAt: new Date().toISOString(),
|
||||
author: userId,
|
||||
image: blobId,
|
||||
images,
|
||||
video: clip,
|
||||
tags,
|
||||
confirmations: [],
|
||||
severity: normalizeSeverity(severity) || 'low',
|
||||
|
|
@ -227,10 +231,15 @@ module.exports = ({ cooler }) => {
|
|||
? String(updatedContent.tags || '').split(',').map(t => t.trim()).filter(Boolean)
|
||||
: ensureArray(report.content.tags);
|
||||
|
||||
let blobId = report.content.image || null;
|
||||
if (updatedContent.image) {
|
||||
blobId = String(updatedContent.image).trim() || null;
|
||||
}
|
||||
const nextImages = normalizeImages(
|
||||
Object.prototype.hasOwnProperty.call(updatedContent, 'images')
|
||||
? updatedContent.images
|
||||
: (report.content.images && report.content.images.length ? report.content.images : (updatedContent.image || report.content.image))
|
||||
);
|
||||
const blobId = nextImages[0] || null;
|
||||
const nextVideo = Object.prototype.hasOwnProperty.call(updatedContent, 'video')
|
||||
? normalizeVideo(updatedContent.video)
|
||||
: normalizeVideo(report.content.video);
|
||||
|
||||
const nextStatus = Object.prototype.hasOwnProperty.call(updatedContent, 'status')
|
||||
? normalizeStatus(updatedContent.status)
|
||||
|
|
@ -258,6 +267,8 @@ module.exports = ({ cooler }) => {
|
|||
type: 'report',
|
||||
replaces: tipId,
|
||||
image: blobId,
|
||||
images: nextImages,
|
||||
video: nextVideo,
|
||||
tags,
|
||||
confirmations,
|
||||
opinions: agg.opinions,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const pull = require('../server/node_modules/pull-stream');
|
||||
const moment = require('../server/node_modules/moment');
|
||||
const { normalizeImages, normalizeVideo } = require('./media_gallery');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
const { dedupeBy, norm } = require('../backend/dedupe');
|
||||
|
|
@ -131,6 +132,8 @@ module.exports = ({ cooler, pmModel }) => {
|
|||
id: node.key,
|
||||
rootId,
|
||||
...c,
|
||||
images: normalizeImages(c.images),
|
||||
video: normalizeVideo(c.video),
|
||||
assignees,
|
||||
opinions,
|
||||
opinions_inhabitants: voters,
|
||||
|
|
@ -139,7 +142,7 @@ module.exports = ({ cooler, pmModel }) => {
|
|||
};
|
||||
|
||||
return {
|
||||
async createTask(title, description, startTime, endTime, priority, location = '', tagsRaw = [], isPublic) {
|
||||
async createTask(title, description, startTime, endTime, priority, location = '', tagsRaw = [], isPublic, media = {}) {
|
||||
const ssb = await openSsb();
|
||||
const userId = ssb.id;
|
||||
|
||||
|
|
@ -166,6 +169,8 @@ module.exports = ({ cooler, pmModel }) => {
|
|||
location,
|
||||
tags,
|
||||
isPublic: visibility,
|
||||
images: normalizeImages(media && media.images),
|
||||
video: normalizeVideo(media && media.video),
|
||||
assignees: [userId],
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'OPEN',
|
||||
|
|
@ -253,6 +258,8 @@ module.exports = ({ cooler, pmModel }) => {
|
|||
tags: newTags,
|
||||
isPublic: newVisibility,
|
||||
status: newStatus,
|
||||
images: normalizeImages(updatedData.images !== undefined ? updatedData.images : c.images),
|
||||
video: updatedData.video !== undefined ? normalizeVideo(updatedData.video) : normalizeVideo(c.video),
|
||||
assignees: nextAssignees,
|
||||
updatedAt: new Date().toISOString(),
|
||||
replaces: taskId
|
||||
|
|
|
|||
|
|
@ -671,7 +671,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
if (typeof c.ch !== 'string' || typeof c.s !== 'string' || typeof c.ek !== 'string') continue;
|
||||
if (inviteTombstoned.has(m.key)) continue;
|
||||
if (tribeCrypto.hashInviteCode(code, c.s) !== c.ch) continue;
|
||||
const chain = tribeCrypto.decryptChainFromInvite(c.ek, code, c.s);
|
||||
const chain = tribeCrypto.decryptChainFromInvite(c.ek, code, c.s, 3);
|
||||
if (Array.isArray(chain) && chain.length) {
|
||||
matched = { msgKey: m.key, codeHash: c.ch, chain, multi: c.multi === 1 || c.multi === true };
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@krakenslab/oasis",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.5",
|
||||
"description": "Oasis - Social Networking Utopia",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -23,4 +23,9 @@ const megabyte = Math.pow(2, 20);
|
|||
config.blobs = config.blobs || {};
|
||||
config.blobs.max = 50 * megabyte;
|
||||
|
||||
config.statePath = (name) => {
|
||||
if (process.env.OASIS_STATE_DIR) return path.join(process.env.OASIS_STATE_DIR, name);
|
||||
return config.path ? path.join(config.path, name) : null;
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
|
|
|
|||
|
|
@ -9,74 +9,34 @@ exports.aiView = (history = [], userPrompt = '') => {
|
|||
div({ class: "tags-header" },
|
||||
h2(i18n.aiTitle),
|
||||
p(i18n.aiDescription),
|
||||
userPrompt ? div({ class: 'user-prompt', style: 'margin-bottom: 2em; font-size: 0.95em; color: #888;' },
|
||||
userPrompt ? div({ class: 'user-prompt' },
|
||||
`${i18n.aiPromptUsed || 'System Prompt'}: `,
|
||||
span({ style: 'font-style: italic;' }, `"${userPrompt}"`)
|
||||
span({ class: 'user-prompt-text' }, `"${userPrompt}"`)
|
||||
) : null,
|
||||
form({ method: 'POST', action: '/ai', style: "margin-bottom: 0;" },
|
||||
form({ method: 'POST', action: '/ai', class: 'ai-prompt-form' },
|
||||
textarea({ name: 'input', rows: 4, placeholder: i18n.aiInputPlaceholder, required: true }),
|
||||
br(),
|
||||
div({ style: "display: flex; gap: 1.5em; justify-content: flex-end; align-items: center; margin-top: 0.7em;" },
|
||||
div({ class: 'ai-submit-row' },
|
||||
button({ type: 'submit' }, i18n.aiSubmitButton)
|
||||
)
|
||||
),
|
||||
div({ style: "display: flex; justify-content: flex-end; margin-bottom: 2em;" },
|
||||
form({ method: 'POST', action: '/ai/clear', style: "display: inline;" },
|
||||
button({
|
||||
type: 'submit',
|
||||
style: `
|
||||
background: #b80c09;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.4em 1.2em;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
margin-left: 1em;
|
||||
`
|
||||
}, i18n.aiClearHistory)
|
||||
div({ class: 'ai-clear-row' },
|
||||
form({ method: 'POST', action: '/ai/clear', class: 'ai-clear-form' },
|
||||
button({ type: 'submit', class: 'ai-clear-btn' }, i18n.aiClearHistory)
|
||||
)
|
||||
),
|
||||
br(),
|
||||
...history.map(entry =>
|
||||
div({
|
||||
class: 'chat-entry',
|
||||
style: `
|
||||
margin-bottom: 2em;
|
||||
position: relative;
|
||||
background: #191919;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 8px #0004;
|
||||
padding-top: 1.8em;
|
||||
`
|
||||
},
|
||||
entry.timestamp ? span({
|
||||
style: `
|
||||
position: absolute;
|
||||
top: 0.5em;
|
||||
right: 1.3em;
|
||||
font-size: 0.92em;
|
||||
color: #888;
|
||||
`
|
||||
}, new Date(entry.timestamp).toLocaleString()) : null,
|
||||
div({ class: 'chat-entry' },
|
||||
entry.timestamp
|
||||
? span({ class: 'chat-entry-timestamp' }, new Date(entry.timestamp).toLocaleString())
|
||||
: null,
|
||||
br(), br(),
|
||||
div({ class: 'user-question', style: 'margin-bottom: 0.75em;' },
|
||||
div({ class: 'user-question' },
|
||||
h2(`${i18n.aiUserQuestion}:`),
|
||||
p(...renderUrl(entry.question))
|
||||
),
|
||||
div({
|
||||
class: 'ai-response',
|
||||
style: `
|
||||
max-width: 800px;
|
||||
margin: auto;
|
||||
background: #111;
|
||||
padding: 1.25em;
|
||||
border-radius: 6px;
|
||||
font-family: sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #ffcc00;
|
||||
`
|
||||
},
|
||||
div({ class: 'ai-response' },
|
||||
h2(`${i18n.aiResponseTitle}:`),
|
||||
...String(entry.answer || '')
|
||||
.split('\n\n')
|
||||
|
|
@ -84,31 +44,19 @@ exports.aiView = (history = [], userPrompt = '') => {
|
|||
paragraph
|
||||
.split('\n')
|
||||
.map(line =>
|
||||
p({ style: "margin-bottom: 1.2em;" }, ...renderUrl(line.trim()))
|
||||
p(...renderUrl(line.trim()))
|
||||
)
|
||||
)
|
||||
),
|
||||
div({
|
||||
class: 'ai-train-bar',
|
||||
style: `
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:12px;
|
||||
margin: 12px auto 8px auto;
|
||||
max-width: 800px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid #2a2a2a;
|
||||
flex-wrap: wrap;
|
||||
`
|
||||
},
|
||||
div({ class: 'ai-train-bar' },
|
||||
Array.isArray(entry.snippets) && entry.snippets.length
|
||||
? span({ style: 'color:#9aa; font-size:0.95em;' }, `${i18n.aiSnippetsUsed}: ${entry.snippets.length}`)
|
||||
? span({ class: 'ai-snippets-used' }, `${i18n.aiSnippetsUsed}: ${entry.snippets.length}`)
|
||||
: null,
|
||||
h2(`${i18n.statsAITraining}:`),
|
||||
entry.trainStatus === 'approved'
|
||||
? span({ style: 'color:#5ad25a; font-weight:600;' }, i18n.aiTrainApproved)
|
||||
? span({ class: 'ai-train-approved' }, i18n.aiTrainApproved)
|
||||
: entry.trainStatus === 'rejected'
|
||||
? span({ style: 'color:#ff6b6b; font-weight:600;' }, i18n.aiTrainRejected)
|
||||
? span({ class: 'ai-train-rejected' }, i18n.aiTrainRejected)
|
||||
: null,
|
||||
entry.trainStatus === 'approved' || entry.trainStatus === 'rejected'
|
||||
? null
|
||||
|
|
|
|||
|
|
@ -297,7 +297,8 @@ const renderTaxes = (data, lookup) => {
|
|||
);
|
||||
};
|
||||
|
||||
const renderOverviewSummaryTable = (s, rules, userEcoinTax) => {
|
||||
const renderOverviewSummaryTable = (s, rules, userEcoinTax, isPub = false) => {
|
||||
const ubiWired = !!s.pubId || isPub;
|
||||
const score = Number(s.userEngagementScore || 0);
|
||||
const pool = Number(s.pool || 0);
|
||||
const W = Math.max(1, Number(s.weightsSum || 1));
|
||||
|
|
@ -315,16 +316,16 @@ const renderOverviewSummaryTable = (s, rules, userEcoinTax) => {
|
|||
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)),
|
||||
ubiWired ? kvRow(i18n.bankUbiAvailability, span({ class: availClass }, availLabel)) : null,
|
||||
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`),
|
||||
ubiWired ? kvRow(i18n.bankYourUbiMonth || 'Your UBI (this month)', `${future.toFixed(6)} ECO`) : null,
|
||||
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.bankYourFundsMonth || 'Your Funds (this month)', `${(future + Number(s.industryBalance || 0) - tax).toFixed(6)} ECO`)
|
||||
kvRow(i18n.bankYourFundsMonth || 'Your Funds (this month)', `${((ubiWired ? future : 0) + Number(s.industryBalance || 0) - tax).toFixed(6)} ECO`)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
|
@ -621,7 +622,7 @@ const renderBankingView = (data, filter, userId, isPub) =>
|
|||
generateFilterButtons(["overview","exchange","taxes","mine","pending","closed","claimed","expired","epochs","rules","addresses"], filter, "/banking"),
|
||||
filter === "overview"
|
||||
? div(
|
||||
renderOverviewSummaryTable(data.summary || {}, data.rules, data.userTotalTax || data.userEcoinTax),
|
||||
renderOverviewSummaryTable(data.summary || {}, data.rules, data.userTotalTax || data.userEcoinTax, isPub),
|
||||
renderClaimUBIBlock(data.pendingUBI || null, isPub, data.alreadyClaimed, (data.summary || {}).pubId, (data.summary || {}).hasValidWallet, (data.summary || {}).ubiAvailability),
|
||||
allocationsTable((data.allocations || []).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), userId)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ const renderFediverseReach = (prefs, i18nObj = {}) => {
|
|||
a({ href: url, target: '_blank', rel: 'noopener noreferrer', class: 'pm-exposition-chip-link' },
|
||||
span({ class: 'pm-exposition-chip pm-exposition-fediverse' },
|
||||
span({ class: 'pm-exposition-icon' }, '🐘'),
|
||||
span({ class: 'pm-exposition-text' }, i18nObj.fediverse || 'Fediverse')
|
||||
span({ class: 'pm-exposition-text' }, i18nObj.fediverse || 'Multiverse')
|
||||
)
|
||||
),
|
||||
a({ href: url, target: '_blank', rel: 'noopener noreferrer', class: 'fediverse-reach-url' }, handle)
|
||||
|
|
|
|||
77
src/views/comments_view.js
Normal file
77
src/views/comments_view.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
const { div, p, h2, span, a, form, input, textarea, button, label, br, details, summary } = require("../server/node_modules/hyperaxe");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { i18n, userLink } = require("./main_views");
|
||||
const { renderUrl } = require("../backend/renderUrl");
|
||||
|
||||
const COMMENT_ICON = "✑";
|
||||
|
||||
const visibleComments = (comments) => (Array.isArray(comments) ? comments : []).filter(c => {
|
||||
const t = c && c.value && c.value.content && c.value.content.text;
|
||||
return t && String(t).trim();
|
||||
});
|
||||
|
||||
const renderCommentCard = (c) => {
|
||||
const author = (c && c.value && c.value.author) || "";
|
||||
const ts = (c && c.value && c.value.timestamp) || (c && c.timestamp);
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm") : "";
|
||||
const relDate = ts ? moment(ts).fromNow() : "";
|
||||
const content = (c && c.value && c.value.content) || {};
|
||||
const rootId = content.fork || content.root || null;
|
||||
const text = content.text || "";
|
||||
|
||||
return div({ class: "votations-comment-card", id: c && c.key ? c.key : undefined },
|
||||
span({ class: "created-at" },
|
||||
span(i18n.createdBy),
|
||||
author ? userLink(author) : span("(unknown)"),
|
||||
absDate ? span(" | ") : "",
|
||||
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
|
||||
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
|
||||
relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : ""
|
||||
),
|
||||
p({ class: "votations-comment-text" }, ...renderUrl(String(text)))
|
||||
);
|
||||
};
|
||||
|
||||
const renderCommentsSection = ({ action, comments = [], returnTo = null, closedNote = null, extraClass = "" } = {}) => {
|
||||
const list = visibleComments(comments);
|
||||
return details({ class: "comments-collapse" + (extraClass ? ` ${extraClass}` : "") },
|
||||
summary({ class: list.length > 0 ? "comments-summary engage-on" : "comments-summary" },
|
||||
span({ class: "comments-summary-icon" }, COMMENT_ICON),
|
||||
span({ class: "comments-summary-count" }, `(${list.length})`)
|
||||
),
|
||||
div({ class: "vote-comments-section" },
|
||||
closedNote
|
||||
? p({ class: "muted" }, closedNote)
|
||||
: div({ class: "comment-form-wrapper" },
|
||||
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
|
||||
form({ method: "POST", action, class: "comment-form", enctype: "multipart/form-data" },
|
||||
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
||||
textarea({ name: "text", rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }),
|
||||
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
|
||||
br(),
|
||||
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
|
||||
)
|
||||
),
|
||||
list.length
|
||||
? div({ class: "comments-list" }, ...list.map(renderCommentCard))
|
||||
: (closedNote ? null : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderCommentsLink = ({ href, count = 0 } = {}) => {
|
||||
if (!href) return null;
|
||||
const total = Number(count) || 0;
|
||||
return a({ href, class: total > 0 ? "comments-summary engage-on comments-link" : "comments-summary comments-link" },
|
||||
span({ class: "comments-summary-icon" }, COMMENT_ICON),
|
||||
span({ class: "comments-summary-count" }, `(${total})`)
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
COMMENT_ICON,
|
||||
renderCommentsLink,
|
||||
visibleComments,
|
||||
renderCommentCard,
|
||||
renderCommentsSection
|
||||
};
|
||||
|
|
@ -188,13 +188,13 @@ exports.devFileView = async (file) => {
|
|||
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: "title", maxlength: "100", 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: "title", maxlength: "100", value: taskTitle }),
|
||||
input({ type: "hidden", name: "description", value: reportDescription }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.devCreateTask || "Create Task")
|
||||
),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,30 @@ const userId = config.keys.id;
|
|||
const safeArr = (v) => (Array.isArray(v) ? v : []);
|
||||
const safeText = (v) => String(v || "").trim();
|
||||
|
||||
const FILTER_KINDS = [
|
||||
{ value: "audios", label: () => i18n.favoritesFilterAudios },
|
||||
{ value: "bookmarks", label: () => i18n.favoritesFilterBookmarks },
|
||||
{ value: "calendars", label: () => i18n.favoritesFilterCalendars },
|
||||
{ value: "chats", label: () => i18n.favoritesFilterChats },
|
||||
{ value: "documents", label: () => i18n.favoritesFilterDocuments },
|
||||
{ value: "events", label: () => i18n.favoritesFilterEvents },
|
||||
{ value: "forum", label: () => i18n.favoritesFilterForum },
|
||||
{ value: "housing", label: () => i18n.favoritesFilterHousing },
|
||||
{ value: "images", label: () => i18n.favoritesFilterImages },
|
||||
{ value: "jobs", label: () => i18n.favoritesFilterJobs },
|
||||
{ value: "maps", label: () => i18n.favoritesFilterMaps },
|
||||
{ value: "market", label: () => i18n.favoritesFilterMarket },
|
||||
{ value: "pads", label: () => i18n.favoritesFilterPads },
|
||||
{ value: "projects", label: () => i18n.favoritesFilterProjects },
|
||||
{ value: "reports", label: () => i18n.favoritesFilterReports },
|
||||
{ value: "shopProducts", label: () => i18n.favoritesFilterShopProducts },
|
||||
{ value: "tasks", label: () => i18n.favoritesFilterTasks },
|
||||
{ value: "torrents", label: () => i18n.favoritesFilterTorrents },
|
||||
{ value: "transfers", label: () => i18n.favoritesFilterTransfers },
|
||||
{ value: "videos", label: () => i18n.favoritesFilterVideos },
|
||||
{ value: "votes", label: () => i18n.favoritesFilterVotes }
|
||||
];
|
||||
|
||||
const buildReturnTo = (filter) => {
|
||||
const f = safeText(filter || "all");
|
||||
return `/favorites?filter=${encodeURIComponent(f)}`;
|
||||
|
|
@ -25,6 +49,47 @@ const renderTags = (tags) => {
|
|||
: null;
|
||||
};
|
||||
|
||||
const DETAIL_KEYS = [
|
||||
"status", "category", "severity", "priority", "location", "place",
|
||||
"date", "deadline", "startTime", "endTime", "price", "salary", "amount",
|
||||
"concept", "question", "housing_type", "property_type", "item_type", "sector"
|
||||
];
|
||||
|
||||
const fieldLabel = (key) => {
|
||||
const camel = key.replace(/_(.)/g, (_, c) => c.toUpperCase());
|
||||
return i18n[camel + "Label"] || i18n["search" + camel.charAt(0).toUpperCase() + camel.slice(1)] || camel.toUpperCase();
|
||||
};
|
||||
|
||||
const fieldValue = (v) => {
|
||||
if (v === null || v === undefined) return "";
|
||||
if (Array.isArray(v)) return v.filter(Boolean).join(", ");
|
||||
if (typeof v === "object") return "";
|
||||
return String(v).trim();
|
||||
};
|
||||
|
||||
const renderDetailFields = (item) => {
|
||||
const content = item && item.content;
|
||||
if (!content || typeof content !== "object") return null;
|
||||
const rows = DETAIL_KEYS
|
||||
.map((k) => [k, fieldValue(content[k])])
|
||||
.filter(([, v]) => v)
|
||||
.map(([k, v]) =>
|
||||
div({ class: "card-field" },
|
||||
span({ class: "card-label" }, `${fieldLabel(k)}: `),
|
||||
span({ class: "card-value" }, v)
|
||||
)
|
||||
);
|
||||
const members = ["members", "participants", "attendees", "assignees", "confirmedBy"]
|
||||
.map((k) => [k, Array.isArray(content[k]) ? content[k].length : null])
|
||||
.find(([, n]) => typeof n === "number" && n > 0);
|
||||
if (members) {
|
||||
rows.push(div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${fieldLabel(members[0])}: ${members[1]}`)
|
||||
));
|
||||
}
|
||||
return rows.length ? div({ class: "cv-card-fields" }, ...rows) : null;
|
||||
};
|
||||
|
||||
const renderBookmarkUrl = (item) => {
|
||||
if (item.kind !== "bookmarks") return null;
|
||||
if (!item.url) return null;
|
||||
|
|
@ -60,7 +125,7 @@ const renderFavoriteCard = (item, filter) => {
|
|||
const title = safeText(item.title) || safeText(item.name) || safeText(item.category) || safeText(item.url) || "";
|
||||
|
||||
const ts = item.updatedAt || item.createdAt;
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm") : "";
|
||||
|
||||
const isOwn = item.author && String(item.author) === String(userId);
|
||||
return div(
|
||||
|
|
@ -95,6 +160,7 @@ const renderFavoriteCard = (item, filter) => {
|
|||
renderImagePreview(item),
|
||||
renderBookmarkUrl(item),
|
||||
safeText(item.description) ? p(...renderUrl(item.description)) : null,
|
||||
renderDetailFields(item),
|
||||
renderTags(item.tags),
|
||||
p(
|
||||
{ class: "card-footer" },
|
||||
|
|
@ -105,7 +171,7 @@ const renderFavoriteCard = (item, filter) => {
|
|||
);
|
||||
};
|
||||
|
||||
exports.favoritesView = async (items, filter = "all", counts = {}) => {
|
||||
exports.favoritesView = async (items, filter = "all", counts = {}, q = "") => {
|
||||
const c = counts || {};
|
||||
const total = typeof c.all === "number" ? c.all : safeArr(items).length;
|
||||
|
||||
|
|
@ -125,53 +191,22 @@ exports.favoritesView = async (items, filter = "all", counts = {}) => {
|
|||
{ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterRecent} (${total})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "audios", class: filter === "audios" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterAudios} (${c.audios || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "bookmarks", class: filter === "bookmarks" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterBookmarks} (${c.bookmarks || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "documents", class: filter === "documents" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterDocuments} (${c.documents || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "images", class: filter === "images" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterImages} (${c.images || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "maps", class: filter === "maps" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterMaps} (${c.maps || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "pads", class: filter === "pads" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterPads || "PADS"} (${c.pads || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "chats", class: filter === "chats" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterChats || "CHATS"} (${c.chats || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "calendars", class: filter === "calendars" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterCalendars || "CALENDARS"} (${c.calendars || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "videos", class: filter === "videos" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterVideos} (${c.videos || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "torrents", class: filter === "torrents" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterTorrents || "TORRENTS"} (${c.torrents || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "market", class: filter === "market" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterMarket || "MARKET"} (${c.market || 0})`
|
||||
),
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: "shopProducts", class: filter === "shopProducts" ? "filter-btn active" : "filter-btn" },
|
||||
`${i18n.favoritesFilterShopProducts || "SHOP ITEMS"} (${c.shopProducts || 0})`
|
||||
...FILTER_KINDS.map((k) =>
|
||||
button(
|
||||
{ type: "submit", name: "filter", value: k.value, class: filter === k.value ? "filter-btn active" : "filter-btn" },
|
||||
`${k.label()} (${c[k.value] || 0})`
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/favorites", class: "filter-box" },
|
||||
input({ type: "hidden", name: "filter", value: filter }),
|
||||
input({ type: "text", name: "q", value: q, placeholder: i18n.favoritesSearchPlaceholder, class: "filter-box__input" }),
|
||||
div({ class: "filter-box__controls" },
|
||||
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const moment = require("../server/node_modules/moment");
|
|||
const fmtDate = (ts) => {
|
||||
if (!ts) return "";
|
||||
const m = moment(ts);
|
||||
return m.isValid() ? m.format("YYYY-MM-DD HH:mm") : "";
|
||||
return m.isValid() ? m.format("YYYY/MM/DD HH:mm") : "";
|
||||
};
|
||||
|
||||
const renderMedia = (m) => {
|
||||
|
|
@ -111,7 +111,7 @@ const mastodonBox = (account, stats, actions, showLabel) => {
|
|||
|
||||
const disconnectedBlock = () => {
|
||||
const text = String(i18n.fediverseDisconnected || "");
|
||||
const settingsLink = a({ href: "/settings#fediverse" }, i18n.fediverseEmptyLink || "settings");
|
||||
const settingsLink = a({ href: "/settings#multiverse" }, i18n.fediverseEmptyLink || "settings");
|
||||
const parts = text.split("%LINK%");
|
||||
const para = parts.length > 1
|
||||
? p(parts[0], settingsLink, parts.slice(1).join("%LINK%"))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const { div, h2, p, section, button, form, a, span, textarea, br, input, h1, label } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, renderOpinionsVoting, userLink, renderContentActions } = require("./main_views");
|
||||
const { renderCommentsSection: renderSharedCommentsSection, renderCommentsLink } = require("./comments_view");
|
||||
const { template, i18n, renderOpinionsVoting, userLink, renderContentActions, renderEngagement } = require("./main_views");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const { renderTextWithStyles } = require("../backend/renderTextWithStyles");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
|
|
@ -56,7 +57,7 @@ const generateFilterButtons = (filters, currentFilter, action, extra = {}) => {
|
|||
{ method: "GET", action },
|
||||
input({ type: "hidden", name: "filter", value: mode }),
|
||||
...hiddenInputs(extra),
|
||||
button({ type: "submit", class: cur === mode ? "filter-btn active" : "filter-btn" }, i18n[mode + "Button"] || mode)
|
||||
button({ type: "submit", class: cur === mode ? "filter-btn active" : "filter-btn" }, String(i18n[mode + "Button"] || mode).toUpperCase())
|
||||
)
|
||||
);
|
||||
};
|
||||
|
|
@ -79,65 +80,11 @@ const renderCardField = (labelText, value) =>
|
|||
);
|
||||
|
||||
const renderFeedCommentsSection = (feedKey, comments = []) => {
|
||||
const list = Array.isArray(comments) ? comments : [];
|
||||
const commentsCount = list.length;
|
||||
|
||||
return div(
|
||||
{ class: "vote-comments-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
|
||||
span({ class: "card-value" }, String(commentsCount))
|
||||
),
|
||||
div(
|
||||
{ class: "comment-form-wrapper" },
|
||||
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel || i18n.feedPostComment || "Post a comment"),
|
||||
form(
|
||||
{ method: "POST", action: `/feed/${encodeURIComponent(feedKey)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
|
||||
textarea({
|
||||
id: "comment-text",
|
||||
name: "text",
|
||||
rows: 4,
|
||||
class: "comment-textarea",
|
||||
placeholder: i18n.voteNewCommentPlaceholder || ""
|
||||
}),
|
||||
div({ class: "comment-file-upload" }, label(i18n.uploadMedia || "Upload media"), input({ type: "file", name: "blob" })),
|
||||
br(),
|
||||
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton || i18n.feedPostComment || "Send")
|
||||
)
|
||||
),
|
||||
list.length
|
||||
? div(
|
||||
{ class: "comments-list" },
|
||||
list.map((c) => {
|
||||
const author = c?.value?.author || "";
|
||||
const ts = c?.value?.timestamp || c?.timestamp;
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
|
||||
const relDate = ts ? moment(ts).fromNow() : "";
|
||||
|
||||
const content = c?.value?.content || {};
|
||||
const text = content.text || c?.value?.text || "";
|
||||
const threadRoot = content.fork || content.root || null;
|
||||
|
||||
return div(
|
||||
{ class: "votations-comment-card" },
|
||||
span(
|
||||
{ class: "created-at" },
|
||||
span(i18n.createdBy),
|
||||
author ? userLink(author) : span("(unknown)"),
|
||||
absDate ? span(" | ") : "",
|
||||
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
|
||||
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
|
||||
relDate && threadRoot
|
||||
? a({ href: `/thread/${encodeURIComponent(threadRoot)}#${encodeURIComponent(c.key)}` }, relDate)
|
||||
: ""
|
||||
),
|
||||
p({ class: "votations-comment-text" }, ...renderUrl(text))
|
||||
);
|
||||
})
|
||||
)
|
||||
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet || i18n.noComments || "")
|
||||
);
|
||||
return renderSharedCommentsSection({
|
||||
action: `/feed/${encodeURIComponent(feedKey)}/comments`,
|
||||
comments: comments,
|
||||
returnTo: null
|
||||
});
|
||||
};
|
||||
|
||||
const renderFeedCard = (feed) => {
|
||||
|
|
@ -163,7 +110,7 @@ const renderFeedCard = (feed) => {
|
|||
div(
|
||||
{ class: "card-header activity-card-header" },
|
||||
span(),
|
||||
renderContentActions(feed.key, `/feed/${encodeURIComponent(feed.key)}`)
|
||||
renderContentActions(feed.key, `/feed/${encodeURIComponent(feed.key)}`, { author: authorId, reportTitle: safeText })
|
||||
),
|
||||
div(
|
||||
{ class: "card-section feed-card-body" },
|
||||
|
|
@ -189,8 +136,8 @@ const renderFeedCard = (feed) => {
|
|||
const maxVal = Math.max(...entries.map(([, v]) => Number(v)));
|
||||
const dominant = entries.filter(([, v]) => Number(v) === maxVal).map(([k]) => i18n['vote' + k.charAt(0).toUpperCase() + k.slice(1)] || k);
|
||||
return [
|
||||
span({ style: 'margin:0 8px;opacity:0.5;' }, '|'),
|
||||
span({ style: 'font-weight:700;' }, `${i18n.moreVoted || 'More Voted'}: ${dominant.join(' + ')}`)
|
||||
span({ class: 'feed-vote-sep' }, '|'),
|
||||
span({ class: 'feed-vote-dominant' }, `${i18n.moreVoted || 'More Voted'}: ${dominant.join(' + ')}`)
|
||||
];
|
||||
})()
|
||||
),
|
||||
|
|
@ -202,25 +149,9 @@ const renderFeedCard = (feed) => {
|
|||
)
|
||||
)
|
||||
),
|
||||
div(
|
||||
{ class: "card-comments-summary" },
|
||||
span({ class: "card-label" }, `${i18n.voteCommentsLabel || "Comments"}:`),
|
||||
span({ class: "card-value" }, String(commentCount)),
|
||||
br(),
|
||||
br(),
|
||||
form(
|
||||
{ method: "GET", action: `/feed/${encodeURIComponent(feed.key)}` },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton || i18n.feedOpenDiscussion || "Open Discussion")
|
||||
)
|
||||
),
|
||||
div(
|
||||
{ class: "card-comments-summary feed-opinions-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "),
|
||||
span({ class: "card-value" }, String(totalCount))
|
||||
),
|
||||
renderOpinionsVoting('/feed/opinions', feed.key, content.opinions, null, content.opinions_inhabitants)
|
||||
renderEngagement(feed.key,
|
||||
renderOpinionsVoting('/feed/opinions', feed.key, content.opinions, null, content.opinions_inhabitants),
|
||||
renderCommentsLink({ href: `/feed/${encodeURIComponent(feed.key)}`, count: commentCount })
|
||||
)
|
||||
)
|
||||
);
|
||||
|
|
@ -264,11 +195,13 @@ exports.feedView = (feeds, opts = "ALL") => {
|
|||
div(
|
||||
{ class: "feed-tools-row" },
|
||||
form(
|
||||
{ method: "GET", action: "/feed", class: "feed-search-form" },
|
||||
{ method: "GET", action: "/feed", class: "filter-box" },
|
||||
input({ type: "hidden", name: "filter", value: filter }),
|
||||
tag ? input({ type: "hidden", name: "tag", value: tag }) : null,
|
||||
input({ type: "text", name: "q", value: q, placeholder: i18n.searchPlaceholder || "Search", class: "feed-search-input" }),
|
||||
button({ type: "submit", class: "filter-btn feed-search-btn" }, i18n.searchButton || "Search")
|
||||
input({ type: "text", name: "q", value: q, placeholder: i18n.searchPlaceholder || "Search", class: "filter-box__input" }),
|
||||
div({ class: "filter-box__controls" },
|
||||
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton || "Search")
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
|
|
@ -321,7 +254,7 @@ exports.feedCreateView = (opts = {}) => {
|
|||
);
|
||||
};
|
||||
|
||||
exports.singleFeedView = (feed, comments = []) => {
|
||||
exports.singleFeedView = (feed, comments = [], params = {}) => {
|
||||
const content = feed.value?.content || {};
|
||||
const rawText = typeof content.text === "string" ? content.text : "";
|
||||
const safeText = rawText.trim();
|
||||
|
|
@ -335,6 +268,7 @@ exports.singleFeedView = (feed, comments = []) => {
|
|||
|
||||
return template(
|
||||
i18n.feedDetailTitle || "Feed",
|
||||
section(div({ class: "tags-header" }, h2(i18n.feedTitle), p(i18n.FeedshareYourOpinions))),
|
||||
section(
|
||||
div(
|
||||
{ class: "filters" },
|
||||
|
|
@ -349,6 +283,10 @@ exports.singleFeedView = (feed, comments = []) => {
|
|||
),
|
||||
div(
|
||||
{ class: "bookmark-item card feed-detail-card" },
|
||||
div({ class: "card-header activity-card-header" },
|
||||
span(),
|
||||
renderContentActions(feed.key, null, { spread: params.spreads || null, author: authorId, reportTitle: safeText })
|
||||
),
|
||||
br,
|
||||
div(
|
||||
{ class: "feed-row" },
|
||||
|
|
@ -380,15 +318,9 @@ exports.singleFeedView = (feed, comments = []) => {
|
|||
)
|
||||
)
|
||||
),
|
||||
renderFeedCommentsSection(feed.key, comments),
|
||||
div(
|
||||
{ class: "card-comments-summary feed-opinions-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "),
|
||||
span({ class: "card-value" }, String(Object.values(content.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)))
|
||||
),
|
||||
renderOpinionsVoting('/feed/opinions', feed.key, content.opinions, null, content.opinions_inhabitants)
|
||||
renderEngagement(feed.key,
|
||||
renderOpinionsVoting('/feed/opinions', feed.key, content.opinions, null, content.opinions_inhabitants),
|
||||
renderFeedCommentsSection(feed.key, comments)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
|
|
|||
118
src/views/gallery_view.js
Normal file
118
src/views/gallery_view.js
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
const { div, a, span, img, video, input, button, label, br } = require("../server/node_modules/hyperaxe")
|
||||
const { i18n } = require("./main_views")
|
||||
const { MAX_IMAGES } = require("../models/media_gallery")
|
||||
|
||||
const safeArr = (v) => (Array.isArray(v) ? v : [])
|
||||
const safeText = (v) => String(v == null ? "" : v).trim()
|
||||
|
||||
const blobIdOf = (entry) => {
|
||||
const value = safeText(entry)
|
||||
const m = value.match(/\(([^)\s]+)\)/)
|
||||
return m ? m[1] : value
|
||||
}
|
||||
|
||||
const blobUrl = (id, size) => size
|
||||
? `/image/${size}/${encodeURIComponent(id)}`
|
||||
: `/blob/${encodeURIComponent(id)}`
|
||||
|
||||
const isVideoEntry = (entry) => /\[video:[^\]]*\]\(/.test(String(entry || ""))
|
||||
|
||||
const imagesOf = (item) => {
|
||||
const list = safeArr(item && item.images).filter(Boolean)
|
||||
if (list.length) return list
|
||||
return item && item.image ? [item.image] : []
|
||||
}
|
||||
|
||||
const videoOf = (item) => safeText(item && item.video)
|
||||
|
||||
const renderMediaThumb = (entry, alt = "") => isVideoEntry(entry)
|
||||
? video({ controls: true, class: "gallery-image", src: blobUrl(blobIdOf(entry)) })
|
||||
: img({ src: blobUrl(blobIdOf(entry), 256), class: "gallery-image", alt })
|
||||
|
||||
const lightboxId = (scope, itemId, index) => `${scope}-photo-${encodeURIComponent(itemId)}-${index}`
|
||||
|
||||
let zoomSeq = 0
|
||||
const renderZoomableImage = (src, { alt = "", imgClass = "" } = {}) => {
|
||||
if (!src) return null
|
||||
zoomSeq = (zoomSeq + 1) % 1000000
|
||||
const id = `zoom-${zoomSeq}-${String(src).replace(/[^a-zA-Z0-9]/g, "").slice(-10)}`
|
||||
return [
|
||||
a({ href: `#${id}`, class: "zoom-link" }, img({ src, class: imgClass, alt })),
|
||||
div({ id, class: "lightbox" },
|
||||
a({ href: "#", class: "lightbox-close" }, "×"),
|
||||
img({ src, class: "lightbox-image", alt })
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
const renderPhotoGallery = (item, scope = "media") => {
|
||||
const list = imagesOf(item)
|
||||
const clip = videoOf(item)
|
||||
if (!list.length && !clip) return null
|
||||
const id = (item && (item.id || item.key || item.rootId)) || ""
|
||||
return div({ class: "media-gallery" },
|
||||
list.length
|
||||
? div({ class: "gallery" },
|
||||
list.map((entry, i) =>
|
||||
isVideoEntry(entry)
|
||||
? span({ class: "gallery-item" }, renderMediaThumb(entry))
|
||||
: a({ href: `#${lightboxId(scope, id, i)}`, class: "gallery-item" }, renderMediaThumb(entry))
|
||||
)
|
||||
)
|
||||
: null,
|
||||
list.filter(entry => !isVideoEntry(entry)).map((entry) =>
|
||||
div({ id: lightboxId(scope, id, list.indexOf(entry)), class: "lightbox" },
|
||||
a({ href: "#", class: "lightbox-close" }, "×"),
|
||||
img({ src: blobUrl(blobIdOf(entry)), class: "lightbox-image", alt: "" })
|
||||
)
|
||||
),
|
||||
clip
|
||||
? div({ class: "media-video media-video-centered" },
|
||||
video({ controls: true, class: "media-video-player", src: blobUrl(blobIdOf(clip)) })
|
||||
)
|
||||
: null
|
||||
)
|
||||
}
|
||||
|
||||
const renderGalleryFields = (item = {}, isEdit = false, maxImages = MAX_IMAGES) => {
|
||||
const list = imagesOf(item)
|
||||
return [
|
||||
label(i18n.galleryImages),
|
||||
br(),
|
||||
...list.map(entry => input({ type: "hidden", name: "keepImages", value: entry })),
|
||||
list.length < maxImages
|
||||
? div({ class: "media-file-row" },
|
||||
input({ type: "file", name: "images", accept: "image/*", multiple: true }),
|
||||
button({ type: "submit", name: "action", value: "addPhoto", attrs: { formnovalidate: "formnovalidate" }, class: "filter-btn media-add-btn" }, i18n.galleryAddPhoto)
|
||||
)
|
||||
: null,
|
||||
br(),
|
||||
list.length
|
||||
? [
|
||||
div({ class: "gallery media-form-gallery" },
|
||||
list.map((entry, i) =>
|
||||
span({ class: "gallery-item" },
|
||||
renderMediaThumb(entry),
|
||||
button({ type: "submit", name: "removePhoto", value: String(i), attrs: { formnovalidate: "formnovalidate" }, class: "media-remove-btn" }, i18n.galleryRemovePhoto)
|
||||
)
|
||||
)
|
||||
),
|
||||
br()
|
||||
]
|
||||
: null,
|
||||
br()
|
||||
]
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_IMAGES,
|
||||
blobIdOf,
|
||||
blobUrl,
|
||||
isVideoEntry,
|
||||
imagesOf,
|
||||
videoOf,
|
||||
renderMediaThumb,
|
||||
renderPhotoGallery,
|
||||
renderGalleryFields,
|
||||
renderZoomableImage
|
||||
}
|
||||
|
|
@ -99,13 +99,13 @@ exports.gamesView = (filter = 'all', hall = null) => {
|
|||
form({ method: 'GET', action: '/games' },
|
||||
input({ type: 'hidden', name: 'filter', value: 'all' }),
|
||||
button({ type: 'submit', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' },
|
||||
i18n.gamesFilterAll
|
||||
String(i18n.gamesFilterAll).toUpperCase()
|
||||
)
|
||||
),
|
||||
form({ method: 'GET', action: '/games' },
|
||||
input({ type: 'hidden', name: 'filter', value: 'scoring' }),
|
||||
button({ type: 'submit', class: filter === 'scoring' ? 'filter-btn active' : 'filter-btn' },
|
||||
i18n.gamesFilterScoring
|
||||
String(i18n.gamesFilterScoring).toUpperCase()
|
||||
)
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -88,9 +88,9 @@ const buildGraphSvg = (me, peers) => {
|
|||
+ `</svg>`;
|
||||
};
|
||||
|
||||
const kpi = (label, value) => div({ class: 'stats-kpi' },
|
||||
div({ class: 'stats-kpi-label' }, label),
|
||||
div({ class: 'stats-kpi-value' }, String(value))
|
||||
const kpi = (label, value) => div({ class: 'stats-kpi stats-kpi-inline' },
|
||||
span({ class: 'stats-kpi-label' }, `${String(label).toUpperCase()}: `),
|
||||
span({ class: 'stats-kpi-value' }, String(value))
|
||||
);
|
||||
|
||||
const legendItem = (kind, label) =>
|
||||
|
|
@ -126,27 +126,27 @@ exports.graphosView = ({ filter, me, peers, kpis, focus = null, focusId = null,
|
|||
modes.map(m =>
|
||||
form({ method: 'GET', action: '/graphos' },
|
||||
input({ type: 'hidden', name: 'filter', value: m }),
|
||||
button({ type: 'submit', class: filter === m ? 'filter-btn active' : 'filter-btn' }, i18n[m + 'Button'])
|
||||
button({ type: 'submit', class: filter === m ? 'filter-btn active' : 'filter-btn' }, String(i18n[m + 'Button']).toUpperCase())
|
||||
)
|
||||
)
|
||||
),
|
||||
capped
|
||||
? p({ class: 'graphos-cap-note' }, `${i18n.graphosShowing || 'Showing'} ${shown} / ${total}`)
|
||||
: null,
|
||||
div({ class: 'graphos-legend' },
|
||||
legendItem('me', i18n.graphosYou || 'You'),
|
||||
legendItem('online', i18n.online || 'Online'),
|
||||
filter !== 'MINE' ? legendItem('discovered', i18n.discovered || 'Discovered') : null,
|
||||
filter !== 'MINE' ? legendItem('unknown', i18n.unknown || 'Unknown') : null
|
||||
),
|
||||
div({ class: 'graphos-canvas', innerHTML: buildGraphSvg(me, peers) }),
|
||||
div({ class: 'stats-block' },
|
||||
div({ class: 'stats-block graphos-stats' },
|
||||
div({ class: 'stats-grid' },
|
||||
kpi(i18n.graphosTotalNodes || 'Total nodes', kpis.total),
|
||||
kpi(i18n.online || 'Online', kpis.online),
|
||||
filter !== 'MINE' ? kpi(i18n.discovered || 'Discovered', kpis.discovered) : null,
|
||||
filter !== 'MINE' ? kpi(i18n.unknown || 'Unknown', kpis.unknown) : null
|
||||
)
|
||||
),
|
||||
div({ class: 'graphos-canvas', innerHTML: buildGraphSvg(me, peers) }),
|
||||
div({ class: 'graphos-legend' },
|
||||
legendItem('me', i18n.graphosYou || 'You'),
|
||||
legendItem('online', i18n.online || 'Online'),
|
||||
filter !== 'MINE' ? legendItem('discovered', i18n.discovered || 'Discovered') : null,
|
||||
filter !== 'MINE' ? legendItem('unknown', i18n.unknown || 'Unknown') : null
|
||||
)
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const { div, h2, p, section, button, form, img, a, textarea, input, br, span, strong } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, userLink, renderUserSensors, renderContentActions } = require('./main_views');
|
||||
const { div, h2, p, section, button, form, img, a, textarea, input, span, strong } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, userLink, renderUserSensors, renderContentActions, renderRelationshipBlock } = require('./main_views');
|
||||
const { renderZoomableImage } = require('./gallery_view');
|
||||
const { renderContentStats } = require('./clearnet_view');
|
||||
const { renderUrl } = require('../backend/renderUrl');
|
||||
const { getConfig } = require('../configs/config-manager');
|
||||
|
|
@ -48,20 +49,40 @@ function resolvePhoto(photoField, size = 256) {
|
|||
if (photoField.startsWith('/blob/')) return photoField;
|
||||
if (photoField.startsWith('/image/')) {
|
||||
if (isDefaultImageId(photoField)) return '/assets/images/default-avatar.png';
|
||||
return photoField.replace('/image/256/','/image/'+size+'/').replace('/image/512/','/image/'+size+'/');
|
||||
return photoField.replace('/image/256/','/image/'+size+'/').replace('/image/512/','/image/'+size+'/') + '?fallback=avatar';
|
||||
}
|
||||
}
|
||||
return toImageUrl(photoField, size);
|
||||
return toImageUrl(photoField, size) + '?fallback=avatar';
|
||||
}
|
||||
|
||||
const generateFilterButtons = (filters, currentFilter) =>
|
||||
const filterLabel = (mode) =>
|
||||
String(i18n[mode + 'Button'] || i18n[mode + 'SectionTitle'] || mode).toUpperCase();
|
||||
|
||||
const generateFilterButtons = (filters, currentFilter, labelOf = filterLabel) =>
|
||||
filters.map(mode =>
|
||||
form({ method: 'GET', action: '/inhabitants' },
|
||||
input({ type: 'hidden', name: 'filter', value: mode }),
|
||||
button({
|
||||
type: 'submit',
|
||||
class: currentFilter === mode ? 'filter-btn active' : 'filter-btn'
|
||||
}, i18n[mode + 'Button'] || i18n[mode + 'SectionTitle'] || mode)
|
||||
}, labelOf(mode))
|
||||
)
|
||||
);
|
||||
|
||||
const MAIN_FILTERS = ['all', 'TOP', 'contacts', 'blocked', 'CVs', 'SUGGESTED', 'GALLERY'];
|
||||
|
||||
const renderMainFilters = (filter, isTop) =>
|
||||
div({ class: 'inhabitant-action' },
|
||||
...MAIN_FILTERS.map(mode =>
|
||||
form({ method: 'GET', action: '/inhabitants' },
|
||||
input({ type: 'hidden', name: 'filter', value: mode === 'TOP' ? 'TOP ACTIVITY' : mode }),
|
||||
button({
|
||||
type: 'submit',
|
||||
class: (mode === 'TOP' ? isTop : filter === mode) ? 'filter-btn active' : 'filter-btn'
|
||||
}, mode === 'TOP'
|
||||
? String(i18n.topSectionTitle || 'TOP').toUpperCase()
|
||||
: filterLabel(mode))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
|
@ -89,6 +110,27 @@ function lastActivityBadge(user, isMe) {
|
|||
|
||||
const lightboxId = (id) => 'inhabitant_' + String(id || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
|
||||
const cvField = (labelText, value) => value
|
||||
? div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, `${labelText}: `),
|
||||
span({ class: 'card-value' }, value)
|
||||
)
|
||||
: null;
|
||||
|
||||
const renderCvFields = (user) => {
|
||||
const languages = Array.isArray(user.languages) ? user.languages.filter(Boolean) : [];
|
||||
const skills = Array.isArray(user.skills) ? user.skills.filter(Boolean) : [];
|
||||
const fields = [
|
||||
cvField(i18n.locationLabel, user.location),
|
||||
cvField(i18n.languagesLabel, languages.length ? languages.join(', ').toUpperCase() : ''),
|
||||
cvField(i18n.skillsLabel, skills.length ? skills.join(', ') : ''),
|
||||
cvField(i18n.statusLabel || 'Status', user.status),
|
||||
cvField(i18n.preferencesLabel || 'Preferences', user.preferences),
|
||||
cvField(i18n.createdAtLabel || 'Created at', user.createdAt ? new Date(user.createdAt).toLocaleString() : '')
|
||||
].filter(Boolean);
|
||||
return fields.length ? div({ class: 'cv-card-fields' }, ...fields) : null;
|
||||
};
|
||||
|
||||
const renderInhabitantCard = (user, filter, currentUserId, fediverseConfigured) => {
|
||||
const isMe = user.id === currentUserId;
|
||||
const raw = user.visibilityPrefs || {};
|
||||
|
|
@ -102,7 +144,7 @@ const renderInhabitantCard = (user, filter, currentUserId, fediverseConfigured)
|
|||
wallet: raw.wallet === true,
|
||||
ecoTax: raw.ecoTax !== false,
|
||||
larpSign: raw.larpSign === true,
|
||||
gpg: raw.gpg !== false,
|
||||
gpg: raw.gpg === true,
|
||||
clearnet: hasClearnet,
|
||||
fediverse: raw.fediverse === true,
|
||||
fediverseHandle: typeof raw.fediverseHandle === 'string' ? raw.fediverseHandle : ''
|
||||
|
|
@ -111,7 +153,7 @@ const renderInhabitantCard = (user, filter, currentUserId, fediverseConfigured)
|
|||
div(
|
||||
{ class: 'card-header activity-card-header' },
|
||||
span(),
|
||||
renderContentActions(user.id, `/inhabitant/${encodeURIComponent(user.id)}`)
|
||||
renderContentActions(user.id, `/inhabitant/${encodeURIComponent(user.id)}`, { author: user.id })
|
||||
),
|
||||
div({ class: 'card-section inhabitants-card-body' },
|
||||
div({ class: 'inhabitant-card' },
|
||||
|
|
@ -128,26 +170,30 @@ const renderInhabitantCard = (user, filter, currentUserId, fediverseConfigured)
|
|||
estimatedUBI: user.estimatedUBI, lastClaimedDate: user.lastClaimedDate, totalClaimed: user.totalClaimed,
|
||||
larpHouse: user.larpHouse, stats: user.stats
|
||||
}, { relationshipNode: isMe ? span({ class: 'status you' }, i18n.relationshipYou) : null, excludeContent: true }),
|
||||
filter === 'CVs'
|
||||
? div(
|
||||
{ class: 'cv-actions doc-export-actions' },
|
||||
form(
|
||||
{ method: 'GET', action: `/cv/pdf/${encodeURIComponent(user.id)}` },
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.generatePdf)
|
||||
),
|
||||
form(
|
||||
{ method: 'POST', action: `/cv/share/${encodeURIComponent(user.id)}` },
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.sharePm)
|
||||
)
|
||||
)
|
||||
: null,
|
||||
!isMe
|
||||
? div(
|
||||
{ class: 'cv-actions' },
|
||||
form(
|
||||
{ method: 'GET', action: '/pm' },
|
||||
input({ type: 'hidden', name: 'recipients', value: user.id }),
|
||||
button({ type: 'submit', class: 'btn' }, i18n.pmCreateButton)
|
||||
)
|
||||
{ class: 'inhabitant-user-actions' },
|
||||
div({ class: 'inhabitant-relationship' }, renderRelationshipBlock(user.relationship || {}, user.id))
|
||||
)
|
||||
: null
|
||||
),
|
||||
div({ class: 'inhabitant-details' },
|
||||
h2(user.name || 'Anonymous'),
|
||||
user.description ? p(...renderUrl(user.description)) : null,
|
||||
filter === 'MATCHSKILLS' && user.commonSkills?.length
|
||||
? div({ class: 'matchskills' },
|
||||
p(`${i18n.commonSkills}: ${user.commonSkills.join(', ')}`),
|
||||
p(`${i18n.matchScore}: ${Math.round((user.matchScore || 0) * 100)}%`)
|
||||
)
|
||||
: null,
|
||||
filter === 'CVs' ? renderCvFields(user) : null,
|
||||
filter === 'SUGGESTED' && (user.followsYou || user.commonSkills?.length || user.mutualCount)
|
||||
? div({ class: 'suggested-meta' },
|
||||
user.followsYou ? span({ class: 'suggested-badge' }, i18n.suggestedFollowsYou || 'Follows you') : null,
|
||||
|
|
@ -160,31 +206,6 @@ const renderInhabitantCard = (user, filter, currentUserId, fediverseConfigured)
|
|||
filter === 'blocked' && user.isBlocked
|
||||
? p(i18n.blockedLabel) : null,
|
||||
p(userLink(user.id)),
|
||||
!isMe ? (() => {
|
||||
const rel = user.relationship || {}
|
||||
const blockedBoth = rel.blocking && rel.blockedBy
|
||||
const mutual = rel.following && rel.followsMe
|
||||
const supportAction = rel.following ? 'unfollow' : (rel.blocking ? 'unblock' : 'follow')
|
||||
return div({ class: 'relationship-status inhabitant-relationship' },
|
||||
blockedBoth
|
||||
? span({ class: 'status blocked' }, i18n.relationshipMutualBlock)
|
||||
: [
|
||||
rel.blocking ? span({ class: 'status blocked' }, i18n.relationshipBlocking) : null,
|
||||
rel.blockedBy ? span({ class: 'status blocked-by' }, i18n.relationshipBlockedBy) : null,
|
||||
mutual
|
||||
? span({ class: 'status mutual' }, i18n.relationshipMutuals)
|
||||
: [
|
||||
span({ class: 'status supporting' }, rel.following ? i18n.relationshipFollowing : i18n.relationshipNone),
|
||||
span({ class: 'status supported-by' }, rel.followsMe ? i18n.relationshipTheyFollow : i18n.relationshipNotFollowing)
|
||||
]
|
||||
],
|
||||
div({ class: 'relationship-actions' },
|
||||
form({ method: 'POST', action: `/${supportAction}/${encodeURIComponent(user.id)}` },
|
||||
button({ type: 'submit', class: 'btn' }, i18n[supportAction])
|
||||
)
|
||||
)
|
||||
)
|
||||
})() : null,
|
||||
renderContentStats(user.stats, i18n)
|
||||
)
|
||||
)
|
||||
|
|
@ -229,19 +250,11 @@ function msgIdOf(m) {
|
|||
}
|
||||
|
||||
exports.inhabitantsView = (inhabitants, filter, query, currentUserId, fediverseConfigured) => {
|
||||
const title = filter === 'contacts' ? i18n.yourContacts
|
||||
: filter === 'CVs' ? i18n.allCVs
|
||||
: filter === 'MATCHSKILLS' ? i18n.matchSkills
|
||||
: filter === 'SUGGESTED' ? i18n.suggestedSectionTitle
|
||||
: filter === 'blocked' ? i18n.blockedSectionTitle
|
||||
: filter === 'GALLERY' ? i18n.gallerySectionTitle
|
||||
: filter === 'TOP KARMA' ? i18n.topkarmaSectionTitle
|
||||
: filter === 'TOP ECO' ? (i18n.topecoSectionTitle || 'Top Eco')
|
||||
: filter === 'TOP ACTIVITY' ? i18n.topactivitySectionTitle
|
||||
: i18n.allInhabitants;
|
||||
const title = i18n.allInhabitants;
|
||||
|
||||
const showCVFilters = filter === 'CVs' || filter === 'MATCHSKILLS';
|
||||
const filters = ['all', 'TOP ACTIVITY', 'TOP KARMA', 'TOP ECO', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'];
|
||||
const showCVFilters = filter === 'CVs';
|
||||
const TOP_FILTERS = ['TOP ACTIVITY', 'TOP INACTIVITY', 'TOP KARMA', 'TOP ECO'];
|
||||
const isTop = TOP_FILTERS.includes(filter);
|
||||
|
||||
return template(
|
||||
title,
|
||||
|
|
@ -251,28 +264,33 @@ exports.inhabitantsView = (inhabitants, filter, query, currentUserId, fediverseC
|
|||
p(i18n.discoverPeople)
|
||||
),
|
||||
div({ class: 'filters' },
|
||||
form({ method: 'GET', action: '/inhabitants' },
|
||||
form({ method: 'GET', action: '/inhabitants', class: 'filter-box' },
|
||||
input({ type: 'hidden', name: 'filter', value: filter }),
|
||||
input({
|
||||
type: 'text',
|
||||
name: 'search',
|
||||
placeholder: i18n.searchInhabitantsPlaceholder,
|
||||
value: (query && query.search) || ''
|
||||
value: (query && query.search) || '',
|
||||
class: 'filter-box__input'
|
||||
}),
|
||||
showCVFilters
|
||||
? [
|
||||
input({ type: 'text', name: 'location', placeholder: i18n.filterLocation, value: (query && query.location) || '' }),
|
||||
input({ type: 'text', name: 'language', placeholder: i18n.filterLanguage, value: (query && query.language) || '' }),
|
||||
input({ type: 'text', name: 'skills', placeholder: i18n.filterSkills, value: (query && query.skills) || '' })
|
||||
]
|
||||
: null,
|
||||
br(),
|
||||
button({ type: 'submit' }, i18n.applyFilters)
|
||||
div({ class: 'filter-box__controls' },
|
||||
showCVFilters
|
||||
? [
|
||||
input({ type: 'text', name: 'location', placeholder: i18n.filterLocation, value: (query && query.location) || '', class: 'filter-box__number' }),
|
||||
input({ type: 'text', name: 'language', placeholder: i18n.filterLanguage, value: (query && query.language) || '', class: 'filter-box__number' }),
|
||||
input({ type: 'text', name: 'skills', placeholder: i18n.filterSkills, value: (query && query.skills) || '', class: 'filter-box__number' })
|
||||
]
|
||||
: null,
|
||||
button({ type: 'submit', class: 'filter-box__button' }, i18n.searchButton)
|
||||
)
|
||||
)
|
||||
),
|
||||
div({ class: 'inhabitant-action' },
|
||||
...generateFilterButtons(filters, filter)
|
||||
),
|
||||
renderMainFilters(filter, isTop),
|
||||
isTop
|
||||
? div({ class: 'inhabitant-action inhabitant-subfilters' },
|
||||
...generateFilterButtons(TOP_FILTERS, filter, (mode) => filterLabel(mode).replace(/^TOP\s+/, ''))
|
||||
)
|
||||
: null,
|
||||
filter === 'GALLERY'
|
||||
? renderGalleryInhabitants(inhabitants)
|
||||
: div({ class: 'inhabitants-list' },
|
||||
|
|
@ -338,7 +356,7 @@ exports.inhabitantsProfileView = (payload, currentUserId, fediverseConfigured) =
|
|||
wallet: rawPrefs.wallet === true,
|
||||
ecoTax: rawPrefs.ecoTax !== false,
|
||||
larpSign: rawPrefs.larpSign === true,
|
||||
gpg: rawPrefs.gpg !== false,
|
||||
gpg: rawPrefs.gpg === true,
|
||||
clearnet: rawPrefs.clearnet === true || clearnetSubKeys.some(k => rawPrefs[k] === true),
|
||||
fediverse: rawPrefs.fediverse === true,
|
||||
fediverseHandle: typeof rawPrefs.fediverseHandle === 'string' ? rawPrefs.fediverseHandle : ''
|
||||
|
|
@ -350,14 +368,18 @@ exports.inhabitantsProfileView = (payload, currentUserId, fediverseConfigured) =
|
|||
const providedBucket = typeof safe.lastActivityBucket === 'string' ? safe.lastActivityBucket : null;
|
||||
const dotClass = providedBucket === 'green' ? 'green' : providedBucket === 'orange' ? 'orange' : 'red';
|
||||
|
||||
const fieldNodes = [
|
||||
cvField(i18n.locationLabel, location),
|
||||
cvField(i18n.languagesLabel, languages.length ? languages.join(', ').toUpperCase() : ''),
|
||||
cvField(i18n.skillsLabel, skills.length ? skills.join(', ') : ''),
|
||||
cvField(i18n.statusLabel || 'Status', status),
|
||||
cvField(i18n.preferencesLabel || 'Preferences', preferences),
|
||||
cvField(i18n.createdAtLabel || 'Created at', createdAt)
|
||||
].filter(Boolean);
|
||||
|
||||
const detailNodes = [
|
||||
description ? p(...renderUrl(description)) : null,
|
||||
location ? p(`${i18n.locationLabel}: ${location}`) : null,
|
||||
languages.length ? p(`${i18n.languagesLabel}: ${languages.join(', ').toUpperCase()}`) : null,
|
||||
skills.length ? p(`${i18n.skillsLabel}: ${skills.join(', ')}`) : null,
|
||||
status ? p(`${i18n.statusLabel || 'Status'}: ${status}`) : null,
|
||||
preferences ? p(`${i18n.preferencesLabel || 'Preferences'}: ${preferences}`) : null,
|
||||
createdAt ? p(`${i18n.createdAtLabel || 'Created at'}: ${createdAt}`) : null
|
||||
fieldNodes.length ? div({ class: 'cv-card-fields' }, ...fieldNodes) : null
|
||||
].filter(Boolean);
|
||||
|
||||
return template(
|
||||
|
|
@ -367,9 +389,7 @@ exports.inhabitantsProfileView = (payload, currentUserId, fediverseConfigured) =
|
|||
h2(title),
|
||||
p(i18n.discoverPeople)
|
||||
),
|
||||
div({ class: 'mode-buttons' },
|
||||
...generateFilterButtons(['all', 'TOP ACTIVITY', 'TOP KARMA', 'TOP ECO', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'], 'all')
|
||||
),
|
||||
renderMainFilters('all', false),
|
||||
div({ class: 'inhabitant-card' },
|
||||
div({ class: 'inhabitant-left' },
|
||||
img({ class: 'inhabitant-photo-details', src: image, alt: name || 'Anonymous' }),
|
||||
|
|
@ -382,11 +402,16 @@ exports.inhabitantsProfileView = (payload, currentUserId, fediverseConfigured) =
|
|||
larpHouse, stats: safe.stats
|
||||
}, { excludeContent: true }),
|
||||
(!isMe && (id || viewedId))
|
||||
? form(
|
||||
{ method: 'GET', action: '/pm' },
|
||||
input({ type: 'hidden', name: 'recipients', value: id || viewedId }),
|
||||
button({ type: 'submit', class: 'btn' }, i18n.pmCreateButton)
|
||||
? div({ class: 'cv-actions doc-export-actions' },
|
||||
form(
|
||||
{ method: 'GET', action: '/pm' },
|
||||
input({ type: 'hidden', name: 'recipients', value: id || viewedId }),
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.pmCreateButton)
|
||||
)
|
||||
)
|
||||
: null,
|
||||
(!isMe && (id || viewedId) && safe.relationship)
|
||||
? div({ class: 'inhabitant-relationship' }, renderRelationshipBlock(safe.relationship, id || viewedId))
|
||||
: null
|
||||
),
|
||||
(() => {
|
||||
|
|
@ -405,7 +430,7 @@ exports.inhabitantsProfileView = (payload, currentUserId, fediverseConfigured) =
|
|||
return div({ class: 'post' },
|
||||
visitBtn,
|
||||
parts.clean && parts.clean.trim() ? p(...renderUrl(parts.clean)) : null,
|
||||
...(parts.imgs || []).map(src => img({ src, class: 'post-image', alt: 'image' }))
|
||||
...(parts.imgs || []).map(src => renderZoomableImage(src, { imgClass: 'post-image', alt: 'image' }))
|
||||
);
|
||||
})
|
||||
) ]
|
||||
|
|
@ -420,3 +445,4 @@ exports.inhabitantsProfileView = (payload, currentUserId, fediverseConfigured) =
|
|||
};
|
||||
|
||||
exports.lastActivityBadge = lastActivityBadge;
|
||||
exports.resolvePhoto = resolvePhoto;
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
|||
section(
|
||||
div({ class: 'pubs-section' },
|
||||
h2(i18n.invitesPubsTitle),
|
||||
p(i18n.invitesPubsHint),
|
||||
form(
|
||||
{ action: '/settings/invite/accept', method: 'post' },
|
||||
input({ name: 'invite', type: 'text', placeholder: i18n.invitesPubInviteCodePlaceholder, autofocus: true, required: true }),
|
||||
|
|
@ -129,6 +130,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
|||
section(
|
||||
div({ class: 'federations-section' },
|
||||
h2(i18n.invitesFederationsTitle || 'Federations'),
|
||||
p(i18n.invitesFederationsHint),
|
||||
div({ class: 'conn-actions invites-pubs-actions' },
|
||||
form({ action: '/invites/refresh-pubs', method: 'post' }, button({ type: 'submit' }, i18n.invitesPubsRefresh || 'Refresh')),
|
||||
form({ action: '/invites/clear-unreachable', method: 'post' }, button({ type: 'submit' }, i18n.invitesPubsClearUnreachable || 'Remove unreachable')),
|
||||
|
|
@ -180,6 +182,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
|||
section(
|
||||
div({ class: 'invites-houses', id: 'invites-houses' },
|
||||
h2(i18n.invitesHousesTitle || 'Houses'),
|
||||
p(i18n.invitesHousesHint),
|
||||
form(
|
||||
{ action: '/larp/invite/redeem', method: 'post' },
|
||||
input({ type: 'hidden', name: 'returnTo', value: '/invites' }),
|
||||
|
|
@ -192,6 +195,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
|||
section(
|
||||
div({ class: 'invites-tribes', id: 'invites-tribes' },
|
||||
h2(i18n.invitesTribesTitle),
|
||||
p(i18n.invitesTribesHint),
|
||||
form(
|
||||
{ action: '/tribes/join-code', method: 'post' },
|
||||
input({ name: 'inviteCode', type: 'text', placeholder: i18n.invitesTribeInviteCodePlaceholder, required: true }),
|
||||
|
|
@ -203,6 +207,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
|||
section(
|
||||
div({ class: 'invites-chats', id: 'invites-chats' },
|
||||
h2(i18n.invitesChatsTitle || 'Chats'),
|
||||
p(i18n.invitesChatsHint),
|
||||
form(
|
||||
{ action: '/chats/join-code', method: 'post' },
|
||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesChatInviteCodePlaceholder || 'Enter chat invite code', required: true }),
|
||||
|
|
@ -214,6 +219,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
|||
section(
|
||||
div({ class: 'invites-pads', id: 'invites-pads' },
|
||||
h2(i18n.invitesPadsTitle || 'Pads'),
|
||||
p(i18n.invitesPadsHint),
|
||||
form(
|
||||
{ action: '/pads/join-code', method: 'post' },
|
||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesPadInviteCodePlaceholder || 'Enter pad invite code', required: true }),
|
||||
|
|
@ -225,6 +231,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
|||
section(
|
||||
div({ class: 'invites-calendars', id: 'invites-calendars' },
|
||||
h2(i18n.invitesCalendarsTitle || 'Calendars'),
|
||||
p(i18n.invitesCalendarsHint),
|
||||
form(
|
||||
{ action: '/calendars/join-code', method: 'post' },
|
||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesCalendarInviteCodePlaceholder || 'Enter calendar invite code', required: true }),
|
||||
|
|
@ -236,6 +243,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
|||
section(
|
||||
div({ class: 'invites-events', id: 'invites-events' },
|
||||
h2(i18n.invitesEventsTitle || 'Events'),
|
||||
p(i18n.invitesEventsHint),
|
||||
form(
|
||||
{ action: '/events/join-code', method: 'post' },
|
||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesEventInviteCodePlaceholder || 'Enter event invite code', required: true }),
|
||||
|
|
@ -247,6 +255,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
|||
section(
|
||||
div({ class: 'invites-forums', id: 'invites-forums' },
|
||||
h2(i18n.invitesForumsTitle || 'Forums'),
|
||||
p(i18n.invitesForumsHint),
|
||||
form(
|
||||
{ action: '/forum/join-code', method: 'post' },
|
||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesForumInviteCodePlaceholder || 'Enter forum invite code', required: true }),
|
||||
|
|
@ -258,6 +267,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
|||
section(
|
||||
div({ class: 'invites-maps', id: 'invites-maps' },
|
||||
h2(i18n.invitesMapsTitle || 'Maps'),
|
||||
p(i18n.invitesMapsHint),
|
||||
form(
|
||||
{ action: '/maps/join-code', method: 'post' },
|
||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesMapInviteCodePlaceholder || 'Enter map invite code', required: true }),
|
||||
|
|
@ -269,6 +279,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
|||
section(
|
||||
div({ class: 'invites-shops', id: 'invites-shops' },
|
||||
h2(i18n.invitesShopsTitle || 'Shops'),
|
||||
p(i18n.invitesShopsHint),
|
||||
form(
|
||||
{ action: '/shops/join-code', method: 'post' },
|
||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesShopInviteCodePlaceholder || 'Enter shop invite code', required: true }),
|
||||
|
|
@ -280,6 +291,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
|||
section(
|
||||
div({ class: 'invites-inhabitants', id: 'invites-inhabitants' },
|
||||
h2(i18n.invitesInhabitantsTitle || 'Inhabitants'),
|
||||
p(i18n.invitesInhabitantsHint),
|
||||
form(
|
||||
{ action: '/invites/inhabitant/follow', method: 'post' },
|
||||
input({ name: 'feedId', id: 'inh_oasis_id', type: 'text', placeholder: '@...=.ed25519', pattern: '@[A-Za-z0-9+/_\\-]{43}=\\.ed25519', required: true, maxlength: 56 }),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
const { div, h1, h2, h3, p, section, button, form, input, span, img, a, br, table, tr, td, textarea, label, strong } = require("../server/node_modules/hyperaxe");
|
||||
const { div, h1, h2, h3, p, section, button, form, input, span, img, a, br, table, tr, td, textarea, label, strong, details, summary } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, userLink } = require("./main_views");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
|
||||
const userId = config.keys.id;
|
||||
const moment = require("../server/node_modules/moment");
|
||||
|
||||
const fmtCycle = (c) => c && c.formatted ? c.formatted : '';
|
||||
|
|
@ -11,16 +14,24 @@ const renderCycleBanner = (cycle) => div({ class: 'larp-cycle-banner' },
|
|||
span({ class: 'larp-cycle-value' }, fmtCycle(cycle))
|
||||
);
|
||||
|
||||
const renderHouseBadges = ({ myHouse, governingHouse }) => div({ class: 'larp-house-badges' },
|
||||
myHouse ? div({ class: 'larp-my-house' },
|
||||
span({ class: 'larp-my-house-label' }, i18n.larpMyHouse || 'My House'),
|
||||
a({ href: `/larp/${myHouse.key}`, class: 'larp-my-house-link' }, myHouse.name)
|
||||
) : null,
|
||||
governingHouse ? div({ class: 'larp-my-house' },
|
||||
span({ class: 'larp-my-house-label' }, i18n.larpGoverning || 'Governing'),
|
||||
a({ href: `/larp/${governingHouse.key}`, class: 'larp-my-house-link' }, governingHouse.name)
|
||||
) : null
|
||||
);
|
||||
const renderHouseBadges = ({ myHouse, governingHouse, houses }) => {
|
||||
const academia = Array.isArray(houses) ? houses.find(h => h.key === 'academia') : null;
|
||||
const isNewcomer = !myHouse;
|
||||
return div({ class: 'larp-house-badges' },
|
||||
myHouse ? div({ class: 'larp-my-house' },
|
||||
span({ class: 'larp-my-house-label' }, i18n.larpMyHouse || 'My House'),
|
||||
a({ href: `/larp/${myHouse.key}`, class: 'larp-my-house-link' }, myHouse.name)
|
||||
) : null,
|
||||
governingHouse ? div({ class: 'larp-my-house' },
|
||||
span({ class: 'larp-my-house-label' }, i18n.larpGoverning || 'Ruling:'),
|
||||
a({ href: `/larp/${governingHouse.key}`, class: 'larp-my-house-link' }, governingHouse.name)
|
||||
) : null,
|
||||
isNewcomer && academia ? div({ class: 'larp-my-house larp-academia-hint' },
|
||||
span({ class: 'larp-my-house-label' }, i18n.larpStartHere),
|
||||
a({ href: `/larp/${academia.key}`, class: 'larp-my-house-link' }, academia.name)
|
||||
) : null
|
||||
);
|
||||
};
|
||||
|
||||
const renderHouseNav = (houses, currentKey) => div({ class: 'larp-house-nav' },
|
||||
span({ class: 'larp-house-nav-label' }, i18n.larpAllHouses || 'Houses:'),
|
||||
|
|
@ -31,7 +42,7 @@ const renderHouseNav = (houses, currentKey) => div({ class: 'larp-house-nav' },
|
|||
}, h.name))
|
||||
);
|
||||
|
||||
const renderHousePanel = (house, members, posts, { canPost, viewerHouseKey, cyclesUntilRuling }) => {
|
||||
const renderHousePanel = (house, members, posts, { canPost, viewerHouseKey, cyclesUntilRuling, canTakeTest = false }) => {
|
||||
const { renderEncryptedChip } = require('./clearnet_view');
|
||||
const isInHouse = viewerHouseKey === house.key;
|
||||
const isInLarp = viewerHouseKey !== null && viewerHouseKey !== undefined;
|
||||
|
|
@ -66,6 +77,9 @@ const renderHousePanel = (house, members, posts, { canPost, viewerHouseKey, cycl
|
|||
),
|
||||
p({ class: 'larp-detail-description' }, house.description),
|
||||
div({ class: 'larp-actions' },
|
||||
isAcademia && isInHouse && canTakeTest
|
||||
? a({ href: '/larp/test', class: 'filter-btn larp-start-journey' }, i18n.larpStartJourney)
|
||||
: null,
|
||||
isInHouse
|
||||
? a({ href: `/larp/tribe/${encodeURIComponent(house.key)}`, class: 'filter-btn' }, i18n.larpVisitTribe || 'Visit Tribe')
|
||||
: null,
|
||||
|
|
@ -162,54 +176,63 @@ const renderAcademiaJoinPanel = (allHouses, testStatus, housesById, questions, m
|
|||
div({ class: 'larp-academia-grid' },
|
||||
ordered.map(h => a({ href: `/larp/${h.key}`, class: h.key === myKey ? 'larp-academia-thumb larp-academia-thumb-mine' : 'larp-academia-thumb' },
|
||||
img({ src: houseImageSrc(h), alt: h.name, class: 'larp-academia-thumb-image' }),
|
||||
span({ class: 'larp-academia-thumb-name' }, h.name)
|
||||
span({ class: 'larp-academia-thumb-name' }, h.name),
|
||||
table({ class: 'larp-info-table larp-academia-thumb-table' },
|
||||
tr(td({ class: 'card-label' }, i18n.larpRolesLabel || 'Roles'), td({ class: 'card-value' }, h.roles)),
|
||||
tr(td({ class: 'card-label' }, i18n.larpFunctionLabel || 'Function'), td({ class: 'card-value' }, h.function)),
|
||||
tr(td({ class: 'card-label' }, i18n.larpMembersCount || 'Members'), td({ class: 'card-value' }, String(h.memberCount || 0)))
|
||||
)
|
||||
))
|
||||
),
|
||||
canTake && qs.length
|
||||
? [
|
||||
h2(i18n.larpWillTestTitle || 'WILL test'),
|
||||
p({ class: 'larp-will-test-hint' }, i18n.larpWillTestHint || 'Once completed, you will be assigned the house that best matches your answers. Your individual answers are processed locally and never stored — only the resulting house assignment is published to your feed.'),
|
||||
form({ method: 'POST', action: '/larp/test', class: 'larp-test-form larp-test-form-compact' },
|
||||
qs.map((q, idx) => div({ class: 'larp-test-question' },
|
||||
p({ class: 'larp-test-q-text' }, strong(`${idx + 1}. `), i18n[q.key] || q.question),
|
||||
div({ class: 'larp-test-options' },
|
||||
q.options.map((opt, oi) => label({ class: 'larp-test-option' },
|
||||
input({ type: 'radio', name: `q${idx}`, value: String(oi), required: 'required' }),
|
||||
' ', i18n[opt.key] || opt.text
|
||||
))
|
||||
)
|
||||
)),
|
||||
div({ class: 'larp-actions' },
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.larpTestSubmit || 'Join House')
|
||||
)
|
||||
)
|
||||
]
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const renderPostsBlock = (posts, house, canPost) => div({ class: 'larp-posts-block' },
|
||||
h2(i18n.larpPostsTitle || 'Wall'),
|
||||
canPost
|
||||
? form({ method: 'POST', action: '/larp/post', class: 'larp-post-form' },
|
||||
input({ type: 'hidden', name: 'house', value: house.key }),
|
||||
textarea({ id: 'larp_post_text', name: 'text', rows: '3', maxlength: '4000', placeholder: i18n.larpPostPlaceholder || 'What does this house need to say?' }),
|
||||
button({ type: 'submit', class: 'filter-btn larp-post-submit' }, i18n.larpPostSubmit || 'Publish')
|
||||
)
|
||||
: null,
|
||||
posts.length === 0
|
||||
? p({ class: 'empty' }, i18n.larpPostsEmpty || 'No posts yet.')
|
||||
: div({ class: 'larp-posts-list' },
|
||||
posts.map(post => div({ class: 'larp-post' },
|
||||
div({ class: 'larp-post-head' },
|
||||
userLink(post.author),
|
||||
span({ class: 'larp-post-time' }, moment(post.createdAt).format('YYYY-MM-DD HH:mm'))
|
||||
),
|
||||
p({ class: 'larp-post-text' }, post.text)
|
||||
))
|
||||
)
|
||||
|
||||
const renderPostsBlock = (posts, house, canPost) => {
|
||||
const stream = (Array.isArray(posts) ? posts : [])
|
||||
.map(post => ({ ts: Date.parse(post.createdAt) || post.ts || 0, post }))
|
||||
.sort((a, b) => b.ts - a.ts);
|
||||
|
||||
return div({ class: 'larp-posts-block' },
|
||||
h2(i18n.larpPostsTitle || 'Wall'),
|
||||
canPost
|
||||
? form({ method: 'POST', action: '/larp/post', class: 'larp-post-form' },
|
||||
input({ type: 'hidden', name: 'house', value: house.key }),
|
||||
textarea({ id: 'larp_post_text', name: 'text', rows: '3', maxlength: '4000', placeholder: i18n.larpPostPlaceholder || 'What does this house need to say?' }),
|
||||
button({ type: 'submit', class: 'filter-btn larp-post-submit' }, i18n.larpPostSubmit || 'Publish')
|
||||
)
|
||||
: null,
|
||||
stream.length === 0
|
||||
? p({ class: 'empty' }, i18n.larpPostsEmpty || 'No posts yet.')
|
||||
: div({ class: 'larp-posts-list' },
|
||||
stream.map(entry => div({ class: 'larp-post' },
|
||||
div({ class: 'larp-post-head' },
|
||||
userLink(entry.post.author),
|
||||
span({ class: 'larp-post-time' }, moment(entry.post.createdAt).format('YYYY/MM/DD HH:mm'))
|
||||
),
|
||||
p({ class: 'larp-post-text' }, entry.post.text)
|
||||
))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderHouseSearch = (q) => div({ class: 'filters' },
|
||||
form({ method: 'GET', action: '/larp', class: 'filter-box' },
|
||||
input({ type: 'hidden', name: 'filter', value: 'houses' }),
|
||||
input({ type: 'text', name: 'q', value: q || '', placeholder: i18n.larpSearchPlaceholder, class: 'filter-box__input' }),
|
||||
div({ class: 'filter-box__controls' },
|
||||
button({ type: 'submit', class: 'filter-box__button' }, i18n.searchButton)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const matchesHouse = (house, q) => {
|
||||
const needle = String(q || '').trim().toLowerCase();
|
||||
if (!needle) return true;
|
||||
return ['name', 'motto', 'roles', 'function', 'description']
|
||||
.some(f => String(house[f] || '').toLowerCase().includes(needle));
|
||||
};
|
||||
|
||||
const renderModeButtons = (filter) => div({ class: 'mode-buttons stats-mode-row' },
|
||||
['ruling', 'houses', 'rules'].map(m =>
|
||||
form({ method: 'GET', action: '/larp' },
|
||||
|
|
@ -226,33 +249,29 @@ const renderModeButtons = (filter) => div({ class: 'mode-buttons stats-mode-row'
|
|||
)
|
||||
);
|
||||
|
||||
const renderRules = () => div({ class: 'larp-rules' },
|
||||
h2(i18n.larpRulesTitle || 'F.A.Q.'),
|
||||
div({ class: 'larp-rules-section' },
|
||||
h3(i18n.larpRulesEntryTitle || 'Starting house'),
|
||||
p(i18n.larpRulesEntryText || 'Every inhabitant begins in ACADEMIA by default.')
|
||||
),
|
||||
div({ class: 'larp-rules-section' },
|
||||
h3(i18n.larpRulesOneHouseTitle || 'One house at a time'),
|
||||
p(i18n.larpRulesOneHouseText || 'You can belong to at most one house at any given moment. If you belong to none, you are in ACADEMIA. Every non-ACADEMIA house page shows a "Leave House" button to its members that resets membership back to ACADEMIA. Leaving does NOT waive the test cooldown.')
|
||||
),
|
||||
div({ class: 'larp-rules-section' },
|
||||
h3(i18n.larpRulesTestEntry || 'WILL test'),
|
||||
p(i18n.larpRulesTestEntryText || 'Each option weights one or more houses; when you submit, the system tallies the scores and auto-admits you into the highest-scoring house.')
|
||||
),
|
||||
div({ class: 'larp-rules-section' },
|
||||
h3(i18n.larpRulesInviteEntry || 'Invitation code'),
|
||||
p(i18n.larpRulesInviteEntryText || 'House members can generate invitation codes that grant direct access to the house.')
|
||||
),
|
||||
div({ class: 'larp-rules-section' },
|
||||
h3(i18n.larpRulesGovernanceTitle || 'Governance Wall'),
|
||||
p(i18n.larpRulesGovernanceText || 'During its cycle, the governing house wears the "Ruling" badge and is the only one whose Wall is surfaced publicly under the RULING tab.')
|
||||
),
|
||||
div({ class: 'larp-rules-section' },
|
||||
h3(i18n.larpRulesSignTitle || 'L.A.R.P. Emblem'),
|
||||
p(i18n.larpRulesSignText || 'Inhabitants can choose whether to add a sensor on their avatar with the emblem of their current house.')
|
||||
)
|
||||
);
|
||||
const renderRules = () => {
|
||||
const points = [
|
||||
i18n.larpRulesEntryText,
|
||||
i18n.larpRulesOneHouseText,
|
||||
i18n.larpRulesTestEntryText,
|
||||
i18n.larpRulesInviteEntryText,
|
||||
i18n.larpRulesGovernanceText,
|
||||
i18n.larpRulesWallText,
|
||||
i18n.larpRulesSignText
|
||||
];
|
||||
return div(
|
||||
{ class: 'card' },
|
||||
h2(i18n.larpRulesTitle || 'F.A.Q.'),
|
||||
div({ class: 'rules-points' },
|
||||
points.filter(Boolean).map((text, i) =>
|
||||
div({ class: 'rules-point' },
|
||||
span({ class: 'rules-point-num' }, String(i + 1)),
|
||||
span({ class: 'rules-point-text' }, text)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderHousesGrid = (houses, myHouseKey, governingKey) => {
|
||||
const inLarp = !!myHouseKey;
|
||||
|
|
@ -277,7 +296,9 @@ const renderHousesGrid = (houses, myHouseKey, governingKey) => {
|
|||
),
|
||||
p({ class: 'larp-card-motto' }, '“' + h.motto + '”'),
|
||||
p({ class: 'larp-card-roles' }, h.roles),
|
||||
p({ class: 'larp-card-count' }, `${i18n.larpMembersCount || 'Members'}: ${h.memberCount || 0}`),
|
||||
div({ class: 'tribe-card-members' },
|
||||
span({ class: 'tribe-members-count' }, `${i18n.larpMembersCount || 'Members'}: ${h.memberCount || 0}`)
|
||||
),
|
||||
h.openInviteCode ? p({ class: 'larp-card-count' }, i18n.tribeInviteCodeText, span({ class: 'tribe-open-invite-code' }, h.openInviteCode)) : null
|
||||
)
|
||||
);
|
||||
|
|
@ -285,11 +306,13 @@ const renderHousesGrid = (houses, myHouseKey, governingKey) => {
|
|||
);
|
||||
};
|
||||
|
||||
exports.larpListView = ({ filter, houses, myHouseKey, cycle, governingKey, governingHouse, governingMembers, governingPosts, canPost }) => {
|
||||
exports.larpListView = ({ filter, houses, myHouseKey, cycle, governingKey, governingHouse, governingMembers, governingPosts, canPost, q }) => {
|
||||
const title = i18n.larpTitle || 'L.A.R.P.';
|
||||
const description = i18n.larpDescription || 'A live action role-playing layer for collaborative experimentation.';
|
||||
const myHouse = houses.find(h => h.key === myHouseKey) || null;
|
||||
const mode = filter === 'houses' ? 'houses' : filter === 'rules' ? 'rules' : 'ruling';
|
||||
const search = String(q || '').trim();
|
||||
const mode = search ? 'houses' : filter === 'houses' ? 'houses' : filter === 'rules' ? 'rules' : 'ruling';
|
||||
const matched = search ? houses.filter(h => matchesHouse(h, search)) : houses;
|
||||
|
||||
return template(
|
||||
title,
|
||||
|
|
@ -299,10 +322,13 @@ exports.larpListView = ({ filter, houses, myHouseKey, cycle, governingKey, gover
|
|||
p(description)
|
||||
),
|
||||
renderCycleBanner(cycle),
|
||||
renderHouseBadges({ myHouse, governingHouse }),
|
||||
renderHouseBadges({ myHouse, governingHouse, houses }),
|
||||
renderModeButtons(mode),
|
||||
renderHouseSearch(search),
|
||||
mode === 'houses'
|
||||
? renderHousesGrid(houses, myHouseKey, governingKey)
|
||||
? (matched.length
|
||||
? renderHousesGrid(matched, myHouseKey, governingKey)
|
||||
: p({ class: 'no-content' }, i18n.larpNoHousesMatch))
|
||||
: mode === 'rules'
|
||||
? renderRules()
|
||||
: [
|
||||
|
|
@ -330,7 +356,8 @@ exports.larpHouseView = ({ house, members, myHouseKey, cycle, governingKey, hous
|
|||
const isAcademia = house.key === 'academia';
|
||||
const viewerInAcademia = myHouseKey === 'academia';
|
||||
const viewerIsMember = myHouseKey === house.key && house.key !== 'academia';
|
||||
const showWall = viewerIsMember || house.key === governingKey;
|
||||
const canWriteWall = myHouseKey === house.key;
|
||||
const showWall = viewerIsMember || house.key === governingKey || house.key === 'academia';
|
||||
const cycles = computeCyclesUntilRuling(house, governingKey);
|
||||
const housesById = Object.fromEntries(houses.map(h => [h.key, h]));
|
||||
|
||||
|
|
@ -342,9 +369,14 @@ exports.larpHouseView = ({ house, members, myHouseKey, cycle, governingKey, hous
|
|||
p({ class: 'larp-detail-motto' }, '“' + house.motto + '”')
|
||||
),
|
||||
renderCycleBanner(cycle),
|
||||
renderHouseBadges({ myHouse, governingHouse }),
|
||||
renderHouseBadges({ myHouse, governingHouse, houses }),
|
||||
renderHouseNav(houses, house.key),
|
||||
renderHousePanel(house, members, posts, { canPost, viewerHouseKey: myHouseKey, cyclesUntilRuling: cycles }),
|
||||
renderHousePanel(house, members, posts, {
|
||||
canPost,
|
||||
viewerHouseKey: myHouseKey,
|
||||
cyclesUntilRuling: cycles,
|
||||
canTakeTest: isAcademia && viewerInAcademia && !!(testStatus && testStatus.allowed)
|
||||
}),
|
||||
inviteCode
|
||||
? div({ class: 'larp-invite-banner' },
|
||||
p({ class: 'larp-invite-banner-title' }, strong(i18n.larpInviteBannerTitle || 'New invitation code')),
|
||||
|
|
@ -352,10 +384,10 @@ exports.larpHouseView = ({ house, members, myHouseKey, cycle, governingKey, hous
|
|||
p({ class: 'larp-invite-banner-code' }, inviteCode)
|
||||
)
|
||||
: null,
|
||||
isAcademia && viewerInAcademia ? renderAcademiaJoinPanel(houses, testStatus, housesById, questions, myHouseKey) : null,
|
||||
showWall
|
||||
? renderPostsBlock(posts, house, viewerIsMember)
|
||||
: null,
|
||||
isAcademia && viewerInAcademia ? renderAcademiaJoinPanel(houses, testStatus, housesById, questions, myHouseKey) : null
|
||||
? renderPostsBlock(posts, house, canWriteWall)
|
||||
: null
|
||||
)
|
||||
);
|
||||
};
|
||||
|
|
@ -363,17 +395,19 @@ exports.larpHouseView = ({ house, members, myHouseKey, cycle, governingKey, hous
|
|||
exports.larpTestView = ({ questions, cycle, houses, myHouseKey, governingKey, testStatus }) => {
|
||||
const myHouse = houses.find(h => h.key === myHouseKey) || null;
|
||||
const governingHouse = houses.find(h => h.key === governingKey) || null;
|
||||
const titleText = i18n.larpTestTitle || 'Profile test';
|
||||
const title = i18n.larpTitle || 'L.A.R.P.';
|
||||
const description = i18n.larpDescription || 'A live action role-playing layer for collaborative experimentation.';
|
||||
|
||||
return template(
|
||||
titleText,
|
||||
title,
|
||||
section(
|
||||
div({ class: 'tags-header' },
|
||||
h1(titleText),
|
||||
p(i18n.larpTestIntro || 'Answer the psychological questions. The system will pick the house that best fits your answers. You can attempt one test every 30 cycles.')
|
||||
h1(title),
|
||||
p(description)
|
||||
),
|
||||
renderCycleBanner(cycle),
|
||||
renderHouseBadges({ myHouse, governingHouse }),
|
||||
renderHouseBadges({ myHouse, governingHouse, houses }),
|
||||
renderModeButtons(''),
|
||||
testStatus && !testStatus.allowed
|
||||
? div({ class: 'larp-test-cooldown' },
|
||||
p((i18n.larpTestCooldownActive || 'Next test available in') + ' ' + formatCooldown(testStatus.nextAt - Date.now()) + ' ' + (i18n.larpTestCooldownDays || 'cycles')),
|
||||
|
|
@ -409,7 +443,7 @@ exports.larpTestResultView = ({ house, result, cycle, houses, myHouseKey, govern
|
|||
section(
|
||||
div({ class: 'tags-header' }, h1(titleText)),
|
||||
renderCycleBanner(cycle),
|
||||
renderHouseBadges({ myHouse, governingHouse }),
|
||||
renderHouseBadges({ myHouse, governingHouse, houses }),
|
||||
div({ class: 'larp-test-result' },
|
||||
ranking.length
|
||||
? div({ class: 'larp-test-ranking' },
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
const { div, h2, p, section, button, form, span, table, thead, tbody, tr, th, td, input, textarea, br, option, select } = require("../server/node_modules/hyperaxe");
|
||||
const { div, h2, p, section, button, form, span, table, thead, tbody, tr, th, td, input, textarea, br, option, select, a, label } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n } = require("./main_views");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const { renderUrl } = require("../backend/renderUrl");
|
||||
|
||||
const userId = config.keys.id;
|
||||
|
||||
const safeArr = v => Array.isArray(v) ? v : [];
|
||||
|
||||
const FILTERS = ["today", "week", "month", "year", "always"];
|
||||
|
|
@ -18,22 +21,22 @@ const filterLabel = (f) => {
|
|||
return map[f] || f.toUpperCase();
|
||||
};
|
||||
|
||||
const renderFilterBar = (current, hasItems = false) =>
|
||||
div({ class: "logs-toolbar" },
|
||||
form({ method: "GET", action: "/logs", class: "logs-toolbar-inline" },
|
||||
const renderFilterBar = (current, hasItems = true) =>
|
||||
div({ class: "activity-sub-filter" },
|
||||
form({ method: "GET", action: "/logs", class: "sub-filter-form" },
|
||||
FILTERS.map(f =>
|
||||
button({
|
||||
type: "submit", name: "filter", value: f,
|
||||
class: current === f ? "filter-btn active" : "filter-btn"
|
||||
}, filterLabel(f))
|
||||
}, String(filterLabel(f)).toUpperCase())
|
||||
)
|
||||
),
|
||||
form({ method: "GET", action: "/logs", class: "logs-toolbar-inline" },
|
||||
form({ method: "GET", action: "/logs", class: "sub-filter-form" },
|
||||
input({ type: "hidden", name: "view", value: "create" }),
|
||||
button({ type: "submit", class: "create-button" }, i18n.logsCreate || 'Create Log')
|
||||
),
|
||||
hasItems
|
||||
? form({ method: "GET", action: "/logs/export", class: "logs-toolbar-inline" },
|
||||
? form({ method: "GET", action: "/logs/export", class: "sub-filter-form" },
|
||||
button({ type: "submit", class: "create-button" }, i18n.logsExport || 'Export Logs')
|
||||
)
|
||||
: null
|
||||
|
|
@ -71,12 +74,9 @@ const renderToolbar = (current, search, hasItems) =>
|
|||
renderSearchBox(current, search)
|
||||
);
|
||||
|
||||
const MAX_PREVIEW = 140;
|
||||
|
||||
const truncate = (str) => {
|
||||
const s = String(str || '');
|
||||
if (s.length <= MAX_PREVIEW) return s;
|
||||
return s.slice(0, MAX_PREVIEW).replace(/\s+\S*$/, '') + '…';
|
||||
const truncate = (value, max = 160) => {
|
||||
const text = String(value == null ? '' : value).trim();
|
||||
return text.length > max ? `${text.slice(0, max)}\u2026` : text;
|
||||
};
|
||||
|
||||
const renderLogPreview = (item) => {
|
||||
|
|
@ -91,9 +91,8 @@ const renderTable = (items) => {
|
|||
tr(
|
||||
th(i18n.logsColumnDate || 'Date'),
|
||||
th(i18n.logsColumnType || 'Type'),
|
||||
th(i18n.logsColumnLog || 'Log'),
|
||||
th(''),
|
||||
th('')
|
||||
th({ class: "logs-col-log" }, i18n.logsColumnLog || 'Log'),
|
||||
th({ class: "logs-col-actions" }, '')
|
||||
)
|
||||
),
|
||||
tbody(
|
||||
|
|
@ -114,13 +113,13 @@ const renderTable = (items) => {
|
|||
renderLogPreview(item)
|
||||
),
|
||||
td({ class: "logs-col-actions" },
|
||||
form({ method: "GET", action: `/logs/view/${encodeURIComponent(item.key)}` },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.logsViewDetails || 'View Details')
|
||||
)
|
||||
),
|
||||
td({ class: "logs-col-actions" },
|
||||
form({ method: "GET", action: `/logs/export/${encodeURIComponent(item.key)}` },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.logsExportOne || 'Export')
|
||||
div({ class: "logs-row-actions" },
|
||||
form({ method: "GET", action: `/logs/view/${encodeURIComponent(item.key)}` },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.logsViewDetails || 'View Details')
|
||||
),
|
||||
form({ method: "GET", action: `/logs/export/${encodeURIComponent(item.key)}` },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.logsExportOne || 'Export')
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -161,7 +160,6 @@ const renderCreateForm = (mode, aiModOn) => {
|
|||
: div({ class: "div-center audio-form" },
|
||||
form({ method: "POST", action: "/logs/create" },
|
||||
input({ type: "hidden", name: "mode", value: "manual" }),
|
||||
span(i18n.logsManualPrompt || 'Write your log'), br(),
|
||||
textarea({ name: "text", rows: "8", required: true, placeholder: i18n.logsTextPlaceholder || 'Describe your experiences...' }),
|
||||
br(), br(),
|
||||
button({ type: "submit", class: "create-button" }, i18n.logsWriteButton || 'Write')
|
||||
|
|
@ -172,9 +170,8 @@ const renderCreateForm = (mode, aiModOn) => {
|
|||
|
||||
const renderEditForm = (entry) => {
|
||||
return div({ class: "div-center audio-form" },
|
||||
h2(i18n.logsEditTitle || 'Edit Log'),
|
||||
form({ method: "POST", action: `/logs/edit/${encodeURIComponent(entry.key)}` },
|
||||
span(i18n.logsManualPrompt || 'Write your log...'), br(),
|
||||
h2(i18n.logsEditTitle || 'Update Log'),
|
||||
form({ method: "POST", action: `/logs/update/${encodeURIComponent(entry.key)}` },
|
||||
textarea({ name: "text", rows: "8", required: true }, entry.text || ''),
|
||||
input({ type: "hidden", name: "label", value: entry.label || '' }),
|
||||
br(), br(),
|
||||
|
|
@ -189,25 +186,24 @@ const renderDetail = (entry) => {
|
|||
h2(headerLine),
|
||||
entry.label ? div({ class: "logs-entry-label" }, entry.label) : null,
|
||||
div({ class: "logs-detail-text" }, ...renderUrl(String(entry.text || ''))),
|
||||
div({ class: "logs-detail-actions" },
|
||||
div({ class: "tribe-side-actions logs-detail-actions" },
|
||||
form({ method: "GET", action: "/logs" },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.walletBack || 'Back')
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.walletBack || 'Back')
|
||||
),
|
||||
form({ method: "GET", action: `/logs/export/${encodeURIComponent(entry.key)}` },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.logsExportOne || 'Export')
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.generatePdf)
|
||||
),
|
||||
form({ method: "GET", action: "/logs" },
|
||||
input({ type: "hidden", name: "view", value: "edit" }),
|
||||
input({ type: "hidden", name: "id", value: entry.key }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.logsEdit || 'Edit')
|
||||
form({ method: "GET", action: `/logs/edit/${encodeURIComponent(entry.key)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.logsEdit || 'Update')
|
||||
),
|
||||
form({ method: "POST", action: `/logs/delete/${encodeURIComponent(entry.key)}` },
|
||||
button({ type: "submit", class: "filter-btn danger-btn" }, i18n.logsDelete || 'Delete')
|
||||
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.logsDelete || 'Delete')
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
exports.logsView = (items, filter, mode, opts = {}) => {
|
||||
const listTitle = i18n.logsTitle || 'Logs';
|
||||
const description = i18n.logsDescription || 'Record your experience in the network.';
|
||||
|
|
@ -215,30 +211,23 @@ exports.logsView = (items, filter, mode, opts = {}) => {
|
|||
const aiModOn = !!opts.aiModOn;
|
||||
const hasItems = Array.isArray(items) && items.length > 0;
|
||||
|
||||
const screen = (...blocks) => template(
|
||||
listTitle,
|
||||
section(
|
||||
div({ class: "tags-header" }, h2(listTitle), p(description)),
|
||||
renderFilterBar(filter),
|
||||
...blocks
|
||||
)
|
||||
);
|
||||
|
||||
if (view === 'create') {
|
||||
const h = i18n.logsCreateTitle || 'Create Log';
|
||||
const body = section(
|
||||
div({ class: "tags-header" }, h2(h), p(description)),
|
||||
renderFilterBar(filter, hasItems),
|
||||
renderCreateForm(mode, aiModOn)
|
||||
);
|
||||
return template(h, body);
|
||||
return screen(renderCreateForm(mode, aiModOn));
|
||||
}
|
||||
if (view === 'edit' && opts.entry) {
|
||||
const h = i18n.logsEditTitle || 'Edit Log';
|
||||
const body = section(
|
||||
div({ class: "tags-header" }, h2(h), p(description)),
|
||||
renderEditForm(opts.entry)
|
||||
);
|
||||
return template(h, body);
|
||||
return screen(renderEditForm(opts.entry));
|
||||
}
|
||||
if (view === 'detail' && opts.entry) {
|
||||
const h = i18n.logsViewTitle || 'Log';
|
||||
const body = section(
|
||||
div({ class: "tags-header" }, h2(h), p(description)),
|
||||
renderDetail(opts.entry)
|
||||
);
|
||||
return template(h, body);
|
||||
return screen(renderDetail(opts.entry));
|
||||
}
|
||||
const body = section(
|
||||
div({ class: "tags-header" }, h2(listTitle), p(description)),
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ const renderBcsList = (bcsAudios) => {
|
|||
span({ class: "card-label" }, `${i18n.melodyByLabel || "By"}: `),
|
||||
userLink(a.author),
|
||||
span({ class: "melody-meta-sep" }, " · "),
|
||||
span({ class: "card-value" }, moment(a.createdAt).format("YYYY/MM/DD HH:mm:ss"))
|
||||
span({ class: "card-value" }, moment(a.createdAt).format("YYYY/MM/DD HH:mm"))
|
||||
)
|
||||
),
|
||||
a.url
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const { div, h2, h3, h4, p, section, button, form, a, span, br, textarea, input, label, select, option, table, tr, td } = require("../server/node_modules/hyperaxe")
|
||||
const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton } = require("./main_views")
|
||||
const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton , renderContentActions } = require("./main_views")
|
||||
const { renderEncryptedChip } = require("./clearnet_view")
|
||||
const moment = require("../server/node_modules/moment")
|
||||
const { config } = require("../server/SSB_server.js")
|
||||
|
|
@ -105,6 +105,10 @@ const renderPadCard = (pad, filter, spreadInfo) => {
|
|||
renderLifespanChip(pad.lifetime, i18n)
|
||||
].filter(Boolean)
|
||||
return div({ class: "tribe-card" },
|
||||
div({ class: "card-header activity-card-header" },
|
||||
span(),
|
||||
renderContentActions(pad.rootId || pad.key, `/pads/${encodeURIComponent(pad.rootId)}`, { spread: spreadInfo || null, author: pad.author, favKind: 'pads', isFavorite: pad.isFavorite, reportTitle: pad.title })
|
||||
),
|
||||
div({ class: "tribe-card-body" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" },
|
||||
|
|
@ -113,17 +117,10 @@ const renderPadCard = (pad, filter, spreadInfo) => {
|
|||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
pad.deadline
|
||||
? p({ class: "job-meta-line" }, `${i18n.padDeadlineLabel || "Deadline"}: ${moment(pad.deadline).format("YYYY-MM-DD HH:mm")}`)
|
||||
? p({ class: "job-meta-line" }, `${i18n.padDeadlineLabel || "Deadline"}: ${moment(pad.deadline).format("YYYY/MM/DD HH:mm")}`)
|
||||
: null,
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.padMembersLabel || "Members"}: ${pad.members.length}`)
|
||||
),
|
||||
(() => {
|
||||
const btn = renderSpreadButton(pad.rootId, spreadInfo);
|
||||
return btn ? div({ class: "card-spread-left" }, btn) : null;
|
||||
})(),
|
||||
div({ class: "visit-btn-centered" },
|
||||
a({ href: `/pads/${encodeURIComponent(pad.rootId)}`, class: "filter-btn" }, i18n.padVisitPad || "Visit Pad")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -139,7 +136,7 @@ const renderCreateForm = (padToEdit, params) => {
|
|||
},
|
||||
tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null,
|
||||
span(i18n.padTitleLabel || "Title"), require("../server/node_modules/hyperaxe").br(),
|
||||
input({ type: "text", name: "title", value: padToEdit ? padToEdit.title : "", placeholder: i18n.padTitlePlaceholder || "Enter pad title...", required: true }),
|
||||
input({ type: "text", name: "title", maxlength: "100", value: padToEdit ? padToEdit.title : "", placeholder: i18n.padTitlePlaceholder || "Enter pad title...", required: true }),
|
||||
require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(),
|
||||
span(i18n.padStatusLabel || "Status"), require("../server/node_modules/hyperaxe").br(),
|
||||
select({ name: "status" },
|
||||
|
|
@ -178,14 +175,7 @@ exports.renderPadInvitePage = (code) => {
|
|||
exports.padsView = async (pads, filter, padToEdit, params) => {
|
||||
const q = String((params && params.q) || "").trim()
|
||||
const isForm = filter === "create" || filter === "edit"
|
||||
const headerMap = {
|
||||
all: i18n.padAllSectionTitle || "Pads",
|
||||
mine: i18n.padMineSectionTitle || "Your Pads",
|
||||
recent: i18n.padRecentSectionTitle || "Recent Pads",
|
||||
open: i18n.padOpenSectionTitle || "Open Pads",
|
||||
closed: i18n.padClosedSectionTitle || "Closed Pads"
|
||||
}
|
||||
const headerText = headerMap[filter] || headerMap.all
|
||||
const headerText = i18n.padsTitle
|
||||
|
||||
const filteredPads = q
|
||||
? pads.filter(pd => String(pd.title || "").toLowerCase().includes(q.toLowerCase()))
|
||||
|
|
@ -199,12 +189,12 @@ exports.padsView = async (pads, filter, padToEdit, params) => {
|
|||
renderModeButtons(filter),
|
||||
!isForm
|
||||
? div({ class: "filters" },
|
||||
form({ method: "GET", action: "/pads" },
|
||||
form({ method: "GET", action: "/pads", class: "filter-box" },
|
||||
input({ type: "hidden", name: "filter", value: filter }),
|
||||
input({ type: "text", name: "q", placeholder: i18n.padSearchPlaceholder || "Search pads...", value: q }),
|
||||
br(),
|
||||
button({ type: "submit" }, i18n.search),
|
||||
br()
|
||||
input({ type: "text", name: "q", placeholder: i18n.padSearchPlaceholder || "Search pads...", value: q, class: "filter-box__input" }),
|
||||
div({ class: "filter-box__controls" },
|
||||
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)
|
||||
)
|
||||
)
|
||||
)
|
||||
: null,
|
||||
|
|
@ -213,7 +203,7 @@ exports.padsView = async (pads, filter, padToEdit, params) => {
|
|||
: div(
|
||||
filteredPads.length === 0
|
||||
? p({ class: "no-content" }, i18n.padsNoItems || "No pads found.")
|
||||
: div({ class: "tribe-grid" }, ...filteredPads.map(pd => renderPadCard(pd, filter, params && params.spreadMap && params.spreadMap.get(pd.rootId))))
|
||||
: div({ class: "tribe-grid pads-grid" }, ...filteredPads.map(pd => renderPadCard(pd, filter, params && params.spreadMap && params.spreadMap.get(pd.rootId))))
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -225,7 +215,6 @@ exports.singlePadView = async (pad, entries, params) => {
|
|||
const isMember = pad.members.includes(userId) || (!!pad.tribeId && !!pad.isTribeMember)
|
||||
const padClosed = pad.isClosed
|
||||
const returnTo = `/pads/${encodeURIComponent(pad.rootId)}`
|
||||
const shareUrl = `/pads/${encodeURIComponent(pad.rootId)}`
|
||||
const isRestrictedInviteOnly = !isMember && !isAuthor && pad.status === "INVITE-ONLY"
|
||||
|
||||
const tags = !isRestrictedInviteOnly && Array.isArray(pad.tags) && pad.tags.length > 0
|
||||
|
|
@ -237,76 +226,65 @@ exports.singlePadView = async (pad, entries, params) => {
|
|||
renderEncryptedChip(i18n),
|
||||
renderLifespanChip(pad.lifetime, i18n)
|
||||
].filter(Boolean)
|
||||
const inviteActions = [
|
||||
isAuthor && pad.status === "INVITE-ONLY"
|
||||
? form({ method: "POST", action: `/pads/generate-invite/${encodeURIComponent(pad.rootId)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeGenerateInvite)
|
||||
)
|
||||
: null,
|
||||
(() => {
|
||||
if (!(isAuthor && pad.status === "INVITE-ONLY")) return null
|
||||
const invs = Array.isArray(pad.invites) ? pad.invites : []
|
||||
const openInvite = invs.find(inv => typeof inv === "object" && inv && inv.public === true && inv.code)
|
||||
if (openInvite) return [
|
||||
div({ class: "tribe-open-invite" },
|
||||
span({ class: "card-label" }, i18n.tribeInviteCodeText),
|
||||
span({ class: "tribe-open-invite-code" }, openInvite.code)
|
||||
),
|
||||
form({ method: "POST", action: `/pads/open-invite/remove/${encodeURIComponent(pad.rootId)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeRemoveInvitation)
|
||||
)
|
||||
]
|
||||
return form({ method: "POST", action: `/pads/open-invite/create/${encodeURIComponent(pad.rootId)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeOpenInvitation)
|
||||
)
|
||||
})()
|
||||
].filter(Boolean)
|
||||
|
||||
const padSide = div({ class: "tribe-side" },
|
||||
div({ class: "card-header activity-card-header" },
|
||||
renderContentActions(pad.rootId, null, {
|
||||
author: pad.author,
|
||||
favKind: 'pads',
|
||||
isFavorite: pad.isFavorite,
|
||||
spread: (params && params.spreads) || null,
|
||||
returnTo,
|
||||
reportTitle: pad.title
|
||||
})
|
||||
),
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, pad.title || "\u2014")
|
||||
),
|
||||
detailChips.length ? div({ class: "card-chips-row" }, ...detailChips) : null,
|
||||
div({ class: "shop-share" },
|
||||
span({ class: "tribe-info-label" }, i18n.padShareUrl || "Share URL"),
|
||||
input({ type: "text", readonly: true, value: shareUrl, class: "shop-share-input" })
|
||||
table({ class: "tribe-info-table jobs-info-table" },
|
||||
tr(td({ class: "tribe-info-label" }, i18n.padCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(pad.createdAt).format("YYYY/MM/DD HH:mm"))),
|
||||
(isRestrictedInviteOnly || !pad.deadline) ? null : tr(td({ class: "tribe-info-label" }, i18n.padDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, moment(pad.deadline).format("YYYY/MM/DD HH:mm"))),
|
||||
isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-value pad-author-cell", colspan: "4" }, userLink(pad.author)))
|
||||
),
|
||||
(() => {
|
||||
const btn = renderSpreadButton(pad.rootId);
|
||||
return btn ? div({ class: "card-spread-centered" }, btn) : null;
|
||||
})(),
|
||||
tags,
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.padMembersLabel || "Members"}: ${pad.members.length}`)
|
||||
),
|
||||
table({ class: "tribe-info-table" },
|
||||
tr(td({ class: "tribe-info-label" }, i18n.padCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(pad.createdAt).format("YYYY-MM-DD"))),
|
||||
isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-value pad-author-cell", colspan: "4" }, userLink(pad.author))),
|
||||
isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-label" }, i18n.padDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, pad.deadline ? moment(pad.deadline).format("YYYY-MM-DD HH:mm") : "\u2014"))
|
||||
),
|
||||
isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions" },
|
||||
isAuthor && pad.status === "INVITE-ONLY"
|
||||
? form({ method: "POST", action: `/pads/generate-invite/${encodeURIComponent(pad.rootId)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeGenerateInvite)
|
||||
)
|
||||
: null,
|
||||
(() => {
|
||||
if (!(isAuthor && pad.status === "INVITE-ONLY")) return null
|
||||
const invs = Array.isArray(pad.invites) ? pad.invites : []
|
||||
const openInvite = invs.find(inv => typeof inv === "object" && inv && inv.public === true && inv.code)
|
||||
if (openInvite) return [
|
||||
div({ class: "tribe-open-invite" },
|
||||
span({ class: "card-label" }, i18n.tribeInviteCodeText),
|
||||
span({ class: "tribe-open-invite-code" }, openInvite.code)
|
||||
),
|
||||
form({ method: "POST", action: `/pads/open-invite/remove/${encodeURIComponent(pad.rootId)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeRemoveInvitation)
|
||||
)
|
||||
]
|
||||
return form({ method: "POST", action: `/pads/open-invite/create/${encodeURIComponent(pad.rootId)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeOpenInvitation)
|
||||
)
|
||||
})(),
|
||||
form(
|
||||
{ method: "POST", action: pad.isFavorite ? `/pads/favorites/remove/${encodeURIComponent(pad.key)}` : `/pads/favorites/add/${encodeURIComponent(pad.key)}` },
|
||||
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
||||
button({ type: "submit", class: "tribe-action-btn" }, pad.isFavorite ? (i18n.padRemoveFavorite || "Remove Favorite") : (i18n.padAddFavorite || "Add Favorite"))
|
||||
),
|
||||
!isAuthor
|
||||
? a({ href: `/pm?to=${encodeURIComponent(pad.author)}`, class: "tribe-action-btn" }, "PM")
|
||||
: null,
|
||||
isAuthor
|
||||
? form({ method: "GET", action: "/pads" },
|
||||
input({ type: "hidden", name: "filter", value: "edit" }),
|
||||
input({ type: "hidden", name: "id", value: pad.rootId }),
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.padUpdate || "Update")
|
||||
)
|
||||
: null,
|
||||
isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions housing-status-row" },
|
||||
span({ class: "card-label" }, `${i18n.padStatusLabel || "Status"}: `),
|
||||
renderPadStatusChip(pad.status, padClosed),
|
||||
isAuthor && pad.status !== "CLOSED" && !padClosed
|
||||
? form({ method: "POST", action: `/pads/close/${encodeURIComponent(pad.rootId)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.padClose || "Close Pad")
|
||||
)
|
||||
: null,
|
||||
isAuthor
|
||||
? form({ method: "POST", action: `/pads/delete/${encodeURIComponent(pad.rootId)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.padDelete || "Delete")
|
||||
button({ type: "submit", class: "status-btn project-control-btn" }, i18n.padClose || "Close")
|
||||
)
|
||||
: null
|
||||
),
|
||||
(isRestrictedInviteOnly || !inviteActions.length) ? null : div({ class: "tribe-side-actions" }, ...inviteActions),
|
||||
!isAuthor && pad.status === "INVITE-ONLY" && !isMember
|
||||
? div({ class: "pad-invite-section" },
|
||||
a({ class: "tribe-action-btn", href: "/invites#invites-pads" }, i18n.tribeEnterInvite)
|
||||
|
|
@ -317,7 +295,16 @@ exports.singlePadView = async (pad, entries, params) => {
|
|||
button({ type: "submit", class: "create-button" }, i18n.padStartEditing || "START EDITING!")
|
||||
)
|
||||
: null,
|
||||
tags
|
||||
isRestrictedInviteOnly || !isAuthor ? null : div({ class: "tribe-side-actions" },
|
||||
form({ method: "GET", action: "/pads" },
|
||||
input({ type: "hidden", name: "filter", value: "edit" }),
|
||||
input({ type: "hidden", name: "id", value: pad.rootId }),
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.padUpdate || "Update")
|
||||
),
|
||||
form({ method: "POST", action: `/pads/delete/${encodeURIComponent(pad.rootId)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.padDelete || "Delete")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
let canonicalEntries = entries
|
||||
|
|
@ -343,7 +330,7 @@ exports.singlePadView = async (pad, entries, params) => {
|
|||
h4(i18n.padVersionHistory || "Version History"),
|
||||
...visibleEntries.slice().reverse().map((e, idx) =>
|
||||
div({ class: "pad-version-item" },
|
||||
span({ class: "pad-version-date" }, moment(e.createdAt).format("YYYY-MM-DD HH:mm")),
|
||||
span({ class: "pad-version-date" }, moment(e.createdAt).format("YYYY/MM/DD HH:mm")),
|
||||
span({ class: "pad-version-author" },
|
||||
span({ class: "pad-author-swatch " + memberColorClass(pad.members, e.author) }),
|
||||
userLink(e.author)
|
||||
|
|
|
|||
|
|
@ -47,22 +47,22 @@ exports.pixeliaView = (pixelArt, errorMessage) => {
|
|||
input({ type: "number", id: "y", name: "y", min: 1, max: gridHeight, required: true }),
|
||||
label({ for: "color" }, i18n.colorLabel),
|
||||
select({ id: "color", name: "color", required: true },
|
||||
option({ value: "#000000", style: "background-color:#000000;" }, "Black"),
|
||||
option({ value: "#ffffff", style: "background-color:#ffffff;" }, "White"),
|
||||
option({ value: "#17f018", style: "background-color:#17f018;" }, "Green"),
|
||||
option({ value: "#ffbb00", style: "background-color:#ffbb00;" }, "Yellow"),
|
||||
option({ value: "#ff0000", style: "background-color:#ff0000;" }, "Red"),
|
||||
option({ value: "#0000ff", style: "background-color:#0000ff;" }, "Blue"),
|
||||
option({ value: "#ffff00", style: "background-color:#ffff00;" }, "Lime"),
|
||||
option({ value: "#00ff00", style: "background-color:#00ff00;" }, "Spring Green"),
|
||||
option({ value: "#00ffff", style: "background-color:#00ffff;" }, "Aqua"),
|
||||
option({ value: "#ff00ff", style: "background-color:#ff00ff;" }, "Fuchsia"),
|
||||
option({ value: "#a52a2a", style: "background-color:#a52a2a;" }, "Brown"),
|
||||
option({ value: "#800080", style: "background-color:#800080;" }, "Purple"),
|
||||
option({ value: "#808000", style: "background-color:#808000;" }, "Olive"),
|
||||
option({ value: "#00bfff", style: "background-color:#00bfff;" }, "Deep Sky Blue"),
|
||||
option({ value: "#d3d3d3", style: "background-color:#d3d3d3;" }, "Light Grey"),
|
||||
option({ value: "#ff6347", style: "background-color:#ff6347;" }, "Tomato")
|
||||
option({ value: "#000000", class: "pixelia-swatch pixelia-swatch-000000" }, "Black"),
|
||||
option({ value: "#ffffff", class: "pixelia-swatch pixelia-swatch-ffffff" }, "White"),
|
||||
option({ value: "#17f018", class: "pixelia-swatch pixelia-swatch-17f018" }, "Green"),
|
||||
option({ value: "#ffbb00", class: "pixelia-swatch pixelia-swatch-ffbb00" }, "Yellow"),
|
||||
option({ value: "#ff0000", class: "pixelia-swatch pixelia-swatch-ff0000" }, "Red"),
|
||||
option({ value: "#0000ff", class: "pixelia-swatch pixelia-swatch-0000ff" }, "Blue"),
|
||||
option({ value: "#ffff00", class: "pixelia-swatch pixelia-swatch-ffff00" }, "Lime"),
|
||||
option({ value: "#00ff00", class: "pixelia-swatch pixelia-swatch-00ff00" }, "Spring Green"),
|
||||
option({ value: "#00ffff", class: "pixelia-swatch pixelia-swatch-00ffff" }, "Aqua"),
|
||||
option({ value: "#ff00ff", class: "pixelia-swatch pixelia-swatch-ff00ff" }, "Fuchsia"),
|
||||
option({ value: "#a52a2a", class: "pixelia-swatch pixelia-swatch-a52a2a" }, "Brown"),
|
||||
option({ value: "#800080", class: "pixelia-swatch pixelia-swatch-800080" }, "Purple"),
|
||||
option({ value: "#808000", class: "pixelia-swatch pixelia-swatch-808000" }, "Olive"),
|
||||
option({ value: "#00bfff", class: "pixelia-swatch pixelia-swatch-00bfff" }, "Deep Sky Blue"),
|
||||
option({ value: "#d3d3d3", class: "pixelia-swatch pixelia-swatch-d3d3d3" }, "Light Grey"),
|
||||
option({ value: "#ff6347", class: "pixelia-swatch pixelia-swatch-ff6347" }, "Tomato")
|
||||
),
|
||||
button({ type: "submit" }, i18n.paintButton)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -156,7 +156,13 @@ exports.pmView = async (initialRecipients = '', initialSubject = '', initialText
|
|||
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 }),
|
||||
fileSharePreview.recipient
|
||||
? input({ type: "hidden", name: "recipient", value: fileSharePreview.recipient })
|
||||
: div({ class: "pm-fileshare-recipient" },
|
||||
label({ for: "fs-preview-recipient" }, i18n.pmRecipients),
|
||||
br(),
|
||||
input({ id: "fs-preview-recipient", type: "text", name: "recipient", placeholder: i18n.fileShareRecipientPlaceholder || 'Enter Oasis ID (@....ed25519)', required: true, maxlength: "120" })
|
||||
),
|
||||
input({ type: "hidden", name: "subject", value: fileSharePreview.subject || '' }),
|
||||
input({ type: "hidden", name: "manifestBlobId", value: fileSharePreview.manifestBlobId }),
|
||||
input({ type: "hidden", name: "keyHex", value: fileSharePreview.keyHex }),
|
||||
|
|
|
|||
323
src/views/polls_view.js
Normal file
323
src/views/polls_view.js
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
const { div, h2, p, section, button, form, a, input, label, span, textarea, br, table, tr, td } = require("../server/node_modules/hyperaxe");
|
||||
const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view");
|
||||
const { template, i18n, userLink, renderOpinionsVoting, renderEngagement, renderSpreadButton, renderContentActions, renderStateChip, renderLifespanChip, renderSpreadEditWarning } = require("./main_views");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const { renderUrl } = require("../backend/renderUrl");
|
||||
const { MAX_OPTIONS, MIN_OPTIONS, MAX_OPTION_LENGTH } = require("../models/polls_model_limits");
|
||||
|
||||
const userId = config.keys.id;
|
||||
|
||||
const FILTERS = [
|
||||
{ key: "ALL", i18n: "pollFilterAll" },
|
||||
{ key: "MINE", i18n: "pollFilterMine" },
|
||||
{ key: "RECENT", i18n: "pollFilterRecent" },
|
||||
{ key: "TOP", i18n: "pollFilterTop" },
|
||||
{ key: "VOTED", i18n: "pollFilterVoted" },
|
||||
{ key: "OPEN", i18n: "pollFilterOpen" },
|
||||
{ key: "CLOSED", i18n: "pollFilterClosed" }
|
||||
];
|
||||
|
||||
const safeArr = (v) => (Array.isArray(v) ? v : []);
|
||||
|
||||
const pct = (n, total) => (total > 0 ? Math.round((Number(n) || 0) / total * 100) : 0);
|
||||
const letterOf = (i) => {
|
||||
let n = Number(i) || 0;
|
||||
let out = "";
|
||||
do {
|
||||
out = String.fromCharCode(65 + (n % 26)) + out;
|
||||
n = Math.floor(n / 26) - 1;
|
||||
} while (n >= 0);
|
||||
return out;
|
||||
};
|
||||
const pctStep = (n, total) => Math.round(pct(n, total) / 5) * 5;
|
||||
|
||||
const outcomeOf = (poll) => {
|
||||
if (!poll.totalVoters) return i18n.pollNoVotesYet;
|
||||
const max = Math.max(...poll.options.map(o => poll.counts[o] || 0));
|
||||
if (!max) return i18n.pollNoVotesYet;
|
||||
const winners = poll.options
|
||||
.map((o, i) => ({ o, letter: letterOf(i) }))
|
||||
.filter(({ o }) => (poll.counts[o] || 0) === max);
|
||||
if (winners.length > 1) return `${i18n.pollTie}: ${winners.map(w => `${w.letter})`).join(", ")}`;
|
||||
return `${winners[0].letter}) ${pct(max, poll.totalVoters)}%`;
|
||||
};
|
||||
|
||||
const renderStatusChip = (poll) =>
|
||||
renderStateChip(poll.status === "OPEN" ? "whole" : "mutuals", "◆",
|
||||
poll.status === "OPEN" ? i18n.pollStatusOpen : i18n.pollStatusClosed);
|
||||
|
||||
const renderModeChips = (poll) => [
|
||||
poll.anonymous ? renderStateChip("mutuals", "◈", i18n.pollAnonymous) : null,
|
||||
poll.multiple ? renderStateChip("whole", "☰", i18n.pollMultiple) : null
|
||||
].filter(Boolean);
|
||||
|
||||
const renderResults = (poll) =>
|
||||
table({ class: "poll-results" },
|
||||
...poll.options.map((opt, i) =>
|
||||
tr({ class: poll.myChoices.includes(opt) ? "poll-result-row poll-result-mine" : "poll-result-row" },
|
||||
td({ class: "poll-result-option" }, `${letterOf(i)})`),
|
||||
td({ class: "poll-result-bar" },
|
||||
div({ class: "poll-bar" },
|
||||
div({ class: `poll-bar-fill poll-bar-fill-${pctStep(poll.counts[opt], poll.totalVoters)} poll-hue-${i % 8}` })
|
||||
)
|
||||
),
|
||||
td({ class: "poll-result-count" },
|
||||
`${poll.counts[opt] || 0} (${pct(poll.counts[opt], poll.totalVoters)}%)`)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const renderBallot = (poll, returnTo, basePath) => {
|
||||
if (poll.status === "CLOSED" || poll.hasVoted) return null;
|
||||
const inputType = poll.multiple ? "checkbox" : "radio";
|
||||
return form({ method: "POST", action: `${basePath}/vote/${encodeURIComponent(poll.id)}`, class: "poll-ballot" },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
...poll.options.map((opt, i) =>
|
||||
div({ class: "poll-option-row" },
|
||||
label({ for: `opt-${i}-${poll.id}` },
|
||||
input({
|
||||
type: inputType, name: "choices", value: opt, id: `opt-${i}-${poll.id}`,
|
||||
...(poll.myChoices.includes(opt) ? { checked: true } : {})
|
||||
}),
|
||||
" ",
|
||||
`${letterOf(i)}) ${opt}`
|
||||
)
|
||||
)
|
||||
),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.pollVoteButton)
|
||||
);
|
||||
};
|
||||
|
||||
const renderPollCard = (poll, filter, spreadInfo, basePath = "/polls") => {
|
||||
const href = `${basePath}/${encodeURIComponent(poll.id)}`;
|
||||
const isOwn = String(poll.author) === String(userId);
|
||||
const chips = [
|
||||
renderStatusChip(poll),
|
||||
...renderModeChips(poll),
|
||||
poll.deadline
|
||||
? renderStateChip(poll.status === "OPEN" ? "whole" : "mutuals", "◷", `${i18n.pollDeadline}: ${moment(poll.deadline).format("YYYY/MM/DD HH:mm")}`)
|
||||
: null,
|
||||
renderLifespanChip(poll.lifetime, i18n)
|
||||
].filter(Boolean);
|
||||
return div({ class: "tribe-card poll-card" + (isOwn ? " own-content" : "") },
|
||||
div({ class: "card-header activity-card-header" },
|
||||
span(),
|
||||
renderContentActions(poll.id, href, { spread: spreadInfo || null, author: poll.author, favKind: 'polls', isFavorite: poll.isFavorite, reportTitle: poll.question })
|
||||
),
|
||||
div({ class: "tribe-card-body" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, a({ href }, poll.question))
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.pollVoters}: ${poll.totalVoters}`)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderCreateForm = (poll = null, params = {}) => {
|
||||
const isEdit = !!poll;
|
||||
const action = isEdit ? `/polls/update/${encodeURIComponent(poll.id)}` : "/polls/create";
|
||||
return section(
|
||||
params.spreadWarning || null,
|
||||
div({ class: "div-center audio-form" },
|
||||
h2(isEdit ? i18n.pollEditTitle : i18n.pollCreateTitle),
|
||||
form({ method: "POST", action },
|
||||
label(i18n.pollQuestion),
|
||||
br(),
|
||||
input({ type: "text", name: "question", required: true, maxlength: "300", value: isEdit ? poll.question : "", placeholder: i18n.pollQuestionPlaceholder }),
|
||||
br(), br(),
|
||||
label(i18n.pollOptions),
|
||||
br(),
|
||||
textarea({
|
||||
name: "options",
|
||||
rows: String(MAX_OPTIONS),
|
||||
required: true,
|
||||
maxlength: String(MAX_OPTIONS * (MAX_OPTION_LENGTH + 1))
|
||||
}, isEdit ? poll.options.join("\n") : ""),
|
||||
br(),
|
||||
div({ class: "poll-switch" },
|
||||
input({ type: "hidden", name: "anonymous", value: "0" }),
|
||||
label(
|
||||
input({ type: "checkbox", name: "anonymous", value: "1", ...(isEdit && poll.anonymous ? { checked: true } : {}) }),
|
||||
" ", i18n.pollAnonymousLabel
|
||||
)
|
||||
),
|
||||
div({ class: "poll-switch" },
|
||||
input({ type: "hidden", name: "multiple", value: "0" }),
|
||||
label(
|
||||
input({ type: "checkbox", name: "multiple", value: "1", ...(isEdit && poll.multiple ? { checked: true } : {}) }),
|
||||
" ", i18n.pollMultipleLabel
|
||||
)
|
||||
),
|
||||
br(),
|
||||
label(i18n.pollDeadline),
|
||||
br(),
|
||||
input({
|
||||
type: "datetime-local", name: "deadline",
|
||||
min: moment().format("YYYY-MM-DDTHH:mm"),
|
||||
value: isEdit && poll.deadline ? moment(poll.deadline).format("YYYY-MM-DDTHH:mm") : ""
|
||||
}),
|
||||
br(), br(),
|
||||
label(i18n.pollTags),
|
||||
br(),
|
||||
input({ type: "text", name: "tags", value: isEdit ? poll.tags.join(", ") : "" }),
|
||||
br(), br(),
|
||||
button({ type: "submit", class: "create-button" }, isEdit ? i18n.pollUpdateButton : i18n.pollPublishButton)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderComments = (poll, comments, basePath) => {
|
||||
const href = `${basePath}/${encodeURIComponent(poll.id)}`;
|
||||
return renderSharedCommentsSection({
|
||||
action: `${href}/comments`,
|
||||
comments: comments,
|
||||
returnTo: href
|
||||
});
|
||||
};
|
||||
|
||||
const renderFilterBar = (filter, q, showSearch = true) =>
|
||||
section(
|
||||
div({ class: "filters" },
|
||||
form({ method: "GET", action: "/polls", class: "ui-toolbar ui-toolbar--filters" },
|
||||
...FILTERS.map(f =>
|
||||
button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, String(i18n[f.i18n]).toUpperCase())
|
||||
),
|
||||
button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.pollCreateButton)
|
||||
)
|
||||
),
|
||||
showSearch ? div({ class: "filters" },
|
||||
form({ method: "GET", action: "/polls", class: "filter-box" },
|
||||
input({ type: "hidden", name: "filter", value: filter }),
|
||||
input({ type: "text", name: "q", value: q || "", placeholder: i18n.pollSearchPlaceholder, class: "filter-box__input" }),
|
||||
div({ class: "filter-box__controls" },
|
||||
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)
|
||||
)
|
||||
)
|
||||
) : null
|
||||
);
|
||||
|
||||
exports.pollsView = async (polls = [], filter = "ALL", params = {}) => {
|
||||
const mode = String(filter).toUpperCase();
|
||||
const spreadMap = params.spreadMap instanceof Map ? params.spreadMap : new Map();
|
||||
if (mode === "CREATE" || mode === "EDIT") {
|
||||
const editing = params.poll || null;
|
||||
if (editing) params = { ...params, spreadWarning: await renderSpreadEditWarning(editing.id) };
|
||||
return template(
|
||||
i18n.pollsTitle,
|
||||
section(div({ class: "tags-header" }, h2(i18n.pollsTitle), p(i18n.pollsDescription))),
|
||||
renderFilterBar(mode, params.q, false),
|
||||
renderCreateForm(editing, params)
|
||||
);
|
||||
}
|
||||
return template(
|
||||
i18n.pollsTitle,
|
||||
section(div({ class: "tags-header" }, h2(i18n.pollsTitle), p(i18n.pollsDescription))),
|
||||
renderFilterBar(mode, params.q),
|
||||
section(
|
||||
polls.length
|
||||
? div({ class: "jobs-grid" }, ...polls.map(pl => renderPollCard(pl, mode, spreadMap.get(pl.id))))
|
||||
: p({ class: "no-content" }, i18n.pollsNoItems)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.singlePollView = async (poll, comments = [], params = {}) => {
|
||||
const basePath = "/polls";
|
||||
const href = `${basePath}/${encodeURIComponent(poll.id)}`;
|
||||
const isOwn = String(poll.author) === String(userId);
|
||||
const chips = [renderStatusChip(poll), ...renderModeChips(poll), renderLifespanChip(poll.lifetime, i18n)].filter(Boolean);
|
||||
|
||||
const infoRows = [];
|
||||
const pushRow = (labelText, valueNode) =>
|
||||
infoRows.push(tr(
|
||||
td({ class: "tribe-info-label" }, labelText),
|
||||
td({ class: "tribe-info-value" }, valueNode)
|
||||
));
|
||||
if (poll.deadline) pushRow(i18n.pollDeadline, moment(poll.deadline).format("YYYY/MM/DD HH:mm"));
|
||||
pushRow(i18n.pollOutcome, outcomeOf(poll));
|
||||
|
||||
const tagsNode = poll.tags.length
|
||||
? div({ class: "card-tags" }, poll.tags.map(tag =>
|
||||
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)))
|
||||
: null;
|
||||
|
||||
const ownerActions = isOwn
|
||||
? div({ class: "tribe-side-actions" },
|
||||
poll.totalVoters === 0 && poll.status === "OPEN"
|
||||
? form({ method: "GET", action: "/polls" },
|
||||
input({ type: "hidden", name: "filter", value: "EDIT" }),
|
||||
input({ type: "hidden", name: "id", value: poll.id }),
|
||||
button({ type: "submit", class: "update-btn" }, i18n.pollUpdateButton)
|
||||
)
|
||||
: null,
|
||||
form({ method: "POST", action: `/polls/delete/${encodeURIComponent(poll.id)}` },
|
||||
button({ type: "submit", class: "delete-btn" }, i18n.pollDeleteButton)
|
||||
)
|
||||
)
|
||||
: null;
|
||||
|
||||
const pollSide = div({ class: "tribe-side" },
|
||||
div({ class: "card-header activity-card-header" },
|
||||
renderContentActions(poll.id, null, { spread: params.spreads || null, author: poll.author, favKind: 'polls', isFavorite: poll.isFavorite, returnTo: href, reportTitle: poll.question })
|
||||
),
|
||||
div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, poll.question)),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
table({ class: "tribe-info-table jobs-info-table" }, ...infoRows),
|
||||
tagsNode,
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.pollVoters}: ${poll.totalVoters}`)
|
||||
),
|
||||
isOwn
|
||||
? div({ class: "tribe-side-actions housing-status-row" },
|
||||
span({ class: "card-label" }, `${i18n.pollStatusLabel}: `),
|
||||
renderStatusChip(poll),
|
||||
poll.status === "OPEN"
|
||||
? form({ method: "POST", action: `/polls/close/${encodeURIComponent(poll.id)}` },
|
||||
button({ type: "submit", class: "status-btn project-control-btn" }, i18n.pollCloseButton)
|
||||
)
|
||||
: null
|
||||
)
|
||||
: null,
|
||||
ownerActions
|
||||
);
|
||||
|
||||
const pollMain = div({ class: "tribe-main" },
|
||||
div({ class: "job-section" },
|
||||
h2({ class: "job-section-title" }, i18n.pollOutcome),
|
||||
renderBallot(poll, href, basePath),
|
||||
renderResults(poll)
|
||||
),
|
||||
poll.votersHidden
|
||||
? null
|
||||
: (poll.voters.length
|
||||
? div({ class: "card-assigned-list" }, ...poll.voters.map(v => userLink(v)))
|
||||
: null),
|
||||
p({ class: "card-footer" },
|
||||
span({ class: "date-link" }, moment(poll.createdAt).format("YYYY/MM/DD HH:mm")),
|
||||
span(" · "),
|
||||
userLink(poll.author)
|
||||
),
|
||||
renderEngagement(poll.id,
|
||||
renderOpinionsVoting('/polls/opinions', poll.id, poll.opinions, href, poll.opinions_inhabitants),
|
||||
renderComments(poll, comments, basePath)
|
||||
)
|
||||
);
|
||||
|
||||
return template(
|
||||
poll.question,
|
||||
section(div({ class: "tags-header" }, h2(i18n.pollsTitle), p(i18n.pollsDescription))),
|
||||
renderFilterBar(String(params.filter || "ALL").toUpperCase(), params.q, false),
|
||||
section(div({ class: "tribe-details" }, pollSide, pollMain))
|
||||
);
|
||||
};
|
||||
|
||||
exports.renderPollCard = renderPollCard;
|
||||
exports.renderResults = renderResults;
|
||||
exports.renderBallot = renderBallot;
|
||||
exports.outcomeOf = outcomeOf;
|
||||
exports.letterOf = letterOf;
|
||||
|
|
@ -177,19 +177,19 @@ exports.statsView = (stats, filter) => {
|
|||
const pct = networkCO2 > 0 ? Math.min(100, (userCO2 / networkCO2) * 100) : 0;
|
||||
return div({ class: 'carbon-chart' },
|
||||
div({ class: 'carbon-bar-label' },
|
||||
span(i18n.statsCarbonUser || 'Your footprint'),
|
||||
span(`${userCO2} g CO₂`)
|
||||
),
|
||||
div({ class: 'carbon-bar-track' },
|
||||
div({ class: `carbon-bar-fill carbon-bar-mine ${wClass(pct)}` })
|
||||
),
|
||||
div({ class: 'carbon-bar-label' },
|
||||
span(i18n.statsCarbonNetwork || 'Network total'),
|
||||
span(`HcT — ${i18n.statsCarbonNetwork || 'Network total'}`),
|
||||
span(`${networkCO2} g CO₂`)
|
||||
),
|
||||
div({ class: 'carbon-bar-track' },
|
||||
div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' })
|
||||
),
|
||||
div({ class: 'carbon-bar-label' },
|
||||
span(`HcH — ${i18n.statsCarbonUser || 'Your footprint'}`),
|
||||
span(`${userCO2} g CO₂`)
|
||||
),
|
||||
div({ class: 'carbon-bar-track' },
|
||||
div({ class: `carbon-bar-fill carbon-bar-mine ${wClass(pct)}` })
|
||||
),
|
||||
p({ class: 'carbon-bar-note' }, strong(`${pct.toFixed(1)}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'}`),
|
||||
p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
|
||||
);
|
||||
|
|
@ -201,13 +201,6 @@ exports.statsView = (stats, filter) => {
|
|||
const tombCO2 = parseFloat((tombMB * kWhPerMB * gCO2PerKWh).toFixed(4));
|
||||
const tombPct = networkCO2 > 0 ? Math.min(100, (tombCO2 / networkCO2) * 100) : 0;
|
||||
return div({ class: 'carbon-chart' },
|
||||
div({ class: 'carbon-bar-label' },
|
||||
span(i18n.statsCarbonTombstone || 'Tombstoning footprint'),
|
||||
span(`${tombCO2} g CO₂`)
|
||||
),
|
||||
div({ class: 'carbon-bar-track' },
|
||||
div({ class: `carbon-bar-fill carbon-bar-mine ${wClass(tombPct)}` })
|
||||
),
|
||||
div({ class: 'carbon-bar-label' },
|
||||
span(i18n.statsCarbonNetwork || 'Network total'),
|
||||
span(`${networkCO2} g CO₂`)
|
||||
|
|
@ -215,62 +208,59 @@ exports.statsView = (stats, filter) => {
|
|||
div({ class: 'carbon-bar-track' },
|
||||
div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' })
|
||||
),
|
||||
div({ class: 'carbon-bar-label' },
|
||||
span(i18n.statsCarbonTombstone || 'Tombstoning footprint'),
|
||||
span(`${tombCO2} g CO₂`)
|
||||
),
|
||||
div({ class: 'carbon-bar-track' },
|
||||
div({ class: `carbon-bar-fill carbon-bar-mine ${wClass(tombPct)}` })
|
||||
),
|
||||
p({ class: 'carbon-bar-note' }, strong(`${tombPct.toFixed(1)}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'} (${tombCount} tombstones × ~${avgTombBytes} bytes)`),
|
||||
p({ class: 'carbon-bar-formula' }, 'Based on estimated tombstone message size ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
|
||||
);
|
||||
}
|
||||
const pct = Math.min(100, (networkCO2 / maxAnnualCO2) * 100);
|
||||
const now = new Date();
|
||||
const yearStart = new Date(now.getFullYear(), 0, 1).getTime();
|
||||
const yearEnd = new Date(now.getFullYear() + 1, 0, 1).getTime();
|
||||
const yearPct = Math.min(100, ((now.getTime() - yearStart) / (yearEnd - yearStart)) * 100);
|
||||
return div({ class: 'carbon-chart' },
|
||||
div({ class: 'carbon-bar-label' },
|
||||
span(i18n.statsCarbonNetwork || 'Network footprint'),
|
||||
span(`${networkCO2} g CO₂`)
|
||||
),
|
||||
div({ class: 'carbon-bar-track' },
|
||||
div({ class: `carbon-bar-fill carbon-bar-network ${wClass(pct)}` })
|
||||
),
|
||||
div({ class: 'carbon-bar-label' },
|
||||
span(i18n.statsCarbonMaxAnnual || 'Annual max estimate'),
|
||||
span(`${maxAnnualCO2} g CO₂`)
|
||||
),
|
||||
div({ class: 'carbon-bar-track' },
|
||||
div({ class: 'carbon-bar-fill carbon-bar-max stats-w-100' })
|
||||
div({ class: `carbon-bar-fill carbon-bar-max ${wClass(yearPct)}` })
|
||||
),
|
||||
p({ class: 'carbon-bar-note' }, strong(`${pct.toFixed(1)}%`), ` ${i18n.statsCarbonOfEstMax || 'of estimated max capacity'}`),
|
||||
div({ class: 'carbon-bar-label' },
|
||||
span(`HcT — ${i18n.statsCarbonNetwork || 'Network footprint'}`),
|
||||
span(`${networkCO2} g CO₂`)
|
||||
),
|
||||
div({ class: 'carbon-bar-track' },
|
||||
div({ class: `carbon-bar-fill carbon-bar-network ${wClass(pct)}` })
|
||||
),
|
||||
p({ class: 'carbon-bar-note' }, strong(`${pct.toFixed(1)}%`), ` ${i18n.statsCarbonOfEstMax || 'of estimated max capacity'} · `, strong(`${yearPct.toFixed(1)}%`), ` ${i18n.statsCarbonYearProgress || 'of the year elapsed'}`),
|
||||
p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
|
||||
);
|
||||
})();
|
||||
|
||||
const headerCard = div({ class: 'stats-card' },
|
||||
const storageCard = div({ class: 'stats-card' },
|
||||
h3({ class: 'stats-section-h' }, i18n.statsStorageTitle || 'Storage'),
|
||||
table({ class: 'block-info-table' },
|
||||
tr(td({ class: 'card-label' }, i18n.statsCreatedAt), td({ class: 'card-value' }, stats.createdAt)),
|
||||
tr(td({ class: 'card-label' }, 'ID'), td({ class: 'card-value' }, userLink(stats.id))),
|
||||
tr(td({ class: 'card-label' }, i18n.statsBlobsSize), td({ class: 'card-value' }, stats.statsBlobsSize)),
|
||||
tr(td({ class: 'card-label' }, i18n.statsBlockchainSize), td({ class: 'card-value' }, stats.statsBlockchainSize)),
|
||||
tr(td({ class: 'card-label' }, i18n.statsSize), td({ class: 'card-value' }, stats.folderSize))
|
||||
)
|
||||
);
|
||||
|
||||
const totalInhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 0;
|
||||
const networkKPIs = stats.networkKPIs || {};
|
||||
|
||||
const topStrip = div({ class: 'stats-block' },
|
||||
kpiGrid(
|
||||
kpi(i18n.bankingUserEngagementScore, C(stats, 'karmaScore')),
|
||||
kpi(i18n.statsUsersTitle, totalInhabitants),
|
||||
kpi(i18n.statsTotalMsgs || 'Total messages', networkKPIs.totalMsgs || 0),
|
||||
kpi(i18n.statsLogsTitle || 'Logs', stats?.logsCount || 0),
|
||||
kpi(i18n.statsAITraining, C(stats, 'aiExchange') || 0),
|
||||
kpi(i18n.statsPUBs, stats.pubsCount || 0)
|
||||
)
|
||||
);
|
||||
|
||||
const carbonCard = div({ class: 'stats-card' },
|
||||
h3({ class: 'stats-section-h' }, i18n.statsCarbonFootprintTitle || 'Carbon Footprint'),
|
||||
carbonChart
|
||||
);
|
||||
|
||||
const bankingCard = div({ class: 'stats-card' },
|
||||
const accountCard = div({ class: 'stats-card' },
|
||||
h3({ class: 'stats-section-h' }, i18n.statsAccountTitle || 'Account'),
|
||||
table({ class: 'block-info-table' },
|
||||
tr(td({ class: 'card-label' }, 'ID'), td({ class: 'card-value' }, userLink(stats.id))),
|
||||
tr(td({ class: 'card-label' }, i18n.statsCreatedAt), td({ class: 'card-value' }, stats.createdAt)),
|
||||
C(stats, 'karmaScore') > 0
|
||||
? tr(td({ class: 'card-label' }, i18n.bankingUserEngagementScore), td({ class: 'card-value' }, String(C(stats, 'karmaScore'))))
|
||||
: null,
|
||||
tr(td({ class: 'card-label' }, i18n.statsEcoWalletLabel), td({ class: 'card-value' }, a({ href: '/wallet', class: 'stats-link-break' }, stats?.banking?.myAddress || i18n.statsEcoWalletNotConfigured))),
|
||||
tr(td({ class: 'card-label' }, i18n.profileGpgChip || 'GPG'), td({ class: 'card-value' },
|
||||
stats?.gpgFingerprint
|
||||
|
|
@ -280,7 +270,27 @@ exports.statsView = (stats, filter) => {
|
|||
)
|
||||
);
|
||||
|
||||
const totalInhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 0;
|
||||
const networkKPIs = stats.networkKPIs || {};
|
||||
|
||||
const networkStrip = div({ class: 'stats-block' },
|
||||
h3({ class: 'stats-section-h' }, i18n.statsNetworkTitle || 'Network'),
|
||||
kpiGrid(
|
||||
kpi(i18n.statsUsersTitle, totalInhabitants),
|
||||
kpi(i18n.statsTotalMsgs || 'Total messages', networkKPIs.totalMsgs || 0),
|
||||
kpi(i18n.statsLogsTitle || 'Logs', stats?.logsCount || 0),
|
||||
kpi(i18n.statsAITraining, C(stats, 'aiExchange') || 0),
|
||||
kpi(i18n.statsPUBs, stats.pubsCount || 0)
|
||||
)
|
||||
);
|
||||
|
||||
const carbonCard = div({ id: 'carbon', class: 'stats-card' },
|
||||
h3({ class: 'stats-section-h' }, i18n.statsCarbonFootprintTitle || 'Carbon Footprint'),
|
||||
carbonChart
|
||||
);
|
||||
|
||||
const networkBlock = div({ class: 'stats-block' },
|
||||
h3({ class: 'stats-section-h' }, i18n.statsAveragesTitle || 'Averages'),
|
||||
kpiGrid(
|
||||
filter === 'MINE'
|
||||
? kpi(i18n.statsMyShare || 'Your share of the network', `${fmtNum(networkKPIs.myShare || 0)}%`)
|
||||
|
|
@ -454,6 +464,7 @@ exports.statsView = (stats, filter) => {
|
|||
}
|
||||
return div({ class: 'stats-container' }, [
|
||||
div({ class: 'stats-block' },
|
||||
h3({ class: 'stats-section-h' }, i18n.TOMBSTONEButton),
|
||||
kpiGrid(
|
||||
kpi(i18n.TOMBSTONEButton, userTomb),
|
||||
kpi(i18n.statsTombstoneRatio, `${(stats.tombstoneKPIs?.ratio || 0).toFixed(2)}%`)
|
||||
|
|
@ -466,6 +477,7 @@ exports.statsView = (stats, filter) => {
|
|||
const ets = stats.ecoTaxStats || null;
|
||||
const userTax = Number(stats.userEcoinTax || 0);
|
||||
const ecoTaxBlock = ets ? div({ class: 'stats-card' },
|
||||
h3({ class: 'stats-section-h' }, i18n.statsEcoTaxTitle || 'ECO Tax'),
|
||||
table({ class: 'block-info-table' },
|
||||
filter === 'MINE'
|
||||
? tr(td({ class: 'card-label' }, i18n.bankTaxesUserAmount || 'Your ECO tax'), td({ class: 'card-value' }, `${userTax.toFixed(6)} ECO`))
|
||||
|
|
@ -489,18 +501,19 @@ exports.statsView = (stats, filter) => {
|
|||
modes.map(m =>
|
||||
form({ method: 'GET', action: '/stats' },
|
||||
input({ type: 'hidden', name: 'filter', value: m }),
|
||||
button({ type: 'submit', class: filter === m ? 'filter-btn active' : 'filter-btn' }, i18n[m + 'Button'])
|
||||
button({ type: 'submit', class: filter === m ? 'filter-btn active' : 'filter-btn' }, String(i18n[m + 'Button']).toUpperCase())
|
||||
)
|
||||
)
|
||||
),
|
||||
section(
|
||||
topStrip,
|
||||
headerCard,
|
||||
filter === 'ALL' ? networkStrip : null,
|
||||
filter === 'MINE' ? accountCard : null,
|
||||
filter === 'ALL' ? storageCard : null,
|
||||
tombMode,
|
||||
carbonCard,
|
||||
filter !== 'TOMBSTONE' ? ecoTaxBlock : null,
|
||||
bankingCard,
|
||||
allMode,
|
||||
mineMode,
|
||||
tombMode
|
||||
mineMode
|
||||
)
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const { div, h2, p, section, button, form, a, input, br, span, label, select, option, progress, table, tr, td } = require("../server/node_modules/hyperaxe")
|
||||
const { template, i18n, renderOpinionsVoting, userLink, renderStateChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions } = require("./main_views")
|
||||
const { template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderStateChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions } = require("./main_views")
|
||||
const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view")
|
||||
const moment = require("../server/node_modules/moment")
|
||||
const { config } = require("../server/SSB_server.js")
|
||||
|
||||
|
|
@ -130,7 +131,7 @@ const renderUpdatedLabel = (createdAt, updatedAt) => {
|
|||
const updatedTs = updatedAt ? new Date(updatedAt).getTime() : NaN
|
||||
const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs)
|
||||
return showUpdated
|
||||
? span({ class: "votations-comment-date" }, ` | ${i18n.transfersUpdatedAt}: ${moment(updatedAt).format("YYYY-MM-DD HH:mm")}`)
|
||||
? span({ class: "votations-comment-date" }, ` | ${i18n.transfersUpdatedAt}: ${moment(updatedAt).format("YYYY/MM/DD HH:mm")}`)
|
||||
: null
|
||||
}
|
||||
|
||||
|
|
@ -174,7 +175,7 @@ const generateTransferCard = (transfer, filter, params = {}) => {
|
|||
div(
|
||||
{ class: "card-header activity-card-header" },
|
||||
span(),
|
||||
renderContentActions(transfer.id, `/transfers/${encodeURIComponent(transfer.id)}`)
|
||||
renderContentActions(transfer.id, `/transfers/${encodeURIComponent(transfer.id)}`, { spread: params.spreadMap && params.spreadMap.get(transfer.id) || null, author: transfer.from, favKind: 'transfers', isFavorite: transfer.isFavorite, reportTitle: transfer.concept })
|
||||
),
|
||||
div({ class: "card-section transfer-card-body" },
|
||||
div({ class: "shop-title-row" },
|
||||
|
|
@ -184,24 +185,14 @@ const generateTransferCard = (transfer, filter, params = {}) => {
|
|||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
!isUbi && dl && dl.isValid()
|
||||
? p({ class: "card-date-highlight" }, dl.format("YYYY-MM-DD HH:mm"))
|
||||
? p({ class: "card-date-highlight" }, dl.format("YYYY/MM/DD HH:mm"))
|
||||
: null,
|
||||
cat !== "TRUST"
|
||||
? div({ class: "job-price-line card-salary" }, fmtAmountWithUnit(transfer))
|
||||
? div({ class: "job-price-line card-salary transfer-amount-centered" }, fmtAmountWithUnit(transfer))
|
||||
: null,
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.transfersConfirmations}: ${confirmedCount}/${required}`)
|
||||
),
|
||||
div(
|
||||
{ class: "card-comments-summary feed-opinions-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "),
|
||||
span({ class: "card-value" }, String(Object.values(transfer.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)))
|
||||
),
|
||||
renderOpinionsVoting('/transfers/opinions', transfer.id, transfer.opinions, returnTo, transfer.opinions_inhabitants)
|
||||
),
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(transfer.id, params.spreadMap && params.spreadMap.get(transfer.id)))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -209,20 +200,7 @@ const generateTransferCard = (transfer, filter, params = {}) => {
|
|||
exports.transferView = async (transfers, filter, transferId, params = {}) => {
|
||||
const normalizedFilter = filter === "favs" ? "all" : (filter || "all")
|
||||
|
||||
const title =
|
||||
normalizedFilter === "mine" ? i18n.transfersMineSectionTitle :
|
||||
normalizedFilter === "ubi" ? i18n.transfersUBISectionTitle :
|
||||
normalizedFilter === "pending" ? i18n.transfersPendingSectionTitle :
|
||||
normalizedFilter === "top" ? i18n.transfersTopSectionTitle :
|
||||
normalizedFilter === "unconfirmed" ? i18n.transfersUnconfirmedSectionTitle :
|
||||
normalizedFilter === "closed" ? i18n.transfersClosedSectionTitle :
|
||||
normalizedFilter === "discarded" ? i18n.transfersDiscardedSectionTitle :
|
||||
normalizedFilter === "create" ? i18n.transfersCreateSectionTitle :
|
||||
normalizedFilter === "edit" ? i18n.transfersUpdateSectionTitle :
|
||||
normalizedFilter === "economic" ? (i18n.transfersEconomicSectionTitle || (i18n.transfersCategoryEconomic + " " + i18n.transfersTitle)) :
|
||||
normalizedFilter === "time" ? (i18n.transfersTimeSectionTitle || (i18n.transfersCategoryTime + " " + i18n.transfersTitle)) :
|
||||
normalizedFilter === "trust" ? (i18n.transfersTrustSectionTitle || (i18n.transfersCategoryTrust + " " + i18n.transfersTitle)) :
|
||||
i18n.transfersAllSectionTitle
|
||||
const title = i18n.transfersTitle
|
||||
|
||||
const q = safeText(params.q || "")
|
||||
const minAmountRaw = params.minAmount ?? ""
|
||||
|
|
@ -295,18 +273,18 @@ exports.transferView = async (transfers, filter, transferId, params = {}) => {
|
|||
input({ type: "hidden", name: "minAmount", value: String(minAmountRaw ?? "") }),
|
||||
input({ type: "hidden", name: "maxAmount", value: String(maxAmountRaw ?? "") }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: normalizedFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: normalizedFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "ubi", class: normalizedFilter === "ubi" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterUBI),
|
||||
button({ type: "submit", name: "filter", value: "all", class: normalizedFilter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.transfersFilterAll).toUpperCase()),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: normalizedFilter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.transfersFilterMine).toUpperCase()),
|
||||
button({ type: "submit", name: "filter", value: "ubi", class: normalizedFilter === "ubi" ? "filter-btn active" : "filter-btn" }, String(i18n.transfersFilterUBI).toUpperCase()),
|
||||
button({ type: "submit", name: "filter", value: "economic", class: normalizedFilter === "economic" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterEconomic || (i18n.transfersCategoryEconomic || "ECONOMIC")),
|
||||
button({ type: "submit", name: "filter", value: "time", class: normalizedFilter === "time" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTime || (i18n.transfersCategoryTime || "TIME")),
|
||||
button({ type: "submit", name: "filter", value: "trust", class: normalizedFilter === "trust" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTrust || (i18n.transfersCategoryTrust || "TRUST")),
|
||||
button({ type: "submit", name: "filter", value: "market", class: normalizedFilter === "market" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterMarket),
|
||||
button({ type: "submit", name: "filter", value: "pending", class: normalizedFilter === "pending" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterPending),
|
||||
button({ type: "submit", name: "filter", value: "unconfirmed", class: normalizedFilter === "unconfirmed" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterUnconfirmed),
|
||||
button({ type: "submit", name: "filter", value: "closed", class: normalizedFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterClosed),
|
||||
button({ type: "submit", name: "filter", value: "discarded", class: normalizedFilter === "discarded" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterDiscarded),
|
||||
button({ type: "submit", name: "filter", value: "top", class: normalizedFilter === "top" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTop),
|
||||
button({ type: "submit", name: "filter", value: "market", class: normalizedFilter === "market" ? "filter-btn active" : "filter-btn" }, String(i18n.transfersFilterMarket).toUpperCase()),
|
||||
button({ type: "submit", name: "filter", value: "pending", class: normalizedFilter === "pending" ? "filter-btn active" : "filter-btn" }, String(i18n.transfersFilterPending).toUpperCase()),
|
||||
button({ type: "submit", name: "filter", value: "unconfirmed", class: normalizedFilter === "unconfirmed" ? "filter-btn active" : "filter-btn" }, String(i18n.transfersFilterUnconfirmed).toUpperCase()),
|
||||
button({ type: "submit", name: "filter", value: "closed", class: normalizedFilter === "closed" ? "filter-btn active" : "filter-btn" }, String(i18n.transfersFilterClosed).toUpperCase()),
|
||||
button({ type: "submit", name: "filter", value: "discarded", class: normalizedFilter === "discarded" ? "filter-btn active" : "filter-btn" }, String(i18n.transfersFilterDiscarded).toUpperCase()),
|
||||
button({ type: "submit", name: "filter", value: "top", class: normalizedFilter === "top" ? "filter-btn active" : "filter-btn" }, String(i18n.transfersFilterTop).toUpperCase()),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.transfersCreateButton)
|
||||
)
|
||||
)
|
||||
|
|
@ -463,9 +441,14 @@ exports.singleTransferView = async (transfer, filter, params = {}) => {
|
|||
button({ type: "submit", class: "delete-btn" }, i18n.transfersDeleteButton)
|
||||
))
|
||||
}
|
||||
sideActions.push(form({ method: "GET", action: `/transfers/contract/${encodeURIComponent(transfer.id)}`, class: "transfer-contract-form" },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.transfersExportContract || 'Create Contract')
|
||||
))
|
||||
const exportActions = div({ class: "doc-export-actions" },
|
||||
form({ method: "GET", action: `/transfers/contract/${encodeURIComponent(transfer.id)}`, class: "transfer-contract-form" },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.transfersExportContract)
|
||||
),
|
||||
form({ method: "POST", action: `/transfers/${encodeURIComponent(transfer.id)}/share` },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.sharePm)
|
||||
)
|
||||
)
|
||||
|
||||
const infoRows = []
|
||||
const pushRow = (labelText, valueNode) =>
|
||||
|
|
@ -477,15 +460,24 @@ exports.singleTransferView = async (transfer, filter, params = {}) => {
|
|||
pushRow(i18n.transfersTo, userLink(transfer.to))
|
||||
if (cat !== "TRUST") pushRow(i18n.transfersAmount, fmtAmountWithUnit(transfer))
|
||||
pushRow(i18n.transfersCategory || "Category", categoryLabel(cat))
|
||||
if (!isUbi) pushRow(i18n.transfersDeadline, dl && dl.isValid() ? dl.format("YYYY-MM-DD HH:mm") : "")
|
||||
if (!isUbi) pushRow(i18n.transfersDeadline, dl && dl.isValid() ? dl.format("YYYY/MM/DD HH:mm") : "")
|
||||
pushRow(i18n.transfersStatus, i18n[statusKey(transfer.status)] || String(transfer.status || ""))
|
||||
|
||||
const transferSide = div({ class: "tribe-side" },
|
||||
div({ class: "card-header activity-card-header" },
|
||||
renderContentActions(transfer.id, null, {
|
||||
spread: params.spreads || null,
|
||||
author: transfer.from,
|
||||
favKind: 'transfers',
|
||||
isFavorite: transfer.isFavorite,
|
||||
returnTo,
|
||||
reportTitle: transfer.concept
|
||||
})
|
||||
),
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, transfer.concept || i18n.transfersTitle)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(transfer.id, params.spreads)),
|
||||
tagsNode,
|
||||
div({ class: "tribe-card-members" },
|
||||
span({ class: "tribe-members-count" }, `${i18n.transfersConfirmations}: ${confirmedCount}/${required}`)
|
||||
|
|
@ -496,50 +488,42 @@ exports.singleTransferView = async (transfer, filter, params = {}) => {
|
|||
span({ class: "card-value" }, a({ class: "user-link", href: `/blockexplorer/block/${encodeURIComponent(params.block.id)}` }, params.block.id))
|
||||
)
|
||||
: null,
|
||||
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null
|
||||
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null,
|
||||
exportActions
|
||||
)
|
||||
|
||||
const confirmedList = Array.isArray(transfer.confirmedBy) ? transfer.confirmedBy.filter(Boolean) : []
|
||||
|
||||
const transferMain = div({ class: "tribe-main" },
|
||||
div({ class: "job-section" },
|
||||
div({ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.transfersFrom}: `),
|
||||
span({ class: "card-value" }, userLink(transfer.from))
|
||||
),
|
||||
div({ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.transfersTo}: `),
|
||||
span({ class: "card-value" }, userLink(transfer.to))
|
||||
),
|
||||
cat !== "TRUST" ? div({ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.transfersAmount}: `),
|
||||
span({ class: "card-value card-salary" }, fmtAmountWithUnit(transfer))
|
||||
) : null,
|
||||
div({ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.transfersCategory || "Category"}: `),
|
||||
span({ class: "card-value" }, categoryLabel(cat))
|
||||
),
|
||||
div({ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.transfersConcept}: `),
|
||||
span({ class: "card-value" }, transfer.concept || "")
|
||||
),
|
||||
!isUbi && dl && dl.isValid() ? div({ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.transfersDeadline}: `),
|
||||
span({ class: "card-value" }, dl.format("YYYY-MM-DD HH:mm"))
|
||||
) : null,
|
||||
div({ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.transfersStatus}: `),
|
||||
span({ class: "card-value" }, i18n[statusKey(transfer.status)] || String(transfer.status || ""))
|
||||
)
|
||||
h2({ class: "job-section-title" }, i18n.transfersConcept),
|
||||
p({ class: "tribe-side-description" }, transfer.concept || "")
|
||||
),
|
||||
div({ class: "job-section" },
|
||||
h2({ class: "job-section-title" }, i18n.transfersConfirmations),
|
||||
p({ class: "tribe-side-description" }, `${confirmedCount}/${required}`),
|
||||
confirmedList.length
|
||||
? div({ class: "card-assigned-list" }, ...confirmedList.map(id => userLink(id)))
|
||||
: null
|
||||
),
|
||||
p({ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${moment(transfer.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `),
|
||||
span({ class: "date-link" }, `${moment(transfer.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
|
||||
userLink(transfer.from),
|
||||
renderUpdatedLabel(transfer.createdAt, transfer.updatedAt)
|
||||
),
|
||||
renderOpinionsVoting('/transfers/opinions', transfer.id, transfer.opinions, returnTo, transfer.opinions_inhabitants)
|
||||
renderEngagement(transfer.id,
|
||||
renderOpinionsVoting('/transfers/opinions', transfer.id, transfer.opinions, returnTo, transfer.opinions_inhabitants),
|
||||
renderSharedCommentsSection({
|
||||
action: `/transfers/${encodeURIComponent(transfer.id)}/comments`,
|
||||
comments: params.comments || [],
|
||||
returnTo
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
return template(
|
||||
transfer.concept,
|
||||
section(div({ class: "tags-header" }, h2(i18n.transfersTitle), p(i18n.transfersDescription))),
|
||||
section(
|
||||
div(
|
||||
{ class: "filters" },
|
||||
|
|
@ -549,18 +533,18 @@ exports.singleTransferView = async (transfer, filter, params = {}) => {
|
|||
input({ type: "hidden", name: "minAmount", value: String(params.minAmount ?? "") }),
|
||||
input({ type: "hidden", name: "maxAmount", value: String(params.maxAmount ?? "") }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: normalizedFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: normalizedFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "ubi", class: normalizedFilter === "ubi" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterUBI),
|
||||
button({ type: "submit", name: "filter", value: "all", class: normalizedFilter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.transfersFilterAll).toUpperCase()),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: normalizedFilter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.transfersFilterMine).toUpperCase()),
|
||||
button({ type: "submit", name: "filter", value: "ubi", class: normalizedFilter === "ubi" ? "filter-btn active" : "filter-btn" }, String(i18n.transfersFilterUBI).toUpperCase()),
|
||||
button({ type: "submit", name: "filter", value: "economic", class: normalizedFilter === "economic" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterEconomic || (i18n.transfersCategoryEconomic || "ECONOMIC")),
|
||||
button({ type: "submit", name: "filter", value: "time", class: normalizedFilter === "time" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTime || (i18n.transfersCategoryTime || "TIME")),
|
||||
button({ type: "submit", name: "filter", value: "trust", class: normalizedFilter === "trust" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTrust || (i18n.transfersCategoryTrust || "TRUST")),
|
||||
button({ type: "submit", name: "filter", value: "market", class: normalizedFilter === "market" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterMarket),
|
||||
button({ type: "submit", name: "filter", value: "pending", class: normalizedFilter === "pending" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterPending),
|
||||
button({ type: "submit", name: "filter", value: "unconfirmed", class: normalizedFilter === "unconfirmed" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterUnconfirmed),
|
||||
button({ type: "submit", name: "filter", value: "closed", class: normalizedFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterClosed),
|
||||
button({ type: "submit", name: "filter", value: "discarded", class: normalizedFilter === "discarded" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterDiscarded),
|
||||
button({ type: "submit", name: "filter", value: "top", class: normalizedFilter === "top" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTop),
|
||||
button({ type: "submit", name: "filter", value: "market", class: normalizedFilter === "market" ? "filter-btn active" : "filter-btn" }, String(i18n.transfersFilterMarket).toUpperCase()),
|
||||
button({ type: "submit", name: "filter", value: "pending", class: normalizedFilter === "pending" ? "filter-btn active" : "filter-btn" }, String(i18n.transfersFilterPending).toUpperCase()),
|
||||
button({ type: "submit", name: "filter", value: "unconfirmed", class: normalizedFilter === "unconfirmed" ? "filter-btn active" : "filter-btn" }, String(i18n.transfersFilterUnconfirmed).toUpperCase()),
|
||||
button({ type: "submit", name: "filter", value: "closed", class: normalizedFilter === "closed" ? "filter-btn active" : "filter-btn" }, String(i18n.transfersFilterClosed).toUpperCase()),
|
||||
button({ type: "submit", name: "filter", value: "discarded", class: normalizedFilter === "discarded" ? "filter-btn active" : "filter-btn" }, String(i18n.transfersFilterDiscarded).toUpperCase()),
|
||||
button({ type: "submit", name: "filter", value: "top", class: normalizedFilter === "top" ? "filter-btn active" : "filter-btn" }, String(i18n.transfersFilterTop).toUpperCase()),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.transfersCreateButton)
|
||||
)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ const { div, h2, h3, p, section, button, form, a, input, img, label, select, opt
|
|||
const moment = require("../server/node_modules/moment");
|
||||
const { template, i18n, userLink, renderStateChip, renderPrivacyChip, renderLifespanChip, renderModeChip, renderInviteQrCard, renderContentActions } = require('./main_views');
|
||||
const { renderEncryptedChip: renderTribeEncryptedChip } = require('./clearnet_view');
|
||||
const { renderResults: renderPollResults, renderBallot: renderPollBallot } = require('./polls_view');
|
||||
const { config } = require('../server/SSB_server.js');
|
||||
const { renderUrl } = require('../backend/renderUrl');
|
||||
const { renderMapLocationUrl, renderMapLocationGrid, renderMapLocationVisitLabel } = require("./maps_view");
|
||||
|
|
@ -166,25 +167,17 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
|
|||
return cb - ca;
|
||||
});
|
||||
|
||||
const title =
|
||||
filter === 'recent' ? i18n.tribeRecentSectionTitle :
|
||||
filter === 'mine' ? i18n.tribeMineSectionTitle :
|
||||
filter === 'create' ? i18n.tribeCreateSectionTitle :
|
||||
filter === 'edit' ? i18n.tribeUpdateSectionTitle :
|
||||
filter === 'gallery' ? i18n.tribeGallerySectionTitle :
|
||||
filter === 'top' ? i18n.tribeTopSectionTitle :
|
||||
filter === 'subtribes' ? (i18n.tribeSubTribes || 'SUB-TRIBES') :
|
||||
i18n.tribeAllSectionTitle;
|
||||
const title = i18n.tribesTitle;
|
||||
|
||||
const header = div({ class: 'tags-header' }, h2(title), p(i18n.tribeDescription));
|
||||
|
||||
const filters = div({ class: 'filters' },
|
||||
form({ method: 'GET', action: '/tribes' },
|
||||
form({ method: 'GET', action: '/tribes', class: 'filter-box' },
|
||||
input({ type: 'hidden', name: 'filter', value: filter }),
|
||||
input({ type: 'text', name: 'search', placeholder: i18n.searchTribesPlaceholder, value: query.search || '' }),
|
||||
br(),
|
||||
button({ type: 'submit' }, i18n.applyFilters),
|
||||
br()
|
||||
input({ type: 'text', name: 'search', placeholder: i18n.searchTribesPlaceholder, value: query.search || '', class: 'filter-box__input' }),
|
||||
div({ class: 'filter-box__controls' },
|
||||
button({ type: 'submit', class: 'filter-box__button' }, i18n.searchButton)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
|
@ -214,7 +207,7 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
|
|||
},
|
||||
label({ for: 'title' }, i18n.tribeTitleLabel),
|
||||
br,
|
||||
input({ type: 'text', name: 'title', id: 'title', required: true, placeholder: i18n.tribeTitlePlaceholder, value: tribeToEdit.title || '' }),
|
||||
input({ type: 'text', name: 'title', maxlength: '100', id: 'title', required: true, placeholder: i18n.tribeTitlePlaceholder, value: tribeToEdit.title || '' }),
|
||||
br(),
|
||||
label({ for: 'description' }, i18n.tribeDescriptionLabel),
|
||||
br,
|
||||
|
|
@ -265,7 +258,7 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
|
|||
return div({ class: 'trending-card tribes-card' + (isOwn ? ' own-content' : '') },
|
||||
div({ class: 'card-header activity-card-header' },
|
||||
span(),
|
||||
renderContentActions(t.id, `/tribe/${encodeURIComponent(t.id)}`)
|
||||
renderContentActions(t.id, `/tribe/${encodeURIComponent(t.id)}`, { author: t.author, reportTitle: t.title })
|
||||
),
|
||||
div({ class: 'card-section tribes-card-body' },
|
||||
div({ class: 'tribe-card-image-wrapper' },
|
||||
|
|
@ -358,7 +351,7 @@ const renderFeedTribeView = async (feedItems, tribe, query = {}, filter) => {
|
|||
form({ method: 'GET', action: `/tribe/${encodeURIComponent(tribe.id)}` },
|
||||
input({ type: 'hidden', name: 'section', value: 'feed' }),
|
||||
input({ type: 'hidden', name: 'feedFilter', value: f }),
|
||||
button({ type: 'submit', class: feedFilter === f ? 'filter-btn active' : 'filter-btn' }, i18n[`tribeFeedFilter${f}`])
|
||||
button({ type: 'submit', class: feedFilter === f ? 'filter-btn active' : 'filter-btn' }, String(i18n[`tribeFeedFilter${f}`]).toUpperCase())
|
||||
)
|
||||
)
|
||||
),
|
||||
|
|
@ -399,7 +392,7 @@ const renderSectionNav = (tribe, section) => {
|
|||
if (!tribe.parentTribeId) firstGroup.push({ key: 'governance', label: i18n.tribeSectionGovernance || 'GOVERNANCE' });
|
||||
const sections = [
|
||||
{ items: firstGroup },
|
||||
{ items: [{ key: 'votations', label: i18n.tribeSectionVotations }, { key: 'events', label: i18n.tribeSectionEvents }, { key: 'tasks', label: i18n.tribeSectionTasks }] },
|
||||
{ items: [{ key: 'votations', label: i18n.tribeSectionVotations }, { key: 'polls', label: i18n.pollsTitle }, { key: 'events', label: i18n.tribeSectionEvents }, { key: 'tasks', label: i18n.tribeSectionTasks }] },
|
||||
{ items: [{ key: 'feed', label: i18n.tribeSectionFeed }, { key: 'forum', label: i18n.tribeSectionForum }, { key: 'maps', label: i18n.tribeSectionMaps || 'MAPS' }, { key: 'torrents', label: i18n.tribeSectionTorrents || 'TORRENTS' }, { key: 'pads', label: i18n.tribeSectionPads || 'PADS' }, { key: 'chats', label: i18n.tribeSectionChats || 'CHATS' }, { key: 'calendars', label: i18n.tribeSectionCalendars || 'CALENDARS' }] },
|
||||
{ items: [{ key: 'images', label: i18n.tribeSectionImages || 'IMAGES' }, { key: 'audios', label: i18n.tribeSectionAudios || 'AUDIOS' }, { key: 'videos', label: i18n.tribeSectionVideos || 'VIDEOS' }, { key: 'documents', label: i18n.tribeSectionDocuments || 'DOCUMENTS' }, { key: 'bookmarks', label: i18n.tribeSectionBookmarks || 'BOOKMARKS' }] },
|
||||
{ items: [{ key: 'tags', label: i18n.tribeSectionTags || 'TAGS' }, { key: 'search', label: i18n.tribeSectionSearch }] },
|
||||
|
|
@ -476,12 +469,12 @@ const contentTypeVerb = (ct) => {
|
|||
};
|
||||
|
||||
const contentTypeName = (ct) => {
|
||||
const map = { event: i18n.tribeSectionEvents, task: i18n.tribeSectionTasks, votation: i18n.tribeSectionVotations, forum: i18n.tribeSectionForum, 'forum-reply': i18n.tribeSectionForum, media: i18n.tribeSectionMedia, feed: i18n.tribeSectionFeed, pad: i18n.tribeSectionPads || 'PADS', chat: i18n.tribeSectionChats || 'CHATS', calendar: i18n.tribeSectionCalendars || 'CALENDARS', map: i18n.tribeSectionMaps || 'MAPS' };
|
||||
const map = { event: i18n.tribeSectionEvents, task: i18n.tribeSectionTasks, votation: i18n.tribeSectionVotations, poll: i18n.pollsTitle, forum: i18n.tribeSectionForum, 'forum-reply': i18n.tribeSectionForum, media: i18n.tribeSectionMedia, feed: i18n.tribeSectionFeed, pad: i18n.tribeSectionPads || 'PADS', chat: i18n.tribeSectionChats || 'CHATS', calendar: i18n.tribeSectionCalendars || 'CALENDARS', map: i18n.tribeSectionMaps || 'MAPS' };
|
||||
return map[ct] || ct;
|
||||
};
|
||||
|
||||
const activitySectionMap = {
|
||||
event: 'events', task: 'tasks', votation: 'votations',
|
||||
event: 'events', task: 'tasks', votation: 'votations', poll: 'polls',
|
||||
forum: 'forum', 'forum-reply': 'forum',
|
||||
feed: 'feed',
|
||||
pad: 'pads', chat: 'chats', calendar: 'calendars', map: 'maps', torrent: 'torrents'
|
||||
|
|
@ -522,7 +515,7 @@ const renderTribeActivitySection = (tribe, sectionData) => {
|
|||
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,
|
||||
r.text ? p({ class: 'post-text post-text-pre' }, 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, '')
|
||||
|
|
@ -910,6 +903,72 @@ const renderTasksSection = (tribe, items, query) => {
|
|||
);
|
||||
};
|
||||
|
||||
const renderTribePollsSection = (tribe, items, query) => {
|
||||
const polls = Array.isArray(items) ? items : [];
|
||||
const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
|
||||
if (query.action === 'create') {
|
||||
return div({ class: 'create-tribe-form' },
|
||||
form({ method: 'POST', action: `${tribeUrl}/polls/create` },
|
||||
label(i18n.pollQuestion), br,
|
||||
input({ type: 'text', name: 'question', required: true, maxlength: '300', placeholder: i18n.pollQuestionPlaceholder }), br(),
|
||||
label(i18n.pollOptions), br,
|
||||
textarea({ name: 'options', rows: 5, required: true, placeholder: i18n.pollOptionsPlaceholder }, ''), br(),
|
||||
div({ class: 'poll-switch' },
|
||||
input({ type: 'hidden', name: 'anonymous', value: '0' }),
|
||||
label(input({ type: 'checkbox', name: 'anonymous', value: '1' }), ' ', i18n.pollAnonymousLabel)
|
||||
),
|
||||
div({ class: 'poll-switch' },
|
||||
input({ type: 'hidden', name: 'multiple', value: '0' }),
|
||||
label(input({ type: 'checkbox', name: 'multiple', value: '1' }), ' ', i18n.pollMultipleLabel)
|
||||
),
|
||||
button({ type: 'submit', class: 'create-button' }, i18n.pollPublishButton)
|
||||
)
|
||||
);
|
||||
}
|
||||
const returnTo = `${tribeUrl}?section=polls`;
|
||||
return div({ class: 'tribe-content-list' },
|
||||
div({ class: 'tribe-content-header' },
|
||||
h2(i18n.pollsTitle),
|
||||
form({ method: 'GET', action: tribeUrl },
|
||||
input({ type: 'hidden', name: 'section', value: 'polls' }),
|
||||
input({ type: 'hidden', name: 'action', value: 'create' }),
|
||||
button({ type: 'submit', class: 'create-button' }, i18n.pollCreateButton)
|
||||
)
|
||||
),
|
||||
polls.length === 0 ? p(i18n.pollsNoItems) :
|
||||
polls.map(poll => div({ class: 'tribe-content-card poll-card' },
|
||||
h2(poll.undecryptable ? i18n.contentAccessDenied : poll.question),
|
||||
poll.undecryptable ? null : div({ class: 'card-chips-row' },
|
||||
statusBadge(poll.status),
|
||||
poll.anonymous ? span({ class: 'chat-poll-tag' }, i18n.pollAnonymous) : null,
|
||||
poll.multiple ? span({ class: 'chat-poll-tag' }, i18n.pollMultiple) : null
|
||||
),
|
||||
poll.undecryptable ? null : (poll.hasVoted || poll.status === 'CLOSED' ? renderPollResults(poll) : null),
|
||||
poll.undecryptable ? null : renderPollBallot(poll, returnTo, '/polls'),
|
||||
poll.undecryptable ? null : div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, `${i18n.pollVoters}: `),
|
||||
span({ class: 'card-value' }, String(poll.totalVoters))
|
||||
),
|
||||
div({ class: 'card-field' },
|
||||
span({ class: 'card-label' }, i18n.createdBy + ': '),
|
||||
userLink(poll.author)
|
||||
),
|
||||
poll.author === userId && poll.status === 'OPEN'
|
||||
? form({ method: 'POST', action: `/polls/close/${encodeURIComponent(poll.id)}` },
|
||||
input({ type: 'hidden', name: 'returnTo', value: returnTo }),
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.pollCloseButton)
|
||||
)
|
||||
: null,
|
||||
poll.author === userId
|
||||
? form({ method: 'POST', action: `/polls/delete/${encodeURIComponent(poll.id)}` },
|
||||
input({ type: 'hidden', name: 'returnTo', value: returnTo }),
|
||||
button({ type: 'submit', class: 'filter-btn danger-btn' }, i18n.pollDeleteButton)
|
||||
)
|
||||
: null
|
||||
))
|
||||
);
|
||||
};
|
||||
|
||||
const renderVotationsSection = (tribe, items, query) => {
|
||||
const votations = Array.isArray(items) ? items : [];
|
||||
const action = query.action;
|
||||
|
|
@ -918,7 +977,7 @@ const renderVotationsSection = (tribe, items, query) => {
|
|||
return div({ class: 'create-tribe-form' },
|
||||
form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/votations/create` },
|
||||
label({ for: 'title' }, i18n.tribeVotationTitle), br,
|
||||
input({ type: 'text', name: 'title', id: 'title', required: true, placeholder: i18n.tribeVotationTitle }), br(),
|
||||
input({ type: 'text', name: 'title', maxlength: '100', id: 'title', required: true, placeholder: i18n.tribeVotationTitle }), br(),
|
||||
label({ for: 'description' }, i18n.tribeVotationDescription), br,
|
||||
textarea({ name: 'description', id: 'description', rows: 3, placeholder: i18n.tribeVotationDescription }, ''), br(),
|
||||
label({ for: 'deadline' }, i18n.tribeVotationDeadline), br,
|
||||
|
|
@ -1128,12 +1187,7 @@ const renderForumSection = (tribe, items, query) => {
|
|||
t.description ? div({ class: 'forum-body' }, ...renderUrl((t.description || '').substring(0, 200))) : null,
|
||||
div({ class: 'forum-meta' },
|
||||
span({ class: 'forum-positive-votes' }, `▲: ${t.refeeds || 0}`),
|
||||
span({ class: 'forum-messages' }, `${(i18n.forumMessages || i18n.tribeForumReplies || 'MESSAGES').toUpperCase()}: ${replyCount}`),
|
||||
form({ method: 'GET', action: tribeUrl, class: 'visit-forum-form' },
|
||||
input({ type: 'hidden', name: 'section', value: 'forum' }),
|
||||
input({ type: 'hidden', name: 'thread', value: t.id }),
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.forumVisitButton || 'VISIT')
|
||||
)
|
||||
span({ class: 'forum-messages' }, `${(i18n.forumMessages || i18n.tribeForumReplies || 'MESSAGES').toUpperCase()}: ${replyCount}`)
|
||||
),
|
||||
div({ class: 'forum-footer' },
|
||||
span({ class: 'date-link' }, `${new Date(t.createdAt).toLocaleString()} ${i18n.performed || ''}`),
|
||||
|
|
@ -1182,7 +1236,7 @@ const renderTribeMediaTypeSection = (tribe, items, query, mediaType) => {
|
|||
input({ type: 'hidden', name: 'mediaType', value: 'bookmark' }),
|
||||
input({ type: 'hidden', name: 'returnSection', value: sectionKey }),
|
||||
label({ for: 'title' }, i18n.tribeMediaTitle), br,
|
||||
input({ type: 'text', name: 'title', id: 'title', required: true, placeholder: i18n.tribeMediaTitle }), br(),
|
||||
input({ type: 'text', name: 'title', maxlength: '100', id: 'title', required: true, placeholder: i18n.tribeMediaTitle }), br(),
|
||||
label({ for: 'url' }, i18n.bookmarkUrlLabel || 'URL'), br,
|
||||
input({ type: 'url', name: 'url', id: 'url', required: true, placeholder: 'https://' }), br(),br(),
|
||||
label({ for: 'description' }, i18n.tribeMediaDescription), br,
|
||||
|
|
@ -1196,7 +1250,7 @@ const renderTribeMediaTypeSection = (tribe, items, query, mediaType) => {
|
|||
input({ type: 'hidden', name: 'mediaType', value: mediaType }),
|
||||
input({ type: 'hidden', name: 'returnSection', value: sectionKey }),
|
||||
label({ for: 'title' }, i18n.tribeMediaTitle), br,
|
||||
input({ type: 'text', name: 'title', id: 'title', required: true, placeholder: i18n.tribeMediaTitle }), br(),
|
||||
input({ type: 'text', name: 'title', maxlength: '100', id: 'title', required: true, placeholder: i18n.tribeMediaTitle }), br(),
|
||||
label({ for: 'description' }, i18n.tribeMediaDescription), br,
|
||||
textarea({ name: 'description', id: 'description', rows: 3, placeholder: i18n.tribeMediaDescription }, ''), br(),
|
||||
label({ for: 'media' }, i18n.tribeMediaUpload), br,
|
||||
|
|
@ -1542,6 +1596,7 @@ exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => {
|
|||
case 'events': sectionContent = renderEventsSection(tribe, sectionData, query); break;
|
||||
case 'tasks': sectionContent = renderTasksSection(tribe, sectionData, query); break;
|
||||
case 'votations': sectionContent = renderVotationsSection(tribe, sectionData, query); break;
|
||||
case 'polls': sectionContent = renderTribePollsSection(tribe, sectionData, query); break;
|
||||
case 'forum': sectionContent = renderForumSection(tribe, sectionData, query); break;
|
||||
case 'subtribes': sectionContent = renderSubTribesSection(tribe, sectionData, query); break;
|
||||
case 'search': sectionContent = renderTribeSearchSection(tribe, sectionData, query); break;
|
||||
|
|
@ -1636,6 +1691,15 @@ exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => {
|
|||
? a({ class: 'tribe-action-btn', href: inviteHref }, i18n.tribeEnterInvite)
|
||||
: null
|
||||
) : null,
|
||||
(isLarpHouse && larpHouseKey === 'academia' && isOutsider)
|
||||
? div({ class: 'tribe-side-actions' },
|
||||
form({ method: 'POST', action: '/larp/join' },
|
||||
input({ type: 'hidden', name: 'house', value: 'academia' }),
|
||||
input({ type: 'hidden', name: 'returnTo', value: `/tribe/${encodeURIComponent(tribe.id)}` }),
|
||||
button({ type: 'submit', class: 'tribe-action-btn' }, i18n.larpInviteRedeem || 'Join House')
|
||||
)
|
||||
)
|
||||
: null,
|
||||
(hasOpenInvite && canManageOpenInvite) ? div({ class: 'tribe-open-invite-panel' },
|
||||
div({ class: 'tribe-open-invite' },
|
||||
span({ class: 'tribe-members-count' }, i18n.tribeInviteCodeText),
|
||||
|
|
@ -1868,7 +1932,7 @@ const rulesBlock = (tribe, rules, isCreator) => div({},
|
|||
h3(i18n.tribeGovernanceAddRule || 'Add rule'),
|
||||
form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/rule/add` },
|
||||
label({}, i18n.tribeGovRuleTitle || 'Title'), br(),
|
||||
input({ type: 'text', name: 'title', required: true }), br(), br(),
|
||||
input({ type: 'text', name: 'title', maxlength: '100', required: true }), br(), br(),
|
||||
label({}, i18n.tribeGovRuleBody || 'Body'), br(),
|
||||
textarea({ name: 'body', rows: 4 }), br(), br(),
|
||||
button({ type: 'submit', class: 'create-button' }, i18n.save || 'Save')
|
||||
|
|
|
|||
|
|
@ -93,6 +93,32 @@ const stepContent = (key, lang, profile) => {
|
|||
)
|
||||
)
|
||||
}
|
||||
if (key === "ux") {
|
||||
let cfg = {}
|
||||
try { cfg = require("../configs/config-manager.js").getConfig() || {} } catch (_) {}
|
||||
const cur = cfg.ux?.current === "ainav" ? "ainav" : cfg.ux?.current === "chats" ? "chats" : "blocks"
|
||||
const chatsOn = cfg.modules?.chatsMod === "on"
|
||||
const aiOn = cfg.modules?.aiNavMod === "on"
|
||||
const uxCard = (value, title, image) => label({ class: "welcome-ux-option" },
|
||||
input({ type: "radio", name: "ux", value, ...(value === "blocks" ? { checked: true } : {}) }),
|
||||
img({ src: image, class: "welcome-ux-shot", alt: title }),
|
||||
span({ class: "welcome-ux-label" }, title)
|
||||
)
|
||||
return {
|
||||
title: i18n.welcomeStepUxTitle || "Choose your interface",
|
||||
text: i18n.welcomeStepUxText || "Pick how Oasis should look. You can change it later in Settings.",
|
||||
action: form({ method: "POST", action: "/welcome/ux", class: "welcome-ux-form" },
|
||||
div({ class: "welcome-ux-grid" },
|
||||
uxCard("blocks", i18n.uxModeMenus || "Blocks", "/assets/images/ux-blocks.png"),
|
||||
chatsOn ? uxCard("chats", i18n.chatsTitle || "Chats", "/assets/images/ux-chats.png") : null,
|
||||
aiOn ? uxCard("ainav", i18n.uxModeAINav || "AI", "/assets/images/ux-ainav.png") : null
|
||||
),
|
||||
div({ class: "welcome-action" },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.welcomeStepUxAction || "Use this view")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (key === "backup") return {
|
||||
title: i18n.welcomeStepBackupTitle || "Backup your ID",
|
||||
text: i18n.welcomeStepBackupText || "Your identity is a key file on this device.",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue